Abstract
RRF (reciprocal rank fusion) combines ranked lists from different retrievers into one. Each document scores from every list it appears in, and the scores are summed. It was proposed by Cormack, Clarke and Büttcher (SIGIR 2009) with , and it is now the default way to fuse BM25 and vector search in hybrid search.
It looks only at ranks, never at scores. A BM25 score of 7.3 and a cosine similarity of 0.82 can’t be added, but “1st” and “3rd” can.
Cue
- What does a document score from one list? → , or 0 if the list doesn’t contain it
- Why fuse ranks rather than scores? → Scores from different retrievers are on different scales and distributions. Ranks are always comparable
- What does do? → Flattens the curve, so rank 1 is worth only 15% more than rank 10. Agreement between lists beats a single first place
- Rank 60 in two lists vs rank 1 in one? → beats . Agreement wins
- How is RRF related to MRR? → Same reciprocal, but MRR is over queries, and RRF is over lists
Formula
For a set of ranked lists , where is document ‘s 1-based rank in list :
A document missing from a list gets 0 from it. Sort by , highest first. Weighted RRF multiplies each list’s contribution by a weight , so you can trust one retriever more:
Two different s
Why ranks, not scores
The obvious way to combine retrievers is to add their scores. It doesn’t work well:
- BM25 is unbounded. Scores depend on the query length and the corpus, so a “good” score for one query is a poor one for another.
- Cosine similarity is bunched. Embedding models often put everything between 0.7 and 0.9, so the gap between relevant and irrelevant is tiny.
- Normalising is fragile. Min-max scaling each list to hangs on the single best and worst result, so one outlier reshapes the whole list.
RRF sidesteps all of this. It needs no training data, no tuning per query, and no knowledge of how either retriever scores.
What does
controls how steeply the score falls with rank:
| Rank | 1 | 2 | 5 | 10 | 50 |
|---|---|---|---|---|---|
| (like MRR) | 1.000 | 0.500 | 0.200 | 0.100 | 0.020 |
| 0.0164 | 0.0161 | 0.0154 | 0.0143 | 0.0091 | |
| Rank 1 ÷ rank 10 | 10× at | 1.15× at |
- Small trusts each retriever’s top pick. One list’s first place can outweigh everything else.
- Large rewards consensus. A document that both retrievers rank fairly well beats one that only a single retriever loves.
60 isn’t magic. Cormack et al. found it worked well across TREC collections, and results are not very sensitive to it. Treat it as a sensible default, and tune only if you have labelled queries to tune against.
Worked example
The BM25 results for “search index” from the BM25 worked example, fused with a (made-up) vector search over the same six documents, :
| Doc | BM25 rank | Vector rank | RRF | Fused rank | ||
|---|---|---|---|---|---|---|
| d3 | 1 | 3 | 0.01639 | 0.01587 | 0.03226 | 1 |
| d1 | 3 | 2 | 0.01587 | 0.01613 | 0.03200 | 2 |
| d4 | – | 1 | – | 0.01639 | 0.01639 | 3 |
| d6 | 2 | – | 0.01613 | – | 0.01613 | 4 |
| d2 | – | 4 | – | 0.01563 | 0.01563 | 5 |
- d3 and d1 appear in both lists, so they score roughly double and take the top two places.
- d4 is the vector search’s first pick, but BM25 never found it, so it drops to third.
- d6 is BM25’s second pick but has no semantic match, so it drops to fourth.
The BM25 scores (0.985, 0.888, 0.611) never enter the calculation.
Implementation (Rust)
use std::collections::HashMap;
use std::hash::Hash;
const K: f64 = 60.0; // Cormack et al.'s constant: larger values flatten the gap between ranks
/// Fuse ranked lists of ids (each best first) into one list, best first.
pub fn rrf<T: Clone + Eq + Hash + Ord>(lists: &[Vec<T>]) -> Vec<(T, f64)> {
let weighted: Vec<(&[T], f64)> = lists.iter().map(|l| (l.as_slice(), 1.0)).collect();
weighted_rrf(&weighted)
}
/// Each list contributes `weight / (K + rank)` to every id it contains.
/// An id missing from a list gets nothing from that list.
pub fn weighted_rrf<T: Clone + Eq + Hash + Ord>(lists: &[(&[T], f64)]) -> Vec<(T, f64)> {
let mut scores: HashMap<T, f64> = HashMap::new();
for (list, weight) in lists {
for (i, id) in list.iter().enumerate() {
let rank = (i + 1) as f64; // ranks are 1-based
*scores.entry(id.clone()).or_insert(0.0) += weight / (K + rank);
}
}
let mut fused: Vec<_> = scores.into_iter().collect();
fused.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
fused
}
#[cfg(test)]
mod tests {
use super::*;
fn ids<T: Clone>(fused: &[(T, f64)]) -> Vec<T> {
fused.iter().map(|(id, _)| id.clone()).collect()
}
#[test]
fn worked_example() {
let bm25 = vec!["d3", "d6", "d1"];
let vector = vec!["d4", "d1", "d3", "d2"];
assert_eq!(ids(&rrf(&[bm25, vector])), ["d3", "d1", "d4", "d6", "d2"]);
}
#[test]
fn agreement_beats_one_first_place() {
// "x" is 60th in both lists, "top" is 1st in one list only
let fill = |p: &str, n: usize| (1..=n).map(|i| format!("{p}{i}")).collect::<Vec<_>>();
let a = [vec!["top".to_string()], fill("a", 58), vec!["x".to_string()]].concat();
let b = [fill("b", 59), vec!["x".to_string()]].concat();
let order = ids(&rrf(&[a, b]));
let pos = |id: &str| order.iter().position(|d| d == id).unwrap();
assert!(pos("x") < pos("top"));
}
#[test]
fn weights_tip_a_disagreement() {
let keyword = (&["a", "b"][..], 1.0);
let vector = (&["b", "a"][..], 3.0);
assert_eq!(ids(&weighted_rrf(&[keyword, vector])), ["b", "a"]);
}
#[test]
fn ties_break_by_id() {
assert_eq!(ids(&rrf(&[vec!["b", "a"], vec!["a", "b"]])), ["a", "b"]);
}
}Notes on the Rust:
- Generic over the id type. Anything hashable and ordered works, such as
&str,u64or a chunk-id struct. total_cmpplus an id tiebreak makes the order total and reproducible. Ties are common in RRF because scores depend only on ranks: two documents at ranks (1, 2) and (2, 1) score exactly the same.- It assumes each list has no duplicate ids. A list with the same id twice would credit it twice. This bites when one retriever returns chunks and you fuse by document id. Deduplicate first, keeping each document’s best rank.
In practice
- Fuse a window, not everything. Each retriever returns its top (often 50–100), and RRF fuses those. A document ranked below the window gets 0 from that list. A deeper window lets late agreement count, at the cost of more retrieval work.
- Search engines build it in. Elasticsearch and OpenSearch have RRF for hybrid queries, with
rank_constantdefaulting to 60. Azure AI Search uses RRF for its hybrid mode. - Rerank afterwards. RRF is cheap but crude. A common pipeline is BM25 + vectors → RRF → a cross-encoder reranker on the top 20–50. qmd’s
querydoes exactly this over this vault. - Measure it. Compare BM25 alone, vectors alone and the fused list on the same questions with Hit@k and MRR. Fusion usually beats both, but not always: if one retriever is much weaker, it can drag good results down.
RRF vs score fusion
| RRF | Score fusion (e.g. ) | |
|---|---|---|
| Uses | Ranks only | Normalised scores |
| Needs | Nothing | A normalisation scheme and a tuned |
| Keeps | Order | How far apart results are (“1st by a mile” vs “1st by a hair”) |
| Best when | No labelled data, or retrievers you can’t inspect | Labelled queries to tune on |
Bruch, Gai and Ingber (An Analysis of Fusion Functions for Hybrid Retrieval, 2023) found that a tuned convex combination of scores can beat RRF, and that RRF is more sensitive to than often assumed. RRF remains the safe default when you can’t tune.
Strengths and blind spots
| ✅ Scale-free | Works with any retriever that returns a ranked list, even a black-box API. |
| ✅ No training | One constant, and the default is usually fine. |
| ✅ Rewards consensus | A document two different methods both find is usually relevant. |
| ❌ Throws away scores | It can’t tell a confident first place from a near tie. |
| ❌ Weak lists still vote | A poor retriever adds noise unless you down-weight it. |
| ❌ Window-dependent | A document just outside one list’s top loses that list’s vote entirely. |
Related
- BM25: the usual keyword half of hybrid search
- Hit@K, MRR, Partial@K, Recall@K: how to measure whether fusion helped
- Bytes, AI, QMD, Semantic search over an Obsidian vault
- Cormack, Clarke & Büttcher, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (SIGIR 2009)
- Bruch, Gai & Ingber, An Analysis of Fusion Functions for Hybrid Retrieval (ACM TOIS, 2023)