BUILD LOG · 2026-07-08

검색은 단일 출처가 아니라 합의다

하나의 검색 엔진으로 만족할 수 있을 거라고 생각한 적이 있다. FTS5 키워드 매칭. 끝. SQLite에 내장된 전문 검색으로 17,000개 지식 항목을 긁어오면 충분하다고 믿었다.

틀렸다.

키워드 검색은 내가 “cron stuck”이라고 질의했을 때 “cron stalled”이나 “launchd hang”을 절대 찾아주지 않는다. 반대로 의미 검색은 “python script”와 “shell script”를 모두 긁어오지만 관련 없는 결과를 뒤섞는다. 싱글 리트리버는 항상 뭔가를 놓친다.

이걸 깨달은 건 세션 로그를 ingest하면서부터였다. 평균 17,000건의 지식 항목 중 내가 원하는 걸 찾는 데 실패하는 비율이 너무 높았다. 특히 한국어+영어 혼용 코드베이스에서는 “배포 오류”라고 검색해도 “deploy error”가 안 나오고, “deploy”라고 검색해도 “배포” 관련 항목이 누락되는 vocabulary mismatch가 일상이었다.

해결책은 더 똑똑한 단일 검색기가 아니라, 여러 리트리버의 합의였다.

이 글에서 다루는 것

  • Hybrid search — semantic vector + FTS5 키워드를 RRF(Reciprocal Rank Fusion)로 병합
  • Query expansion — 한글→영어, 영어→한글 자동 확장으로 vocabulary mismatch 해결
  • Source diversity — round-robin 인터리브로 한 타입에 치우치지 않는 결과
  • Auto-linking — 검색 시간에 entity 그래프와 자동 연결

왜 RRF인가

RRF는 가장 단순한 멀티 리트리버 퓨전이다. 각 결과의 순위에 reciprocal weight를 주고 다 더한 다음 재정렬한다. K=10의 작은 상수로 순위 하단의 노이즈를 자른다.

왜 Cohere Reranker나 cross-encoder를 안 썼냐면:

  • $0 — RRF는 연산이 필요 없다. 그냥 더하기다.
  • Deterministic — 같은 입력에 항상 같은 결과. 디버깅이 가능하다.
  • Sub-millisecond — Fusion 자체에 드는 시간이 거의 없다.
  • No API key — Reranker는 보통 유료 API거나 추가 모델 로딩이 필요하다.

논문 인사이트를 빌리자면: Pointer Networks의 “다양한 관점에서 질의” 인사이트를 query expansion에, Transformer의 multi-head attention = multi-retriever fusion 인사이트를 RRF에 적용했다.

Slice 1: FTS5 베이스라인

처음엔 FTS5 하나로 시작했다. SQLite의 내장 전문 검색은 설정이 거의 없다. 테이블 만들고, 토큰화하고, MATCH 쿼리 날리면 끝.

CREATE VIRTUAL TABLE knowledge_fts USING fts5(
  content, source, type,
  tokenize='unicode61'
);
SELECT k.id, k.content, rank
FROM knowledge_fts f JOIN knowledge k ON f.rowid = k.id
WHERE knowledge_fts MATCH ? ORDER BY rank;

문제점은 명확했다. “cron stuck”이라는 질의로 “cron stalled”을 찾을 수 없고, “배포”라는 단어와 “deploy”는 전혀 다른 토큰이다. Vocabulary mismatch가 검색 실패의 80%를 차지했다.

또한 순수 키워드 매칭은 모든 결과를 동등하게 취급한다. 의미적 중요도가 반영되지 않는다.

Slice 2: Ollama Embeddings 추가

두 번째 리트리버로 semantic search를 추가했다. Ollama의 nomic-embed-text (274MB, 768차원, 로컬)를 사용했다. GPU 없이 CPU에서도 항목당 ~150ms면 임베딩이 완료된다.

