Skip to main content

hermes_core/query/
fusion.rs

1//! Hybrid score fusion: combine ranked lists from independent queries.
2//!
3//! Unlike the L2 reranker (which re-scores the *first-stage candidates*),
4//! fusion takes the *union* of several result lists — a document only found
5//! by the dense query can still surface in the fused top-k even if the
6//! sparse query missed it entirely, and vice versa.
7//!
8//! Typical use: run a sparse (BM25/SPLADE) query and a dense vector query,
9//! then fuse with Reciprocal Rank Fusion:
10//!
11//! ```ignore
12//! let results = searcher
13//!     .search_fused(
14//!         &[(&sparse_query, 1.0), (&dense_query, 1.0)],
15//!         10,
16//!         FusionMethod::default(),
17//!     )
18//!     .await?;
19//! ```
20
21use rustc_hash::FxHashMap;
22
23use super::vector::MultiValueCombiner;
24use super::{ScoredPosition, SearchResult, compare_search_results_desc};
25
26/// Default RRF rank constant (from Cormack et al., the standard choice).
27pub const DEFAULT_RRF_K: f32 = 60.0;
28/// Maximum independently executed lists accepted by the Searcher fusion API.
29pub const MAX_FUSION_SUB_QUERIES: usize = 16;
30/// Maximum aggregate list slots retained before fusion.
31pub const MAX_FUSION_CANDIDATE_SLOTS: usize = 200_000;
32/// Maximum per-ordinal chunk contributions materialized during fusion.
33pub const MAX_FUSION_CHUNK_SLOTS: usize = 500_000;
34
35/// Method for fusing multiple ranked result lists.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum FusionMethod {
38    /// Reciprocal Rank Fusion: `score(d) = Σ_i w_i / (k + rank_i(d))`.
39    ///
40    /// Rank-based, so it is insensitive to incompatible score scales
41    /// (BM25 vs cosine similarity). `k` dampens the impact of top ranks;
42    /// 60 is the standard value.
43    Rrf { k: f32 },
44    /// Weighted sum of min-max normalized scores:
45    /// `score(d) = Σ_i w_i * (s_i(d) - min_i) / (max_i - min_i)`.
46    ///
47    /// Score-based, preserves score gaps within each list. Sensitive to
48    /// outliers; prefer RRF unless the score distributions are known.
49    ///
50    /// Degenerate lists where every score is identical (including
51    /// single-result lists) have no min-max range; every document in such a
52    /// list contributes the full `weight`, as if tied at the top. Avoid
53    /// feeding filter-like subqueries (many docs, constant score) through
54    /// this method — use `Rrf`, which only depends on ranks.
55    NormalizedWeightedSum,
56}
57
58impl Default for FusionMethod {
59    fn default() -> Self {
60        FusionMethod::Rrf { k: DEFAULT_RRF_K }
61    }
62}
63
64/// Reciprocal Rank Fusion contribution of a single 1-based rank.
65/// Shared by list fusion here and the L1/L2 reranker fusion.
66#[inline]
67pub(crate) fn rrf_contribution(k: f32, rank: usize) -> f32 {
68    1.0 / (k + rank as f32)
69}
70
71/// Fuse multiple ranked result lists into a single top-`limit` list.
72///
73/// Each input list must be sorted by descending score (the order produced
74/// by `Searcher::search`). `weight` scales that list's contribution.
75/// Documents are keyed by `(segment_id, doc_id)`; a document absent from a
76/// list contributes nothing for that list. Positions from the first list
77/// containing the document are preserved.
78pub fn fuse_ranked_lists(
79    lists: Vec<(Vec<SearchResult>, f32)>,
80    method: FusionMethod,
81    limit: usize,
82) -> Vec<SearchResult> {
83    // Avoid reserving an attacker-controlled sum up front. The map can grow
84    // naturally if a trusted embedded caller intentionally fuses more.
85    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        // Precompute min-max normalization bounds for score-based fusion
96        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                    // Single-result lists normalize to 1.0 (inv_range == 0)
115                    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
141/// Fuse multiple ranked result lists at **chunk granularity**.
142///
143/// Sub-query results are exploded into per-chunk entries keyed by
144/// `(segment_id, doc_id, ordinal)` — for multi-vector fields the ordinal is
145/// the chunk index, and results without per-ordinal scores (e.g. text
146/// queries) contribute a single pseudo-chunk with ordinal 0. Chunks are
147/// ranked *within each list by chunk score*, fused with `method` per chunk
148/// key, then combined into a document score with `combiner`.
149///
150/// Compared to doc-level [`fuse_ranked_lists`]:
151/// - Cross-vertical corroboration on the **same chunk** compounds (both
152///   contributions land on one key), while scattered hits on different
153///   chunks do not inflate the doc under a `Max`-style combiner — an
154///   unreliable vertical's noise cannot outvote a strong single-vertical hit.
155/// - Fused results carry per-chunk `positions`, so `ordinal_scores` survive
156///   fusion (chunk attribution for snippets / chunk selection).
157///
158/// `MultiValueCombiner::Max` is the recommended combiner. `LogSumExp` is
159/// also safe now that it is a softmax-weighted maximum — at RRF's small
160/// score scale it degrades toward a mean rather than growing with chunk
161/// count — but `Max` states the intent directly.
162pub 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); // (segment, doc, ordinal)
169
170    let mut fused: FxHashMap<ChunkKey, f32> = FxHashMap::default();
171    // Reused scratch: this list's chunks as (key, chunk_score)
172    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                // No per-chunk detail (text query / positions not collected):
186                // the whole doc is one pseudo-chunk at ordinal 0.
187                chunks.push(((result.segment_id, result.doc_id, 0), result.score));
188            }
189        }
190        if chunks.is_empty() {
191            continue;
192        }
193
194        // Rank chunks within this list by chunk score (desc); deterministic
195        // tiebreak on the key.
196        chunks.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
197
198        // Min-max bounds for score-based fusion
199        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    // Group fused chunks by document and combine into doc scores
225    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
258/// Validated, bounded entry point for chunk-level fusion used by Searcher and
259/// the server. The legacy pure helper remains available for trusted embedded
260/// callers, while request-facing paths must account for ordinal expansion
261/// before allocating fusion maps.
262pub 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            // Results without positions contribute one pseudo-chunk.
311            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        // doc 3 only appears in the dense list — union fusion must keep it
341        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        // doc 1 is rank 1 + rank 2 → highest fused score
352        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        // docs 2 and 3 both have a single rank contribution
356        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        // Same ranks, but list b weighted 2x → doc 2 wins
366        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        // Incompatible scales: BM25-ish vs cosine-ish
374        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        // doc 1: 0.5*1.0 + 0.5*0.5 = 0.75; doc 2: 0.5*0.5 + 0.5*1.0 = 0.75;
385        // doc 3: 0. Ties broken by doc_id.
386        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            // Doc score = max chunk (mirrors a Max combiner upstream)
412            score: chunks.iter().map(|&(_, s)| s).fold(0.0, f32::max),
413            segment_id: 1,
414            positions,
415        }
416    }
417
418    /// The multilingual/short-query regression: a doc that is rank 1 in the
419    /// reliable vertical must not be outvoted by a mediocre doc present in
420    /// both lists on DIFFERENT chunks. Under doc-level RRF it was
421    /// (2/(60+5) > 1/(60+1)); chunk-level fusion with Max fixes it.
422    #[test]
423    fn test_chunked_fusion_junk_vertical_does_not_outvote() {
424        // Sparse (reliable): doc 1 is the clear best; doc 9 is mediocre.
425        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        // Dense (junk for this query): confident ranks over noise; doc 9
433        // appears again but on a DIFFERENT chunk.
434        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    /// Same-chunk corroboration across verticals compounds; different-chunk
456    /// hits do not (under Max).
457    #[test]
458    fn test_chunked_fusion_same_chunk_corroboration_wins() {
459        // Doc 1: sparse chunk 3 rank 1 + dense chunk 3 rank 1 (same chunk)
460        // Doc 2: sparse chunk 0 rank 2 + dense chunk 7 rank 2 (different chunks)
461        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        // Doc 1's fused chunk 3 = 1/61 + 1/61; doc 2's best chunk = 1/62
473        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        // Per-chunk attribution survives fusion
478        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    /// Results without per-chunk detail (e.g. text queries) fuse as a single
484    /// pseudo-chunk at ordinal 0 and can corroborate vector chunk 0.
485    #[test]
486    fn test_chunked_fusion_pseudo_chunk_for_docs_without_positions() {
487        let text = vec![result(1, 3.0), result(2, 2.0)]; // no positions
488        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        // Same doc_id in different segments = different documents
527        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}