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};
25
26/// Default RRF rank constant (from Cormack et al., the standard choice).
27pub const DEFAULT_RRF_K: f32 = 60.0;
28
29/// Method for fusing multiple ranked result lists.
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub enum FusionMethod {
32    /// Reciprocal Rank Fusion: `score(d) = Σ_i w_i / (k + rank_i(d))`.
33    ///
34    /// Rank-based, so it is insensitive to incompatible score scales
35    /// (BM25 vs cosine similarity). `k` dampens the impact of top ranks;
36    /// 60 is the standard value.
37    Rrf { k: f32 },
38    /// Weighted sum of min-max normalized scores:
39    /// `score(d) = Σ_i w_i * (s_i(d) - min_i) / (max_i - min_i)`.
40    ///
41    /// Score-based, preserves score gaps within each list. Sensitive to
42    /// outliers; prefer RRF unless the score distributions are known.
43    ///
44    /// Degenerate lists where every score is identical (including
45    /// single-result lists) have no min-max range; every document in such a
46    /// list contributes the full `weight`, as if tied at the top. Avoid
47    /// feeding filter-like subqueries (many docs, constant score) through
48    /// this method — use `Rrf`, which only depends on ranks.
49    NormalizedWeightedSum,
50}
51
52impl Default for FusionMethod {
53    fn default() -> Self {
54        FusionMethod::Rrf { k: DEFAULT_RRF_K }
55    }
56}
57
58/// Reciprocal Rank Fusion contribution of a single 1-based rank.
59/// Shared by list fusion here and the L1/L2 reranker fusion.
60#[inline]
61pub(crate) fn rrf_contribution(k: f32, rank: usize) -> f32 {
62    1.0 / (k + rank as f32)
63}
64
65/// Fuse multiple ranked result lists into a single top-`limit` list.
66///
67/// Each input list must be sorted by descending score (the order produced
68/// by `Searcher::search`). `weight` scales that list's contribution.
69/// Documents are keyed by `(segment_id, doc_id)`; a document absent from a
70/// list contributes nothing for that list. Positions from the first list
71/// containing the document are preserved.
72pub 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        // Precompute min-max normalization bounds for score-based fusion
83        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                    // Single-result lists normalize to 1.0 (inv_range == 0)
102                    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
132/// Fuse multiple ranked result lists at **chunk granularity**.
133///
134/// Sub-query results are exploded into per-chunk entries keyed by
135/// `(segment_id, doc_id, ordinal)` — for multi-vector fields the ordinal is
136/// the chunk index, and results without per-ordinal scores (e.g. text
137/// queries) contribute a single pseudo-chunk with ordinal 0. Chunks are
138/// ranked *within each list by chunk score*, fused with `method` per chunk
139/// key, then combined into a document score with `combiner`.
140///
141/// Compared to doc-level [`fuse_ranked_lists`]:
142/// - Cross-vertical corroboration on the **same chunk** compounds (both
143///   contributions land on one key), while scattered hits on different
144///   chunks do not inflate the doc under a `Max`-style combiner — an
145///   unreliable vertical's noise cannot outvote a strong single-vertical hit.
146/// - Fused results carry per-chunk `positions`, so `ordinal_scores` survive
147///   fusion (chunk attribution for snippets / chunk selection).
148///
149/// `MultiValueCombiner::Max` is the recommended combiner: RRF contributions
150/// are small in magnitude, which makes `LogSumExp` degenerate (temperature
151/// far exceeds the score scale).
152pub 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); // (segment, doc, ordinal)
159
160    let mut fused: FxHashMap<ChunkKey, f32> = FxHashMap::default();
161    // Reused scratch: this list's chunks as (key, chunk_score)
162    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                // No per-chunk detail (text query / positions not collected):
176                // the whole doc is one pseudo-chunk at ordinal 0.
177                chunks.push(((result.segment_id, result.doc_id, 0), result.score));
178            }
179        }
180        if chunks.is_empty() {
181            continue;
182        }
183
184        // Rank chunks within this list by chunk score (desc); deterministic
185        // tiebreak on the key.
186        chunks.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
187
188        // Min-max bounds for score-based fusion
189        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    // Group fused chunks by document and combine into doc scores
215    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        // doc 3 only appears in the dense list — union fusion must keep it
268        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        // doc 1 is rank 1 + rank 2 → highest fused score
279        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        // docs 2 and 3 both have a single rank contribution
283        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        // Same ranks, but list b weighted 2x → doc 2 wins
293        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        // Incompatible scales: BM25-ish vs cosine-ish
301        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        // doc 1: 0.5*1.0 + 0.5*0.5 = 0.75; doc 2: 0.5*0.5 + 0.5*1.0 = 0.75;
312        // doc 3: 0. Ties broken by doc_id.
313        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            // Doc score = max chunk (mirrors a Max combiner upstream)
339            score: chunks.iter().map(|&(_, s)| s).fold(0.0, f32::max),
340            segment_id: 1,
341            positions,
342        }
343    }
344
345    /// The multilingual/short-query regression: a doc that is rank 1 in the
346    /// reliable vertical must not be outvoted by a mediocre doc present in
347    /// both lists on DIFFERENT chunks. Under doc-level RRF it was
348    /// (2/(60+5) > 1/(60+1)); chunk-level fusion with Max fixes it.
349    #[test]
350    fn test_chunked_fusion_junk_vertical_does_not_outvote() {
351        // Sparse (reliable): doc 1 is the clear best; doc 9 is mediocre.
352        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        // Dense (junk for this query): confident ranks over noise; doc 9
360        // appears again but on a DIFFERENT chunk.
361        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    /// Same-chunk corroboration across verticals compounds; different-chunk
383    /// hits do not (under Max).
384    #[test]
385    fn test_chunked_fusion_same_chunk_corroboration_wins() {
386        // Doc 1: sparse chunk 3 rank 1 + dense chunk 3 rank 1 (same chunk)
387        // Doc 2: sparse chunk 0 rank 2 + dense chunk 7 rank 2 (different chunks)
388        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        // Doc 1's fused chunk 3 = 1/61 + 1/61; doc 2's best chunk = 1/62
400        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        // Per-chunk attribution survives fusion
405        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    /// Results without per-chunk detail (e.g. text queries) fuse as a single
411    /// pseudo-chunk at ordinal 0 and can corroborate vector chunk 0.
412    #[test]
413    fn test_chunked_fusion_pseudo_chunk_for_docs_without_positions() {
414        let text = vec![result(1, 3.0), result(2, 2.0)]; // no positions
415        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        // Same doc_id in different segments = different documents
432        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}