Skip to main content

Reciprocal Rank Fusion

Reciprocal Rank Fusion (RRF) combines two or more ranked result lists into one. Reach for it when no single signal — BM25 over one field, BM25 over another, fuzzy match, vector distance — captures every relevant document, and each surfaces some that the others miss.

See Setup for the shared dataset used in the examples. The normalized-scores comparison at the end brings its own small corpus.

How it works

For each branch, every matching document gets a rank (1, 2, 3, ...) under that branch's own scoring. RRF combines those ranks per document with:

rrf_score(d) = Σ over branches  1 / (k + rank_in_branch(d))

A document missing from a branch contributes nothing for that branch. Documents that rank high in any branch end up with a high combined score; documents that rank high in several branches dominate.

k controls how steeply top ranks outweigh lower ranks. The default in the original paper and in Elasticsearch is 60.

Template

Copy the skeleton and replace each branch with your own ranking query:

WITH fused AS (
-- Branch 1
SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (
SELECT id, BM25(movies_idx.tableoid) AS s FROM movies_idx
WHERE title @@ ts_phrase('YOUR_QUERY')
ORDER BY s DESC LIMIT 100
) t
UNION ALL
-- Branch 2
SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (
SELECT id, BM25(movies_idx.tableoid) AS s FROM movies_idx
WHERE description @@ ts_phrase('YOUR_QUERY')
ORDER BY s DESC LIMIT 100
) t
)
SELECT id, SUM(1.0 / (60 + rank)) AS rrf_score
FROM fused
GROUP BY id
ORDER BY rrf_score DESC
LIMIT 10;

Each branch:

  • selects (id, score) rows that match whatever predicate you want,
  • sorts by its own score, capped with a per-branch LIMIT (the window size — see Tuning),
  • assigns ranks with RANK().

The outer query sums 1 / (60 + rank) per id and returns the top fused results.

Worked example

Same query word, two fields. "Alien" appears in the title of one film and only in the description of another:

Query
WITH fused AS (  SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (    SELECT id, BM25(movies_idx.tableoid) AS s FROM movies_idx    WHERE title @@ ts_phrase('alien') ORDER BY s DESC LIMIT 100  ) t  UNION ALL  SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (    SELECT id, BM25(movies_idx.tableoid) AS s FROM movies_idx    WHERE description @@ ts_phrase('alien') ORDER BY s DESC LIMIT 100  ) t)SELECT m.id, m.title, SUM(1.0 / (60 + rank))::DECIMAL(6,5) AS rrfFROM fused f JOIN movies m ON m.id = f.idGROUP BY m.id, m.titleORDER BY rrf DESC, m.idLIMIT 5;
Result
 id | title                         | rrf----+-------------------------------+---------  7 | Star Trek: The Motion Picture | 0.01639  8 | Alien                         | 0.01639

Each branch alone returns one document. Fused, both surface — and a document matching both fields would score about twice as high.

Tuning

k — top-rank weight

k = 60 is the published default and works well out of the box. Lower k widens the gap between top ranks; higher k flattens the curve so that the set of candidates matters more than the order within each branch.

k1/(k+1)1/(k+10)Top-vs-10 ratio
100.09090.05001.8×
600.01640.01431.15×
2000.004980.004761.05×

Window size — per-branch LIMIT

Each branch's LIMIT N is the window: only the top N results per branch contribute. A document outside every branch's window scores 0.

  • Navigational ("I know what I want") queries: LIMIT 50–100.
  • Exploratory queries where the long tail matters: LIMIT 200+, at the cost of more rows flowing into the GROUP BY.

More branches

Add another UNION ALL block per extra signal — the shape doesn't change:

WITH fused AS (
-- branch 1: title BM25
SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (...) t
UNION ALL
-- branch 2: description BM25
SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (...) t
UNION ALL
-- branch 3: fuzzy, n-gram, or any other ranked source
SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (...) t
)
SELECT id, SUM(1.0 / (60 + rank)) AS rrf_score
FROM fused GROUP BY id ORDER BY rrf_score DESC LIMIT 10;

