[개발기] 검색어가 달라도 찾아준다: 대화 로그에 벡터 검색 붙이기

58.207.***.***
2

이 글은 seCall — AI 에이전트 대화 세션을 수집·검색하는 CLI 도구 — 의 기능 개발기 시리즈 두 번째 편입니다. 바이브코딩 테스트 프로젝트의 일환으로 만들었으니 세부적인 코드는 모르고 알고 싶지도 않습니다. 몰라도 됩니다 봐도 모릅니다 다만 시행착오 끝에 적용한 기능에 사용된 기술과 도입 이유를 풀어보고 싶어서 쓰는 글입니다. (이전 편: SQLite 하나로 터미널에이전트 이전 대화 검색하기 - 한국어 형태소 분석을 왜 해야하나)

문제: 단어가 같아야만 찾을 수 있다

이전 편에서 FTS5 + 한국어 형태소 분석으로 BM25 검색을 만들었습니다. "모델을"에서 "모델"을 찾을 수 있게 됐습니다. 그런데 쓰다 보니 뭔가뭔가 합니다.

"아키텍처"로 검색하면 "설계"가 안 잡힙니다. "에러"로 검색하면 "버그"가 안 나옵니다. 사람은 같은 의미인 걸 아는데 BM25는 글자가 다르면 남남입니다. 특히 AI 대화는 한국어와 영어가 뒤섞여 있어서, "embedding"이라고 물어봤다가 "임베딩"이라고 대답받은 대화는 어느 쪽으로 검색해도 반쪽만 걸립니다.

결국 텍스트 매칭과는 다른 축의 검색이 필요했습니다.

임베딩이란 — 문장을 숫자로 바꾸기

벡터 검색의 핵심은 임베딩(embedding)입니다. 문장을 수백~수천 개의 숫자 배열(벡터)로 변환하는 것인데, 이때 의미가 비슷한 문장은 비슷한 숫자 배열이 나옵니다.

"아키텍처를 설계했다"와 "시스템 구조를 잡았다"는 글자가 전혀 다르지만, 임베딩 모델을 거치면 거의 같은 방향의 벡터가 나옵니다. 이 두 벡터 사이의 거리(코사인 유사도)를 재면 "이 두 문장은 비슷하다"는 걸 수치로 알 수 있습니다.

쉽게 말하면 — BM25가 "같은 단어가 있느냐"를 보는 사전 검색이라면, 벡터 검색은 "비슷한 맥락이냐"를 보는 의미 검색입니다.

임베딩 모델: BGE-M3

어떤 모델로 벡터를 만드느냐가 검색 품질을 결정합니다. seCall에서는 BGE-M3를 선택했습니다.

  • 다국어 지원: 한국어 + 영어 혼합 텍스트에 강합니다. AI 대화는 한영이 뒤섞이는 게 일상이니 이게 중요했습니다

  • 1024 차원: 의미를 충분히 담으면서도 저장·검색 비용이 과하지 않은 사이즈

  • 오픈소스: HuggingFace에서 누구나 받을 수 있음

사실 이것도 시행착오가 있었는데, 처음에는 OpenAI의 text-embedding-3 시리즈도 써봤습니다. 품질은 좋은데 API 호출이 필요하니 대화 수천 개를 임베딩하면 돈이 나갑니다. 그리고 "로컬에서 다 해결하겠다"는 원래 취지와도 맞지 않았습니다. "클로드 MAX200은 결제하더라도 API에 충전은 하지 않는다!"라는 제 철학과도 맞지 않습니다.

임베딩을 어디서 돌릴 것인가 — 세 가지 백엔드

임베딩 모델이 정해졌으면 이걸 어디서 실행할지 결정해야 합니다. 이게 생각보다 고민이 됩니다.

1. Ollama (기본값)

로컬 LLM 런타임으로 유명한 Ollama에 임베딩 모델도 올릴 수 있습니다. ollama pull bge-m3 한 줄이면 끝. 이미 Ollama를 쓰고 있는 사람이라면 가장 편합니다. seCall의 기본값도 이겁니다.

