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(
162 lists: Vec<(Vec<SearchResult>, f32)>,
163 method: FusionMethod,
164 combiner: MultiValueCombiner,
165 limit: usize,
166) -> Vec<SearchResult> {
167 type ChunkKey = (u128, u32, u32); let mut fused: FxHashMap<ChunkKey, f32> = FxHashMap::default();
170 let mut chunks: Vec<(ChunkKey, f32)> = Vec::new();
172
173 for (list, weight) in lists {
174 chunks.clear();
175 for result in &list {
176 let mut had_positions = false;
177 for (_field_id, scored_positions) in &result.positions {
178 for sp in scored_positions {
179 had_positions = true;
180 chunks.push(((result.segment_id, result.doc_id, sp.position), sp.score));
181 }
182 }
183 if !had_positions {
184 chunks.push(((result.segment_id, result.doc_id, 0), result.score));
187 }
188 }
189 if chunks.is_empty() {
190 continue;
191 }
192
193 chunks.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
196
197 let (min_score, inv_range) = match method {
199 FusionMethod::NormalizedWeightedSum => {
200 let max = chunks.first().map(|c| c.1).unwrap_or(0.0);
201 let min = chunks.last().map(|c| c.1).unwrap_or(0.0);
202 let range = max - min;
203 (min, if range > 0.0 { 1.0 / range } else { 0.0 })
204 }
205 _ => (0.0, 0.0),
206 };
207
208 for (rank, &(key, score)) in chunks.iter().enumerate() {
209 let contribution = match method {
210 FusionMethod::Rrf { k } => weight * rrf_contribution(k, rank + 1),
211 FusionMethod::NormalizedWeightedSum => {
212 if inv_range > 0.0 {
213 weight * (score - min_score) * inv_range
214 } else {
215 weight
216 }
217 }
218 };
219 *fused.entry(key).or_insert(0.0) += contribution;
220 }
221 }
222
223 let mut docs: FxHashMap<(u128, u32), Vec<(u32, f32)>> = FxHashMap::default();
225 for ((segment_id, doc_id, ordinal), score) in fused {
226 docs.entry((segment_id, doc_id))
227 .or_default()
228 .push((ordinal, score));
229 }
230
231 let mut results: Vec<SearchResult> = docs
232 .into_iter()
233 .map(|((segment_id, doc_id), mut ordinals)| {
234 ordinals.sort_unstable_by_key(|&(ord, _)| ord);
235 let score = combiner.combine(&ordinals);
236 let scored_positions: Vec<ScoredPosition> = ordinals
237 .into_iter()
238 .map(|(ord, s)| ScoredPosition::new(ord, s))
239 .collect();
240 SearchResult {
241 doc_id,
242 score,
243 segment_id,
244 positions: vec![(0, scored_positions)],
245 }
246 })
247 .collect();
248
249 if results.len() > limit {
250 results.select_nth_unstable_by(limit, compare_search_results_desc);
251 results.truncate(limit);
252 }
253 results.sort_unstable_by(compare_search_results_desc);
254 results
255}
256
257pub fn try_fuse_ranked_lists_chunked(
262 lists: Vec<(Vec<SearchResult>, f32)>,
263 method: FusionMethod,
264 combiner: MultiValueCombiner,
265 limit: usize,
266) -> Result<Vec<SearchResult>, String> {
267 if lists.is_empty() {
268 return Err("fusion requires at least one ranked list".to_string());
269 }
270 if lists.len() > MAX_FUSION_SUB_QUERIES {
271 return Err(format!(
272 "fusion supports at most {MAX_FUSION_SUB_QUERIES} ranked lists"
273 ));
274 }
275 if let FusionMethod::Rrf { k } = method
276 && (!k.is_finite() || k < 0.0)
277 {
278 return Err(format!(
279 "fusion RRF k must be finite and non-negative, got {k}"
280 ));
281 }
282 combiner.validate()?;
283
284 let mut candidates = 0usize;
285 let mut chunks = 0usize;
286 for (list_index, (list, weight)) in lists.iter().enumerate() {
287 if !weight.is_finite() || *weight < 0.0 {
288 return Err(format!(
289 "fusion list weight at index {list_index} must be finite and non-negative, \
290 got {weight}"
291 ));
292 }
293 candidates = candidates
294 .checked_add(list.len())
295 .ok_or_else(|| "fusion candidate count overflow".to_string())?;
296 if candidates > MAX_FUSION_CANDIDATE_SLOTS {
297 return Err(format!(
298 "fusion contains more than {MAX_FUSION_CANDIDATE_SLOTS} candidate slots"
299 ));
300 }
301 for result in list {
302 let position_count = result
303 .positions
304 .iter()
305 .try_fold(0usize, |count, (_, positions)| {
306 count.checked_add(positions.len())
307 })
308 .ok_or_else(|| "fusion chunk count overflow".to_string())?;
309 chunks = chunks
311 .checked_add(position_count.max(1))
312 .ok_or_else(|| "fusion chunk count overflow".to_string())?;
313 if chunks > MAX_FUSION_CHUNK_SLOTS {
314 return Err(format!(
315 "fusion expands to more than {MAX_FUSION_CHUNK_SLOTS} ordinal chunks"
316 ));
317 }
318 }
319 }
320
321 Ok(fuse_ranked_lists_chunked(lists, method, combiner, limit))
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn result(doc_id: u32, score: f32) -> SearchResult {
329 SearchResult {
330 doc_id,
331 score,
332 segment_id: 1,
333 positions: Vec::new(),
334 }
335 }
336
337 #[test]
338 fn test_rrf_union_includes_single_list_docs() {
339 let sparse = vec![result(1, 10.0), result(2, 5.0)];
341 let dense = vec![result(3, 0.9), result(1, 0.8)];
342
343 let fused = fuse_ranked_lists(
344 vec![(sparse, 1.0), (dense, 1.0)],
345 FusionMethod::Rrf { k: 60.0 },
346 10,
347 );
348
349 assert_eq!(fused.len(), 3);
350 assert_eq!(fused[0].doc_id, 1);
352 let expected = 1.0 / 61.0 + 1.0 / 62.0;
353 assert!((fused[0].score - expected).abs() < 1e-6);
354 let ids: Vec<u32> = fused.iter().map(|r| r.doc_id).collect();
356 assert!(ids.contains(&2) && ids.contains(&3));
357 }
358
359 #[test]
360 fn test_rrf_weights_scale_contribution() {
361 let a = vec![result(1, 1.0)];
362 let b = vec![result(2, 1.0)];
363
364 let fused = fuse_ranked_lists(vec![(a, 1.0), (b, 2.0)], FusionMethod::Rrf { k: 60.0 }, 10);
366 assert_eq!(fused[0].doc_id, 2);
367 assert!((fused[0].score - 2.0 / 61.0).abs() < 1e-6);
368 }
369
370 #[test]
371 fn test_normalized_weighted_sum() {
372 let sparse = vec![result(1, 20.0), result(2, 10.0), result(3, 0.0)];
374 let dense = vec![result(2, 0.99), result(1, 0.55), result(3, 0.11)];
375
376 let fused = fuse_ranked_lists(
377 vec![(sparse, 0.5), (dense, 0.5)],
378 FusionMethod::NormalizedWeightedSum,
379 10,
380 );
381
382 assert_eq!(fused.len(), 3);
383 assert_eq!(fused[0].doc_id, 1);
386 assert!((fused[0].score - 0.75).abs() < 1e-6);
387 assert!((fused[1].score - 0.75).abs() < 1e-6);
388 assert_eq!(fused[2].doc_id, 3);
389 assert!(fused[2].score.abs() < 1e-6);
390 }
391
392 #[test]
393 fn test_limit_truncation() {
394 let list: Vec<SearchResult> = (0..100).map(|i| result(i, 100.0 - i as f32)).collect();
395 let fused = fuse_ranked_lists(vec![(list, 1.0)], FusionMethod::default(), 5);
396 assert_eq!(fused.len(), 5);
397 assert_eq!(fused[0].doc_id, 0);
398 }
399
400 fn chunked(doc_id: u32, chunks: &[(u32, f32)]) -> SearchResult {
401 let positions = vec![(
402 0u32,
403 chunks
404 .iter()
405 .map(|&(ord, s)| ScoredPosition::new(ord, s))
406 .collect(),
407 )];
408 SearchResult {
409 doc_id,
410 score: chunks.iter().map(|&(_, s)| s).fold(0.0, f32::max),
412 segment_id: 1,
413 positions,
414 }
415 }
416
417 #[test]
422 fn test_chunked_fusion_junk_vertical_does_not_outvote() {
423 let sparse = vec![
425 chunked(1, &[(0, 10.0)]),
426 chunked(2, &[(0, 5.0)]),
427 chunked(3, &[(0, 4.0)]),
428 chunked(4, &[(0, 3.0)]),
429 chunked(9, &[(2, 2.0)]),
430 ];
431 let dense = vec![
434 chunked(7, &[(0, 0.31)]),
435 chunked(8, &[(1, 0.30)]),
436 chunked(6, &[(0, 0.29)]),
437 chunked(5, &[(3, 0.28)]),
438 chunked(9, &[(5, 0.27)]),
439 ];
440
441 let fused = fuse_ranked_lists_chunked(
442 vec![(sparse, 1.0), (dense, 1.0)],
443 FusionMethod::Rrf { k: 60.0 },
444 MultiValueCombiner::Max,
445 10,
446 );
447
448 assert_eq!(
449 fused[0].doc_id, 1,
450 "sparse rank-1 doc must win over doc 9 (present in both lists on different chunks)"
451 );
452 }
453
454 #[test]
457 fn test_chunked_fusion_same_chunk_corroboration_wins() {
458 let sparse = vec![chunked(1, &[(3, 9.0)]), chunked(2, &[(0, 8.0)])];
461 let dense = vec![chunked(1, &[(3, 0.9)]), chunked(2, &[(7, 0.8)])];
462
463 let fused = fuse_ranked_lists_chunked(
464 vec![(sparse, 1.0), (dense, 1.0)],
465 FusionMethod::Rrf { k: 60.0 },
466 MultiValueCombiner::Max,
467 10,
468 );
469
470 assert_eq!(fused[0].doc_id, 1);
471 let expected_doc1 = 2.0 / 61.0;
473 assert!((fused[0].score - expected_doc1).abs() < 1e-6);
474 assert!(fused[1].score < expected_doc1 / 1.9);
475
476 let (_, positions) = &fused[0].positions[0..1][0];
478 assert_eq!(positions.len(), 1);
479 assert_eq!(positions[0].position, 3, "fused chunk ordinal preserved");
480 }
481
482 #[test]
485 fn test_chunked_fusion_pseudo_chunk_for_docs_without_positions() {
486 let text = vec![result(1, 3.0), result(2, 2.0)]; let dense = vec![chunked(1, &[(0, 0.9)])];
488
489 let fused = fuse_ranked_lists_chunked(
490 vec![(text, 1.0), (dense, 1.0)],
491 FusionMethod::Rrf { k: 60.0 },
492 MultiValueCombiner::Max,
493 10,
494 );
495
496 assert_eq!(fused[0].doc_id, 1);
497 assert!((fused[0].score - 2.0 / 61.0).abs() < 1e-6);
498 assert_eq!(fused.len(), 2);
499 }
500
501 #[test]
502 fn test_validated_chunked_fusion_rejects_invalid_parameters() {
503 assert!(
504 try_fuse_ranked_lists_chunked(
505 vec![(vec![result(1, 1.0)], -1.0)],
506 FusionMethod::default(),
507 MultiValueCombiner::Max,
508 10,
509 )
510 .is_err()
511 );
512 assert!(
513 try_fuse_ranked_lists_chunked(
514 vec![(vec![result(1, 1.0)], 1.0)],
515 FusionMethod::Rrf { k: f32::NAN },
516 MultiValueCombiner::Max,
517 10,
518 )
519 .is_err()
520 );
521 }
522
523 #[test]
524 fn test_duplicate_across_segments_not_merged() {
525 let mut a = result(1, 1.0);
527 a.segment_id = 1;
528 let mut b = result(1, 1.0);
529 b.segment_id = 2;
530
531 let fused = fuse_ranked_lists(
532 vec![(vec![a], 1.0), (vec![b], 1.0)],
533 FusionMethod::default(),
534 10,
535 );
536 assert_eq!(fused.len(), 2);
537 }
538}