All notes

검색은 일단 grep부터, 임베딩은 제값 할 때

우리 스택에서 검색은 정반대 방식 두 가지로 돌아간다. 에이전트 메모리는 그냥 파일과 grep, 손님용 검색은 하이브리드 임베딩. 둘 다 유행이 아니라 측정으로 정한 결정이다.

3 분 읽기서울과 나트랑에서 씁니다

우리 스택에서 검색은 두 군데 쓰이는데, 그 둘의 구조가 정반대로 갈렸다. 에이전트 메모리는 잘 추린 노트 수백 개라 파일과 grep으로 돌리고, 손님용 검색은 여러 언어가 섞인 레코드 수만 건이라 하이브리드 임베딩으로 돌린다. 이 경계선은 짚고 넘어갈 만하다. 업계 기본값은 둘 다 벡터 데이터베이스를 사서 해결하는 거니까.

grep이 이기는 곳

에이전트 메모리는 작고, 잘 추려져 있고, 이미 추론할 줄 아는 놈이 읽는다. 노트마다 인덱스 파일에 한 줄짜리 설명이 붙어 있다. 에이전트는 40줄짜리 인덱스를 읽고, 뭐가 관련 있는지 판단하고, 파일 두세 개를 연다. 디버깅은 에이전트가 읽은 바로 그 파일을 여는 일이다. 수정은 문단 하나를 고치는 일이다. 같은 코퍼스를 임베딩 뒤에 두고 프로토타입을 만들어 봤더니, 검색은 우리가 일일이 캐물어야 하는 확률 분포가 됐고, 업데이트하려면 다시 임베딩해야 했고, 사고가 터졌을 때 유일하게 중요한 질문에 아무도 답을 못 했다. 왜 저걸 읽었지?

임베딩이 제값을 하는 곳

한국어·영어·베트남어가 뒤섞인 레코드 4만 건을 검색하는 건 완전히 다른 짐승이다. 손님은 "강 근처 싼 스파"라고 치는데 레코드엔 "리버사이드 마사지, 저가"라고 적혀 있다. 키워드는 놓치고 벡터는 잡는다. 하지만 순수 dense 검색은 우리 자체 eval 세트에서 하이브리드에 졌고, 그래서 실제로 돌아가는 건 하이브리드다.

const kw = bm25(query, 40);              // exact terms, names, numbers
const dn = cosine(embed(query), 40);     // paraphrase, cross-lingual
const pool = dedupe([...kw, ...dn]);
const ranked = await llmRerank(query, pool.slice(0, 30));  // one cheap model call
return ranked.slice(0, 8);               // what actually enters the context

실제 검색 로그로 만든 400개 질의 라벨 세트에서 recall@20은 키워드 단독 0.71, dense 단독 0.76, rerank를 얹은 하이브리드 0.90이었다. rerank 호출은 1센트도 안 되는 비용이 드는데, 우리가 시도해 본 어떤 임베딩 모델 교체보다도 이 마지막 구간을 잘 메꿔 줬다.

품질은 사실 청킹에 있다

512토큰짜리 고정 윈도우는 표를 헤더에서, 답을 질문에서 잘라 낸다. 우리는 문서 구조, 그러니까 제목과 목록 경계를 기준으로 청킹하고, 청크마다 출처 경로와 바이트 오프셋을 달아 둔다. 그래서 모델이 인용하는 건 뭐든 원래 문장에서 클릭 한 번 거리다. 출처를 못 대는 검색 시스템은 지연만 짧은 소문 생성기다.