Another RRF strategy: normalized scores

RANK() deliberately discards how far apart the scores are: whether the top hit beats the runner-up by 10× or by a rounding error, they fuse as ranks 1 and 2 either way. When that magnitude carries real signal, keep it — min–max normalize each branch's scores to [0, 1] and sum the normalized values instead of reciprocal ranks:

WITH hits AS (
SELECT 1 AS branch, id, s FROM (
SELECT id, BM25(movies_idx.tableoid) AS s FROM movies_idx
WHERE title @@ ts_phrase('YOUR_QUERY')
ORDER BY s DESC LIMIT 100
) t
UNION ALL
SELECT 2 AS branch, id, s FROM (
SELECT id, BM25(movies_idx.tableoid) AS s FROM movies_idx
WHERE description @@ ts_phrase('YOUR_QUERY')
ORDER BY s DESC LIMIT 100
) t
),
normed AS (
SELECT id,
CASE WHEN MAX(s) OVER w = MIN(s) OVER w THEN 1.0
ELSE (s - MIN(s) OVER w) / (MAX(s) OVER w - MIN(s) OVER w)
END AS ns
FROM hits
WINDOW w AS (PARTITION BY branch)
)
SELECT id, SUM(ns) AS fused_score
FROM normed
GROUP BY id
ORDER BY fused_score DESC
LIMIT 10;

The branches are unchanged; each row just carries a branch tag so the normed CTE can rescale scores per branch (PARTITION BY branch): the best hit in a branch maps to 1, the worst in the window to 0, and everything in between keeps its relative distance. The CASE guards a branch whose scores are all equal, which would otherwise divide by zero. A document that wins one branch by a wide margin keeps that advantage in the fused score — exactly what rank-based fusion erases.

Two caveats: a single outlier score stretches the whole scale and compresses everyone else toward 0, and the formula assumes higher-is-better — for a distance branch (smaller is better), invert it with (MAX(s) OVER w - s) / (MAX(s) OVER w - MIN(s) OVER w).

When the two strategies disagree

The two strategies don't just produce different numbers — they can put a different document on top. A small corpus of blog articles, searched for vector search performance over title and body with ts_any (match any of the terms, so partial matches rank lower):

Schema and sample data
Query
CREATE TABLE articles (id INTEGER PRIMARY KEY, title VARCHAR, body VARCHAR);
INSERT INTO articles VALUES(1, 'Vector Search Performance in Production', 'How we keep latency low for nearest-neighbor workloads at scale.'),(2, 'Search-First Design: Why Search Beats Browsing', 'Our recommendation stack embeds every item as a vector and compares vector distances offline.'),(3, 'Vector Compression Notes', 'Quantization shrinks embeddings with little recall loss.'),(4, 'The Performance Handbook: Everyday Performance Wins', 'Profiling our vector search pipeline doubled throughput: fewer vector reads per search query and one performance fix in the scorer.'),(5, 'Notes on Query Planning', 'The planner picks a plan by estimated cost, including vector scans.'),(6, 'Debugging Performance Regressions', 'A checklist for bisecting slow builds and hot loops.');
CREATE INDEX articles_idx ON articles    USING inverted (id, title basic_dict, body basic_dict);
VACUUM (REFRESH_TABLE) articles;

Rank-based RRF first:

