1use rustc_hash::FxHashMap;
22
23use super::vector::MultiValueCombiner;
24use super::{ScoredPosition, SearchResult};
25
26pub const DEFAULT_RRF_K: f32 = 60.0;
28
29#[derive(Debug, Clone, Copy, PartialEq)]
31pub enum FusionMethod {
32 Rrf { k: f32 },
38 NormalizedWeightedSum,
50}
51
52impl Default for FusionMethod {
53 fn default() -> Self {
54 FusionMethod::Rrf { k: DEFAULT_RRF_K }
55 }
56}
57
58#[inline]
61pub(crate) fn rrf_contribution(k: f32, rank: usize) -> f32 {
62 1.0 / (k + rank as f32)
63}
64
65pub fn fuse_ranked_lists(
73 lists: Vec<(Vec<SearchResult>, f32)>,
74 method: FusionMethod,
75 limit: usize,
76) -> Vec<SearchResult> {
77 let capacity = lists.iter().map(|(l, _)| l.len()).sum();
78 let mut fused: FxHashMap<(u128, u32), SearchResult> =
79 FxHashMap::with_capacity_and_hasher(capacity, Default::default());
80
81 for (list, weight) in lists {
82 let (min_score, inv_range) = match method {
84 FusionMethod::NormalizedWeightedSum if !list.is_empty() => {
85 let mut min = f32::INFINITY;
86 let mut max = f32::NEG_INFINITY;
87 for r in &list {
88 min = min.min(r.score);
89 max = max.max(r.score);
90 }
91 let range = max - min;
92 (min, if range > 0.0 { 1.0 / range } else { 0.0 })
93 }
94 _ => (0.0, 0.0),
95 };
96
97 for (idx, result) in list.into_iter().enumerate() {
98 let contribution = match method {
99 FusionMethod::Rrf { k } => weight * rrf_contribution(k, idx + 1),
100 FusionMethod::NormalizedWeightedSum => {
101 if inv_range > 0.0 {
103 weight * (result.score - min_score) * inv_range
104 } else {
105 weight
106 }
107 }
108 };
109 fused
110 .entry((result.segment_id, result.doc_id))
111 .and_modify(|r| r.score += contribution)
112 .or_insert_with(|| SearchResult {
113 score: contribution,
114 ..result
115 });
116 }
117 }
118
119 let mut results: Vec<SearchResult> = fused.into_values().collect();
120 if results.len() > limit {
121 results.select_nth_unstable_by(limit, |a, b| b.score.total_cmp(&a.score));
122 results.truncate(limit);
123 }
124 results.sort_unstable_by(|a, b| {
125 b.score
126 .total_cmp(&a.score)
127 .then_with(|| a.doc_id.cmp(&b.doc_id))
128 });
129 results
130}
131
132pub fn fuse_ranked_lists_chunked(
153 lists: Vec<(Vec<SearchResult>, f32)>,
154 method: FusionMethod,
155 combiner: MultiValueCombiner,
156 limit: usize,
157) -> Vec<SearchResult> {
158 type ChunkKey = (u128, u32, u32); let mut fused: FxHashMap<ChunkKey, f32> = FxHashMap::default();
161 let mut chunks: Vec<(ChunkKey, f32)> = Vec::new();
163
164 for (list, weight) in lists {
165 chunks.clear();
166 for result in &list {
167 let mut had_positions = false;
168 for (_field_id, scored_positions) in &result.positions {
169 for sp in scored_positions {
170 had_positions = true;
171 chunks.push(((result.segment_id, result.doc_id, sp.position), sp.score));
172 }
173 }
174 if !had_positions {
175 chunks.push(((result.segment_id, result.doc_id, 0), result.score));
178 }
179 }
180 if chunks.is_empty() {
181 continue;
182 }
183
184 chunks.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
187
188 let (min_score, inv_range) = match method {
190 FusionMethod::NormalizedWeightedSum => {
191 let max = chunks.first().map(|c| c.1).unwrap_or(0.0);
192 let min = chunks.last().map(|c| c.1).unwrap_or(0.0);
193 let range = max - min;
194 (min, if range > 0.0 { 1.0 / range } else { 0.0 })
195 }
196 _ => (0.0, 0.0),
197 };
198
199 for (rank, &(key, score)) in chunks.iter().enumerate() {
200 let contribution = match method {
201 FusionMethod::Rrf { k } => weight * rrf_contribution(k, rank + 1),
202 FusionMethod::NormalizedWeightedSum => {
203 if inv_range > 0.0 {
204 weight * (score - min_score) * inv_range
205 } else {
206 weight
207 }
208 }
209 };
210 *fused.entry(key).or_insert(0.0) += contribution;
211 }
212 }
213
214 let mut docs: FxHashMap<(u128, u32), Vec<(u32, f32)>> = FxHashMap::default();
216 for ((segment_id, doc_id, ordinal), score) in fused {
217 docs.entry((segment_id, doc_id))
218 .or_default()
219 .push((ordinal, score));
220 }
221
222 let mut results: Vec<SearchResult> = docs
223 .into_iter()
224 .map(|((segment_id, doc_id), mut ordinals)| {
225 ordinals.sort_unstable_by_key(|&(ord, _)| ord);
226 let score = combiner.combine(&ordinals);
227 let scored_positions: Vec<ScoredPosition> = ordinals
228 .into_iter()
229 .map(|(ord, s)| ScoredPosition::new(ord, s))
230 .collect();
231 SearchResult {
232 doc_id,
233 score,
234 segment_id,
235 positions: vec![(0, scored_positions)],
236 }
237 })
238 .collect();
239
240 if results.len() > limit {
241 results.select_nth_unstable_by(limit, |a, b| b.score.total_cmp(&a.score));
242 results.truncate(limit);
243 }
244 results.sort_unstable_by(|a, b| {
245 b.score
246 .total_cmp(&a.score)
247 .then_with(|| a.doc_id.cmp(&b.doc_id))
248 });
249 results
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn result(doc_id: u32, score: f32) -> SearchResult {
257 SearchResult {
258 doc_id,
259 score,
260 segment_id: 1,
261 positions: Vec::new(),
262 }
263 }
264
265 #[test]
266 fn test_rrf_union_includes_single_list_docs() {
267 let sparse = vec![result(1, 10.0), result(2, 5.0)];
269 let dense = vec![result(3, 0.9), result(1, 0.8)];
270
271 let fused = fuse_ranked_lists(
272 vec![(sparse, 1.0), (dense, 1.0)],
273 FusionMethod::Rrf { k: 60.0 },
274 10,
275 );
276
277 assert_eq!(fused.len(), 3);
278 assert_eq!(fused[0].doc_id, 1);
280 let expected = 1.0 / 61.0 + 1.0 / 62.0;
281 assert!((fused[0].score - expected).abs() < 1e-6);
282 let ids: Vec<u32> = fused.iter().map(|r| r.doc_id).collect();
284 assert!(ids.contains(&2) && ids.contains(&3));
285 }
286
287 #[test]
288 fn test_rrf_weights_scale_contribution() {
289 let a = vec![result(1, 1.0)];
290 let b = vec![result(2, 1.0)];
291
292 let fused = fuse_ranked_lists(vec![(a, 1.0), (b, 2.0)], FusionMethod::Rrf { k: 60.0 }, 10);
294 assert_eq!(fused[0].doc_id, 2);
295 assert!((fused[0].score - 2.0 / 61.0).abs() < 1e-6);
296 }
297
298 #[test]
299 fn test_normalized_weighted_sum() {
300 let sparse = vec![result(1, 20.0), result(2, 10.0), result(3, 0.0)];
302 let dense = vec![result(2, 0.99), result(1, 0.55), result(3, 0.11)];
303
304 let fused = fuse_ranked_lists(
305 vec![(sparse, 0.5), (dense, 0.5)],
306 FusionMethod::NormalizedWeightedSum,
307 10,
308 );
309
310 assert_eq!(fused.len(), 3);
311 assert_eq!(fused[0].doc_id, 1);
314 assert!((fused[0].score - 0.75).abs() < 1e-6);
315 assert!((fused[1].score - 0.75).abs() < 1e-6);
316 assert_eq!(fused[2].doc_id, 3);
317 assert!(fused[2].score.abs() < 1e-6);
318 }
319
320 #[test]
321 fn test_limit_truncation() {
322 let list: Vec<SearchResult> = (0..100).map(|i| result(i, 100.0 - i as f32)).collect();
323 let fused = fuse_ranked_lists(vec![(list, 1.0)], FusionMethod::default(), 5);
324 assert_eq!(fused.len(), 5);
325 assert_eq!(fused[0].doc_id, 0);
326 }
327
328 fn chunked(doc_id: u32, chunks: &[(u32, f32)]) -> SearchResult {
329 let positions = vec![(
330 0u32,
331 chunks
332 .iter()
333 .map(|&(ord, s)| ScoredPosition::new(ord, s))
334 .collect(),
335 )];
336 SearchResult {
337 doc_id,
338 score: chunks.iter().map(|&(_, s)| s).fold(0.0, f32::max),
340 segment_id: 1,
341 positions,
342 }
343 }
344
345 #[test]
350 fn test_chunked_fusion_junk_vertical_does_not_outvote() {
351 let sparse = vec![
353 chunked(1, &[(0, 10.0)]),
354 chunked(2, &[(0, 5.0)]),
355 chunked(3, &[(0, 4.0)]),
356 chunked(4, &[(0, 3.0)]),
357 chunked(9, &[(2, 2.0)]),
358 ];
359 let dense = vec![
362 chunked(7, &[(0, 0.31)]),
363 chunked(8, &[(1, 0.30)]),
364 chunked(6, &[(0, 0.29)]),
365 chunked(5, &[(3, 0.28)]),
366 chunked(9, &[(5, 0.27)]),
367 ];
368
369 let fused = fuse_ranked_lists_chunked(
370 vec![(sparse, 1.0), (dense, 1.0)],
371 FusionMethod::Rrf { k: 60.0 },
372 MultiValueCombiner::Max,
373 10,
374 );
375
376 assert_eq!(
377 fused[0].doc_id, 1,
378 "sparse rank-1 doc must win over doc 9 (present in both lists on different chunks)"
379 );
380 }
381
382 #[test]
385 fn test_chunked_fusion_same_chunk_corroboration_wins() {
386 let sparse = vec![chunked(1, &[(3, 9.0)]), chunked(2, &[(0, 8.0)])];
389 let dense = vec![chunked(1, &[(3, 0.9)]), chunked(2, &[(7, 0.8)])];
390
391 let fused = fuse_ranked_lists_chunked(
392 vec![(sparse, 1.0), (dense, 1.0)],
393 FusionMethod::Rrf { k: 60.0 },
394 MultiValueCombiner::Max,
395 10,
396 );
397
398 assert_eq!(fused[0].doc_id, 1);
399 let expected_doc1 = 2.0 / 61.0;
401 assert!((fused[0].score - expected_doc1).abs() < 1e-6);
402 assert!(fused[1].score < expected_doc1 / 1.9);
403
404 let (_, positions) = &fused[0].positions[0..1][0];
406 assert_eq!(positions.len(), 1);
407 assert_eq!(positions[0].position, 3, "fused chunk ordinal preserved");
408 }
409
410 #[test]
413 fn test_chunked_fusion_pseudo_chunk_for_docs_without_positions() {
414 let text = vec![result(1, 3.0), result(2, 2.0)]; let dense = vec![chunked(1, &[(0, 0.9)])];
416
417 let fused = fuse_ranked_lists_chunked(
418 vec![(text, 1.0), (dense, 1.0)],
419 FusionMethod::Rrf { k: 60.0 },
420 MultiValueCombiner::Max,
421 10,
422 );
423
424 assert_eq!(fused[0].doc_id, 1);
425 assert!((fused[0].score - 2.0 / 61.0).abs() < 1e-6);
426 assert_eq!(fused.len(), 2);
427 }
428
429 #[test]
430 fn test_duplicate_across_segments_not_merged() {
431 let mut a = result(1, 1.0);
433 a.segment_id = 1;
434 let mut b = result(1, 1.0);
435 b.segment_id = 2;
436
437 let fused = fuse_ranked_lists(
438 vec![(vec![a], 1.0), (vec![b], 1.0)],
439 FusionMethod::default(),
440 10,
441 );
442 assert_eq!(fused.len(), 2);
443 }
444}