Skip to main content

hermes_core/query/vector/
combiner.rs

1//! Multi-value score combination strategies for vector search
2
3/// Strategy for combining scores when a document has multiple values for the same field
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub enum MultiValueCombiner {
6    /// Sum all scores (accumulates dot product contributions)
7    Sum,
8    /// Take the maximum score
9    Max,
10    /// Take the average score
11    Avg,
12    /// Softmax-weighted smooth maximum (default)
13    /// `score = Σ softmax(t * sᵢ) * sᵢ`
14    /// Higher temperature → closer to max; lower → closer to mean.
15    ///
16    /// Deliberately NOT the raw `(1/t)·log(Σ exp(t·sᵢ))`: that form adds
17    /// `ln(n)/t` per document, so chunk *count* outranked chunk quality —
18    /// a 300-chunk compendium of mediocre matches beat every focused paper
19    /// (score 0.55 + ln(300)/1.5 ≈ 4.3 vs 0.72 + ln(3)/1.5 ≈ 1.4). The
20    /// softmax weighting is count-invariant (n identical scores combine to
21    /// that score), bounded by the max, and still tracks a dominant score
22    /// at any scale.
23    LogSumExp {
24        /// Temperature parameter (default: 1.5)
25        temperature: f32,
26    },
27    /// Weighted Top-K: weight top scores with exponential decay
28    /// `score = Σ wᵢ * sorted_scores[i]` where `wᵢ = decay^i`
29    WeightedTopK {
30        /// Number of top scores to consider (default: 5)
31        k: usize,
32        /// Decay factor per rank (default: 0.7)
33        decay: f32,
34    },
35}
36
37impl Default for MultiValueCombiner {
38    fn default() -> Self {
39        // LogSumExp with temperature 1.5 provides good balance between
40        // max (best relevance) and sum (saturation from multiple matches)
41        MultiValueCombiner::LogSumExp { temperature: 1.5 }
42    }
43}
44
45impl MultiValueCombiner {
46    pub(crate) fn validate(self) -> Result<(), String> {
47        match self {
48            Self::LogSumExp { temperature } if !temperature.is_finite() || temperature <= 0.0 => {
49                Err(format!(
50                    "LogSumExp temperature must be finite and greater than zero, got {temperature}"
51                ))
52            }
53            Self::WeightedTopK { k: 0, .. } => {
54                Err("WeightedTopK k must be greater than zero".to_string())
55            }
56            Self::WeightedTopK { decay, .. }
57                if !decay.is_finite() || !(0.0..=1.0).contains(&decay) =>
58            {
59                Err(format!(
60                    "WeightedTopK decay must be finite and in [0, 1], got {decay}"
61                ))
62            }
63            _ => Ok(()),
64        }
65    }
66
67    /// Create LogSumExp combiner with default temperature (1.5)
68    pub fn log_sum_exp() -> Self {
69        Self::LogSumExp { temperature: 1.5 }
70    }
71
72    /// Create LogSumExp combiner with custom temperature
73    pub fn log_sum_exp_with_temperature(temperature: f32) -> Self {
74        Self::LogSumExp { temperature }
75    }
76
77    /// Create WeightedTopK combiner with defaults (k=5, decay=0.7)
78    pub fn weighted_top_k() -> Self {
79        Self::WeightedTopK { k: 5, decay: 0.7 }
80    }
81
82    /// Create WeightedTopK combiner with custom parameters
83    pub fn weighted_top_k_with_params(k: usize, decay: f32) -> Self {
84        Self::WeightedTopK { k, decay }
85    }
86
87    /// Combine multiple scores into a single score
88    pub fn combine(&self, scores: &[(u32, f32)]) -> f32 {
89        if scores.is_empty() {
90            return 0.0;
91        }
92
93        // Strict IEEE accumulation on purpose: these reductions run over a
94        // handful of ordinals per document, and measured `algebraic_add`
95        // (docs/algebraic-float-reductions.md) made the exp-bound LogSumExp
96        // loop slower at n=5 (16.5 → 20.4 ns) while changing rounding.
97        match self {
98            MultiValueCombiner::Sum => scores.iter().map(|(_, s)| s).sum(),
99            MultiValueCombiner::Max => scores
100                .iter()
101                .map(|(_, s)| *s)
102                .max_by(|a, b| a.total_cmp(b))
103                .unwrap_or(0.0),
104            MultiValueCombiner::Avg => {
105                let sum: f32 = scores.iter().map(|(_, s)| s).sum();
106                sum / scores.len() as f32
107            }
108            MultiValueCombiner::LogSumExp { temperature } => {
109                // Softmax-weighted average, numerically stabilized by
110                // subtracting the max before exponentiation. The max's own
111                // weight is exp(0) = 1, so the denominator is never zero.
112                let t = *temperature;
113                let max_score = scores
114                    .iter()
115                    .map(|(_, s)| *s)
116                    .max_by(|a, b| a.total_cmp(b))
117                    .unwrap_or(0.0);
118
119                let mut weight_sum = 0.0f32;
120                let mut weighted = 0.0f32;
121                for &(_, s) in scores {
122                    let weight = (t * (s - max_score)).exp();
123                    weight_sum += weight;
124                    weighted += weight * s;
125                }
126                weighted / weight_sum
127            }
128            MultiValueCombiner::WeightedTopK { k, decay } => {
129                let k = (*k).min(scores.len());
130                if k == 0 {
131                    return 0.0;
132                }
133                // Select the top k scores without allocating for the common
134                // small-document case: a stack buffer for up to 16 values,
135                // `select_nth_unstable` to partition when k < len, then sort
136                // only the top k (the decay weights are rank-dependent).
137                const INLINE: usize = 16;
138                let mut inline = [0.0f32; INLINE];
139                let mut spilled: Vec<f32>;
140                let values: &mut [f32] = if scores.len() <= INLINE {
141                    for (slot, &(_, s)) in inline.iter_mut().zip(scores) {
142                        *slot = s;
143                    }
144                    &mut inline[..scores.len()]
145                } else {
146                    spilled = scores.iter().map(|&(_, s)| s).collect();
147                    spilled.as_mut_slice()
148                };
149                if k < values.len() {
150                    values.select_nth_unstable_by(k - 1, |a, b| b.total_cmp(a));
151                }
152                let top = &mut values[..k];
153                top.sort_unstable_by(|a, b| b.total_cmp(a));
154
155                // Apply exponential decay weights
156                let mut weight = 1.0f32;
157                let mut weighted_sum = 0.0f32;
158                let mut weight_total = 0.0f32;
159
160                for &score in top.iter() {
161                    weighted_sum += weight * score;
162                    weight_total += weight;
163                    weight *= decay;
164                }
165
166                if weight_total > 0.0 {
167                    weighted_sum / weight_total
168                } else {
169                    0.0
170                }
171            }
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_combiner_sum() {
182        let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
183        let combiner = MultiValueCombiner::Sum;
184        assert!((combiner.combine(&scores) - 6.0).abs() < 1e-6);
185    }
186
187    #[test]
188    fn test_combiner_max() {
189        let scores = vec![(0, 1.0), (1, 3.0), (2, 2.0)];
190        let combiner = MultiValueCombiner::Max;
191        assert!((combiner.combine(&scores) - 3.0).abs() < 1e-6);
192    }
193
194    #[test]
195    fn test_combiner_avg() {
196        let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
197        let combiner = MultiValueCombiner::Avg;
198        assert!((combiner.combine(&scores) - 2.0).abs() < 1e-6);
199    }
200
201    #[test]
202    fn test_combiner_log_sum_exp() {
203        let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
204        let combiner = MultiValueCombiner::log_sum_exp();
205        let result = combiner.combine(&scores);
206        // A smooth maximum lives between the mean and the max, weighted
207        // toward the max.
208        assert!(result > 2.0, "must exceed the mean, got {result}");
209        assert!(result <= 3.0, "must never exceed the max, got {result}");
210    }
211
212    /// The production incident this pins: a 300-chunk compendium whose chunks
213    /// all score ~0.5 must not outrank a 3-chunk paper whose chunks score
214    /// ~0.7. The additive `ln(n)/t` count term of the raw log-sum-exp did
215    /// exactly that (0.55 + ln(300)/1.5 ≈ 4.3 vs 0.72 + ln(3)/1.5 ≈ 1.4),
216    /// which buried every relevant result for "off-label aripiprazole usage"
217    /// under generic long documents.
218    #[test]
219    fn log_sum_exp_is_count_invariant_and_bounded_by_max() {
220        let combiner = MultiValueCombiner::log_sum_exp();
221
222        // n identical scores combine to that score, regardless of n.
223        let identical: Vec<(u32, f32)> = (0..300).map(|i| (i, 0.7)).collect();
224        let combined = combiner.combine(&identical);
225        assert!(
226            (combined - 0.7).abs() < 1e-3,
227            "300 identical 0.7 chunks must combine to 0.7, got {combined}"
228        );
229
230        // Many mediocre chunks never outrank a few strong ones.
231        let mut compendium: Vec<(u32, f32)> = (0..300).map(|i| (i, 0.5)).collect();
232        compendium.push((300, 0.55));
233        let paper = vec![(0, 0.72), (1, 0.70), (2, 0.65)];
234        let compendium_score = combiner.combine(&compendium);
235        let paper_score = combiner.combine(&paper);
236        assert!(
237            paper_score > compendium_score,
238            "3 strong chunks ({paper_score}) must beat 301 mediocre ones ({compendium_score})"
239        );
240    }
241
242    /// The reason the fix is a softmax-weighted average rather than
243    /// `LSE - ln(n)/t`: subtracting the count term turns the combiner into a
244    /// near-average in the peaked regime, collapsing a document whose single
245    /// chunk scores 15 among 299 zeros to ~6.4. The softmax weighting keeps
246    /// it at the dominant score.
247    #[test]
248    fn log_sum_exp_tracks_a_dominant_score_at_sparse_scale() {
249        let combiner = MultiValueCombiner::log_sum_exp_with_temperature(0.7);
250        let mut scores: Vec<(u32, f32)> = (0..299).map(|i| (i, 0.0)).collect();
251        scores.push((299, 15.0));
252        let combined = combiner.combine(&scores);
253        assert!(
254            (combined - 15.0).abs() < 0.3,
255            "one dominant sparse chunk must keep its score, got {combined}"
256        );
257    }
258
259    #[test]
260    fn test_combiner_log_sum_exp_approaches_max_with_high_temp() {
261        let scores = vec![(0, 1.0), (1, 5.0), (2, 2.0)];
262        // High temperature should approach max
263        let combiner = MultiValueCombiner::log_sum_exp_with_temperature(10.0);
264        let result = combiner.combine(&scores);
265        // Should be very close to max (5.0)
266        assert!((result - 5.0).abs() < 0.5);
267    }
268
269    #[test]
270    fn test_combiner_weighted_top_k() {
271        let scores = vec![(0, 5.0), (1, 3.0), (2, 1.0), (3, 0.5)];
272        let combiner = MultiValueCombiner::weighted_top_k_with_params(3, 0.5);
273        let result = combiner.combine(&scores);
274        // Top 3: 5.0, 3.0, 1.0 with weights 1.0, 0.5, 0.25
275        // weighted_sum = 5*1 + 3*0.5 + 1*0.25 = 6.75
276        // weight_total = 1.75
277        // result = 6.75 / 1.75 ≈ 3.857
278        assert!((result - 3.857).abs() < 0.01);
279    }
280
281    #[test]
282    fn test_combiner_weighted_top_k_less_than_k() {
283        let scores = vec![(0, 2.0), (1, 1.0)];
284        let combiner = MultiValueCombiner::weighted_top_k_with_params(5, 0.7);
285        let result = combiner.combine(&scores);
286        // Only 2 scores, weights 1.0 and 0.7
287        // weighted_sum = 2*1 + 1*0.7 = 2.7
288        // weight_total = 1.7
289        // result = 2.7 / 1.7 ≈ 1.588
290        assert!((result - 1.588).abs() < 0.01);
291    }
292
293    /// The stack/select path must agree with a plain full sort for every
294    /// length around the inline buffer boundary and every k, including
295    /// ties and k larger than the input.
296    #[test]
297    fn weighted_top_k_selection_matches_full_sort_across_inline_boundary() {
298        for len in [1usize, 2, 5, 15, 16, 17, 40] {
299            let scores: Vec<(u32, f32)> = (0..len)
300                .map(|i| (i as u32, ((i * 7919) % 13) as f32 / 13.0))
301                .collect();
302            for k in [1usize, 2, 3, 5, 16, 17, 64] {
303                let combiner = MultiValueCombiner::weighted_top_k_with_params(k, 0.7);
304                let actual = combiner.combine(&scores);
305
306                let mut sorted: Vec<f32> = scores.iter().map(|&(_, s)| s).collect();
307                sorted.sort_unstable_by(|a, b| b.total_cmp(a));
308                sorted.truncate(k);
309                let (mut w, mut ws, mut wt) = (1.0f32, 0.0f32, 0.0f32);
310                for s in sorted {
311                    ws += w * s;
312                    wt += w;
313                    w *= 0.7;
314                }
315                let expected = ws / wt;
316                assert!(
317                    (actual - expected).abs() < 1e-6,
318                    "len {len} k {k}: {actual} vs {expected}"
319                );
320            }
321        }
322    }
323
324    #[test]
325    fn test_combiner_empty_scores() {
326        let scores: Vec<(u32, f32)> = vec![];
327        assert_eq!(MultiValueCombiner::Sum.combine(&scores), 0.0);
328        assert_eq!(MultiValueCombiner::Max.combine(&scores), 0.0);
329        assert_eq!(MultiValueCombiner::Avg.combine(&scores), 0.0);
330        assert_eq!(MultiValueCombiner::log_sum_exp().combine(&scores), 0.0);
331        assert_eq!(MultiValueCombiner::weighted_top_k().combine(&scores), 0.0);
332    }
333
334    #[test]
335    fn test_combiner_single_score() {
336        let scores = vec![(0, 5.0)];
337        // All combiners should return 5.0 for a single score
338        assert!((MultiValueCombiner::Sum.combine(&scores) - 5.0).abs() < 1e-6);
339        assert!((MultiValueCombiner::Max.combine(&scores) - 5.0).abs() < 1e-6);
340        assert!((MultiValueCombiner::Avg.combine(&scores) - 5.0).abs() < 1e-6);
341        assert!((MultiValueCombiner::log_sum_exp().combine(&scores) - 5.0).abs() < 1e-6);
342        assert!((MultiValueCombiner::weighted_top_k().combine(&scores) - 5.0).abs() < 1e-6);
343    }
344
345    #[test]
346    fn test_default_combiner_is_log_sum_exp() {
347        let combiner = MultiValueCombiner::default();
348        match combiner {
349            MultiValueCombiner::LogSumExp { temperature } => {
350                assert!((temperature - 1.5).abs() < 1e-6);
351            }
352            _ => panic!("Default combiner should be LogSumExp"),
353        }
354    }
355}