Query
WITH fused AS (  SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (    SELECT id, BM25(articles_idx.tableoid) AS s FROM articles_idx    WHERE title @@ ts_any(['vector','search','performance']::TSQUERY[])    ORDER BY s DESC LIMIT 100  ) t  UNION ALL  SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM (    SELECT id, BM25(articles_idx.tableoid) AS s FROM articles_idx    WHERE body @@ ts_any(['vector','search','performance']::TSQUERY[])    ORDER BY s DESC LIMIT 100  ) t)SELECT a.id, a.title, SUM(1.0 / (60 + rank))::DECIMAL(6,5) AS rrf_scoreFROM fused f JOIN articles a ON a.id = f.idGROUP BY a.id, a.titleORDER BY rrf_score DESC, a.idLIMIT 3;
Result
 id | title                                               | rrf_score----+-----------------------------------------------------+-----------  2 | Search-First Design: Why Search Beats Browsing      |   0.03226  4 | The Performance Handbook: Everyday Performance Wins |   0.03202  1 | Vector Search Performance in Production             |   0.01639

"Search-First Design" wins — yet it never came close to winning either branch. It finished a distant second in both: its title score is 1.8 against the title winner's 4.1, its body score 1.3 against the body winner's 6.6. Ranks erase those margins; all RRF sees is "2nd + 2nd", which beats any single first place.

Now the same two branches fused with normalized scores:

Query
WITH hits AS (  SELECT 1 AS branch, id, s FROM (    SELECT id, BM25(articles_idx.tableoid) AS s FROM articles_idx    WHERE title @@ ts_any(['vector','search','performance']::TSQUERY[])    ORDER BY s DESC LIMIT 100  ) t  UNION ALL  SELECT 2 AS branch, id, s FROM (    SELECT id, BM25(articles_idx.tableoid) AS s FROM articles_idx    WHERE body @@ ts_any(['vector','search','performance']::TSQUERY[])    ORDER BY s DESC LIMIT 100  ) t),normed AS (  SELECT id,         CASE WHEN MAX(s) OVER w = MIN(s) OVER w THEN 1.0              ELSE (s - MIN(s) OVER w) / (MAX(s) OVER w - MIN(s) OVER w)         END AS ns  FROM hits  WINDOW w AS (PARTITION BY branch))SELECT a.id, a.title, SUM(ns)::DECIMAL(6,5) AS fused_scoreFROM normed n JOIN articles a ON a.id = n.idGROUP BY a.id, a.titleORDER BY fused_score DESC, a.idLIMIT 3;
Result
 id | title                                               | fused_score----+-----------------------------------------------------+-------------  4 | The Performance Handbook: Everyday Performance Wins |     1.06655  1 | Vector Search Performance in Production             |     1.00000  2 | Search-First Design: Why Search Beats Browsing      |     0.29413

The documents that actually dominated a branch move to the top: "The Performance Handbook" (the body-branch winner, with a weak title match as a bonus) edges out "Vector Search Performance in Production" (the title-branch winner), and "Search-First Design" drops to third with a fused score of 0.29 — its two second places are now worth what they were actually worth. Neither ordering is universally right: rank fusion rewards showing up in many signals, score fusion rewards decisive wins in one. Pick which one suits your search better.

Which strategy when

  • Rank-based RRF — the default. Ranks are indifferent to scale, so BM25, vector distance and fuzzy similarity fuse as they are — no per-branch weights or score calibration, just k and the window size from Tuning. Choose it when consensus should win: a document that several signals agree on belongs above a document only one signal likes.
  • Normalized scores — when the margins carry real signal and a decisive win in one branch should outrank lukewarm presence in several. The trade-off is robustness: one outlier score rescales the whole branch, where ranks wouldn't move.
  • Weighted sum of raw scores α·s₁ + β·s₂ — when the branches are already on the same scale (say, BM25 over two similar fields). It keeps magnitudes without the min–max distortion, and the weights give per-branch control that neither strategy above offers.
  • No fusion at all — when one signal dominates. If BM25 alone gives the right answer, fusing in a weaker signal only dilutes the ranking.
  • A calibrated reranker — when downstream code needs "this document is 92% relevant". Every fused score on this page is ordinal, good only for sorting; fuse to get a candidate set, then rerank it with a calibrated model.

See also