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        match self {
94            MultiValueCombiner::Sum => scores.iter().map(|(_, s)| s).sum(),
95            MultiValueCombiner::Max => scores
96                .iter()
97                .map(|(_, s)| *s)
98                .max_by(|a, b| a.total_cmp(b))
99                .unwrap_or(0.0),
100            MultiValueCombiner::Avg => {
101                let sum: f32 = scores.iter().map(|(_, s)| s).sum();
102                sum / scores.len() as f32
103            }
104            MultiValueCombiner::LogSumExp { temperature } => {
105                // Softmax-weighted average, numerically stabilized by
106                // subtracting the max before exponentiation. The max's own
107                // weight is exp(0) = 1, so the denominator is never zero.
108                let t = *temperature;
109                let max_score = scores
110                    .iter()
111                    .map(|(_, s)| *s)
112                    .max_by(|a, b| a.total_cmp(b))
113                    .unwrap_or(0.0);
114
115                let mut weight_sum = 0.0f32;
116                let mut weighted = 0.0f32;
117                for (_, s) in scores {
118                    let weight = (t * (s - max_score)).exp();
119                    weight_sum += weight;
120                    weighted += weight * s;
121                }
122                weighted / weight_sum
123            }
124            MultiValueCombiner::WeightedTopK { k, decay } => {
125                // Sort scores descending and take top k
126                let mut sorted: Vec<f32> = scores.iter().map(|(_, s)| *s).collect();
127                sorted.sort_unstable_by(|a, b| b.total_cmp(a));
128                sorted.truncate(*k);
129
130                // Apply exponential decay weights
131                let mut weight = 1.0f32;
132                let mut weighted_sum = 0.0f32;
133                let mut weight_total = 0.0f32;
134
135                for score in sorted {
136                    weighted_sum += weight * score;
137                    weight_total += weight;
138                    weight *= decay;
139                }
140
141                if weight_total > 0.0 {
142                    weighted_sum / weight_total
143                } else {
144                    0.0
145                }
146            }
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_combiner_sum() {
157        let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
158        let combiner = MultiValueCombiner::Sum;
159        assert!((combiner.combine(&scores) - 6.0).abs() < 1e-6);
160    }
161
162    #[test]
163    fn test_combiner_max() {
164        let scores = vec![(0, 1.0), (1, 3.0), (2, 2.0)];
165        let combiner = MultiValueCombiner::Max;
166        assert!((combiner.combine(&scores) - 3.0).abs() < 1e-6);
167    }
168
169    #[test]
170    fn test_combiner_avg() {
171        let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
172        let combiner = MultiValueCombiner::Avg;
173        assert!((combiner.combine(&scores) - 2.0).abs() < 1e-6);
174    }
175
176    #[test]
177    fn test_combiner_log_sum_exp() {
178        let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
179        let combiner = MultiValueCombiner::log_sum_exp();
180        let result = combiner.combine(&scores);
181        // A smooth maximum lives between the mean and the max, weighted
182        // toward the max.
183        assert!(result > 2.0, "must exceed the mean, got {result}");
184        assert!(result <= 3.0, "must never exceed the max, got {result}");
185    }
186
187    /// The production incident this pins: a 300-chunk compendium whose chunks
188    /// all score ~0.5 must not outrank a 3-chunk paper whose chunks score
189    /// ~0.7. The additive `ln(n)/t` count term of the raw log-sum-exp did
190    /// exactly that (0.55 + ln(300)/1.5 ≈ 4.3 vs 0.72 + ln(3)/1.5 ≈ 1.4),
191    /// which buried every relevant result for "off-label aripiprazole usage"
192    /// under generic long documents.
193    #[test]
194    fn log_sum_exp_is_count_invariant_and_bounded_by_max() {
195        let combiner = MultiValueCombiner::log_sum_exp();
196
197        // n identical scores combine to that score, regardless of n.
198        let identical: Vec<(u32, f32)> = (0..300).map(|i| (i, 0.7)).collect();
199        let combined = combiner.combine(&identical);
200        assert!(
201            (combined - 0.7).abs() < 1e-3,
202            "300 identical 0.7 chunks must combine to 0.7, got {combined}"
203        );
204
205        // Many mediocre chunks never outrank a few strong ones.
206        let mut compendium: Vec<(u32, f32)> = (0..300).map(|i| (i, 0.5)).collect();
207        compendium.push((300, 0.55));
208        let paper = vec![(0, 0.72), (1, 0.70), (2, 0.65)];
209        let compendium_score = combiner.combine(&compendium);
210        let paper_score = combiner.combine(&paper);
211        assert!(
212            paper_score > compendium_score,
213            "3 strong chunks ({paper_score}) must beat 301 mediocre ones ({compendium_score})"
214        );
215    }
216
217    /// The reason the fix is a softmax-weighted average rather than
218    /// `LSE - ln(n)/t`: subtracting the count term turns the combiner into a
219    /// near-average in the peaked regime, collapsing a document whose single
220    /// chunk scores 15 among 299 zeros to ~6.4. The softmax weighting keeps
221    /// it at the dominant score.
222    #[test]
223    fn log_sum_exp_tracks_a_dominant_score_at_sparse_scale() {
224        let combiner = MultiValueCombiner::log_sum_exp_with_temperature(0.7);
225        let mut scores: Vec<(u32, f32)> = (0..299).map(|i| (i, 0.0)).collect();
226        scores.push((299, 15.0));
227        let combined = combiner.combine(&scores);
228        assert!(
229            (combined - 15.0).abs() < 0.3,
230            "one dominant sparse chunk must keep its score, got {combined}"
231        );
232    }
233
234    #[test]
235    fn test_combiner_log_sum_exp_approaches_max_with_high_temp() {
236        let scores = vec![(0, 1.0), (1, 5.0), (2, 2.0)];
237        // High temperature should approach max
238        let combiner = MultiValueCombiner::log_sum_exp_with_temperature(10.0);
239        let result = combiner.combine(&scores);
240        // Should be very close to max (5.0)
241        assert!((result - 5.0).abs() < 0.5);
242    }
243
244    #[test]
245    fn test_combiner_weighted_top_k() {
246        let scores = vec![(0, 5.0), (1, 3.0), (2, 1.0), (3, 0.5)];
247        let combiner = MultiValueCombiner::weighted_top_k_with_params(3, 0.5);
248        let result = combiner.combine(&scores);
249        // Top 3: 5.0, 3.0, 1.0 with weights 1.0, 0.5, 0.25
250        // weighted_sum = 5*1 + 3*0.5 + 1*0.25 = 6.75
251        // weight_total = 1.75
252        // result = 6.75 / 1.75 ≈ 3.857
253        assert!((result - 3.857).abs() < 0.01);
254    }
255
256    #[test]
257    fn test_combiner_weighted_top_k_less_than_k() {
258        let scores = vec![(0, 2.0), (1, 1.0)];
259        let combiner = MultiValueCombiner::weighted_top_k_with_params(5, 0.7);
260        let result = combiner.combine(&scores);
261        // Only 2 scores, weights 1.0 and 0.7
262        // weighted_sum = 2*1 + 1*0.7 = 2.7
263        // weight_total = 1.7
264        // result = 2.7 / 1.7 ≈ 1.588
265        assert!((result - 1.588).abs() < 0.01);
266    }
267
268    #[test]
269    fn test_combiner_empty_scores() {
270        let scores: Vec<(u32, f32)> = vec![];
271        assert_eq!(MultiValueCombiner::Sum.combine(&scores), 0.0);
272        assert_eq!(MultiValueCombiner::Max.combine(&scores), 0.0);
273        assert_eq!(MultiValueCombiner::Avg.combine(&scores), 0.0);
274        assert_eq!(MultiValueCombiner::log_sum_exp().combine(&scores), 0.0);
275        assert_eq!(MultiValueCombiner::weighted_top_k().combine(&scores), 0.0);
276    }
277
278    #[test]
279    fn test_combiner_single_score() {
280        let scores = vec![(0, 5.0)];
281        // All combiners should return 5.0 for a single score
282        assert!((MultiValueCombiner::Sum.combine(&scores) - 5.0).abs() < 1e-6);
283        assert!((MultiValueCombiner::Max.combine(&scores) - 5.0).abs() < 1e-6);
284        assert!((MultiValueCombiner::Avg.combine(&scores) - 5.0).abs() < 1e-6);
285        assert!((MultiValueCombiner::log_sum_exp().combine(&scores) - 5.0).abs() < 1e-6);
286        assert!((MultiValueCombiner::weighted_top_k().combine(&scores) - 5.0).abs() < 1e-6);
287    }
288
289    #[test]
290    fn test_default_combiner_is_log_sum_exp() {
291        let combiner = MultiValueCombiner::default();
292        match combiner {
293            MultiValueCombiner::LogSumExp { temperature } => {
294                assert!((temperature - 1.5).abs() < 1e-6);
295            }
296            _ => panic!("Default combiner should be LogSumExp"),
297        }
298    }
299}