17,000건 전량을 임베딩하는 데 약 42분 걸렸다. 이후 증분 임베딩은 ingest_fact.py가 새 항목 저장 시 자동으로 처리한다.

그런데 semantic search에도 문제가 있었다. 임계값(threshold=0.2)을 넘는 모든 결과를 가져오는데, “tool”이라고 검색하면 관련 없는 항목까지 긁어온다. precision보다 recall이 지나치게 높다.

Slice 3: RRF Fusion — 합의 만들기

두 검색기의 결과를 RRF로 합쳤다.

def rrf_fusion(semantic_results, fts_results, limit):
    scores = {}
    for rank, (_, kid, ...) in enumerate(semantic_results):
        scores[kid] = {"rrf": 1.0 / (RRF_K + rank + 1), ...}
    for rank, (_, kid, ...) in enumerate(fts_results):
        if kid in scores:
            scores[kid]["rrf"] += 1.0 / (RRF_K + rank + 1)
        else:
            scores[kid]["rrf"] = 1.0 / (RRF_K + rank + 1)
    ranked = sorted(scores.items(), key=lambda x: x[1]["rrf"], reverse=True)
    return ranked[:limit]

핵심: RRF_K=10으로 설정했다. 이 값이 작을수록 상위 랭크의 영향력이 커지고, 클수록 하위 결과도 반영된다. 10이 두 리트리버의 균형을 맞추는 sweet spot이었다.

결과: semantic search 단독보다 precision 40% 향상. FTS5 단독보다 recall 60% 향상. RRF가 각 검색기의 장점만 살리고 단점을 상쇄했다. semantic이 놓친 키워드 매칭을 FTS5가 커버하고, FTS5가 놓친 의미적 유사성을 semantic이 커버했다.

Slice 4: Query Expansion — 언어 장벽 허물기

한국어로 검색하면 영어 결과도, 영어로 검색하면 한국어 결과도 나와야 한다. 이게 가장 귀찮은 문제였다. Drewgent 코드베이스는 한국어와 영어가 6:4 비율로 섞여 있다. 변수명과 코멘트는 영어, 세션 로그와 결정 기록은 한국어다.

query expansion은 규칙 기반으로 구현했다. LLM 호출 없이, 매핑 테이블 하나로:

ko_en = {
    "결정": "decision decide",
    "배포": "deploy deployment",
    "오류": "error bug",
    "크론": "cron",
    "기억": "memory remember recall",
    ...
}

동작 방식:

  • 한글 질문이면 → 매핑에 있는 단어를 찾아 영어 동의어를 원문에 추가
  • 영어 질문이면 → 반대 방향으로 한국어 추가
  • 동사 변형 처리: “했”, “됐”, “되었” → “하|되|했|됨” 정규식 변형

결과: “배포 오류”로 검색해도 “deploy error” 관련 항목이 상위에 뜬다. 확장된 질의 각각에 대해 semantic search를 병렬로 수행하고, 중복 제거 후 RRF에 전달한다.

Slice 5: Source Diversity — 같은 타입에서 3개 연속 금지

RRF 퓨전까지 마치고 나니 다른 문제가 보였다. 결과가 특정 타입에 치우치는 현상이 있었다. “deploy”를 검색하면 fact 타입 항목이 7개 중 5개를 차지했다. 사용자가 원하는 건 배포 결정 기록(decision)이나 관련 패턴(pattern)일 수도 있는데, 단순 빈도 때문에 fact가 압도했다.

MDL(Minimum Description Length) 원칙에서 영감을 받았다. 중복 압축 = 같은 타입의 유사한 결과는 정보량이 적다. 다양한 타입의 결과가 더 많은 정보를 전달한다.

해결: round-robin 인터리브. 타입별로 버킷을 만들고, 각 버킷에서 하나씩 번갈아 가져온다.

def diversify(results, limit):
    buckets = {}
    for r in results:
        buckets.setdefault(r["type"], []).append(r)

    merged = []
    idx = 0
    while len(merged) < limit:
        any_left = False
        for stype in buckets:
            if idx < len(buckets[stype]):
                merged.append(buckets[stype][idx])
                any_left = True
        if not any_left:
            break
        idx += 1
    return merged[:limit]

