Two Retrievers, Two Different Right Answers
A billing assistant searched for duplicate charge on invoice INV-88213. Keyword search returned the passage quoting the invoice number first — BM25 needs the term overlap it has. Vector search returned the policy paragraph "charged twice for the same invoice, credited within five days" first — the paraphrase shares no terms with the query but every meaning.
Each retriever is right about something the other misses:
| Candidate | Keyword rank | Vector rank | Why it lands there |
|---|---|---|---|
clause quoting INV-88213 verbatim |
1 | 4 | exact term match, generic phrasing |
| policy paragraph, no invoice id | 4 | 1 | semantic match, zero term overlap |
The scores cannot be averaged. BM25 returns unbounded idf-weighted sums whose magnitude depends on corpus statistics; cosine similarity lives in a bounded range. Averaging numbers from those two scales is arithmetic without meaning — and the two retrievers do not even agree on how to spend their top ranks.
query --> [keyword search] --> ranked list A --+
| +--> [ fusion ] --> final order --> rerank / prompt
+--> [vector search] --> ranked list B --+
Reciprocal rank fusion sidesteps the scale problem by throwing the scores away. Keep each document's position in each list, convert position to 1 / (k + rank), and sum across lists. Ranks are comparable across any two retrievers by construction — both are just "how high up did you put it" — which is why fusion needs no calibration step at all. The disagreement itself is the asset: Hybrid Search Basics covers why the two channels complement each other, and Reranking with Cross-Encoders covers what to do with the fused list afterwards.
Reciprocal Rank Fusion by Hand
The formula has one input per list — the document's 1-based rank — and one constant:
RRF(d) = sum over lists 1 / (k + rank_of_d_in_that_list) k is usually 60
A document missing from a list contributes nothing; there is no penalty and no credit for absence.
from collections import defaultdict
def rrf(rankings: list[list[str]], k: int = 60) -> dict:
scores = defaultdict(float)
for ranked in rankings: # each list ordered best first
for rank, doc in enumerate(ranked, start=1): # 1-based: the best document is rank 1
scores[doc] += 1 / (k + rank)
return dict(sorted(scores.items(), key=lambda kv: kv[1], reverse=True))
keyword = ["d7", "d2", "d9", "d4"]
vector = ["d21", "d7", "d2", "d4"]
print(list(rrf([keyword, vector])))
Output: ['d7', 'd2', 'd4', 'd21', 'd9']
Work the arithmetic for each document once:
| doc | keyword rank | vector rank | contribution | RRF score |
|---|---|---|---|---|
| d7 | 1 | 2 | 1/61 + 1/62 | 0.03252 |
| d2 | 2 | 3 | 1/62 + 1/63 | 0.03200 |
| d4 | 4 | 4 | 1/64 + 1/64 | 0.03125 |
| d21 | — | 1 | 1/61 | 0.01639 |
| d9 | 3 | — | 1/63 | 0.01587 |
Two readings matter more than the totals. d4 sits mid-list in both searches and beats d21, which was first in one of them — appearing consistently across retrievers outweighs appearing brilliantly once, and that is exactly what the 1/(k + rank) shape encodes. d7 wins by being near the top twice, not by any single great position. Note also what never enters the sum: the magnitude of a BM25 score, the size of a cosine, and any need to decide whether 12.4 "means the same as" 0.71.
start=1 in the loop is the line to defend in review. Zero-based ranks would make the top document worth 1/k instead of 1/(k+1), and every list would gain a phantom half-rank of credit that shifts scores as list length changes.
Tuning k and Logging Who Caused the Rank
k is not a smoothing detail — it decides which kind of document wins. Run the same two lists with k=1:
| doc | score (k=60) | order | score (k=1) | order |
|---|---|---|---|---|
| d7 | 0.03252 | 1 | 0.833 | 1 |
| d2 | 0.03200 | 2 | 0.583 | 2 |
| d4 | 0.03125 | 3 | 0.400 | 4 |
| d21 | 0.01639 | 4 | 0.500 | 3 |
| d9 | 0.01587 | 5 | 0.250 | 5 |
With k=1, d21's single rank-1 appearance outweighs d4's two rank-4s and the order flips. A small k rewards one-list champions; a large k flattens contributions toward "how many lists included it". Neither is correct in the abstract — pick the value that scores best on your labeled queries and re-check it when you add or remove a retriever. 60 is a starting default from the original paper, not a constant of nature.
Fusion is also only as debuggable as the record you keep. Store the per-list rank beside the fused score:
debug = {"doc": "d4", "rrf": 0.03125, "ranks": {"keyword": 4, "vector": 4}}
When the final answer is wrong, that line tells you whether the keyword channel or the vector channel carried the passage — the difference between tuning a retriever and tuning blind.
Failure modes worth checking on every fusion deployment:
- Fusing near-identical lists — two retrievers returning the same order add no coverage; measure list overlap before claiming a fusion win (Hybrid Search Basics).
- Thresholding fused scores —
1/(k + rank)sums are not probabilities; they move wheneverkor the number of lists changes. - Unequal list lengths — absence contributes zero, which is fine, but a list truncated at 5 has far less influence over the outcome than one that runs to 30.
- Fusion as a repair for a broken retriever — a noisy list pushes its documents into the middle of the fused order; fusion merges rankings, it does not clean them.
- No provenance in the logs — without per-retriever ranks, a wrong fused answer cannot be attributed to a channel.
Quiz
1. Why does reciprocal rank fusion discard retriever scores instead of averaging them?
2. keyword = [d7, d2, d9, d4], vector = [d21, d7, d2, d4], k = 60. What is the fused order?
3. Re-running the same two lists with k = 1 instead of k = 60 changes what?
4. When does adding a second retriever through fusion improve nothing?
Flashcards
Question
What is the reciprocal rank fusion formula?
Click to reveal answer
Answer
RRF(d) = sum over lists of 1 / (k + rank_of_d), with 1-based ranks and k usually 60. A document missing from a list contributes nothing.
Question
Why does RRF need no score calibration?
Click to reveal answer
Answer
It discards scores entirely. Rank position means the same thing for BM25, cosine similarity, or any other retriever, so lists with incompatible score scales combine without normalization.
Question
What does the k constant control?
Click to reveal answer
Answer
How much the top ranks dominate. Small k lets a single rank-1 appearance beat consistent mid-list placement; large k flattens contributions toward list membership. Tune it on labeled queries; 60 is a starting default.
Question
What behavior does fusion reward?
Click to reveal answer
Answer
Consistent high placement across lists. A document ranked 4th in both searches outscores one ranked 1st in a single list, and documents found by only one retriever still enter the ranking rather than disappearing.
Question
Why log per-retriever contributions?
Click to reveal answer
Answer
Store each document's rank in each list next to its fused score. When the answer is wrong, that record shows which channel carried the passage — without it, tuning fusion is guesswork.
Revision Notes
Key Takeaways
- 1. RRF sums 1/(k + rank) per document across lists, which makes rank — not score — the currency of the merge
- 2. Ranks are comparable across any two retrievers, so fusion needs no calibration between incompatible score scales
- 3. Consistency across lists beats a single top position; k decides how strongly that tradeoff tips
- 4. Log each document's per-list rank, or a wrong fused answer cannot be attributed to a channel
Interview Tips
- • Do one fusion by hand on the board — two lists of four, k=60 — and say out loud why the mid-list document beats the single rank-1 hit
- • Explain what k changes with numbers from both settings, then say how you would choose it rather than quoting 60 as a rule
- • State the calibration argument: BM25 and cosine are not commensurable, and rank is the only quantity they share
- • Bring provenance: what you log per document so a bad answer shows whether keyword or vector carried it
Cheat Sheet
Reciprocal Rank Fusion — Cheat Sheet
Formula
RRF(d) = Σ_lists 1 / (k + rank(d)) ranks are 1-based; absent document = 0
k = 60 typical; small k rewards rank-1 hits, large k rewards list membership
Worked (k=60): keyword [d7,d2,d9,d4], vector [d21,d7,d2,d4]
| doc | keyword | vector | score | place |
|---|---|---|---|---|
| d7 | 1 | 2 | 1/61+1/62 = 0.03252 | 1 |
| d2 | 2 | 3 | 1/62+1/63 = 0.03200 | 2 |
| d4 | 4 | 4 | 2/64 = 0.03125 | 3 |
| d21 | — | 1 | 1/61 = 0.01639 | 4 |
| d9 | 3 | — | 1/63 = 0.01587 | 5 |
Same lists at k=1: d7 0.833 · d2 0.583 · d21 0.500 · d4 0.400 · d9 0.250 — d21 passes d4.
Why it works: ranks are comparable across retrievers by construction, so no calibration between BM25 and cosine scales is needed; consistency across lists beats a single top hit.
Debug record: {"doc": "d4", "rrf": 0.03125, "ranks": {"keyword": 4, "vector": 4}}
Failure modes: fusing near-identical lists · thresholding fused scores as if they were probabilities · unequal list lengths changing channel weight · fusion used to hide a broken retriever · missing per-retriever provenance.
Neighbors: widen candidate coverage with Hybrid Search, reorder the fused list with Reranking, place the step with RAG Pipeline Overview.
Continue Learning
Before You Start
- No prerequisites listed.