Two Scorers, Two Different Failures
Run a keyword retriever and a vector retriever over the same query and they disagree — which means one of them is wrong about something specific. Knowing what each scorer is blind to is the entire design problem.
BM25 sums a per-term score over the query:
┌──► BM25 over inverted index ──► ranked K ┐
│ │
query ─────────────────┤ weighted fusion ──► top-k
│ ▲
└──► embed ──► ANN index ──► ranked V ─────┘
Three knobs, three behaviors: IDF makes a rare term outweigh the; term frequency f grows but saturates at k1, so the fifth occurrence adds little; b normalizes by document length so long documents stop winning by size alone. Under all of it sits exact token matching — a term is either in the inverted index or it is not.
The vector scorer never sees tokens. It embeds the query, probes the ANN index, and returns documents whose vectors point in a similar direction (ANN Index Types). It has no notion of spelling, so E_CONN_RESET can sit near connection reset by peer — and SKU-7741-BLK can sit near SKU-7741-RED, which is exactly wrong.
| Query | BM25 | Vector |
|---|---|---|
SKU-7741-BLK |
exact hit | may rank the red variant right behind it |
| 'ways to get my money back' | no shared terms, empty or junk results | finds the refund policy |
E_CONN_RST typo |
misses entirely | lands near the real error string |
Fusing the Two Scored Lists
The naive merge adds the scores. It never works: BM25 runs to tens or hundreds while cosine tops out at 1.0, so the keyword channel wins every query and the vector channel is decoration. Each list has to be put on a common scale first.
def normalize(scores):
lo, hi = min(scores.values()), max(scores.values())
if hi == lo:
return {k: 0.0 for k in scores} # one doc or all tied: no signal to pass on
return {k: (v - lo) / (hi - lo) for k, v in scores.items()}
def fuse(bm25, vec, alpha=0.6):
b, v = normalize(bm25), normalize(vec)
pooled = {d: alpha * b.get(d, 0.0) + (1 - alpha) * v.get(d, 0.0)
for d in set(bm25) | set(vec)}
return sorted(pooled, key=pooled.get, reverse=True)
bm25 = {'d-sku': 14.2, 'd-policy': 3.1, 'd-faq': 0.9}
vec = {'d-policy': 0.81, 'd-faq': 0.74, 'd-sku': 0.31}
print(fuse(bm25, vec))
Input: the two scored lists above, alpha=0.6 favoring keywords.
Output: ['d-sku', 'd-policy', 'd-faq'] — combined 0.600, 0.499, 0.344.
Read the numbers against the raw inputs. d-sku scored only 0.31 from the vector channel, but its BM25 score was the best in the list, so after normalization it holds 1.0 there and takes the top slot. d-policy is the vector winner at 0.81 and lands second because its keyword showing was weak. Min-max per query is what makes alpha mean anything: without it, alpha is a rounding error next to a BM25 of 14.2. Documents present in only one list get 0.0 in the other — strong single-channel hits can still surface, they just cannot pretend they were corroborated.
When score ranges are unknown or unstable, rank-based fusion removes the calibration problem entirely:
def rrf(rankings, k=60):
totals = {}
for ranking in rankings:
for rank, doc in enumerate(ranking, start=1): # ranks are 1-based
totals[doc] = totals.get(doc, 0.0) + 1 / (k + rank)
return sorted(totals, key=totals.get, reverse=True)
print(rrf([['d-sku', 'd-policy'], ['d-policy', 'd-faq', 'd-sku']]))
Output: ['d-policy', 'd-sku', 'd-faq'] — d-policy appears in both lists and wins on consistency even though d-sku holds the top keyword rank. RRF throws the scores away and uses positions, so it is immune to scale differences between channels; its cost is that score magnitude information is discarded. Fusing Multiple Retrievers covers the k constant and per-retriever logging.
Weight the Mix, Do Not Guess It
Which channel deserves the weight depends on the query, and the query mix is a property of your product:
| Query shape | Example | Channel that carries it |
|---|---|---|
| exact identifier | SKU-7741-BLK, E_CONN_RESET |
BM25 |
| paraphrase with no shared words | 'how do I get my money back' | vector |
| jargon inside prose | pgvector in a setup guide |
both — fusion's actual job |
| single common word | 'refunds' | vector narrows; BM25's IDF is noisy here |
Failure modes that show up in real hybrid stacks:
- Fusing raw scores. The BM25 channel dominates every query and the demo still looks fine, because the demo queries were keyword-shaped.
- Fusing two top-8 lists. Fusion can only merge what each retriever returned. Over-fetch — top 40 or 50 per channel — fuse, then cut to the 8 that fit the prompt.
- Analyzer mismatch on identifiers. A standard analyzer splits
SKU-7741-BLKinto pieces, so neither channel matches the string the user pasted. Keyword or ngram subfields are the fix. - Query embedded with a different model than the corpus. The vector channel returns near-random neighbors while BM25 still works, which hides the break — Embedding Models Explained covers the invariant.
- A weight tuned on paraphrase-shaped test queries. Those flatter vector search; tune
alphaon sampled production queries or you will ship a keyword-proof system that fails on part numbers. - Duplicate ids counted twice. The same document returned by both channels must be deduplicated before fusion, or double-listing reads as double evidence.
None of these can be settled by inspection. The weight is a number that moves recall@k, so it belongs on an evaluation set — labeled query-to-document pairs, recall@k and MRR computed per candidate alpha, best value shipped. That machinery, and how to know when the numbers lie, is Evaluating Embedding Quality.
Quiz
1. Why can BM25 and cosine scores not simply be added together?
2. A document appears in the BM25 list but not in the vector list. Under the fusion code above, what score does it get from the vector channel?
3. What is the main reason to prefer reciprocal rank fusion over weighted score fusion?
4. Retrieval looks strong on the test queries but production users get bad results. What is the most likely mismatch?
Flashcards
Question
What does BM25 reward that vector search does not?
Click to reveal answer
Answer
Exact rare-term matches, weighted by IDF, with term-frequency saturation and document-length normalization — which is why it nails identifiers like SKU codes and error strings.
Question
What does vector search handle that BM25 cannot?
Click to reveal answer
Answer
Paraphrase: queries that share no tokens with the answer. The match is geometric rather than lexical, so wording differences do not break it.
Question
Why normalize each channel's scores before fusing?
Click to reveal answer
Answer
BM25 and cosine live on scales that differ by orders of magnitude. Per-query min-max scaling makes a weight like alpha=0.6 mean 60/40 instead of a rounding error.
Question
How does reciprocal rank fusion combine two lists?
Click to reveal answer
Answer
Sum 1/(k + rank) per document across channels, with k around 60. It uses positions only, so no score calibration is needed and consistency across channels beats one channel's peak.
Question
Why over-fetch before fusing?
Click to reveal answer
Answer
Fusion can only merge documents each retriever actually returned. Pulling top 40 per channel before cutting to the 8 that fit the prompt keeps candidates alive that either channel alone would have dropped.
Revision Notes
Key Takeaways
- 1. BM25 and vector search fail orthogonally — exact tokens versus meaning — so both channels earn their place
- 2. Raw scores cannot be added across channels; normalize per query, or use ranks via reciprocal rank fusion
- 3. Fusion is capped by what each retriever returned, so retrieve wide and cut after merging
- 4. The mixing weight is an evaluation-set number, not a default: it moves recall@k and must be measured
Interview Tips
- • Explain BM25 from its three knobs — IDF, frequency saturation, length normalization — then name the exact failure (paraphrase) that motivates the vector channel
- • Write score fusion and RRF from memory, and justify the per-query normalization step when asked why raw addition fails
- • Say how you would tune alpha: labeled pairs, recall@k across a weight sweep, held-out production queries
- • Answer 'when is hybrid unnecessary' honestly: pure keyword for identifier-only corpora, pure vector for short paraphrase-heavy queries with no exact-match needs
Cheat Sheet
Hybrid Search — Cheat Sheet
Pipeline
query ──┬──► BM25 ──► ranked K ──┐
│ ├──► normalize each ──► alpha·K + (1-alpha)·V ──► top-k
└──► embed ──► ANN ──► ranked V ──┘
Score fusion
b = {k: (v - lo) / (hi - lo) for ...} # per query, per channel
score = alpha * b.get(d, 0.0) + (1 - alpha) * v.get(d, 0.0)
RRF: Σ 1 / (60 + rank) per channel — no calibration, rank only.
Channel guide
| Query | Winner |
|---|---|
| exact id, SKU, error code | BM25 |
| paraphrase, no shared words | vector |
| jargon in prose | fusion |
| one common word | vector; BM25 IDF is noisy |
Tuning: over-fetch 40–50 per channel, fuse, cut to k; pick alpha by recall@k over labeled production queries, not on the demo set.
Failure modes: raw-score addition · top-8 lists fused · analyzer splitting identifiers · query embedded with the wrong model · weight tuned on corpus-shaped queries · duplicate ids counted twice.
Continue Learning
Before You Start
- No prerequisites listed.