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::SearchResult;
24
25/// Default RRF rank constant (from Cormack et al., the standard choice).
26pub const DEFAULT_RRF_K: f32 = 60.0;
27
28/// Method for fusing multiple ranked result lists.
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub enum FusionMethod {
31    /// Reciprocal Rank Fusion: `score(d) = Σ_i w_i / (k + rank_i(d))`.
32    ///
33    /// Rank-based, so it is insensitive to incompatible score scales
34    /// (BM25 vs cosine similarity). `k` dampens the impact of top ranks;
35    /// 60 is the standard value.
36    Rrf { k: f32 },
37    /// Weighted sum of min-max normalized scores:
38    /// `score(d) = Σ_i w_i * (s_i(d) - min_i) / (max_i - min_i)`.
39    ///
40    /// Score-based, preserves score gaps within each list. Sensitive to
41    /// outliers; prefer RRF unless the score distributions are known.
42    ///
43    /// Degenerate lists where every score is identical (including
44    /// single-result lists) have no min-max range; every document in such a
45    /// list contributes the full `weight`, as if tied at the top. Avoid
46    /// feeding filter-like subqueries (many docs, constant score) through
47    /// this method — use `Rrf`, which only depends on ranks.
48    NormalizedWeightedSum,
49}
50
51impl Default for FusionMethod {
52    fn default() -> Self {
53        FusionMethod::Rrf { k: DEFAULT_RRF_K }
54    }
55}
56
57/// Reciprocal Rank Fusion contribution of a single 1-based rank.
58/// Shared by list fusion here and the L1/L2 reranker fusion.
59#[inline]
60pub(crate) fn rrf_contribution(k: f32, rank: usize) -> f32 {
61    1.0 / (k + rank as f32)
62}
63
64/// Fuse multiple ranked result lists into a single top-`limit` list.
65///
66/// Each input list must be sorted by descending score (the order produced
67/// by `Searcher::search`). `weight` scales that list's contribution.
68/// Documents are keyed by `(segment_id, doc_id)`; a document absent from a
69/// list contributes nothing for that list. Positions from the first list
70/// containing the document are preserved.
71pub fn fuse_ranked_lists(
72    lists: Vec<(Vec<SearchResult>, f32)>,
73    method: FusionMethod,
74    limit: usize,
75) -> Vec<SearchResult> {
76    let capacity = lists.iter().map(|(l, _)| l.len()).sum();
77    let mut fused: FxHashMap<(u128, u32), SearchResult> =
78        FxHashMap::with_capacity_and_hasher(capacity, Default::default());
79
80    for (list, weight) in lists {
81        // Precompute min-max normalization bounds for score-based fusion
82        let (min_score, inv_range) = match method {
83            FusionMethod::NormalizedWeightedSum if !list.is_empty() => {
84                let mut min = f32::INFINITY;
85                let mut max = f32::NEG_INFINITY;
86                for r in &list {
87                    min = min.min(r.score);
88                    max = max.max(r.score);
89                }
90                let range = max - min;
91                (min, if range > 0.0 { 1.0 / range } else { 0.0 })
92            }
93            _ => (0.0, 0.0),
94        };
95
96        for (idx, result) in list.into_iter().enumerate() {
97            let contribution = match method {
98                FusionMethod::Rrf { k } => weight * rrf_contribution(k, idx + 1),
99                FusionMethod::NormalizedWeightedSum => {
100                    // Single-result lists normalize to 1.0 (inv_range == 0)
101                    if inv_range > 0.0 {
102                        weight * (result.score - min_score) * inv_range
103                    } else {
104                        weight
105                    }
106                }
107            };
108            fused
109                .entry((result.segment_id, result.doc_id))
110                .and_modify(|r| r.score += contribution)
111                .or_insert_with(|| SearchResult {
112                    score: contribution,
113                    ..result
114                });
115        }
116    }
117
118    let mut results: Vec<SearchResult> = fused.into_values().collect();
119    if results.len() > limit {
120        results.select_nth_unstable_by(limit, |a, b| b.score.total_cmp(&a.score));
121        results.truncate(limit);
122    }
123    results.sort_unstable_by(|a, b| {
124        b.score
125            .total_cmp(&a.score)
126            .then_with(|| a.doc_id.cmp(&b.doc_id))
127    });
128    results
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn result(doc_id: u32, score: f32) -> SearchResult {
136        SearchResult {
137            doc_id,
138            score,
139            segment_id: 1,
140            positions: Vec::new(),
141        }
142    }
143
144    #[test]
145    fn test_rrf_union_includes_single_list_docs() {
146        // doc 3 only appears in the dense list — union fusion must keep it
147        let sparse = vec![result(1, 10.0), result(2, 5.0)];
148        let dense = vec![result(3, 0.9), result(1, 0.8)];
149
150        let fused = fuse_ranked_lists(
151            vec![(sparse, 1.0), (dense, 1.0)],
152            FusionMethod::Rrf { k: 60.0 },
153            10,
154        );
155
156        assert_eq!(fused.len(), 3);
157        // doc 1 is rank 1 + rank 2 → highest fused score
158        assert_eq!(fused[0].doc_id, 1);
159        let expected = 1.0 / 61.0 + 1.0 / 62.0;
160        assert!((fused[0].score - expected).abs() < 1e-6);
161        // docs 2 and 3 both have a single rank contribution
162        let ids: Vec<u32> = fused.iter().map(|r| r.doc_id).collect();
163        assert!(ids.contains(&2) && ids.contains(&3));
164    }
165
166    #[test]
167    fn test_rrf_weights_scale_contribution() {
168        let a = vec![result(1, 1.0)];
169        let b = vec![result(2, 1.0)];
170
171        // Same ranks, but list b weighted 2x → doc 2 wins
172        let fused = fuse_ranked_lists(vec![(a, 1.0), (b, 2.0)], FusionMethod::Rrf { k: 60.0 }, 10);
173        assert_eq!(fused[0].doc_id, 2);
174        assert!((fused[0].score - 2.0 / 61.0).abs() < 1e-6);
175    }
176
177    #[test]
178    fn test_normalized_weighted_sum() {
179        // Incompatible scales: BM25-ish vs cosine-ish
180        let sparse = vec![result(1, 20.0), result(2, 10.0), result(3, 0.0)];
181        let dense = vec![result(2, 0.99), result(1, 0.55), result(3, 0.11)];
182
183        let fused = fuse_ranked_lists(
184            vec![(sparse, 0.5), (dense, 0.5)],
185            FusionMethod::NormalizedWeightedSum,
186            10,
187        );
188
189        assert_eq!(fused.len(), 3);
190        // doc 1: 0.5*1.0 + 0.5*0.5 = 0.75; doc 2: 0.5*0.5 + 0.5*1.0 = 0.75;
191        // doc 3: 0. Ties broken by doc_id.
192        assert_eq!(fused[0].doc_id, 1);
193        assert!((fused[0].score - 0.75).abs() < 1e-6);
194        assert!((fused[1].score - 0.75).abs() < 1e-6);
195        assert_eq!(fused[2].doc_id, 3);
196        assert!(fused[2].score.abs() < 1e-6);
197    }
198
199    #[test]
200    fn test_limit_truncation() {
201        let list: Vec<SearchResult> = (0..100).map(|i| result(i, 100.0 - i as f32)).collect();
202        let fused = fuse_ranked_lists(vec![(list, 1.0)], FusionMethod::default(), 5);
203        assert_eq!(fused.len(), 5);
204        assert_eq!(fused[0].doc_id, 0);
205    }
206
207    #[test]
208    fn test_duplicate_across_segments_not_merged() {
209        // Same doc_id in different segments = different documents
210        let mut a = result(1, 1.0);
211        a.segment_id = 1;
212        let mut b = result(1, 1.0);
213        b.segment_id = 2;
214
215        let fused = fuse_ranked_lists(
216            vec![(vec![a], 1.0), (vec![b], 1.0)],
217            FusionMethod::default(),
218            10,
219        );
220        assert_eq!(fused.len(), 2);
221    }
222}