2. ONNX Runtime (로컬 추론)

Ollama 없이도 돌아가게 하고 싶었습니다. ONNX Runtime을 쓰면 BGE-M3 모델 파일(.onnx)을 직접 로드해서 Rust 안에서 추론합니다. 최초 실행 시 HuggingFace에서 모델을 자동 다운로드(~600MB)하고, 이후에는 오프라인으로 동작합니다. Ollama 같은 별도 서비스를 안 띄워도 되는 게 장점입니다.

3. OpenAI API (클라우드)

로컬 GPU가 없거나 빠른 속도가 필요한 경우를 위해 남겨둔 옵션입니다. API 키만 넣으면 됩니다. 다만 돈이 들고, 대화를 외부 서버로 보내야 한다는 점에서 개인적으로는 비선호입니다. 탈락

사용자가 뭘 좋아할지 몰라 다 준비했어 느낌으로 그냥 모두 넣었습니다. 설정 파일 한 줄로 전환 가능하게 해뒀습니다. 이전 편의 Lindera/Kiwi 선택과 같은 패턴입니다. 개인 도구에서 이런 백엔드 교체 가능한 구조는 나름 유용합니다 — 환경이 바뀌면 설정만 고치면 되니까요.

sqlite-vec 삽질기

이전 편 끝에서 예고했던 이야기입니다. 원래 계획은 이랬습니다:

FTS5(텍스트 검색) + sqlite-vec(벡터 검색) = SQLite 하나로 끝

sqlite-vec는 SQLite 확장으로 벡터 검색을 지원하는 프로젝트입니다. 모두가 이 조합을 쓰고 있고, 개념적으로도 깔끔합니다. 그런데 막상 빌드를 돌리니 제 작고 소중한 맥북에어에서는 C 컴파일 에러가 났습니다. 찾아보니 믿고 있던 sqlite-vec가 아직 알파(0.1)라 플랫폼 호환성이 불안정했습니다.

여기서 선택지가 둘이었습니다:

  • C 빌드 이슈를 직접 파서 고친다 → 이건 좀...(직접 고치게 되면 나중에 본체가 업데이트 한다거나 하면 배보다 배꼽이 더 커지는 경우도 생깁니다)

  • 윈도우나 리눅스로 프로젝트를 옮겨서 작업한다(?)

  • 다른 방식으로 우회한다

  • 트럼프를 암살한다🚀

당연히 후자를 선택했습니다. 😇(트럼프 암살도 실패했습니다)

우회: BLOB 저장 + usearch

결국 벡터 저장은 그냥 SQLite의 일반 BLOB 컬럼에 숫자 배열을 바이트로 직렬화해서 넣었습니다. sqlite-vec의 가상 테이블 기능은 포기하되, 데이터 자체는 여전히 SQLite 한 파일에 있으니 "단일 DB" 원칙은 지킨 셈입니다.

검색 속도는 usearch라는 라이브러리로 해결했습니다. usearch는 HNSW(Hierarchical Navigable Small World)라는 알고리즘을 구현한 Rust 네이티브 라이브러리입니다.

HNSW가 뭐냐면(해커뉴스 소프트웨어 아니구요) — 벡터 수천 개에서 "가장 가까운 것"을 찾을 때, 전부 다 비교하면 느립니다. HNSW는 벡터들을 그래프로 연결해놓고, 가까운 이웃을 따라가면서 탐색하는 방식입니다. 정확도를 약간 포기하는 대신 속도를 극적으로 올리는 근사 최근접 이웃(ANN) 알고리즘입니다. 1000개 벡터라고 다 비교할 필요 없이, 수십 번 점프로 가장 비슷한 걸 찾아냅니다.

그래서 최종 구조는:

  • 벡터 저장: SQLite BLOB 컬럼 (영속)

  • 벡터 검색 인덱스: usearch HNSW 파일 (성능)

  • HNSW 인덱스가 깨지거나 오래되면 BLOB에서 전수 비교하는 폴백도 있음

