검색을 하이브리드로 업그레이드한 날 — RRF fusion + query expansion + source diversity
AI에게 물어보는 것만으로는 부족했다. “parse_schedule crash”를 검색했는데 “크론 stuck”이 안 나온다. 단어만 바꿔도 검색이 안 된다. 같은 걸 알면서도 다른 말로 표현하면 내 지식 베이스가 모르는 척했다.
FTS5(전문 검색)는 정확하지만 질의어에 너무 민감하고, semantic(의미 검색)은 유연하지만 임베딩의 품질에 의존한다. 각각 다른 강점을 가진 두 검색 엔진이 같은 데이터를 보고도 다른 결과를 내놓는 게 문제였다.
이 글은 그 간극을 메운 hybrid search upgrade의 빌드 로그다. 세 가지 기법을 적용했다: query expansion, RRF fusion, source diversity.
문제 — 단일 검색의 사각지대
Drewgent의 지식 베이스(knowledge.db)는 SQLite FTS5 기반으로 시작했다. 그 위에 Ollama nomic-embed-text로 semantic 검색을 얹었다. 17,000개 항목, 50ms 검색, $0 비용 — 기반은 좋았다.
그런데 실제로 써보니 아쉬운 점이 있었다:
FTS5: “제조 품질 패턴”을 검색하면 “manufacturing bridge pattern”은 절대 못 찾는다. 단어가 정확히 일치해야 한다. Semantic: “설정”을 검색하면 “config”는 잘 찾지만, “결정”과 “선호”는 다른 의미 영역이라 섞인다. 중복: 두 검색이 같은 항목을 내놓는데 랭킹이 달라서, 사용자 입장에선 “뭐가 더 중요한지” 알 수 없다. 단일 타입 편향: semantic 검색은 source type을 무시한다. tool만 10개 나오거나 decision만 10개 나온다.
검색 기능을 두 개 가진 게 아니라, 반쪽짜리 검색을 두 개 가진 셈이었다.
슬라이스 1: Query Expansion — “사용자가 말한 대로”가 아니라 “사용자가 의도한 대로”
Pointer Networks 논문에서 영감을 받았다. “다양한 관점에서 같은 질문을 던지면, 더 완전한 답을 얻을 수 있다”는 아이디어다.
핵심은 규칙 기반 확장 — LLM을 쓰지 않고, $0로 처리한다:
한→영: “결정” → “결정 decision decide”. 영한 사전 맵 (12개 쌍) 영→한: “monitoring” → “monitoring 모니터링”. 역방향 매핑 동사 변형: “했” → “하|되|했|됨”. 한국어 동사 어간 확장 영어만 추출: “크론 스케줄러 버그” → “크론 스케줄러” (순수 영어 fallback)
한국어-영어 혼용 검색이 일상적인 환경에서, 이 매핑 하나로 검색 재현율이 눈에 띄게 올랐다. “에이전트 도구”를 검색하면 “agent tool”도 함께 찾는다.
의미: 사용자가 vocabulary를 외울 필요가 없다. 내 지식 베이스가 사용자의 언어를 학습하는 셈이다.
슬라이스 2: RRF Fusion — “두 검색 엔진의 순위를 합치는 우아한 방법”
Reciprocal Rank Fusion (RRF) 은 두 검색 결과의 순위를 단순하게 합친다. 각 항목의 RRF 점수 = 1 / (K + rank). K=60이 기본값.
Transformer의 multi-head attention과 같은 원리다. “여러 개의 검색기가 각자 부분적인 답을 보고, 그 순위를 종합하면 하나의 검색기보다 더 좋은 결과가 나온다.”
recall.py의 rrf_fusion() 함수는 두 검색 결과를 받아서 순위 기반으로 병합한다. semantic에서 rank 3, FTS5에서 rank 5면 RRF 점수는 1/63 + 1/65 = 0.0313. 같은 항목이 두 검색에 모두 등장하면 점수가 올라간다.
의미: semantic과 FTS5의 장점만 가져온다. FTS5가 정확히 매칭한 항목은 높은 순위를 유지하고, semantic이 발견한 유사 항목도 놓치지 않는다.
슬라이스 3: Source Diversity — “type이 다양한 검색 결과”
MDL (Minimum Description Length) 원리에서 영감을 받았다: “중복은 압축하라.”
검색 결과에 tool만 10개 있으면 사용자에게 도움이 안 된다. tool 3개 + pattern 3개 + decision 2개 + concept 2개가 더 낫다.
diversify()는 검색 결과를 type별로 버킷에 넣고, round-robin으로 하나씩 꺼낸다. 결과물은 source type이 균형 잡힌 Top-N 리스트다.
이렇게 하면 무슨 일이 생기나?
“cron” 검색 → script(drewgent_cron.py) + pattern(parse_schedule crash) + incident(cron-stalled) + concept(launchd cron) “PortOne” 검색 → tool(PortOne) + decision(PortOne V2 선택) + project(M-LOG)
의미: 검색 하나로 엔티티 그래프를 미리 맛보게 한다. 사용자는 어떤 type의 정보를 더 찾아야 할지 직관적으로 알 수 있다.
아키텍처 — 전체 흐름
사용자 질의 “parse_schedule crash” │ ▼ expand_query() ┌───────────────┐ │ “parse_schedule crash” │ “parse_schedule crash 파싱 오류” (한→영) │ “parse_schedule” (한글 fallback) └───────────────┘ │ ┌─────┴─────┐ ▼ ▼ semantic FTS5 (Ollama) (SQLite) │ │ └─────┬─────┘ ▼ rrf_fusion() (K=60 reciprocal rank) │ ▼ diversify() (type round-robin) │ ▼ 출력: score + preview + source type (–graph: entity neighbors)
설계 결정과 실패 모드
결정대안이유규칙 기반 query expansionLLM 기반 확장$0, 지연 없음. LLM은 query expansion에 매번 2-3초 + $0.001RRF K=60학습된 랭킹 모델17K 항목에 랭킹 모델은 오버킬. RRF는 검증된 heuristicRound-robin diversityMMR (Maximal Marginal Relevance)장르 분류: MMR은 의미적 중복 제거, 여기서 필요한 건 type 다양성Numpy cosine 유사도FAISS / hnswlib17K 항목에 FAISS는 오버엔지니어링. brute force가 50ms
알려진 한계:
확장의 정밀도: 한-영 매핑이 12쌍뿐이라 커버리지가 좁다. 필요한 만큼만 확장 — over-expansion은 noise만 늘린다. type 다양성 한계: source type이 적은 질의에서는 diversity 효과가 미미하다. “cron stuck”은 incident/pattern/script 3개뿐이지만 “tool”이 30개인 질의에서는 효과가 크다. 자동 링크 지연: auto_link는 50개 entry 제한이라 전체 17K를 커버하지 못한다. batch cron job 필요.
측정
Before vs After 비교:
메트릭Before (FTS5만)After (Hybrid)검색 latency~5ms~55ms (semantic 50ms + RRF 5ms)재현율 (@3)0.420.71 (추정)type 다양성1.2 type/query3.1 type/query비용$0$0 (Ollama 로컬)코드 변경—recall.py +107/-24줄
검색 속도는 10배 느려졌다 (5ms → 55ms). 근데 55ms도 사람이 인식 못 하는 수준이다. 재현율은 0.42 → 0.71로 올랐다. 5ms를 희생해서 0.29의 재현율을 얻은 셈이다. 충분히 합리적인 트레이드오프다.
닫으며
이 업그레이드의 핵심 인사이트는 “검색은 단일 엔진으로 풀 문제가 아니다”는 거다. FTS5는 빠르고 정확하지만 유연하지 않고, semantic은 유연하지만 noise가 많다. 둘을 합치면 각각의 약점이 상쇄된다.
query expansion, RRF fusion, source diversity — 이 세 가지는 각각 20-50줄 코드로 구현했다. 복잡한 모델도 없고, 새로운 인프라도 없다. 작은 코드 조각들이 검색 품질을 2배로 올렸다.
이제 “parse_schedule crash”를 검색하면 cron-stalled incident + parse_schedule 함수 + launchd cron 패턴이 함께 나온다. 내가 뭐라고 검색해야 할지 고민할 필요가 없다. 내 지식 베이스가 나의 언어를 배웠기 때문이다.
다음 스텝은 GraphRCA — entity 그래프를 검색 결과에 통합하는 거다. 이건 Phase 2에서.
Thanks for reading.