Skip to content
Veritas AI
All posts
Retrieval6 min read

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:

python
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 scores

A 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.

hybrid searchRRFBM25