sqlite-vec를 못 쓴 건 아쉽지만, 오히려 결과적으로 HNSW 인덱스를 분리한 덕분에 검색 속도는 더 빠릅니다. 삽질이 항상 나쁜 건 아니더라구요. 크게 뭔가 트레이드 오프한 느낌은 아닙니다(제발)

청킹 — 대화를 적당한 크기로 자르기

임베딩 모델에는 입력 길이 제한이 있습니다. AI 대화 한 턴이 수천 자가 넘는 경우도 흔한데, 이걸 통째로 넣으면 뒷부분의 의미가 희석됩니다.

그래서 대화를 적당한 크기의 청크(chunk)로 자릅니다. seCall에서는 약 3600자 단위로 자르되, 청크 사이에 15% 정도 겹치는 구간을 둡니다. 이 겹침(오버랩) 덕분에 잘리는 경계에서 문맥이 유실되는 걸 완화할 수 있습니다.

각 청크에는 "이 조각이 어느 세션의 몇 번째 턴인지" 메타데이터도 붙입니다. 나중에 검색 결과에서 원본 대화로 돌아갈 수 있어야 하니까요.

실제로 얼마나 걸리나

현실적인 이야기를 하면 — 임베딩은 느립니다. CPU에서 ONNX Runtime으로 BGE-M3를 돌리면 청크 하나에 수백 밀리초가 걸립니다. 대화 600개를 처리하는 데 약 40분 정도 걸렸습니다.(초기 버전에서 3기가 대화를 돌리신 어떤 사용자분은 3일 걸린다고 나왔다던...이 자리를 빌어 다시 한번 죄송합니다)

다만 이건 최초 인덱싱 때만 하는 일이고, 이후에는 새로 추가된 대화만 처리합니다. 그리고 검색 자체는 HNSW 덕분에 밀리초 단위로 끝납니다. 인덱싱은 느려도 검색은 빠른, 전형적인 쓰기 한번·읽기 여러번 패턴입니다.

실제 결과를 보면 최초 인덱싱 몇시간 해줄만 합니다. 돈이 넘치면 오퍼스나 소넷한테 위키도 만들어 달라고 그러면 악행의 자서전이 시리즈로 나옵니다

정리

기술

역할

선택 이유

BGE-M3

임베딩 모델

한영 혼합에 강한 다국어, 오픈소스

ONNX Runtime

로컬 추론

외부 서비스 없이 오프라인 동작

Ollama

기본 임베딩 백엔드

이미 쓰고 있는 사람에게 가장 편함

usearch (HNSW)

벡터 검색 인덱스

sqlite-vec 대안, Rust 네이티브, 빠름

SQLite BLOB

벡터 저장

단일 DB 파일 유지

핵심은 로컬에서, 외부 서비스 없이, 의미 기반 검색을 돌리는 것이었습니다. sqlite-vec를 못 써서 돌아갔지만, 결과적으로 BLOB + HNSW 조합이 더 유연하고 빠릅니다. 삽질도 가끔은 좋은 결과를 줍니다(제발)

다음 편에서는 이 BM25(텍스트 매칭)와 벡터(의미 매칭) 두 검색 결과를 어떻게 하나로 합치는지 — 하이브리드 검색과 RRF 알고리즘 이야기를 해보겠습니다. 관심 있으신 분들이 계시면 썰을 이어가보겠습니다.

본 포스트는 안(no)바이브 작성입니다. 안심하고 읽으셔도 됩니다 😁 맨땅에 헤딩하는게 에이전틱 개발이 아닙니다. 수많은 경험에 기반한 인간지능의 도메인지식과 견고한 마구를 씌운 인공지능의 지치지 않는 정확한 작업의 이인삼각, 대환장콜라보입니다.

로그인한 회원만 댓글 등록이 가능합니다.

개발한당

KR | ID | EN
  • IDR
  • KOR
8.36 0.01

2026.07.10 KEB 하나은행 고시회차 1036회

다가오는 한인 행사일정

  • 등록 된 일정이 없어요!