Why RRF beats score mixing for hybrid search
Leela Desai
Founding Engineer
Your vector store returns cosine similarities between 0 and 1. Your BM25 index returns scores that might be 4.2 or 47.8 depending on term frequency and corpus statistics. Averaging them is meaningless — yet "weighted score mixing" is still the first thing most teams try when they add hybrid search.
The scale problem
BM25 scores are unbounded and corpus-dependent: add documents and the same query's scores shift. Cosine similarity is bounded but its useful range is model-dependent — some embedding models cluster everything between 0.6 and 0.9. Any fixed mixing weight you tune today silently degrades as your corpus grows.
Rank fusion sidesteps it
Reciprocal Rank Fusion ignores scores entirely and fuses positions:
def rrf(rankings: list[list[str]], k: int = 60) -> dict[str, float]:
scores: dict[str, float] = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
return scoresA document ranked #1 by dense search and #8 by BM25 gets
1/61 + 1/69 — no scale mismatch, no tuning drift, no re-calibration when
the corpus doubles.
Why k = 60 works
The constant dampens the head of each ranking. With small k, rank #1
dominates and fusion barely differs from taking one ranking. With large k,
everything flattens toward equal weight. The literature's default of 60 is
remarkably robust — we've moved it across corpora from 10k to 10M chunks
without meaningful nDCG changes.
When score mixing is defensible
If both retrievers emit calibrated probabilities — say, two dense indexes sharing an embedding model — score mixing preserves more information than ranks. In every mixed dense+sparse setup we've measured, RRF matched or beat tuned score mixing, while requiring zero maintenance.
The takeaway: fuse by rank, spend your tuning budget on the reranker.