1use std::collections::HashMap;
4use std::hash::Hash;
5
6pub const DEFAULT_RRF_K: f64 = 60.0;
16
17#[derive(Debug, Clone)]
22pub struct RankedResult<K = String> {
23 pub document_id: K,
25 pub rank: usize,
27 pub score: f32,
29 pub source: &'static str,
31}
32
33#[derive(Debug, Clone)]
35pub struct FusedResult<K = String> {
36 pub document_id: K,
37 pub rrf_score: f64,
38 pub contributions: Vec<(&'static str, f64)>,
40}
41
42fn finish_fusion<K: Clone + Eq + Hash + Ord>(
48 scores: HashMap<K, Vec<(&'static str, f64)>>,
49 top_k: usize,
50) -> Vec<FusedResult<K>> {
51 let mut fused: Vec<FusedResult<K>> = scores
52 .into_iter()
53 .map(|(doc_id, contributions)| {
54 let rrf_score = contributions.iter().map(|(_, s)| s).sum();
55 FusedResult {
56 document_id: doc_id,
57 rrf_score,
58 contributions,
59 }
60 })
61 .collect();
62
63 fused.sort_unstable_by(|a, b| {
64 crate::numeric_cmp::cmp_f64(b.rrf_score, a.rrf_score)
65 .then_with(|| a.document_id.cmp(&b.document_id))
66 });
67 fused.truncate(top_k);
68 fused
69}
70
71pub fn reciprocal_rank_fusion<K: Clone + Eq + Hash + Ord>(
76 ranked_lists: &[Vec<RankedResult<K>>],
77 k: Option<f64>,
78 top_k: usize,
79) -> Vec<FusedResult<K>> {
80 let k = k.unwrap_or(DEFAULT_RRF_K);
81
82 let mut scores: HashMap<K, Vec<(&'static str, f64)>> = HashMap::new();
83
84 for list in ranked_lists {
85 for result in list {
86 let contribution = 1.0 / (k + result.rank as f64 + 1.0);
87 scores
88 .entry(result.document_id.clone())
89 .or_default()
90 .push((result.source, contribution));
91 }
92 }
93
94 finish_fusion(scores, top_k)
95}
96
97pub fn reciprocal_rank_fusion_linear<K: Clone + Eq + Hash + Ord>(
112 ranked_lists: &[Vec<RankedResult<K>>],
113 k: Option<f64>,
114 weights: &[f64],
115 top_k: usize,
116) -> Vec<FusedResult<K>> {
117 assert_eq!(
118 ranked_lists.len(),
119 weights.len(),
120 "weights length must match ranked_lists length"
121 );
122 let k = k.unwrap_or(DEFAULT_RRF_K);
123
124 let mut scores: HashMap<K, Vec<(&'static str, f64)>> = HashMap::new();
125
126 for (list_idx, list) in ranked_lists.iter().enumerate() {
127 let w = weights[list_idx];
128 for result in list {
129 let contribution = w / (k + result.rank as f64 + 1.0);
130 scores
131 .entry(result.document_id.clone())
132 .or_default()
133 .push((result.source, contribution));
134 }
135 }
136
137 finish_fusion(scores, top_k)
138}
139
140pub fn reciprocal_rank_fusion_weighted<K: Clone + Eq + Hash + Ord>(
149 ranked_lists: &[Vec<RankedResult<K>>],
150 k_per_list: &[f64],
151 top_k: usize,
152) -> Vec<FusedResult<K>> {
153 assert_eq!(
154 ranked_lists.len(),
155 k_per_list.len(),
156 "k_per_list length must match ranked_lists length"
157 );
158
159 let mut scores: HashMap<K, Vec<(&'static str, f64)>> = HashMap::new();
160
161 for (list_idx, list) in ranked_lists.iter().enumerate() {
162 let k = k_per_list[list_idx];
163 for result in list {
164 let contribution = 1.0 / (k + result.rank as f64 + 1.0);
165 scores
166 .entry(result.document_id.clone())
167 .or_default()
168 .push((result.source, contribution));
169 }
170 }
171
172 finish_fusion(scores, top_k)
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 fn make_ranked(doc_ids: &[&str], source: &'static str) -> Vec<RankedResult> {
180 doc_ids
181 .iter()
182 .enumerate()
183 .map(|(rank, &id)| RankedResult {
184 document_id: id.to_string(),
185 rank,
186 score: 1.0 - (rank as f32 * 0.1),
187 source,
188 })
189 .collect()
190 }
191
192 #[test]
193 fn single_list_preserves_order() {
194 let list = make_ranked(&["d1", "d2", "d3"], "vector");
195 let fused = reciprocal_rank_fusion(&[list], None, 10);
196 assert_eq!(fused.len(), 3);
197 assert_eq!(fused[0].document_id, "d1");
198 }
199
200 #[test]
201 fn overlapping_lists_boost_common_docs() {
202 let vector = make_ranked(&["d1", "d2", "d3"], "vector");
203 let sparse = make_ranked(&["d2", "d1", "d4"], "sparse");
204 let fused = reciprocal_rank_fusion(&[vector, sparse], None, 10);
205 let top2_ids: Vec<&str> = fused[..2].iter().map(|f| f.document_id.as_str()).collect();
206 assert!(top2_ids.contains(&"d1"));
207 assert!(top2_ids.contains(&"d2"));
208 }
209
210 #[test]
212 fn typed_key_fuses_and_tie_breaks_on_ord() {
213 let make = |ids: &[u32], source: &'static str| -> Vec<RankedResult<u32>> {
214 ids.iter()
215 .enumerate()
216 .map(|(rank, &id)| RankedResult {
217 document_id: id,
218 rank,
219 score: 0.0,
220 source,
221 })
222 .collect()
223 };
224 let a = make(&[7, 3], "a");
225 let b = make(&[3, 9], "b");
226 let fused = reciprocal_rank_fusion(&[a, b], None, 10);
227 assert_eq!(fused[0].document_id, 3);
228 assert_eq!(fused[1].document_id, 7);
230 assert_eq!(fused[2].document_id, 9);
231 }
232
233 #[test]
234 fn weighted_rrf() {
235 let list_a = make_ranked(&["a1", "a2"], "vector");
236 let list_b = make_ranked(&["b1", "a1"], "text");
237 let fused = reciprocal_rank_fusion_weighted(&[list_a, list_b], &[30.0, 120.0], 10);
238 let a1 = fused.iter().find(|f| f.document_id == "a1").unwrap();
239 assert_eq!(a1.contributions.len(), 2);
240 }
241
242 #[test]
243 fn linear_weight_lets_strong_source_dominate() {
244 let strong = make_ranked(&["a1", "a2"], "strong");
249 let weak = make_ranked(&["b1", "a1"], "weak");
250 let fused = reciprocal_rank_fusion_linear(&[strong, weak], None, &[4.0, 0.25], 10);
251
252 let a1 = fused.iter().position(|f| f.document_id == "a1").unwrap();
253 let b1 = fused.iter().position(|f| f.document_id == "b1").unwrap();
254 assert!(a1 < b1, "a1 (rank {a1}) should outrank b1 (rank {b1})");
255
256 let a1_res = &fused[a1];
257 assert_eq!(a1_res.contributions.len(), 2);
258 let strong_contrib = a1_res
260 .contributions
261 .iter()
262 .find(|(src, _)| *src == "strong")
263 .map(|(_, s)| *s)
264 .unwrap();
265 let expected = 4.0 / (DEFAULT_RRF_K + 0.0 + 1.0);
266 assert!((strong_contrib - expected).abs() < 1e-12);
267 }
268
269 #[test]
270 #[should_panic(expected = "weights length must match ranked_lists length")]
271 fn linear_mismatched_weights_panics() {
272 let list = make_ranked(&["d1"], "vector");
273 let _ = reciprocal_rank_fusion_linear(&[list], None, &[1.0, 2.0], 10);
274 }
275
276 #[test]
277 fn empty() {
278 assert!(reciprocal_rank_fusion::<String>(&[], None, 10).is_empty());
279 assert!(reciprocal_rank_fusion_linear::<String>(&[], None, &[], 10).is_empty());
280 assert!(reciprocal_rank_fusion_weighted::<String>(&[], &[], 10).is_empty());
281 }
282}