Pick the right way to fuse BM25 and dense scores before context assembly
Reciprocal Rank Fusion works on ranks, not raw scores, so it sidesteps the fact that BM25 and cosine live on incompatible scales, and it is parameter-free in practice.
Imagine two teachers grading your essay on different scales. One gives marks out of 100, the other gives stars from 0 to 5. Adding 87 plus 4 is nonsense, different units. Averaging after a normalization is fragile because one teacher might happen to give very spread-out scores this term. The robust trick: ask each teacher to rank all the essays from best to worst. Then combine the rankings. An essay that came in first for both teachers is clearly best; an essay that came in first for one and tenth for the other is middling. Rank-based fusion does not care about the units. RRF is the standard recipe for this kind of combination.
Detailed answer & concept explanation~4 min readEverything you need to truly understand this topic: intuition, mechanics, step by step explanation, code, formulas, and worked example. Click to expand.
Everything you need to truly understand this topic: intuition, mechanics, step by step explanation, code, formulas, and worked example. Click to expand.
Everything you need to truly understand this topic: intuition, mechanics, step by step explanation, code, formulas, and worked example.
Everything important, quickly.
3 minutes: state the RRF formula, explain why raw-score addition fails on scale mismatch, mention concatenation loses the agreement signal, note score averaging is brittle on normalization, point to provider-native RRF.
def rrf_fuse(rank_lists: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
"""Reciprocal Rank Fusion across N ranked lists.
Each list is doc_ids ordered best-first.
Returns (doc_id, score) sorted by score desc.
"""
scores: dict[str, float] = {}
for ranks in rank_lists:
for r, doc_id in enumerate(ranks, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + r)
return sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
# Example
dense_top = ["d3", "d1", "d7", "d2", "d5"]
bm25_top = ["d1", "d3", "d9", "d4", "d2"]
fused = rrf_fuse([dense_top, bm25_top])
# d1 and d3 dominate because they ranked high in both listsReal products, models, and research that use this idea.
- Elasticsearch 8.x and OpenSearch 2.x expose `rrf` as the documented fusion operator for hybrid queries.
- Vespa's hybrid retrieval guide uses RRF with k=60 as the default sample configuration.
- Pinecone's 2026 hybrid index uses RRF internally to fuse sparse and dense scores.
- Weaviate's hybrid query API takes an `alpha` for weighted fusion but RRF is documented as the more robust alternative.
- Cohere's hybrid RAG cookbooks for 2026 explicitly use RRF before passing fused candidates to Cohere Rerank 3.5.
What an interviewer would ask next. Try answering before peeking at the approach.
QWhen would you reach for a weighted RRF instead of vanilla RRF?
QHow does RRF interact with reranking downstream?
Don't say thisRed flags and common mistakes that signal junior thinking. Click to expand.
Red flags and common mistakes that signal junior thinking. Click to expand.
Adding raw BM25 and cosine scores. BM25 can range 0 to 30+ depending on document length and term rarity; cosine is bounded around 0.4 to 0.9. The sum is meaningless and varies wildly per query.
The night-before-the-interview bullets. Scan these on the way to the call.
Primary sources. Skim if you want the original framing.
Same topic, related formats. Practice these next.