1use rustc_hash::FxHashMap;
22
23use super::SearchResult;
24
25pub const DEFAULT_RRF_K: f32 = 60.0;
27
28#[derive(Debug, Clone, Copy, PartialEq)]
30pub enum FusionMethod {
31 Rrf { k: f32 },
37 NormalizedWeightedSum,
49}
50
51impl Default for FusionMethod {
52 fn default() -> Self {
53 FusionMethod::Rrf { k: DEFAULT_RRF_K }
54 }
55}
56
57#[inline]
60pub(crate) fn rrf_contribution(k: f32, rank: usize) -> f32 {
61 1.0 / (k + rank as f32)
62}
63
64pub fn fuse_ranked_lists(
72 lists: Vec<(Vec<SearchResult>, f32)>,
73 method: FusionMethod,
74 limit: usize,
75) -> Vec<SearchResult> {
76 let capacity = lists.iter().map(|(l, _)| l.len()).sum();
77 let mut fused: FxHashMap<(u128, u32), SearchResult> =
78 FxHashMap::with_capacity_and_hasher(capacity, Default::default());
79
80 for (list, weight) in lists {
81 let (min_score, inv_range) = match method {
83 FusionMethod::NormalizedWeightedSum if !list.is_empty() => {
84 let mut min = f32::INFINITY;
85 let mut max = f32::NEG_INFINITY;
86 for r in &list {
87 min = min.min(r.score);
88 max = max.max(r.score);
89 }
90 let range = max - min;
91 (min, if range > 0.0 { 1.0 / range } else { 0.0 })
92 }
93 _ => (0.0, 0.0),
94 };
95
96 for (idx, result) in list.into_iter().enumerate() {
97 let contribution = match method {
98 FusionMethod::Rrf { k } => weight * rrf_contribution(k, idx + 1),
99 FusionMethod::NormalizedWeightedSum => {
100 if inv_range > 0.0 {
102 weight * (result.score - min_score) * inv_range
103 } else {
104 weight
105 }
106 }
107 };
108 fused
109 .entry((result.segment_id, result.doc_id))
110 .and_modify(|r| r.score += contribution)
111 .or_insert_with(|| SearchResult {
112 score: contribution,
113 ..result
114 });
115 }
116 }
117
118 let mut results: Vec<SearchResult> = fused.into_values().collect();
119 if results.len() > limit {
120 results.select_nth_unstable_by(limit, |a, b| b.score.total_cmp(&a.score));
121 results.truncate(limit);
122 }
123 results.sort_unstable_by(|a, b| {
124 b.score
125 .total_cmp(&a.score)
126 .then_with(|| a.doc_id.cmp(&b.doc_id))
127 });
128 results
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn result(doc_id: u32, score: f32) -> SearchResult {
136 SearchResult {
137 doc_id,
138 score,
139 segment_id: 1,
140 positions: Vec::new(),
141 }
142 }
143
144 #[test]
145 fn test_rrf_union_includes_single_list_docs() {
146 let sparse = vec![result(1, 10.0), result(2, 5.0)];
148 let dense = vec![result(3, 0.9), result(1, 0.8)];
149
150 let fused = fuse_ranked_lists(
151 vec![(sparse, 1.0), (dense, 1.0)],
152 FusionMethod::Rrf { k: 60.0 },
153 10,
154 );
155
156 assert_eq!(fused.len(), 3);
157 assert_eq!(fused[0].doc_id, 1);
159 let expected = 1.0 / 61.0 + 1.0 / 62.0;
160 assert!((fused[0].score - expected).abs() < 1e-6);
161 let ids: Vec<u32> = fused.iter().map(|r| r.doc_id).collect();
163 assert!(ids.contains(&2) && ids.contains(&3));
164 }
165
166 #[test]
167 fn test_rrf_weights_scale_contribution() {
168 let a = vec![result(1, 1.0)];
169 let b = vec![result(2, 1.0)];
170
171 let fused = fuse_ranked_lists(vec![(a, 1.0), (b, 2.0)], FusionMethod::Rrf { k: 60.0 }, 10);
173 assert_eq!(fused[0].doc_id, 2);
174 assert!((fused[0].score - 2.0 / 61.0).abs() < 1e-6);
175 }
176
177 #[test]
178 fn test_normalized_weighted_sum() {
179 let sparse = vec![result(1, 20.0), result(2, 10.0), result(3, 0.0)];
181 let dense = vec![result(2, 0.99), result(1, 0.55), result(3, 0.11)];
182
183 let fused = fuse_ranked_lists(
184 vec![(sparse, 0.5), (dense, 0.5)],
185 FusionMethod::NormalizedWeightedSum,
186 10,
187 );
188
189 assert_eq!(fused.len(), 3);
190 assert_eq!(fused[0].doc_id, 1);
193 assert!((fused[0].score - 0.75).abs() < 1e-6);
194 assert!((fused[1].score - 0.75).abs() < 1e-6);
195 assert_eq!(fused[2].doc_id, 3);
196 assert!(fused[2].score.abs() < 1e-6);
197 }
198
199 #[test]
200 fn test_limit_truncation() {
201 let list: Vec<SearchResult> = (0..100).map(|i| result(i, 100.0 - i as f32)).collect();
202 let fused = fuse_ranked_lists(vec![(list, 1.0)], FusionMethod::default(), 5);
203 assert_eq!(fused.len(), 5);
204 assert_eq!(fused[0].doc_id, 0);
205 }
206
207 #[test]
208 fn test_duplicate_across_segments_not_merged() {
209 let mut a = result(1, 1.0);
211 a.segment_id = 1;
212 let mut b = result(1, 1.0);
213 b.segment_id = 2;
214
215 let fused = fuse_ranked_lists(
216 vec![(vec![a], 1.0), (vec![b], 1.0)],
217 FusionMethod::default(),
218 10,
219 );
220 assert_eq!(fused.len(), 2);
221 }
222}