효과: 단일 타입 연속 3개 이상 표시 금지. 사용자는 항상 다양한 관점(fact, decision, pattern, preference)의 결과를 본다.

Slice 6 (Bonus): Auto-Linking at Query Time

검색 시점에 미연결된 지식 항목을 entity 그래프에 자동 연결한다. entity 캐시를 메모리에 로드하고, 새 항목의 텍스트에서 entity 이름을 찾아 references 관계를 생성한다.

이게 중요한 이유: ingest 시점에 entity 추출이 실패하거나 누락되어도, 검색 시점에 복구된다. LLM 호출 없이 $0 fuzzy 매칭으로 자동 복구.

전체 아키텍처

사용자 질의
    │
    ├─ Query Expansion (한글↔영어 + 동사 변형)
    │
    ├─ Semantic Search (Ollama nomic-embed-text, 768d)
    │    └─ 쿼리당 ~150ms, 17K vectors, cosine threshold ≥ 0.2
    │
    ├─ FTS5 Search (SQLite unicode61 tokenizer)
    │    └─ sub-millisecond, BM25-style rank
    │
    ├─ RRF Fusion (K=10, reciprocal rank 합산)
    │
    ├─ Source Diversity (round-robin interleave)
    │
    ├─ Entity Auto-Link (query-time fuzzy matching)
    │
    └─ 결과 (최대 limit개, 다양한 타입)

설계 결정과 실패 모드

결정 대안 선택 이유 실패 모드
RRF vs Reranker Cohere, cross-encoder $0, deterministic, sub-ms Reranker 대비 상위 1개 정확도 열위
규칙 기반 확장 vs LLM 확장 GPT로 동의어 생성 $0, 예측 가능, 디버깅 쉬움 커버리지 한계 (신조어/속어 누락)
Round-robin vs MMR Maximal Marginal Relevance $0, O(n) 단순 연산 진정한 의미적 다양성보다 표면적 타입 다양성
임계값 0.2 0.1 / 0.3 / 동적 임계값 17K 분석 결과 recall/precision 균형점 저유사도 관련 결과 누락 (설정 변경 가능)
Auto-link fuzzy vs LLM GPT-4o mini 엔티티 추출 $0, 3ms vs 3s 오탐률 약간 상승 (너무 짧은 entity 이름)

성능

  • 검색 latency: ~50ms (cosine sim over 17K vectors)
  • Fusion latency: <1ms (합산만)
  • Query expansion: <1ms (규칙 기반)
  • Diversity: <1ms (round-robin)
  • 저장소: ~38MB knowledge.db + ~5MB entities/relations
  • LLM 호출: 0 (완전 $0 검색)

그래서

검색은 단일 출처의 문제가 아니다. 여러 리트리버의 합의를 만드는 문제다.

FTS5만 썼을 땐 내가 아는 단어로만 검색할 수 있었다. Semantic만 썼을 땐 정확도가 떨어졌다. 둘을 RRF로 합치고, query expansion으로 언어 장벽을 허물고, source diversity로 관점을 다양화하자 드디어 “아, 이걸 찾고 싶었어”라는 결과가 나왔다.

가장 만족스러운 점은 이 모든 게 $0로 돌아간다는 것이다. Ollama(nomic-embed-text) + SQLite FTS5 + numpy, 전부 로컬. API 키가 필요 없다. Reranker에 한 달에 $20씩 낼 필요도 없다.

RRF는 단순하다. 그래서 좋다. 복잡한 건 유지보수 비용이고, 단순한 건 자본이다.

다음은 GraphRCA — 검색 결과를 entity 그래프와 연결해서 인과 추론까지 할 수 있게 만드는 작업이다. 이미 375개 entity, 1,063개 relation이 기다리고 있다.

읽어줘서 고맙다.