1use rustc_hash::FxHashMap;
22
23use super::vector::MultiValueCombiner;
24use super::{ScoredPosition, SearchResult, compare_search_results_desc};
25
26pub const DEFAULT_RRF_K: f32 = 60.0;
28pub const MAX_FUSION_SUB_QUERIES: usize = 16;
30pub const MAX_FUSION_CANDIDATE_SLOTS: usize = 200_000;
32pub const MAX_FUSION_CHUNK_SLOTS: usize = 500_000;
34
35#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum FusionMethod {
38 Rrf { k: f32 },
44 NormalizedWeightedSum,
56}
57
58impl Default for FusionMethod {
59 fn default() -> Self {
60 FusionMethod::Rrf { k: DEFAULT_RRF_K }
61 }
62}
63
64#[inline]
67pub(crate) fn rrf_contribution(k: f32, rank: usize) -> f32 {
68 1.0 / (k + rank as f32)
69}
70
71pub fn fuse_ranked_lists(
79 lists: Vec<(Vec<SearchResult>, f32)>,
80 method: FusionMethod,
81 limit: usize,
82) -> Vec<SearchResult> {
83 const MAX_INITIAL_FUSION_CAPACITY: usize = 200_000;
86 let capacity = lists
87 .iter()
88 .map(|(list, _)| list.len())
89 .fold(0usize, usize::saturating_add)
90 .min(MAX_INITIAL_FUSION_CAPACITY);
91 let mut fused: FxHashMap<(u128, u32), SearchResult> =
92 FxHashMap::with_capacity_and_hasher(capacity, Default::default());
93
94 for (list, weight) in lists {
95 let (min_score, inv_range) = match method {
97 FusionMethod::NormalizedWeightedSum if !list.is_empty() => {
98 let mut min = f32::INFINITY;
99 let mut max = f32::NEG_INFINITY;
100 for r in &list {
101 min = min.min(r.score);
102 max = max.max(r.score);
103 }
104 let range = max - min;
105 (min, if range > 0.0 { 1.0 / range } else { 0.0 })
106 }
107 _ => (0.0, 0.0),
108 };
109
110 for (idx, result) in list.into_iter().enumerate() {
111 let contribution = match method {
112 FusionMethod::Rrf { k } => weight * rrf_contribution(k, idx + 1),
113 FusionMethod::NormalizedWeightedSum => {
114 if inv_range > 0.0 {
116 weight * (result.score - min_score) * inv_range
117 } else {
118 weight
119 }
120 }
121 };
122 fused
123 .entry((result.segment_id, result.doc_id))
124 .and_modify(|r| r.score += contribution)
125 .or_insert_with(|| SearchResult {
126 score: contribution,
127 ..result
128 });
129 }
130 }
131
132 let mut results: Vec<SearchResult> = fused.into_values().collect();
133 if results.len() > limit {
134 results.select_nth_unstable_by(limit, compare_search_results_desc);
135 results.truncate(limit);
136 }
137 results.sort_unstable_by(compare_search_results_desc);
138 results
139}
140
141pub fn fuse_ranked_lists_chunked(
163 lists: Vec<(Vec<SearchResult>, f32)>,
164 method: FusionMethod,
165 combiner: MultiValueCombiner,
166 limit: usize,
167) -> Vec<SearchResult> {
168 type ChunkKey = (u128, u32, u32); let mut fused: FxHashMap<ChunkKey, f32> = FxHashMap::default();
171 let mut chunks: Vec<(ChunkKey, f32)> = Vec::new();
173
174 for (list, weight) in lists {
175 chunks.clear();
176 for result in &list {
177 let mut had_positions = false;
178 for (_field_id, scored_positions) in &result.positions {
179 for sp in scored_positions {
180 had_positions = true;
181 chunks.push(((result.segment_id, result.doc_id, sp.position), sp.score));
182 }
183 }
184 if !had_positions {
185 chunks.push(((result.segment_id, result.doc_id, 0), result.score));
188 }
189 }
190 if chunks.is_empty() {
191 continue;
192 }
193
194 chunks.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
197
198 let (min_score, inv_range) = match method {
200 FusionMethod::NormalizedWeightedSum => {
201 let max = chunks.first().map(|c| c.1).unwrap_or(0.0);
202 let min = chunks.last().map(|c| c.1).unwrap_or(0.0);
203 let range = max - min;
204 (min, if range > 0.0 { 1.0 / range } else { 0.0 })
205 }
206 _ => (0.0, 0.0),
207 };
208
209 for (rank, &(key, score)) in chunks.iter().enumerate() {
210 let contribution = match method {
211 FusionMethod::Rrf { k } => weight * rrf_contribution(k, rank + 1),
212 FusionMethod::NormalizedWeightedSum => {
213 if inv_range > 0.0 {
214 weight * (score - min_score) * inv_range
215 } else {
216 weight
217 }
218 }
219 };
220 *fused.entry(key).or_insert(0.0) += contribution;
221 }
222 }
223
224 let mut docs: FxHashMap<(u128, u32), Vec<(u32, f32)>> = FxHashMap::default();
226 for ((segment_id, doc_id, ordinal), score) in fused {
227 docs.entry((segment_id, doc_id))
228 .or_default()
229 .push((ordinal, score));
230 }
231
232 let mut results: Vec<SearchResult> = docs
233 .into_iter()
234 .map(|((segment_id, doc_id), mut ordinals)| {
235 ordinals.sort_unstable_by_key(|&(ord, _)| ord);
236 let score = combiner.combine(&ordinals);
237 let scored_positions: Vec<ScoredPosition> = ordinals
238 .into_iter()
239 .map(|(ord, s)| ScoredPosition::new(ord, s))
240 .collect();
241 SearchResult {
242 doc_id,
243 score,
244 segment_id,
245 positions: vec![(0, scored_positions)],
246 }
247 })
248 .collect();
249
250 if results.len() > limit {
251 results.select_nth_unstable_by(limit, compare_search_results_desc);
252 results.truncate(limit);
253 }
254 results.sort_unstable_by(compare_search_results_desc);
255 results
256}
257
258pub fn try_fuse_ranked_lists_chunked(
263 lists: Vec<(Vec<SearchResult>, f32)>,
264 method: FusionMethod,
265 combiner: MultiValueCombiner,
266 limit: usize,
267) -> Result<Vec<SearchResult>, String> {
268 if lists.is_empty() {
269 return Err("fusion requires at least one ranked list".to_string());
270 }
271 if lists.len() > MAX_FUSION_SUB_QUERIES {
272 return Err(format!(
273 "fusion supports at most {MAX_FUSION_SUB_QUERIES} ranked lists"
274 ));
275 }
276 if let FusionMethod::Rrf { k } = method
277 && (!k.is_finite() || k < 0.0)
278 {
279 return Err(format!(
280 "fusion RRF k must be finite and non-negative, got {k}"
281 ));
282 }
283 combiner.validate()?;
284
285 let mut candidates = 0usize;
286 let mut chunks = 0usize;
287 for (list_index, (list, weight)) in lists.iter().enumerate() {
288 if !weight.is_finite() || *weight < 0.0 {
289 return Err(format!(
290 "fusion list weight at index {list_index} must be finite and non-negative, \
291 got {weight}"
292 ));
293 }
294 candidates = candidates
295 .checked_add(list.len())
296 .ok_or_else(|| "fusion candidate count overflow".to_string())?;
297 if candidates > MAX_FUSION_CANDIDATE_SLOTS {
298 return Err(format!(
299 "fusion contains more than {MAX_FUSION_CANDIDATE_SLOTS} candidate slots"
300 ));
301 }
302 for result in list {
303 let position_count = result
304 .positions
305 .iter()
306 .try_fold(0usize, |count, (_, positions)| {
307 count.checked_add(positions.len())
308 })
309 .ok_or_else(|| "fusion chunk count overflow".to_string())?;
310 chunks = chunks
312 .checked_add(position_count.max(1))
313 .ok_or_else(|| "fusion chunk count overflow".to_string())?;
314 if chunks > MAX_FUSION_CHUNK_SLOTS {
315 return Err(format!(
316 "fusion expands to more than {MAX_FUSION_CHUNK_SLOTS} ordinal chunks"
317 ));
318 }
319 }
320 }
321
322 Ok(fuse_ranked_lists_chunked(lists, method, combiner, limit))
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn result(doc_id: u32, score: f32) -> SearchResult {
330 SearchResult {
331 doc_id,
332 score,
333 segment_id: 1,
334 positions: Vec::new(),
335 }
336 }
337
338 #[test]
339 fn test_rrf_union_includes_single_list_docs() {
340 let sparse = vec![result(1, 10.0), result(2, 5.0)];
342 let dense = vec![result(3, 0.9), result(1, 0.8)];
343
344 let fused = fuse_ranked_lists(
345 vec![(sparse, 1.0), (dense, 1.0)],
346 FusionMethod::Rrf { k: 60.0 },
347 10,
348 );
349
350 assert_eq!(fused.len(), 3);
351 assert_eq!(fused[0].doc_id, 1);
353 let expected = 1.0 / 61.0 + 1.0 / 62.0;
354 assert!((fused[0].score - expected).abs() < 1e-6);
355 let ids: Vec<u32> = fused.iter().map(|r| r.doc_id).collect();
357 assert!(ids.contains(&2) && ids.contains(&3));
358 }
359
360 #[test]
361 fn test_rrf_weights_scale_contribution() {
362 let a = vec![result(1, 1.0)];
363 let b = vec![result(2, 1.0)];
364
365 let fused = fuse_ranked_lists(vec![(a, 1.0), (b, 2.0)], FusionMethod::Rrf { k: 60.0 }, 10);
367 assert_eq!(fused[0].doc_id, 2);
368 assert!((fused[0].score - 2.0 / 61.0).abs() < 1e-6);
369 }
370
371 #[test]
372 fn test_normalized_weighted_sum() {
373 let sparse = vec![result(1, 20.0), result(2, 10.0), result(3, 0.0)];
375 let dense = vec![result(2, 0.99), result(1, 0.55), result(3, 0.11)];
376
377 let fused = fuse_ranked_lists(
378 vec![(sparse, 0.5), (dense, 0.5)],
379 FusionMethod::NormalizedWeightedSum,
380 10,
381 );
382
383 assert_eq!(fused.len(), 3);
384 assert_eq!(fused[0].doc_id, 1);
387 assert!((fused[0].score - 0.75).abs() < 1e-6);
388 assert!((fused[1].score - 0.75).abs() < 1e-6);
389 assert_eq!(fused[2].doc_id, 3);
390 assert!(fused[2].score.abs() < 1e-6);
391 }
392
393 #[test]
394 fn test_limit_truncation() {
395 let list: Vec<SearchResult> = (0..100).map(|i| result(i, 100.0 - i as f32)).collect();
396 let fused = fuse_ranked_lists(vec![(list, 1.0)], FusionMethod::default(), 5);
397 assert_eq!(fused.len(), 5);
398 assert_eq!(fused[0].doc_id, 0);
399 }
400
401 fn chunked(doc_id: u32, chunks: &[(u32, f32)]) -> SearchResult {
402 let positions = vec![(
403 0u32,
404 chunks
405 .iter()
406 .map(|&(ord, s)| ScoredPosition::new(ord, s))
407 .collect(),
408 )];
409 SearchResult {
410 doc_id,
411 score: chunks.iter().map(|&(_, s)| s).fold(0.0, f32::max),
413 segment_id: 1,
414 positions,
415 }
416 }
417
418 #[test]
423 fn test_chunked_fusion_junk_vertical_does_not_outvote() {
424 let sparse = vec![
426 chunked(1, &[(0, 10.0)]),
427 chunked(2, &[(0, 5.0)]),
428 chunked(3, &[(0, 4.0)]),
429 chunked(4, &[(0, 3.0)]),
430 chunked(9, &[(2, 2.0)]),
431 ];
432 let dense = vec![
435 chunked(7, &[(0, 0.31)]),
436 chunked(8, &[(1, 0.30)]),
437 chunked(6, &[(0, 0.29)]),
438 chunked(5, &[(3, 0.28)]),
439 chunked(9, &[(5, 0.27)]),
440 ];
441
442 let fused = fuse_ranked_lists_chunked(
443 vec![(sparse, 1.0), (dense, 1.0)],
444 FusionMethod::Rrf { k: 60.0 },
445 MultiValueCombiner::Max,
446 10,
447 );
448
449 assert_eq!(
450 fused[0].doc_id, 1,
451 "sparse rank-1 doc must win over doc 9 (present in both lists on different chunks)"
452 );
453 }
454
455 #[test]
458 fn test_chunked_fusion_same_chunk_corroboration_wins() {
459 let sparse = vec![chunked(1, &[(3, 9.0)]), chunked(2, &[(0, 8.0)])];
462 let dense = vec![chunked(1, &[(3, 0.9)]), chunked(2, &[(7, 0.8)])];
463
464 let fused = fuse_ranked_lists_chunked(
465 vec![(sparse, 1.0), (dense, 1.0)],
466 FusionMethod::Rrf { k: 60.0 },
467 MultiValueCombiner::Max,
468 10,
469 );
470
471 assert_eq!(fused[0].doc_id, 1);
472 let expected_doc1 = 2.0 / 61.0;
474 assert!((fused[0].score - expected_doc1).abs() < 1e-6);
475 assert!(fused[1].score < expected_doc1 / 1.9);
476
477 let (_, positions) = &fused[0].positions[0..1][0];
479 assert_eq!(positions.len(), 1);
480 assert_eq!(positions[0].position, 3, "fused chunk ordinal preserved");
481 }
482
483 #[test]
486 fn test_chunked_fusion_pseudo_chunk_for_docs_without_positions() {
487 let text = vec![result(1, 3.0), result(2, 2.0)]; let dense = vec![chunked(1, &[(0, 0.9)])];
489
490 let fused = fuse_ranked_lists_chunked(
491 vec![(text, 1.0), (dense, 1.0)],
492 FusionMethod::Rrf { k: 60.0 },
493 MultiValueCombiner::Max,
494 10,
495 );
496
497 assert_eq!(fused[0].doc_id, 1);
498 assert!((fused[0].score - 2.0 / 61.0).abs() < 1e-6);
499 assert_eq!(fused.len(), 2);
500 }
501
502 #[test]
503 fn test_validated_chunked_fusion_rejects_invalid_parameters() {
504 assert!(
505 try_fuse_ranked_lists_chunked(
506 vec![(vec![result(1, 1.0)], -1.0)],
507 FusionMethod::default(),
508 MultiValueCombiner::Max,
509 10,
510 )
511 .is_err()
512 );
513 assert!(
514 try_fuse_ranked_lists_chunked(
515 vec![(vec![result(1, 1.0)], 1.0)],
516 FusionMethod::Rrf { k: f32::NAN },
517 MultiValueCombiner::Max,
518 10,
519 )
520 .is_err()
521 );
522 }
523
524 #[test]
525 fn test_duplicate_across_segments_not_merged() {
526 let mut a = result(1, 1.0);
528 a.segment_id = 1;
529 let mut b = result(1, 1.0);
530 b.segment_id = 2;
531
532 let fused = fuse_ranked_lists(
533 vec![(vec![a], 1.0), (vec![b], 1.0)],
534 FusionMethod::default(),
535 10,
536 );
537 assert_eq!(fused.len(), 2);
538 }
539}