0010: Hybrid BM25 + dense 검색 + RRF 융합
0010: Hybrid BM25 + dense 검색 + RRF 융합
(원래 #159 에서 ADR 0009 로 랜딩, 먼저 랜딩한 동시 0009-external-baseline-comparison.md 와 파일시스템 충돌 해결 위해 0010 으로 renumber.)
- Status: accepted
- Date: 2026-05-11
- Related:
rag_core.py, ADR 0001, ADR 0002, issue #119
TL;DR
retrieval_backend ∈ {dense, hybrid}직교 knob 추가, defaultdense(ADR 0001 보존).hybrid는 BM25 + dense 를 RRF(k=60) 융합 — score-scale 튜닝 회피.- 희소-결정적 term(법령명·사업 코드·약칭) lexical 매치 갭 해결, 새 모델 의존성 없음.
배경
현재 검색은 dense + lexical(Jaccard 토큰 overlap + topic substring) + 메타데이터 가중(rag_core.py:1832). lexical scorer 는 set-overlap 만 — term frequency·IDF 없음 — 한국어 RFP 쿼리를 지배하는 희소-결정적 term(법령명·사업 코드·기관 약칭·사업 식별번호)의 exact match 를 under-weight 한다. dense 만으로는 hashing fallback 에서 충돌하고 MiniLM 하에서 paraphrastic 이웃에 의해 희석. ADR 0002 가 entity 축(메타데이터 우선) anchor; term 축은 미해결.
BM25 는 lexical-match 기준선의 오랜 표준; RRF(Reciprocal Rank Fusion)는 score-scale 정규화 없이 두 이종 ranking 결합. 둘 다 well-understood·deterministic·모델 의존성 없음. BGE-M3 식 학습 sparse 검색도 동일 갭을 닫지만 임베딩 모델을 동시 변경 — 이슈 risks 섹션이 지적한 confound, 자체 분석 변형으로 deferred.
결정
직교 파이프라인 knob retrieval_backend 추가, 값 {"dense", "hybrid"}, 두 프리셋 모두 default "dense". "hybrid" 시 dense cosine + BM25(index.json 의 chunk-token corpus) 양쪽으로 candidate ranking, RRF k=60 융합:
score = 1/(60 + rank_dense) + 1/(60 + rank_bm25)
기존 dense + lexical + metadata 가중 경로는 retrieval_backend == "dense" 일 때 verbatim 보존. BM25 는 rag_core.py:450 의 regex tokenizer 재사용(KoNLPy 없음, 이전 결정); BM25Okapi 인덱스는 lazy 빌드 + index 객체에 캐시(index["_bm25"]) — index.json schema 불변. 진단 필드 bm25·rank_rrf 가 기존 dense/lexical/metadata 옆에 score_parts 에 추가.
결과
Wins
- 희소-term 쿼리(사업 코드·약칭·외래어/한자 표기) lexical-match 갭 해결, 새 모델 비용 없음
retrieval_backend가retrieval_mode(flat/hierarchical, ADR 0002) +metadata_first와 직교 — 기존 6 row 옆 신규 row 로 clean ablation 가능- default
"dense"가naive_baselinebit-for-bit 보존 — ADR 0001 충족 - RRF 가 weighted fusion 의 score-scale 튜닝(dense ∈ [0,1] vs BM25 ∈ [0,∞)) 회피
Costs
- 신규 의존성
rank_bm25(pure-Python, MIT). CI + demo 환경에 pip install 1개 추가 - lazy BM25 빌드가 인덱스 첫 쿼리에 ~O(N·avg_tokens) 추가; 인덱스 객체 lifetime 동안 캐시
score_partsschema 가 키 2개 증가. 진단 필드이지 ADR 0003 답변 계약 일부 아님 —schema_versionbump 불필요
한국어 형태소 tokenizer 레이어 (ADR 0031, 통합)
ADR 0031 이 이 ADR 의 hybrid BM25 표면 위에 직교 분석 변형으로 bm25_tokenizer: "regex" | "kiwi" 추가. ADR 0031 은 여기서 Superseded; 주요 결정 아래.
결정 (ADR 0031, accepted 2026-05-13): rag_pipeline_presets.py 에 bm25_tokenizer config 키 도입. 세 프리셋 모두 "regex" default(ADR 0001 + ADR 0010 baseline byte-equality 보존). eval/config.yaml 에 신규 분석 변형 row full_kiwi — bm25_tokenizer: kiwi + retrieval_backend: hybrid. korean_lexicon.kiwi_tokens 가 kiwipiepy lazy import — import 실패 시 regex 로 fallback(never-raise contract).
재오픈 조건 (셋 모두 충족 시 kiwi 가 default):
- 공개 fixture smoke eval (n=42) 가 kiwipiepy 설치 상태로 실행, real(non-fallback)
full_kiwirow 생성 full_kiwi가hybrid_bm25대비accuracy또는citation_precision에서 ≥ +3pp 향상 + 비중첩 95% CI- install footprint(~30MB
kiwipiepy) + hard CI dep 화 여부를 문서화하는 후속 ADR
Invariant: ADR 0001 naive_baseline golden bit-identical (naive_baseline 은 retrieval_backend: dense 사용, BM25 절대 호출 X). 기존 hybrid row byte-equal.
검토한 대안
- BGE-M3 sparse 채널 — 동일 갭 닫고 multi-vector 표현 동반. 이 ADR 에서 reject — 임베딩 모델 swap 번들; BM25 기여 분리 측정 위해 별도 ablation 으로 deferred
- Weighted dense + BM25 fusion — viable 하나 이종 score scale 에 가중치 둘 선택·방어 필요. RRF 가 그 튜닝 표면 완전 제거; RRF 가 너무 coarse 증명되면 follow-up RRF-k sweep 이 가중치 재튜닝보다 저렴
- 기존 Jaccard lexical scorer 를 BM25 로 교체 — Reject:
naive_baseline동작 silent 변경(ADR 0001 위반) + BM25 효과를 preserved-baseline 계약과 분리 불가 - SPLADE / ColBERT late interaction — 범위 외; hybrid_bm25 가 여전히 측정 가능한 real-data 갭 남기면 재고
BGE-M3 ablation closeout
(2026-05-19, ADR 0058 / PR #966 / issue #957)
본 ADR 라인 59 의 “BGE-M3 sparse 채널 deferred” 는 Phase 3.5 PR #966 에서
측정됐지만, 그 산출물은 real100 private 계열에 필요한 kordoc 26k급 corpus 가
아니라 CSV text fallback 으로 생성된 898-chunk insufficient corpus
artifact 였다. 해당 report 는 retired invalid evidence 로 분류하며, 더 이상
본 ADR 의 load-bearing 근거로 사용하지 않는다. 결정은 ADR 0058
의 역사적 기록으로 남기되, future measurement 는 num_documents >= 50 인
private real100 index 에서 num_chunks <= 1000 을 guard 로 거부하고 kordoc
cache/source 기반 26k급 index 를 요구한다.
Status 는 accepted 유지 — 본 정정은 invalid measurement artifact retirement 와
guard 추가이며, runtime 동작 변경 0.
Verification
retired insufficient-corpus 산출물과 재커밋 방지 guard 를 inventory 문서와
tracked-artifact regression test 에 핀한다. 참조 파일이 keyed 값을 잃으면
scripts/_governance.py --lint-adr-consequences docs/adr/0010-hybrid-bm25-dense-retrieval-rrf.md
가 본 ADR 을 flag 한다.