Notebook 07 · Retrieval
Notebook 06 showed BM25 and dense fail on different queries. Today we follow one real query all the way through fusion, and prove on a labeled corpus that it actually wins.
The setup
Sixteen toy documents was enough to see the two failure modes. To prove fusion actually helps, we need a corpus large enough for the wins and losses to average out honestly.
One query, carried through the whole notebook
| # | doc | text | score |
|---|---|---|---|
| 1 | 85 | "Python 3.10 introduced structural pattern matching..." | 3.920 |
| 2 | 91 | "Python 3.11 introduced fine-grained error locations..." ← gold | 3.761 |
| 3 | 52 | "Increase the application's log level to ERROR..." | 2.678 |
| # | doc | text | score |
|---|---|---|---|
| 1 | 91 | "Python 3.11 introduced fine-grained error locations..." ← gold | 0.782 |
| 2 | 81 | "Python 2.7 reached end of life in 2020..." | 0.733 |
| 3 | 85 | "Python 3.10 introduced structural pattern matching..." | 0.716 |
BM25 puts the wrong document, doc 85, in first place, most likely the same version-fragmentation issue from notebook 06, "3.11" and "3.10" share tokenized pieces. Dense gets it right. Neither one is safe to trust alone, so let's try to combine them.
The obvious first move
For our two contenders, doc 85 and doc 91 (the gold document), add BM25's score and dense's score directly.
Fix attempt 1
Min-max normalize each retriever's full score list first, using that query's own minimum and maximum across all 96 passages: BM25 ranged 0.00 to 3.920, dense ranged 0.288 to 0.782.
| doc | BM25 raw | BM25 norm | dense raw | dense norm |
|---|---|---|---|---|
| 85, wrong | 3.920 | 1.000 | 0.716 | 0.866 |
| 91, gold | 3.761 | 0.959 | 0.782 | 1.000 |
Now blend with α=0.5: final = 0.5·dense_norm + 0.5·bm25_norm
Before we call this solved
Weighted fusion worked here. But look at what it needed to work:
(s-min)/(max-min) divides by almost zero. The code has to add a tiny epsilon just to survive that case.None of this makes weighted fusion wrong. It's just extra machinery running on every single query. Is there a fix that skips all three problems at once?
Fix attempt 2
Reciprocal Rank Fusion never looks at 3.920 or 0.782. It only asks: where did this document land in each list?
k=60. The top result in a list contributes 1/60, the second contributes 1/61, and so on. A document missing from a list contributes 0 from it.
Gold wins again, this time without ever normalizing anything, without an epsilon safeguard, and without needing a single labeled example to pick a setting.
Now zoom out to all 16 queries
That α=0.5 wasn't arbitrary. Sweep it across every labeled query and measure MRR, mean reciprocal rank, how high the correct answer lands on average.
Pure sparse: 0.875. Pure dense: 0.849, actually the worse extreme here. The blend from 0.5 to 0.75 wins outright at 0.969. Neither retriever alone is the answer, the middle ground is, exactly what we just watched happen on one query, now confirmed across sixteen.
The actual proof
| Method | MRR | R@1 | R@3 | nDCG@10 |
|---|---|---|---|---|
| BM25 alone | 0.875 | 0.75 | 1.00 | 0.908 |
| Dense alone | 0.849 | 0.75 | 0.94 | 0.887 |
| Hybrid, RRF | 0.969 | 0.94 | 1.00 | 0.977 |
| Hybrid, weighted (α=0.5) | 0.969 | 0.94 | 1.00 | 0.977 |
Both fusion methods land on identical numbers in this run. R@1 is the number to feel: the gold document is the very top hit 75% of the time for a single retriever, and 94% of the time once you fuse, exactly the jump we watched happen with doc 91.
Same numbers, as a picture
Fusion works because BM25 and dense miss different queries. Combining them recovers the union of their strengths, not the average of their weaknesses.
RRF vs weighted
| RRF | Weighted convex | |
|---|---|---|
| Uses | ranks only | normalized scores |
| Tuning | just k, default 60 is robust | must tune α on a dev set |
| Score-scale safe | immune by construction | only after normalization |
| Best when | no labeled data, want a safe default | you have a dev set, want max quality |
Notebook 07 · summary
Fusion fixes recall and rough ordering. It doesn't sharpen what's exactly at the top.