1use std::collections::HashMap;
7
8use crate::search::types::SearchResult;
9
10pub 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
19pub 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 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"); }
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 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 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 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 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 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}