DEV Community

jidonglab
jidonglab

Posted on

Reciprocal Rank Fusion: Why k=60 Buries Your Best BM25 Hit

A user searches your docs for ERR_4021. BM25 returns the exact page as hit #1 — perfect lexical match, nothing ambiguous about it. Your embedding model has never seen that error code, so it returns 40 vaguely related troubleshooting pages instead. You fuse the two lists with Reciprocal Rank Fusion, the default in Elasticsearch, Qdrant, and LangChain's EnsembleRetriever, and the correct page comes back at position 26.

Nothing was misconfigured. That is exactly what k=60 does.

TL;DR

  • Reciprocal Rank Fusion scores each document as Σ 1/(k + rank_i) across retriever lists. With the default k=60, the top of the curve is nearly flat: rank 1 scores 0.01639, rank 2 scores 0.01613. A one-position move at the top is worth 0.00026.
  • Appearing in a second list is worth up to 0.01639 — about 62× more than moving from rank 2 to rank 1. RRF with k=60 is not rank fusion; it is approval voting with a weak tiebreak.
  • The crossover is exact: with two retrievers and k=60, any document ranked better than 62 in both lists beats a document ranked #1 in only one list. With three retrievers, that threshold expands to 123.
  • This is fine when both retrievers are equally trustworthy and their score scales are incomparable. It is destructive for exact-identifier queries (error codes, SKUs, function names, ticket IDs), where one retriever is definitively right and the other is noise.
  • The fix is not just lowering k. Use RRF as a candidate generator (fuse to top-100, then rerank with a cross-encoder), add per-retriever weights, or route identifier-shaped queries to lexical search only.

What does Reciprocal Rank Fusion actually compute?

RRF ignores scores entirely. It only looks at positions. For a document d appearing at rank r_i in retriever list i:

RRF(d) = Σ_i  w_i / (k + r_i)
Enter fullscreen mode Exit fullscreen mode

Documents missing from a list contribute nothing. That rank-only design is the whole point: BM25 scores are unbounded and query-dependent, cosine similarity lives in [-1, 1], and a ColBERT MaxSim sum is on a third scale again. Normalizing them into a comparable range per query is genuinely hard. Throwing away magnitude and keeping order sidesteps the problem, needs no tuning, and never blows up when one retriever's score distribution shifts.

The cost is that you also throw away confidence. RRF cannot tell the difference between "BM25 ranked this first with a score 10× the runner-up" and "BM25 ranked this first by a hair."

Why does k=60 bury the top BM25 hit?

Because k=60 flattens the head of the reciprocal curve until rank position barely carries information, while list membership carries almost all of it.

Here is the entire argument in one table:

rank 1/(60 + rank)
1 0.016393
2 0.016129
3 0.015873
10 0.014286
20 0.012500
50 0.009091
100 0.006250

The gap between rank 1 and rank 2 is 0.000264. The gap between "in the list at rank 20" and "not in the list at all" is 0.0125 — roughly 47× larger.

So solve for the crossover. A document at rank r in both lists beats a document ranked #1 in one list alone when:

2/(60 + r) > 1/61   →   60 + r < 122   →   r < 62
Enter fullscreen mode Exit fullscreen mode

Any document that both retrievers put in their top 61 outranks the unanimous #1 of a single retriever. If you retrieve top-50 candidates from each side — a very common setting — then every document in the intersection beats a single-list winner, no matter how confident that winner was.

Add a third retriever (say sparse + dense + a rewritten query) and it gets worse: 3/(60 + r) > 1/61 gives r < 123. More retrievers means a larger consensus zone, so the fix people reach for when recall looks bad actively deepens the problem.

What does this look like in code?

Synthetic but faithful to the ERR_4021 case: BM25 nails it at rank 1, the dense retriever misses it completely, and the two lists overlap on 25 mediocre pages.

def rrf(runs, k=60, weights=None):
    """runs: list of ranked doc-id lists (index 0 == rank 1)."""
    weights = weights or [1.0] * len(runs)
    scores = {}
    for run, w in zip(runs, weights):
        for rank, doc in enumerate(run, start=1):
            scores[doc] = scores.get(doc, 0.0) + w / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

# BM25: exact match first, then 39 mediocre pages
bm25   = ["ERR_4021_page"] + [f"mid_{i}" for i in range(1, 40)]
# Dense: never saw the error code; overlaps bm25 on mid_15..mid_39
vector = [f"mid_{i}" for i in range(15, 55)]

for k in (60, 20, 10, 1):
    fused = rrf([bm25, vector], k=k)
    print(f"k={k:>2}  exact match lands at rank {fused.index('ERR_4021_page') + 1}")
Enter fullscreen mode Exit fullscreen mode

Output:

k=60  exact match lands at rank 26
k=20  exact match lands at rank 16
k=10  exact match lands at rank 7
k= 1  exact match lands at rank 2
Enter fullscreen mode Exit fullscreen mode

