Field Log · Entry
Hybrid Search: BM25와 Vector 검색을 RRF로 합치기 (6/10)
오늘의 결론
- Hybrid Search는 BM25와 dense score를 무작정 더하는 것이 아니라 후보 생성과 순위 결합을 분리합니다.
- 두 retriever의 raw score는 의미와 범위가 달라 직접 비교할 수 없습니다.
- RRF는 각 결과의 rank만 사용해 calibration 없이 강한 baseline을 만듭니다.
- fusion 전에 같은 ACL filter와 stable chunk ID를 적용하고, 충분한 candidate window를 확보합니다.
- hybrid가 항상 이기는 것은 아니므로 sparse·dense·fusion을 같은 query bucket에서 비교합니다.
앞 글까지 두 종류의 검색기를 만들었습니다.
- BM25:
E104, 제품명, API field처럼 정확한 term에 강함 - Dense: 같은 단어가 없어도 의도가 비슷한 paraphrase에 강함
둘의 실패가 다르다면 한쪽을 버리는 대신 후보를 합칠 수 있습니다. 이것이 hybrid retrieval의 기본 아이디어입니다.
그림 1. sparse와 dense 검색을 같은 권한 범위에서 병렬 실행하고, raw score가 아니라 각 list의 rank로 RRF를 계산한다. 최종 top-k 전에는 stable ID dedup과 trace를 보존한다.
Hybrid Search의 두 단계
한 문장으로 “BM25 + vector search”라고 부르지만 실제로는 두 단계입니다.
1. Candidate generation
BM25 top-Ns + Dense top-Nd
2. Fusion / ranking
중복 chunk 병합 → fusion score → final top-k
candidate generation의 목적은 gold evidence를 넓게 포함하는 것입니다. fusion의 목적은 그 후보를 좋은 순서로 배치하는 것입니다.
여기에 reranker를 붙이면 세 단계가 됩니다.
sparse N + dense N
→ fusion top-M
→ expensive reranker
→ final top-k
이번 글은 두 번째 단계까지 다룹니다.
두 검색기가 상호 보완적인 이유
BM25가 먼저 찾는 예
query: ERR_CONN_104 해결
document: 오류 코드 ERR_CONN_104의 재시도 설정
희귀 identifier의 IDF가 크고 exact match가 분명합니다.
Dense가 먼저 찾는 예
query: 계정에 다시 들어가는 방법
document: 사용자 인증 복구 및 비밀번호 재설정 절차
공통 term이 적어도 vector 공간에서 가까울 수 있습니다.
둘 다 혼동하는 예
query: E104 연결 시간 초과
BM25 hard negative: E104 인증 만료
Dense hard negative: E140 연결 시간 초과
한 문서는 code만 같고, 다른 문서는 의미만 비슷합니다. 두 신호를 결합하거나 뒤에서 query-document interaction을 보는 reranker가 필요합니다.
Raw score를 바로 더하면 안 된다
다음 score는 같은 단위가 아닙니다.
BM25 scores : [12.8, 8.4, 3.1]
Cosine : [0.84, 0.81, 0.76]
BM25 score 범위는 query term 수, IDF, document 길이에 따라 query마다 달라집니다. cosine score도 embedding model과 corpus에 따라 분포가 다릅니다.
bad_score = bm25_score + cosine_score
위 식에서는 숫자가 큰 BM25가 사실상 모든 순위를 지배할 수 있습니다. score scale이 바뀌면 동일한 weight도 의미가 달라집니다.
RRF는 rank만 사용한다
Reciprocal Rank Fusion(RRF)은 여러 ranked list에서 문서가 몇 위인지 사용합니다.
RRF(d) = Σ 1 / (k + rankᵣ(d))
r∈retrievers
rankᵣ(d): retrieverr에서 documentd의 1-based rankk: 상위 rank 간 차이를 완화하는 상수- list에 없는 document는 그 retriever에서 0 기여
여기서 k는 final top-k의 k와 다른 rank constant입니다. 원 RRF 논문과 여러 구현에서 60이 자주 쓰이지만 evaluation으로 확인합니다.
작은 RRF를 직접 계산한다
두 검색 결과가 다음과 같다고 합시다.
BM25 : A(1), B(2), C(3), E(4)
Dense : C(1), D(2), A(3), F(4)
rank constant = 60
각 후보 점수는 다음과 같습니다.
A = 1/(60+1) + 1/(60+3) ≈ 0.03226
C = 1/(60+3) + 1/(60+1) ≈ 0.03226
B = 1/(60+2) ≈ 0.01613
D = 1/(60+2) ≈ 0.01613
E = 1/(60+4) ≈ 0.01563
F = 1/(60+4) ≈ 0.01563
A와 C는 두 list에 모두 있어 한쪽에서만 높은 B와 D보다 유리합니다. A와 C처럼 동점일 때는 다음 tie-break를 명시합니다.
- 더 많은 retriever에서 등장
- best rank가 더 높음
- stable chunk ID의 lexical order
마지막 규칙은 relevance가 아니라 결과를 재현 가능하게 만드는 결정적 정렬입니다.
Python으로 RRF 구현하기
검색기별 raw 결과를 공통 계약으로 변환합니다.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Candidate:
chunk_id: str
rank: int
raw_score: float
retriever: str
@dataclass
class FusedCandidate:
chunk_id: str
rrf_score: float = 0.0
ranks: dict[str, int] = field(default_factory=dict)
raw_scores: dict[str, float] = field(default_factory=dict)
def reciprocal_rank_fusion(
rankings: dict[str, list[Candidate]],
rank_constant: int = 60,
weights: dict[str, float] | None = None,
) -> list[FusedCandidate]:
if rank_constant < 0:
raise ValueError("rank_constant must be non-negative")
weights = weights or {name: 1.0 for name in rankings}
fused: dict[str, FusedCandidate] = {}
for retriever, candidates in rankings.items():
weight = weights.get(retriever, 1.0)
seen_in_list = set()
for expected_rank, candidate in enumerate(candidates, start=1):
if candidate.chunk_id in seen_in_list:
raise ValueError(
f"duplicate {candidate.chunk_id} in {retriever} list"
)
seen_in_list.add(candidate.chunk_id)
# 전달된 rank를 믿기보다 list 순서로 연속 rank를 다시 부여
rank = expected_rank
item = fused.setdefault(
candidate.chunk_id,
FusedCandidate(chunk_id=candidate.chunk_id),
)
item.rrf_score += weight / (rank_constant + rank)
item.ranks[retriever] = rank
item.raw_scores[retriever] = candidate.raw_score
return sorted(
fused.values(),
key=lambda item: (
-item.rrf_score,
-len(item.ranks),
min(item.ranks.values()),
item.chunk_id,
),
)
raw score는 fusion 계산에는 쓰지 않지만 trace와 debugging을 위해 보존합니다.
rankings = {
"bm25": [
Candidate("A", 1, 12.8, "bm25"),
Candidate("B", 2, 8.4, "bm25"),
Candidate("C", 3, 3.1, "bm25"),
],
"dense": [
Candidate("C", 1, 0.84, "dense"),
Candidate("D", 2, 0.81, "dense"),
Candidate("A", 3, 0.76, "dense"),
],
}
for item in reciprocal_rank_fusion(rankings)[:5]:
print(item.chunk_id, item.rrf_score, item.ranks)
Stable ID가 fusion의 join key다
같은 chunk가 BM25와 vector index에서 다른 ID를 가지면 중복으로 취급됩니다.
BM25 ID : doc-17#chunk-4
Dense ID : vector-row-9831
fusion layer에서는 둘 다 canonical chunk_id로 변환해야 합니다.
source_id + revision_id + chunker_version + source span
→ stable chunk_id
vector row ID와 search engine internal doc ID는 manifest를 통해 canonical ID로 mapping합니다.
같은 text의 중복 문서까지 합칠지는 별도 정책입니다. canonical chunk ID dedup과 near-duplicate content dedup을 구분합니다.
Candidate window가 recall 상한을 만든다
final top-5를 원한다고 각 retriever에서 5개만 가져오면 fusion이 순위를 바꿀 후보가 부족합니다.
sparse top-50 + dense top-50
→ union 최대 100
→ RRF top-30
→ rerank top-30
→ final top-5
rank_window_size 또는 candidate depth를 키우면 gold evidence recall이 오를 수 있지만 다음 비용이 늘어납니다.
- sparse/dense retrieval latency
- network payload
- fusion과 dedup CPU
- reranker pair 수
- trace 크기
candidate depth sweep을 합니다.
N ∈ {10, 20, 50, 100, 200}
측정: union Task Recall@N, fusion nDCG@10, latency, candidate count
gold가 union에 없으면 fusion 식은 정답을 만들 수 없습니다.
RRF rank constant의 의미
k가 작으면 최상위 rank 차이가 크게 반영됩니다.
k = 0 : rank 1 기여 1.0, rank 2 기여 0.5
k = 60 : rank 1 기여 0.01639, rank 2 기여 0.01613
k가 크면 상위 list 안에서 rank 차이는 완만해지고 여러 retriever에 등장한 효과가 상대적으로 중요해집니다.
원 논문의 baseline 값을 그대로 복사할 수는 있지만, 2022년 fusion 분석 연구는 RRF가 parameter와 조건에 민감할 수 있고, label이 있다면 convex combination이 더 나을 수 있음을 보였습니다.
따라서 다음을 함께 sweep합니다.
rank_constant ∈ {10, 20, 60, 100}
window_size ∈ {20, 50, 100}
weights ∈ {(1,1), (2,1), (1,2)}
test set을 tuning에 재사용하지 않고 train/dev/test 또는 cross-validation을 사용합니다.
Weighted RRF
retriever별 weight를 줄 수 있습니다.
score(d) = w_bm25 / (k + rank_bm25(d))
+ w_dense / (k + rank_dense(d))
exact identifier query가 많은 domain에서는 BM25 weight가 유리할 수 있습니다. 그러나 query마다 heuristic으로 weight를 바꾸기 시작하면 routing system이 됩니다.
먼저 고정 weight baseline을 평가하고, 동적 routing은 query classifier의 오류와 fallback까지 별도 평가합니다.
Calibrated score fusion
relevance label이 충분하면 raw score를 비교 가능한 값으로 calibration한 뒤 convex combination할 수 있습니다.
fused(d) = α × calibrated_sparse(d)
+ (1-α) × calibrated_dense(d)
calibration 방법 예:
- global z-score 또는 robust scaling
- query feature를 포함한 logistic calibration
- isotonic regression
- learning-to-rank model
query별 min-max normalization은 간단하지만 outlier와 candidate window에 민감합니다.
한 query에서 top score 하나가 매우 크면 나머지가 0 근처로 압축
candidate 수를 바꾸면 min/max도 바뀜
score fusion을 쓰려면 다음을 versioning합니다.
{
"fusion": "convex-combination",
"alpha": 0.42,
"sparse_calibrator": "isotonic-v3",
"dense_calibrator": "isotonic-v5",
"trained_on": "relevance-set-2026-06",
"candidate_window": 100
}
label이 적을 때 RRF는 좋은 시작점입니다. label이 쌓이면 RRF와 학습된 fusion을 같은 holdout에서 비교합니다.
ACL과 filter는 두 branch에서 같아야 한다
BM25: tenant=acme, acl=employee
Dense: tenant=all, no ACL ← 심각한 오류
fusion 뒤 unauthorized candidate를 제거하는 것으로 충분하지 않습니다. dense branch의 graph 탐색, trace, cache에 이미 노출될 수 있습니다.
공통 retrieval request를 만듭니다.
@dataclass(frozen=True)
class RetrievalRequest:
query: str
tenant_id: str
principal_ids: tuple[str, ...]
as_of: str | None
language: str | None
candidate_k: int
두 adapter가 같은 request의 filter contract를 구현하는지 integration test로 확인합니다.
병렬 실행과 deadline
두 retriever를 순차 실행하면 latency가 합산됩니다.
sequential ≈ sparse 25ms + dense 40ms = 65ms
parallel ≈ max(25ms, 40ms) + fusion overhead
실제 latency에는 connection pool, queue, timeout이 포함됩니다. 공통 deadline을 전달합니다.
import asyncio
async def hybrid_candidates(request: RetrievalRequest):
sparse_task = asyncio.create_task(sparse_search(request))
dense_task = asyncio.create_task(dense_search(request))
sparse, dense = await asyncio.gather(sparse_task, dense_task)
return reciprocal_rank_fusion({
"bm25": sparse,
"dense": dense,
})
production에서는 timeout과 부분 실패 정책을 명시합니다.
선택 가능한 정책
- fail closed: 한 branch 실패 시 전체 실패
- degrade: 성공한 branch만 반환하고 degraded flag 기록
- deadline budget: 정해진 시간까지 온 branch만 fusion
권한 filter 적용 여부를 확인할 수 없는 실패는 degrade하면 안 됩니다. availability와 correctness boundary를 구분합니다.
{
"retrieval_status": "degraded_dense_timeout",
"bm25_ms": 24,
"dense_ms": 80,
"deadline_ms": 75,
"fusion": "single_branch_fallback",
"result_count": 20
}
Parent와 duplicate를 언제 합칠까
overlap chunk 때문에 다음이 top에 함께 올 수 있습니다.
chunk A: "... 비밀번호 재설정 절차는 ..."
chunk B: "비밀번호 재설정 절차는 ... 복구 이메일 ..."
fusion 전에는 canonical chunk ID 중복만 제거합니다. fusion 후에는 다음 단계로 다양성을 관리할 수 있습니다.
- 동일 parent에서 최대
m개 - 원문 span overlap threshold
- near-duplicate text hash
- MMR 기반 relevance-diversity 선택
너무 일찍 content dedup하면 서로 다른 source가 같은 사실을 지지하는 신호를 잃을 수 있습니다. source diversity와 duplicate를 구분합니다.
Fusion trace를 읽을 수 있게 만든다
{
"chunk_id": "chunk-A",
"fusion_rank": 1,
"rrf_score": 0.03226,
"contributions": {
"bm25": {"rank": 1, "raw_score": 12.8, "rrf": 0.01639},
"dense": {"rank": 3, "raw_score": 0.76, "rrf": 0.01587}
},
"filters": {"tenant_id": "acme", "acl": "employee"},
"sparse_index_version": "bm25-v8",
"dense_index_version": "hnsw-v7",
"fusion_version": "rrf-k60-w100-v2"
}
“hybrid가 더 좋았다”가 아니라 어떤 branch가 어떤 후보를 공급하고 fusion이 무엇을 올렸는지 확인합니다.
평가 matrix
같은 candidate chunk와 query set으로 네 run을 만듭니다.
A: BM25 only
B: Dense only
C: Union oracle — 두 list의 gold 포함 여부만 확인
D: RRF fusion
E: Calibrated score fusion (label이 있을 때)
단계별 질문
union recall이 max(A, B)보다 높은가?- RRF가 union의 gold를 final top-k로 올리는가?
- exact identifier bucket이 dense 추가로 regression하지 않는가?
- paraphrase bucket이 BM25 추가로 regression하지 않는가?
- latency와 timeout이 budget 안인가?
| metric | 의미 |
|---|---|
| branch Recall@N | 각 retriever의 후보 생성력 |
| union Recall@N | fusion이 가질 수 있는 recall 상한 |
| MRR/nDCG@k | gold의 최종 순위 품질 |
| unique parent/source | 후보 다양성 |
| p95 latency | 병렬·timeout 포함 운영 비용 |
| degraded rate | branch 실패 빈도 |
Hybrid가 개선되지 않는 경우
두 branch가 거의 같은 후보를 반환
상호 보완성이 작아 union recall이 오르지 않습니다.
한 branch 품질이 매우 낮음
나쁜 후보가 fusion에서 올라와 precision을 낮춥니다.
Candidate window가 너무 작음
각 branch의 gold가 잘리면 fusion할 수 없습니다.
ID mapping이 다름
같은 chunk가 두 후보로 중복되어 양쪽 등장 보너스를 받지 못합니다.
RRF parameter가 query 분포와 안 맞음
최상위 exact match보다 두 list 중간에 있는 generic passage가 과도하게 올라올 수 있습니다.
평가 label이 불완전함
실제로 관련 있는 추가 passage를 false positive로 계산할 수 있습니다.
추천 baseline
처음에는 다음 구성으로 시작합니다.
BM25 top-50 + Dense exact/ANN top-50
→ canonical chunk ID union
→ RRF rank_constant=60
→ fusion top-20
→ 동일 parent 최대 2개
→ LLM context top-5 또는 reranker top-20
숫자는 시작점입니다. union recall과 latency를 보고 candidate depth를 먼저 정하고, 그다음 rank constant와 weight를 조정합니다.
Production 체크리스트
- BM25와 dense가 서로 다른 query bucket을 보완하는지 확인했다.
- raw score를 calibration 없이 직접 더하지 않는다.
- canonical stable chunk ID로 join·dedup한다.
- 두 branch가 동일한 tenant·ACL·time filter를 사용한다.
- candidate window와 final k를 구분한다.
- RRF constant·window·weight sweep을 holdout에서 평가했다.
- sparse/dense를 병렬 실행하고 공통 deadline이 있다.
- branch timeout의 degrade/fail 정책이 명시돼 있다.
- branch별 rank·raw score·fusion contribution을 trace한다.
- union Recall과 final nDCG를 분리한다.
스스로 확인하기
- BM25 score 12와 cosine 0.8을 직접 더하면 안 되는 이유는 무엇인가?
- RRF에서 rank constant가 커질수록 상위 rank 차이는 어떻게 변하는가?
- final top-5인데 branch별 top-5만 가져오면 왜 fusion 상한이 낮아질 수 있는가?
- union recall은 높은데 RRF nDCG가 낮다면 어느 단계를 고쳐야 하는가?
- dense branch timeout 때 결과를 degrade할 수 없는 보안 조건은 무엇인가?
다음 글에서는 fusion이 만든 소수 후보를 query와 함께 깊게 읽는 Cross-Encoder·monoT5·ColBERT Reranking을 비교합니다.
참고자료
- Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods
- An Analysis of Fusion Functions for Hybrid Retrieval
- Elasticsearch Hybrid Search Documentation
- Elasticsearch Reciprocal Rank Fusion
- BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models