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    /// Log-Sum-Exp: smooth maximum approximation (default)
13    /// `score = (1/t) * log(Σ exp(t * sᵢ))`
14    /// Higher temperature → closer to max; lower → closer to mean
15    LogSumExp {
16        /// Temperature parameter (default: 1.5)
17        temperature: f32,
18    },
19    /// Weighted Top-K: weight top scores with exponential decay
20    /// `score = Σ wᵢ * sorted_scores[i]` where `wᵢ = decay^i`
21    WeightedTopK {
22        /// Number of top scores to consider (default: 5)
23        k: usize,
24        /// Decay factor per rank (default: 0.7)
25        decay: f32,
26    },
27}
28
29impl Default for MultiValueCombiner {
30    fn default() -> Self {
31        // LogSumExp with temperature 1.5 provides good balance between
32        // max (best relevance) and sum (saturation from multiple matches)
33        MultiValueCombiner::LogSumExp { temperature: 1.5 }
34    }
35}
36
37impl MultiValueCombiner {
38    pub(crate) fn validate(self) -> Result<(), String> {
39        match self {
40            Self::LogSumExp { temperature } if !temperature.is_finite() || temperature <= 0.0 => {
41                Err(format!(
42                    "LogSumExp temperature must be finite and greater than zero, got {temperature}"
43                ))
44            }
45            Self::WeightedTopK { k: 0, .. } => {
46                Err("WeightedTopK k must be greater than zero".to_string())
47            }
48            Self::WeightedTopK { decay, .. }
49                if !decay.is_finite() || !(0.0..=1.0).contains(&decay) =>
50            {
51                Err(format!(
52                    "WeightedTopK decay must be finite and in [0, 1], got {decay}"
53                ))
54            }
55            _ => Ok(()),
56        }
57    }
58
59    /// Create LogSumExp combiner with default temperature (1.5)
60    pub fn log_sum_exp() -> Self {
61        Self::LogSumExp { temperature: 1.5 }
62    }
63
64    /// Create LogSumExp combiner with custom temperature
65    pub fn log_sum_exp_with_temperature(temperature: f32) -> Self {
66        Self::LogSumExp { temperature }
67    }
68
69    /// Create WeightedTopK combiner with defaults (k=5, decay=0.7)
70    pub fn weighted_top_k() -> Self {
71        Self::WeightedTopK { k: 5, decay: 0.7 }
72    }
73
74    /// Create WeightedTopK combiner with custom parameters
75    pub fn weighted_top_k_with_params(k: usize, decay: f32) -> Self {
76        Self::WeightedTopK { k, decay }
77    }
78
79    /// Combine multiple scores into a single score
80    pub fn combine(&self, scores: &[(u32, f32)]) -> f32 {
81        if scores.is_empty() {
82            return 0.0;
83        }
84
85        match self {
86            MultiValueCombiner::Sum => scores.iter().map(|(_, s)| s).sum(),
87            MultiValueCombiner::Max => scores
88                .iter()
89                .map(|(_, s)| *s)
90                .max_by(|a, b| a.total_cmp(b))
91                .unwrap_or(0.0),
92            MultiValueCombiner::Avg => {
93                let sum: f32 = scores.iter().map(|(_, s)| s).sum();
94                sum / scores.len() as f32
95            }
96            MultiValueCombiner::LogSumExp { temperature } => {
97                // Numerically stable log-sum-exp:
98                // LSE(x) = max(x) + log(Σ exp(xᵢ - max(x)))
99                let t = *temperature;
100                let max_score = scores
101                    .iter()
102                    .map(|(_, s)| *s)
103                    .max_by(|a, b| a.total_cmp(b))
104                    .unwrap_or(0.0);
105
106                let sum_exp: f32 = scores
107                    .iter()
108                    .map(|(_, s)| (t * (s - max_score)).exp())
109                    .sum();
110
111                max_score + sum_exp.ln() / t
112            }
113            MultiValueCombiner::WeightedTopK { k, decay } => {
114                // Sort scores descending and take top k
115                let mut sorted: Vec<f32> = scores.iter().map(|(_, s)| *s).collect();
116                sorted.sort_unstable_by(|a, b| b.total_cmp(a));
117                sorted.truncate(*k);
118
119                // Apply exponential decay weights
120                let mut weight = 1.0f32;
121                let mut weighted_sum = 0.0f32;
122                let mut weight_total = 0.0f32;
123
124                for score in sorted {
125                    weighted_sum += weight * score;
126                    weight_total += weight;
127                    weight *= decay;
128                }
129
130                if weight_total > 0.0 {
131                    weighted_sum / weight_total
132                } else {
133                    0.0
134                }
135            }
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_combiner_sum() {
146        let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
147        let combiner = MultiValueCombiner::Sum;
148        assert!((combiner.combine(&scores) - 6.0).abs() < 1e-6);
149    }
150
151    #[test]
152    fn test_combiner_max() {
153        let scores = vec![(0, 1.0), (1, 3.0), (2, 2.0)];
154        let combiner = MultiValueCombiner::Max;
155        assert!((combiner.combine(&scores) - 3.0).abs() < 1e-6);
156    }
157
158    #[test]
159    fn test_combiner_avg() {
160        let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
161        let combiner = MultiValueCombiner::Avg;
162        assert!((combiner.combine(&scores) - 2.0).abs() < 1e-6);
163    }
164
165    #[test]
166    fn test_combiner_log_sum_exp() {
167        let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
168        let combiner = MultiValueCombiner::log_sum_exp();
169        let result = combiner.combine(&scores);
170        // LogSumExp should be between max (3.0) and max + log(n)/t
171        assert!(result >= 3.0);
172        assert!(result <= 3.0 + (3.0_f32).ln() / 1.5);
173    }
174
175    #[test]
176    fn test_combiner_log_sum_exp_approaches_max_with_high_temp() {
177        let scores = vec![(0, 1.0), (1, 5.0), (2, 2.0)];
178        // High temperature should approach max
179        let combiner = MultiValueCombiner::log_sum_exp_with_temperature(10.0);
180        let result = combiner.combine(&scores);
181        // Should be very close to max (5.0)
182        assert!((result - 5.0).abs() < 0.5);
183    }
184
185    #[test]
186    fn test_combiner_weighted_top_k() {
187        let scores = vec![(0, 5.0), (1, 3.0), (2, 1.0), (3, 0.5)];
188        let combiner = MultiValueCombiner::weighted_top_k_with_params(3, 0.5);
189        let result = combiner.combine(&scores);
190        // Top 3: 5.0, 3.0, 1.0 with weights 1.0, 0.5, 0.25
191        // weighted_sum = 5*1 + 3*0.5 + 1*0.25 = 6.75
192        // weight_total = 1.75
193        // result = 6.75 / 1.75 ≈ 3.857
194        assert!((result - 3.857).abs() < 0.01);
195    }
196
197    #[test]
198    fn test_combiner_weighted_top_k_less_than_k() {
199        let scores = vec![(0, 2.0), (1, 1.0)];
200        let combiner = MultiValueCombiner::weighted_top_k_with_params(5, 0.7);
201        let result = combiner.combine(&scores);
202        // Only 2 scores, weights 1.0 and 0.7
203        // weighted_sum = 2*1 + 1*0.7 = 2.7
204        // weight_total = 1.7
205        // result = 2.7 / 1.7 ≈ 1.588
206        assert!((result - 1.588).abs() < 0.01);
207    }
208
209    #[test]
210    fn test_combiner_empty_scores() {
211        let scores: Vec<(u32, f32)> = vec![];
212        assert_eq!(MultiValueCombiner::Sum.combine(&scores), 0.0);
213        assert_eq!(MultiValueCombiner::Max.combine(&scores), 0.0);
214        assert_eq!(MultiValueCombiner::Avg.combine(&scores), 0.0);
215        assert_eq!(MultiValueCombiner::log_sum_exp().combine(&scores), 0.0);
216        assert_eq!(MultiValueCombiner::weighted_top_k().combine(&scores), 0.0);
217    }
218
219    #[test]
220    fn test_combiner_single_score() {
221        let scores = vec![(0, 5.0)];
222        // All combiners should return 5.0 for a single score
223        assert!((MultiValueCombiner::Sum.combine(&scores) - 5.0).abs() < 1e-6);
224        assert!((MultiValueCombiner::Max.combine(&scores) - 5.0).abs() < 1e-6);
225        assert!((MultiValueCombiner::Avg.combine(&scores) - 5.0).abs() < 1e-6);
226        assert!((MultiValueCombiner::log_sum_exp().combine(&scores) - 5.0).abs() < 1e-6);
227        assert!((MultiValueCombiner::weighted_top_k().combine(&scores) - 5.0).abs() < 1e-6);
228    }
229
230    #[test]
231    fn test_default_combiner_is_log_sum_exp() {
232        let combiner = MultiValueCombiner::default();
233        match combiner {
234            MultiValueCombiner::LogSumExp { temperature } => {
235                assert!((temperature - 1.5).abs() < 1e-6);
236            }
237            _ => panic!("Default combiner should be LogSumExp"),
238        }
239    }
240}