Skip to main content

llm_kernel/search/
rrf.rs

1//! Reciprocal Rank Fusion (RRF) for combining multiple ranked result sets.
2//!
3//! RRF is a simple but effective fusion method:
4//! `score(d) = Σ 1/(k + rank_i(d))` where k is a constant (typically 60).
5
6use std::collections::HashMap;
7
8use crate::search::types::SearchResult;
9
10/// Fuse multiple ranked result lists using Reciprocal Rank Fusion.
11///
12/// `k` is the RRF constant (typically 60). Higher values smooth the
13/// contribution of top-ranked results. Each set contributes equally (weight 1.0).
14pub fn rrf_fuse(result_sets: &[Vec<SearchResult>], k: u32) -> Vec<SearchResult> {
15    let weights = vec![1.0_f32; result_sets.len()];
16    rrf_fuse_weighted(result_sets, &weights, k)
17}
18
19/// Weighted RRF — each result set multiplies its rank contribution by `weight`.
20///
21/// `weights` length must equal `result_sets` length. Lets a caller emphasize one
22/// source (e.g. lexical) over another (e.g. vector) without dropping the rank
23/// signal. `weights=[1.0; n]` reduces to [`rrf_fuse`].
24pub fn rrf_fuse_weighted(
25    result_sets: &[Vec<SearchResult>],
26    weights: &[f32],
27    k: u32,
28) -> Vec<SearchResult> {
29    assert_eq!(
30        result_sets.len(),
31        weights.len(),
32        "rrf_fuse_weighted: result_sets and weights length must match"
33    );
34    // One map (not two) keyed by id: avoids a second lookup + `remove` per doc
35    // on the build path. `text` is taken from the first sighting of each id.
36    let mut agg: HashMap<String, (f32, String)> = HashMap::new();
37
38    for (results, weight) in result_sets.iter().zip(weights.iter().copied()) {
39        for (rank, result) in results.iter().enumerate() {
40            let entry = agg
41                .entry(result.id.clone())
42                .or_insert_with(|| (0.0, result.text.clone()));
43            entry.0 += weight * (1.0 / (k as f32 + rank as f32 + 1.0));
44        }
45    }
46
47    let mut fused: Vec<SearchResult> = agg
48        .into_iter()
49        .map(|(id, (score, text))| SearchResult { text, id, score })
50        .collect();
51
52    fused.sort_by(|a, b| {
53        b.score
54            .partial_cmp(&a.score)
55            .unwrap_or(std::cmp::Ordering::Equal)
56    });
57    fused
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    fn make_results(ids: &[(&str, f32)]) -> Vec<SearchResult> {
65        ids.iter()
66            .map(|(id, score)| SearchResult {
67                id: id.to_string(),
68                score: *score,
69                text: format!("text for {id}"),
70            })
71            .collect()
72    }
73
74    #[test]
75    fn empty_inputs() {
76        let result = rrf_fuse(&[], 60);
77        assert!(result.is_empty());
78    }
79
80    #[test]
81    fn single_list() {
82        let list = make_results(&[("a", 0.9), ("b", 0.5)]);
83        let fused = rrf_fuse(&[list], 60);
84        assert_eq!(fused.len(), 2);
85        assert_eq!(fused[0].id, "a"); // rank 0 → highest RRF score
86    }
87
88    #[test]
89    fn two_lists_overlap() {
90        let bm25 = make_results(&[("a", 0.9), ("b", 0.7)]);
91        let vector = make_results(&[("b", 0.95), ("c", 0.6)]);
92
93        let fused = rrf_fuse(&[bm25, vector], 60);
94        assert_eq!(fused.len(), 3);
95
96        // "b" appears in both lists at rank 0 and 1 → should rank highest
97        assert_eq!(fused[0].id, "b");
98    }
99
100    #[test]
101    fn rrf_score_formula() {
102        let list = make_results(&[("x", 1.0)]);
103        let fused = rrf_fuse(&[list], 60);
104        // rank 0 → 1/(60 + 0 + 1) = 1/61 ≈ 0.01639
105        let expected = 1.0 / 61.0;
106        assert!((fused[0].score - expected).abs() < 1e-6);
107    }
108
109    #[test]
110    fn three_lists() {
111        let a = make_results(&[("doc1", 1.0), ("doc2", 0.8)]);
112        let b = make_results(&[("doc2", 0.9), ("doc3", 0.7)]);
113        let c = make_results(&[("doc1", 0.8), ("doc3", 0.9)]);
114
115        let fused = rrf_fuse(&[a, b, c], 60);
116        assert_eq!(fused.len(), 3);
117
118        // doc1: rank 0 in a, not in b, rank 1 in c → 1/61 + 0 + 1/62
119        // doc2: rank 1 in a, rank 0 in b, not in c → 1/62 + 1/61 + 0
120        // doc3: not in a, rank 1 in b, rank 1 in c → 0 + 1/62 + 1/62
121        // doc1 and doc2 tie (same sum), doc3 is lowest
122        let doc3_score = fused.iter().find(|r| r.id == "doc3").unwrap().score;
123        let doc1_score = fused.iter().find(|r| r.id == "doc1").unwrap().score;
124        assert!(doc1_score > doc3_score);
125    }
126
127    #[test]
128    fn weighted_higher_weight_dominates() {
129        // "a" ranks first only in set 0, "b" ranks first only in set 1.
130        // With weights [2.0, 0.5], set 0 contributes 4× more per rank position,
131        // so "a" must outscore "b".
132        let lexical = make_results(&[("a", 0.9), ("b", 0.3)]);
133        let vector = make_results(&[("b", 0.95), ("a", 0.2)]);
134
135        let fused = rrf_fuse_weighted(&[lexical, vector], &[2.0, 0.5], 60);
136        assert_eq!(fused.len(), 2);
137
138        let a_score = fused.iter().find(|r| r.id == "a").unwrap().score;
139        let b_score = fused.iter().find(|r| r.id == "b").unwrap().score;
140        // a: 2.0/61 + 0.5/62,  b: 2.0/62 + 0.5/61
141        assert!(
142            a_score > b_score,
143            "higher-weighted source must dominate: a={a_score}, b={b_score}"
144        );
145    }
146
147    #[test]
148    fn weighted_uniform_matches_rrf_fuse() {
149        let a = make_results(&[("x", 1.0), ("y", 0.5)]);
150        let b = make_results(&[("y", 0.9), ("z", 0.4)]);
151
152        let plain = rrf_fuse(&[a.clone(), b.clone()], 60);
153        let weighted = rrf_fuse_weighted(&[a, b], &[1.0, 1.0], 60);
154
155        assert_eq!(plain.len(), weighted.len());
156        for (p, w) in plain.iter().zip(weighted.iter()) {
157            assert_eq!(p.id, w.id);
158            assert!((p.score - w.score).abs() < 1e-9);
159        }
160    }
161
162    #[test]
163    #[should_panic(expected = "result_sets and weights length must match")]
164    fn weighted_length_mismatch_panics() {
165        let a = make_results(&[("x", 1.0)]);
166        rrf_fuse_weighted(&[a], &[1.0, 2.0], 60);
167    }
168}