Two things worth internalizing. First, k is not a cosmetic smoothing constant — it moves the correct answer through 24 rank positions. Second, even k=1 does not restore it to #1, because a document that is rank 1 in one list and rank 16 in the other still accumulates more mass. Tuning k mitigates; it does not solve.

Where did k=60 come from, and should you trust it?

It comes from the 2009 SIGIR paper by Cormack, Clarke, and Buettcher that introduced RRF, where the constant was chosen empirically on TREC runs and reported as working well without tuning. That is a legitimate result for the setting it was measured in: fusing many homogeneous full-text IR systems, all reasonably competent, all searching the same corpus, evaluated on nDCG-style metrics over informational queries.

Almost none of that describes a modern hybrid RAG stack. You typically have exactly two retrievers with very different failure modes, a top-k of 20–100 rather than 1000, and a downstream consumer (an LLM) that cares enormously about whether the right chunk is in the top 5 and not much about the shape of the tail. The constant survived into Elasticsearch's rank_constant, LangChain's c parameter, and most vector DB fusion implementations because it was the number in the paper — not because anyone re-measured it on your workload.

Elasticsearch exposes both knobs directly:

{
  "retriever": {
    "rrf": {
      "retrievers": [
        { "standard": { "query": { "match": { "body": "ERR_4021" } } } },
        { "knn": { "field": "embedding", "query_vector_builder": { } , "k": 50, "num_candidates": 200 } }
      ],
      "rank_constant": 20,
      "rank_window_size": 100
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

rank_window_size matters more than people expect: it sets how deep each list goes before fusion, which directly sets how large the consensus zone can be. Fusing top-20 lists with k=60 is a meaningfully different ranker from fusing top-200 lists with k=60, even though the config difference looks like a recall setting.

Which queries does this break in production?

The pattern is: queries where one retriever is right for a reason the other retriever structurally cannot see.

  • Exact identifiers. Error codes, SKUs, CVE numbers, function or table names, ticket IDs. Embedding models tokenize these into fragments and place them near generic surrounding text. BM25 is not merely better here, it is correct and the dense side is noise — but RRF has no channel to express "one of these votes is worthless."
  • Rare proper nouns. A customer name, an internal service name, a config flag. Same mechanism.
  • Negation and narrow qualifiers. "Not supported on ARM", "before v3". Dense retrieval smears these; lexical retrieval at least anchors the rare token.
  • Highly semantic paraphrase queries, where the failure inverts: the dense retriever is right, BM25 returns keyword-overlap junk, and consensus junk buries the correct chunk just as effectively.

The symptom in your evals is distinctive and easy to misread: hybrid search shows better average nDCG than either retriever alone, while recall@5 on your hardest query slice gets worse. Averages hide it because RRF genuinely helps the broad middle of your query distribution. It only destroys the tail where one retriever had high confidence — which is also the tail your users complain about.

How do you fix Reciprocal Rank Fusion without giving it up?

In rough order of payoff:

  1. Treat RRF as recall, not ranking. Fuse to the top 100 and let a cross-encoder reranker decide the final order. RRF's job becomes "get the right chunk into the candidate set," which it does well. Its bad ordering never reaches the model. This is the single highest-value change and it makes k almost irrelevant.
  2. Lower k to 10–20 if you have no reranker. This restores steepness at the head. It also makes the fusion more sensitive to noisy top hits, so measure both slices.
  3. Weight the retrievers. w_lexical = 2.0 on a corpus full of identifiers is a one-line change that shifts the crossover proportionally. Per-query-type weights are better still.
  4. Route instead of fusing. A cheap classifier — or a regex for [A-Z]{2,}_\d+, quoted phrases, and long alphanumeric tokens — can send identifier-shaped queries to BM25 alone. Fusion is the wrong tool when you already know which retriever is authoritative.
  5. Consider score-based fusion with per-query min-max or z-score normalization over the retrieved window, then a convex combination. It preserves confidence, at the cost of being sensitive to score-distribution drift. Weaviate ships both this and rank-based fusion for exactly this reason.

So, why does Reciprocal Rank Fusion with k=60 bury your best BM25 hit?

Because 1/(k + rank) with k=60 compresses rank differences at the head to near-zero while leaving list membership at full value: moving from rank 2 to rank 1 is worth 0.00026, but appearing in a second retriever's list is worth up to 0.01639. That makes RRF an approval-voting scheme, and it produces a hard threshold — with two retrievers, any document both retrievers rank better than 62 outranks a document that is #1 in only one list. For exact-identifier queries, where lexical search is confidently correct and dense retrieval contributes noise, that threshold is guaranteed to bury the right answer under whatever the two retrievers happen to agree on. Lower k to 10–20 to steepen the head, weight or route your retrievers when one is authoritative, and above all use RRF as a candidate generator feeding a cross-encoder reranker rather than as the final ranking function.

Top comments (0)