Chapter 2 of 8 · 15 min read
RRF fuses rankings because BM25 and cosine scores share no scale
On this page
Two retrievers produce two ranked lists, and something has to turn them into one. This chapter is about the rule that does it using nothing but positions, why using nothing but positions is the safe default rather than a compromise, and what the rule's single constant actually controls.
Reciprocal rank fusion — RRF — assigns each document a score built from where it sits in each list, and sums those contributions across lists. Fusion, from the previous chapter, is the merging step itself; what distinguishes one fusion rule from another is which part of a retriever's output it consumes. RRF consumes the least available: the order.
Why no constant converts one score into the other
The first attempt at combining the two arms usually looks like this, written in an afternoon and shipped behind a flag:
fused = 0.6 * (bm25_score / 30.0) + 0.4 * cosine_scoreThe 30 is there because someone looked at a handful of queries and saw BM25 scores topping out around the high twenties. Dividing by it maps a typical top score to something near 1, which puts the two arms in what looks like the same range.
The reason this cannot be repaired by choosing a better constant is a difference in what the two functions are, and it is worth stating as three checkable properties rather than as an assertion.
BM25 is a sum over the query's terms. Its magnitude therefore grows with how many terms the query has, and each term's contribution grows with how rare that term is in this collection today and shrinks with how long the matching document is. So the top BM25 score for a query is not a property of the answer's quality — it is a property of the query's shape and the collection's current statistics, and it varies across queries by an order of magnitude.
Cosine similarity is bounded in [-1, 1] by construction, and for a normalised embedding model on real text it typically lives in a narrow band around 0.7 to 0.9, where the interesting differences are in the third decimal place. Its range barely moves between queries.
Divide the first by a constant and you have applied one fixed scaling to a quantity whose natural size changes per query. The scaling that puts a rare-term query in range puts a common-term query near zero, and vice versa. A score of 14.2 from the first and 0.83 from the second are not the same quantity measured in different units, so there is no conversion factor to be found. Dividing by 30 does not convert them any more than dividing kilometres by 30 converts them to kilograms.
A fusion rule that never reads a score
Reciprocal rank fusion takes the position of a document in each list and ignores
everything else. For a document d appearing in a set of ranked lists L, its
fused score is
RRF(d) = Σ over lists ℓ in L of 1 / (k + rank(d, ℓ))where rank(d, ℓ) is the document's 1-based position in list ℓ, documents absent
from a list contribute nothing, and k is a constant conventionally set to 60.
The implementation is short enough to read in one pass:
from collections import defaultdict
def rrf(rankings: dict[str, list[str]], k: int = 60) -> list[tuple[str, float]]:
"""rankings: retriever name -> ordered list of doc ids, best first."""
scores: dict[str, float] = defaultdict(float)
for doc_ids in rankings.values():
for rank, doc_id in enumerate(doc_ids, start=1):
scores[doc_id] += 1.0 / (k + rank)
return sorted(scores.items(), key=lambda kv: -kv[1])Nothing in that function has an opinion about BM25 or embeddings. It accepts ordered lists of identifiers, which is the one thing both retrievers genuinely produce in a common format, and it is indifferent to whether a list came from an inverted index, an HNSW graph, a cache of previous clicks, or a hand-written editorial list of pinned results. That indifference is why RRF is the default in Elasticsearch, OpenSearch, Weaviate, Qdrant and Vespa: it is the only fusion rule that requires no knowledge of, and no maintenance of, the systems being fused.
The k in the denominator is doing one job, and seeing it is easier before any
table. Without it the weights would be 1, then one half, then one third — the
document in second place would be worth half the document in first. Adding 60
before taking the reciprocal turns those into one sixty-first and one
sixty-second, which differ by well under two percent. So k is a flattener, and
what matters is its size relative to the rank numbers in play. When k is large
compared with the ranks, all positions in a list are worth nearly the same, and
what discriminates is how many lists a document appears in at all.
Working an example by hand shows what the rule prefers. Take the query
gearbox lubricant change interval HX-2140R, which is mixed — an identifier plus
a natural-language request — and so gets a substantive list from each arm.
| Chunk | BM25 rank | Dense rank | RRF score (k=60) | Fused rank |
|---|---|---|---|---|
man/maint/lubrication#schedule | 2 | 1 | 1/62 + 1/61 = 0.03252 | 1 |
cat/hx-2140r/datasheet#lubricant | 1 | 5 | 1/61 + 1/65 = 0.03178 | 2 |
tic/48821#grease-change | 3 | 4 | 1/63 + 1/64 = 0.03150 | 3 |
man/maint/intervals#gearbox | — | 2 | 1/62 = 0.01613 | 4 |
cat/hx-2140/datasheet#overview | — | 3 | 1/63 = 0.01587 | 5 |
cat/hx-2140r/datasheet#torque | 4 | — | 1/64 = 0.01563 | 6 |
bul/2023-11/superseded-parts | 5 | — | 1/65 = 0.01538 | 7 |
The three documents both retrievers found take the top three positions, and every
document found by only one arm falls below every document found by both —
including man/maint/intervals#gearbox, which the dense arm ranked second. With
k=60 the entire range of scores available to a single-list document (0.01639 at
rank 1 down to 0.01250 at rank 20) sits below the floor of what two agreeing
lists produce (0.02469 for a document at rank 20 in both). Agreement is worth
more than either retriever's confidence, structurally, before any tuning.
That is not a fact about this table, and it is worth deriving in general because it is the property that decides most of what follows. The best a single-list document can ever score is one divided by k plus one — the weight of rank 1, which is 0.01639 at k=60. A document in both lists collects two weights. For its total to fall below that best single-list score, each of its two weights would have to be smaller than half of one sixty-first, which requires k plus its rank to exceed twice sixty-one, in both lists at once. At k=60 that means a rank past sixty in each. Since each arm in this system supplies fifty candidates, no document can ever be ranked that deep by both, so within this configuration a document found by both arms outranks every document found by only one — always, with no exceptions and no tuning.
The rule comes from Cormack, Clarke and Büttcher's 2009 SIGIR paper, which fused runs from the TREC ad-hoc and web tracks — the long-running public evaluations that publish a document collection, a judged query set and the result lists competing systems returned for it — and found that this two-line formula beat Condorcet fusion, a rule that ranks one document above another whenever more of the lists put it ahead, and beat supervised learning-to-rank methods, meaning models fitted on judged data to score a document from features such as the retrievers' own scores, given the same data to train on. Their k=60 was picked once on a development set and then held fixed across every subsequent experiment, and the paper's own framing is that the result was insensitive to it. That history matters for how you should treat k in your own system: it is a shape parameter with a defensible default, not a hyperparameter that repays a grid search.
If you have met Borda count — the voting rule where a candidate ranked r out of
n receives n - r points — RRF is its hyperbolic cousin. The comparison is an
analogy about the shape of the weights and nothing more, and it breaks in one
place worth naming: Borda needs to know how long the list is, because the points
depend on n, while RRF does not, which is what lets RRF fuse lists of different
lengths without adjustment. Within its range the analogy is exact. Borda's weights
fall linearly, so the difference between rank 1 and rank 2 equals the difference
between rank 49 and rank 50, which is wrong for retrieval where the top of the
list is where all the information is. RRF's weights fall like 1/rank, steeply at
the head and almost flat in the tail, which matches how relevance actually
distributes across a candidate list, and it also means a document parked at rank
400 contributes something rather than nothing.
What k controls
The constant sets how steep that decay is, and the cleanest way to see it is the ratio between the weight of a rank-1 document and a rank-20 document in the same list.
| k | weight at rank 1 | weight at rank 20 | ratio |
|---|---|---|---|
| 0 | 1.0000 | 0.0500 | 20.0× |
| 10 | 0.0909 | 0.0333 | 2.73× |
| 60 | 0.0164 | 0.0125 | 1.31× |
| 200 | 0.0050 | 0.0045 | 1.09× |
The third column is not an independent measurement, and noticing why is the whole lesson of the table. Dividing one divided by k plus one by one divided by k plus twenty gives k plus twenty over k plus one — the reciprocals invert and the weights themselves cancel out entirely. So every entry in the ratio column falls out of k alone: twenty over one at the top, thirty over eleven next, eighty over sixty-one, then two hundred and twenty over two hundred and one. The two weight columns shrink towards zero as k grows and carry no information the ratio does not; the ratio approaches 1 because adding the same large k to both ranks makes their difference negligible beside it.
Raising k compresses the whole list toward a single value, so being first stops meaning much more than being twentieth, and what remains discriminating is how many lists a document appears in at all. Lowering k does the reverse and lets a single retriever's favourite outrank a document both retrievers liked.
That trade-off has a crossover you can locate. Consider document A, ranked 1 by the dense arm and absent from the lexical list, against document B, ranked 3 by one arm and 8 by the other:
| k | A = 1/(k+1) | B = 1/(k+3) + 1/(k+8) | winner |
|---|---|---|---|
| 1 | 0.5000 | 0.3611 | A |
| 2 | 0.3333 | 0.3000 | A |
| 3 | 0.2500 | 0.2576 | B |
| 10 | 0.0909 | 0.1325 | B |
| 60 | 0.0164 | 0.0306 | B |
At any k above about 2, the corroborated document wins, and by k=60 it wins by nearly two to one. That last figure is not a coincidence of these particular ranks. Once k is large compared with the ranks themselves, k plus 1, k plus 3 and k plus 8 are all nearly k, so A tends toward one divided by k and B toward two divided by k. The ratio tends to two, and the 0.0306 against 0.0164 in the last row is that limit almost reached. Any single-list document, against any double-list document, converges on the same two-to-one disadvantage as k grows.
In practical terms, k=60 says: a document that only one retriever likes has to be at the very top of that retriever's list to compete with a document both retrievers put in their top ten, and it can never beat one both retrievers put in their top twenty. Whether that is the behaviour you want depends on the query class. For the paraphrased thermal query from the last chapter it is fine, because the lexical arm has no opinion at all and the dense arm's top few carry the fused list unopposed. For the bare part-number query it is also fine, because the lexical list is only three documents long and the gold chunk appears in both. It becomes a problem in the case chapter 3 takes up, where one arm is emphatically right and the other is confidently, corroboratively wrong.
The information a position does not carry
Three things fall out of the formula that are easy to miss.
RRF has no notion of a bad list. Every retriever contributes a full slate of
votes on every query, whether its top candidate scored 41.6 or 4.1, because the
formula only knows that something was first. On a query where the lexical arm
found nothing above a BM25 score of 2 — meaning it matched a single mid-frequency
term and nothing else — those fifty candidates enter the fusion with exactly the
weight they would have carried on a query the lexical arm nailed. The usual
mitigation is a weighted variant, Σ wℓ / (k + rank), with a per-retriever
multiplier, or an absolute cutoff that drops an arm's list entirely when its top
score falls below a floor. Both reintroduce a number that has to be maintained,
which is the thing RRF was chosen to avoid.
RRF also loses the margin inside a list. If the dense arm's top five cosines are 0.891, 0.886, 0.879, 0.871 and 0.868, the ordering is very nearly arbitrary — 0.023 of cosine across five documents is within the noise of chunk boundaries — yet RRF treats those five positions as meaningfully ordered and awards weights spanning 0.01639 down to 0.01538. Conversely, when BM25 returns 24.7 followed by 7.9, a gap that says the first document contains an identifier nothing else contains, RRF records only that one came before the other. The rule cannot distinguish a confident ranking from a coin flip.
This is precisely the point the interview question is probing, and it is the stumble in this chapter, because the wrong answer sounds like the right one. RRF does not normalise the two score ranges into a common scale. The test is invariance: multiply every BM25 score in the system by a thousand, or add a constant to every cosine, and the fused output is unchanged down to the last digit, because no position moved. A procedure whose output is identical whether or not the scales differ is not reconciling the scales — it is working on positions so that the question of comparability never arises. Answering that it "normalises them into a common scale" inverts the mechanism.
RRF is also quietly sensitive to how deep each arm retrieved, which is easy to miss because the formula contains no depth parameter. A document ranked 63rd by the dense arm contributes 1/123 if you retrieved 100 candidates and contributes nothing if you retrieved 50, and since the fused ordering near the top depends on which single-list documents are present at all, changing an arm's depth changes results in ways that have nothing to do with the fusion rule. The asymmetric case is worse: retrieving 20 from the lexical arm and 200 from the dense arm gives the dense arm ten times as many opportunities to corroborate itself against nothing, and the fused list drifts toward it. Equal depths per arm are the sane default, and whatever depths you choose belong in the same config block as k so that a later change to one is visibly a change to the fusion.
Finally, RRF's output scores are not interpretable and should not be thresholded. The derivation above says why: a fused score of 0.0325 is "top of both lists" only on a two-arm system with k=60, since the number of terms in the sum is the number of arms and the size of each term is set by k. Change either and the same number means something else. If you need a confidence threshold — a rule that says "return nothing rather than return this" — the fused score will not give you one, and you need either a calibrated score from a downstream reranker or a rule applied to the raw retriever scores before fusion.
What RRF buys is a fusion layer with no state, no calibration and no maintenance, which is the right default and the thing to measure first. What it costs is every bit of information the retrievers expressed through magnitude rather than order, and the two-to-one limit derived above says how that cost is paid: a single arm's emphatic first place is worth about half of two arms' mild agreement, whatever the emphasis was.
Most of the time that is the trade you want, because two arms agreeing is usually better evidence than one arm insisting. The next chapter takes the cases where it is not — where the insisting arm was right and the agreeing arms were both wrong — shows what weighted score fusion recovers, and works out the bill it sends you every time the corpus, the analyser or the embedding model changes underneath it.
Hybrid search — read the current version at https://daviddominguez.dev/learn/en/hybrid-search/rrf-fuses-rankings-because-bm25-and-cosine-scores-share-no-scale