Skip to main content

vecdb_core/hybrid/
fusion.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::errors::Result;
6use crate::types::{SearchResult, VectorId};
7
8// ─────────────────────────────────────────────────────────────────
9// FusionStrategy
10// ─────────────────────────────────────────────────────────────────
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
13pub enum FusionStrategy {
14    /// Weighted sum of normalized scores: alpha*dense + (1-alpha)*sparse
15    #[default]
16    WeightedSum,
17    /// Reciprocal Rank Fusion: combines ranked positions not raw scores
18    ReciprocalRankFusion { k: f32 },
19}
20
21// ─────────────────────────────────────────────────────────────────
22// Normalization free functions
23// ─────────────────────────────────────────────────────────────────
24
25/// Min-max normalize a score slice into [0.0, 1.0].
26///
27/// - Empty input → empty output.
28/// - All-equal scores (range < EPSILON) → all map to 1.0 (avoids divide-by-zero).
29/// - Output order matches input order.
30///
31/// Performance: single-pass fold finds min and max simultaneously.
32// Before: two separate fold passes (one for min, one for max) over the score slice
33// After:  one fold pass with a tuple accumulator; ~2× fewer memory reads for large slices
34pub fn min_max_normalize(scores: &[f32]) -> Vec<f32> {
35    const EPSILON: f32 = 1e-9;
36    if scores.is_empty() {
37        return vec![];
38    }
39    // Single-pass: find min and max in one traversal.
40    let (min, max) = scores
41        .iter()
42        .fold((f32::INFINITY, f32::NEG_INFINITY), |(mn, mx), &s| {
43            (mn.min(s), mx.max(s))
44        });
45    let range = max - min;
46    if range < EPSILON {
47        // All scores equal — map to 1.0 to avoid NaN from 0/0.
48        return vec![1.0f32; scores.len()];
49    }
50    scores.iter().map(|&s| (s - min) / range).collect()
51}
52
53/// Softmax normalize scores.
54///
55/// Uses the max-subtraction trick for numerical stability.
56/// - Empty input → empty output.
57/// - Zero sum (should not occur in practice) → uniform 1/n.
58pub fn softmax_normalize(scores: &[(VectorId, f32)]) -> Vec<(VectorId, f32)> {
59    if scores.is_empty() {
60        return vec![];
61    }
62
63    let max = scores
64        .iter()
65        .map(|(_, s)| *s)
66        .fold(f32::NEG_INFINITY, |a, b| a.max(b));
67
68    let exp_scores: Vec<f32> = scores.iter().map(|(_, s)| (s - max).exp()).collect();
69    let sum: f32 = exp_scores.iter().sum();
70
71    if sum == 0.0 {
72        let uniform = 1.0 / scores.len() as f32;
73        return scores.iter().map(|(id, _)| (id.clone(), uniform)).collect();
74    }
75
76    scores
77        .iter()
78        .zip(exp_scores.iter())
79        .map(|((id, _), exp_s)| (id.clone(), exp_s / sum))
80        .collect()
81}
82
83// ─────────────────────────────────────────────────────────────────
84// Fusion free functions
85// ─────────────────────────────────────────────────────────────────
86
87/// Weighted sum fusion.
88///
89/// `final_score = alpha * dense_norm + (1 - alpha) * sparse_norm`
90///
91/// Documents present in only one list get 0.0 for the missing component.
92/// Output sorted descending by final score.
93pub fn weighted_sum_fusion(
94    dense_scores: &[(VectorId, f32)],
95    sparse_scores: &[(VectorId, f32)],
96    alpha: f32,
97) -> Vec<(VectorId, f32)> {
98    let alpha = alpha.clamp(0.0, 1.0);
99
100    // Extract raw score values, normalize, then zip the IDs back.
101    let dense_raw: Vec<f32> = dense_scores.iter().map(|(_, s)| *s).collect();
102    let sparse_raw: Vec<f32> = sparse_scores.iter().map(|(_, s)| *s).collect();
103    let dense_norm_vals = min_max_normalize(&dense_raw);
104    let sparse_norm_vals = min_max_normalize(&sparse_raw);
105
106    // id → (dense_normalized, sparse_normalized)
107    let mut combined: HashMap<VectorId, (f32, f32)> = HashMap::new();
108
109    for ((id, _), norm) in dense_scores.iter().zip(dense_norm_vals.iter()) {
110        combined.insert(id.clone(), (*norm, 0.0));
111    }
112    for ((id, _), norm) in sparse_scores.iter().zip(sparse_norm_vals.iter()) {
113        combined
114            .entry(id.clone())
115            .and_modify(|e| e.1 = *norm)
116            .or_insert((0.0, *norm));
117    }
118
119    let mut results: Vec<(VectorId, f32)> = combined
120        .into_iter()
121        .map(|(id, (d, s))| (id, alpha * d + (1.0 - alpha) * s))
122        .collect();
123
124    results.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
125    results
126}
127
128/// Reciprocal Rank Fusion.
129///
130/// `RRF(d) = Σ 1 / (k + rank(d, list))` where rank is 1-indexed.
131///
132/// Output sorted descending by RRF score.
133pub fn reciprocal_rank_fusion(
134    dense_results: &[(VectorId, f32)],
135    sparse_results: &[(VectorId, f32)],
136    k: f32,
137) -> Vec<(VectorId, f32)> {
138    let mut rrf_scores: HashMap<VectorId, f32> = HashMap::new();
139
140    for (rank, (id, _)) in dense_results.iter().enumerate() {
141        let contribution = 1.0 / (k + (rank as f32 + 1.0));
142        *rrf_scores.entry(id.clone()).or_insert(0.0) += contribution;
143    }
144    for (rank, (id, _)) in sparse_results.iter().enumerate() {
145        let contribution = 1.0 / (k + (rank as f32 + 1.0));
146        *rrf_scores.entry(id.clone()).or_insert(0.0) += contribution;
147    }
148
149    let mut results: Vec<(VectorId, f32)> = rrf_scores.into_iter().collect();
150    results.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
151    results
152}
153
154// ─────────────────────────────────────────────────────────────────
155// HybridEngine
156// ─────────────────────────────────────────────────────────────────
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct HybridEngine {
160    /// Weight for dense scores (1.0 = pure dense, 0.0 = pure sparse).
161    pub alpha: f32,
162    /// Fusion strategy to use.
163    pub strategy: FusionStrategy,
164    /// Oversampling factor: dense retrieves k * oversample_factor candidates
165    /// before sparse re-scoring.
166    pub oversample_factor: usize,
167}
168
169impl Default for HybridEngine {
170    fn default() -> Self {
171        Self {
172            alpha: 0.7,
173            strategy: FusionStrategy::WeightedSum,
174            oversample_factor: 5,
175        }
176    }
177}
178
179impl HybridEngine {
180    /// Construct a new engine.
181    ///
182    /// # Panics
183    /// Panics if `alpha` is not in `[0.0, 1.0]` or `oversample_factor < 1`.
184    /// These are programming errors, not runtime errors.
185    pub fn new(alpha: f32, strategy: FusionStrategy, oversample_factor: usize) -> Self {
186        assert!(
187            (0.0..=1.0).contains(&alpha),
188            "alpha must be in [0.0, 1.0], got {alpha}"
189        );
190        assert!(
191            oversample_factor >= 1,
192            "oversample_factor must be >= 1, got {oversample_factor}"
193        );
194        Self {
195            alpha,
196            strategy,
197            oversample_factor,
198        }
199    }
200
201    /// Fuse pre-computed dense and sparse result lists into a single ranked list.
202    ///
203    /// - Both empty → empty result.
204    /// - Dense only → pure dense path; sparse_score = None.
205    /// - Sparse only → pure sparse path; dense_score = None.
206    /// - Both present → fuse with `self.strategy`.
207    ///
208    /// `dense_score` and `sparse_score` in each `SearchResult` hold the
209    /// original (pre-normalization) scores.  `score` holds the fused value.
210    /// `payload` and `text` are left as `Null` / `None` — caller hydrates.
211    pub fn fuse(
212        &self,
213        dense_results: Vec<(VectorId, f32)>,
214        sparse_results: Vec<(VectorId, f32)>,
215        k: usize,
216    ) -> Result<Vec<SearchResult>> {
217        if dense_results.is_empty() && sparse_results.is_empty() {
218            return Ok(vec![]);
219        }
220
221        // Pure sparse path.
222        if dense_results.is_empty() {
223            let results = sparse_results
224                .into_iter()
225                .take(k)
226                .map(|(id, score)| SearchResult {
227                    id,
228                    score,
229                    dense_score: None,
230                    sparse_score: Some(score),
231                    payload: serde_json::Value::Null,
232                    text: None,
233                })
234                .collect();
235            return Ok(results);
236        }
237
238        // Pure dense path.
239        if sparse_results.is_empty() {
240            let results = dense_results
241                .into_iter()
242                .take(k)
243                .map(|(id, score)| SearchResult {
244                    id,
245                    score,
246                    dense_score: Some(score),
247                    sparse_score: None,
248                    payload: serde_json::Value::Null,
249                    text: None,
250                })
251                .collect();
252            return Ok(results);
253        }
254
255        // Build original-score lookup maps before consuming the vecs.
256        let dense_map: HashMap<VectorId, f32> = dense_results.iter().cloned().collect();
257        let sparse_map: HashMap<VectorId, f32> = sparse_results.iter().cloned().collect();
258
259        // Fuse.
260        let fused = match &self.strategy {
261            FusionStrategy::WeightedSum => {
262                weighted_sum_fusion(&dense_results, &sparse_results, self.alpha)
263            }
264            FusionStrategy::ReciprocalRankFusion { k: rrf_k } => {
265                reciprocal_rank_fusion(&dense_results, &sparse_results, *rrf_k)
266            }
267        };
268
269        let results = fused
270            .into_iter()
271            .take(k)
272            .map(|(id, final_score)| {
273                let d_score = dense_map.get(&id).copied();
274                let s_score = sparse_map.get(&id).copied();
275                SearchResult {
276                    id,
277                    score: final_score,
278                    dense_score: d_score,
279                    sparse_score: s_score,
280                    payload: serde_json::Value::Null,
281                    text: None,
282                }
283            })
284            .collect();
285
286        Ok(results)
287    }
288
289    /// How many dense candidates to fetch before sparse re-scoring.
290    ///
291    /// Returns `k * oversample_factor` (minimum `k`).
292    pub fn compute_oversample_k(&self, k: usize) -> usize {
293        (k * self.oversample_factor).max(k)
294    }
295}
296
297// ─────────────────────────────────────────────────────────────────
298// Tests
299// ─────────────────────────────────────────────────────────────────
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    // ── Test 1 ───────────────────────────────────────────────────
306    #[test]
307    fn test_min_max_normalize_basic() {
308        // Updated for P23: signature is now &[f32] → Vec<f32> (no IDs).
309        let scores = vec![0.0_f32, 0.5, 1.0];
310        let result = min_max_normalize(&scores);
311        assert_eq!(result.len(), 3);
312        // Order preserved.
313        assert!((result[0] - 0.0).abs() < 1e-6);
314        assert!((result[1] - 0.5).abs() < 1e-6);
315        assert!((result[2] - 1.0).abs() < 1e-6);
316    }
317
318    // ── Test 2 ───────────────────────────────────────────────────
319    #[test]
320    fn test_min_max_normalize_all_equal() {
321        // Updated for P23: all-equal input maps to 1.0 (EPSILON fast-path).
322        let scores = vec![0.7_f32, 0.7, 0.7];
323        let result = min_max_normalize(&scores);
324        assert_eq!(result.len(), 3);
325        for &score in &result {
326            assert!(
327                (score - 1.0).abs() < 1e-6,
328                "expected 1.0 for all-equal input, got {score}"
329            );
330        }
331    }
332
333    // ── Test 3 ───────────────────────────────────────────────────
334    #[test]
335    fn test_min_max_normalize_empty() {
336        // Updated for P23: pass &[f32] directly.
337        let result = min_max_normalize(&[]);
338        assert!(result.is_empty());
339    }
340
341    // ── Test 4 ───────────────────────────────────────────────────
342    #[test]
343    fn test_weighted_sum_fusion_pure_dense() {
344        // alpha=1.0 → dense dominates; "a" has highest dense score.
345        let dense = vec![
346            ("a".to_string(), 0.9_f32),
347            ("b".to_string(), 0.5),
348            ("c".to_string(), 0.1),
349        ];
350        let sparse = vec![
351            ("a".to_string(), 0.1_f32),
352            ("b".to_string(), 0.9),
353            ("c".to_string(), 0.5),
354        ];
355        let result = weighted_sum_fusion(&dense, &sparse, 1.0);
356        assert!(!result.is_empty());
357        assert_eq!(result[0].0, "a", "pure dense: 'a' must rank first");
358        for (_, s) in &result {
359            assert!(*s >= 0.0 && *s <= 1.0 + 1e-6, "score {s} out of [0,1]");
360        }
361    }
362
363    // ── Test 5 ───────────────────────────────────────────────────
364    #[test]
365    fn test_weighted_sum_fusion_pure_sparse() {
366        // alpha=0.0 → sparse dominates; "b" has highest sparse score.
367        let dense = vec![
368            ("a".to_string(), 0.9_f32),
369            ("b".to_string(), 0.5),
370            ("c".to_string(), 0.1),
371        ];
372        let sparse = vec![
373            ("b".to_string(), 0.9_f32),
374            ("a".to_string(), 0.1),
375            ("c".to_string(), 0.5),
376        ];
377        let result = weighted_sum_fusion(&dense, &sparse, 0.0);
378        assert!(!result.is_empty());
379        assert_eq!(result[0].0, "b", "pure sparse: 'b' must rank first");
380    }
381
382    // ── Test 6 ───────────────────────────────────────────────────
383    #[test]
384    fn test_weighted_sum_fusion_doc_only_in_one_list() {
385        // "c" only in sparse; "a","b" only in dense.
386        let dense = vec![("a".to_string(), 0.9_f32), ("b".to_string(), 0.5)];
387        let sparse = vec![("c".to_string(), 0.8_f32)];
388        let result = weighted_sum_fusion(&dense, &sparse, 0.5);
389        let ids: Vec<&str> = result.iter().map(|(id, _)| id.as_str()).collect();
390        assert!(ids.contains(&"a"));
391        assert!(ids.contains(&"b"));
392        assert!(ids.contains(&"c"));
393        assert_eq!(result.len(), 3);
394    }
395
396    // ── Test 7 ───────────────────────────────────────────────────
397    /// RRF: document ranked 2nd dense + 1st sparse beats others.
398    ///
399    /// With k=60 and these rankings:
400    ///   a: rank1 dense + rank3 sparse  → 1/61 + 1/63 ≈ 0.032266
401    ///   b: rank2 dense + rank1 sparse  → 1/62 + 1/61 ≈ 0.032522  ← wins
402    ///   c: rank3 dense + rank2 sparse  → 1/63 + 1/62 ≈ 0.032002
403    #[test]
404    fn test_rrf_basic() {
405        let dense = vec![
406            ("a".to_string(), 0.9_f32), // rank 1
407            ("b".to_string(), 0.5),     // rank 2
408            ("c".to_string(), 0.1),     // rank 3
409        ];
410        // b=rank1 in sparse, c=rank2, a=rank3 → b gets 1/61+1/62 > a's 1/61+1/63
411        let sparse = vec![
412            ("b".to_string(), 0.9_f32), // rank 1
413            ("c".to_string(), 0.5),     // rank 2
414            ("a".to_string(), 0.1),     // rank 3
415        ];
416        let result = reciprocal_rank_fusion(&dense, &sparse, 60.0);
417        assert_eq!(result.len(), 3);
418        let ids: Vec<&str> = result.iter().map(|(id, _)| id.as_str()).collect();
419        assert!(ids.contains(&"a"));
420        assert!(ids.contains(&"b"));
421        assert!(ids.contains(&"c"));
422        assert_eq!(
423            result[0].0, "b",
424            "b (2nd dense + 1st sparse) must rank first"
425        );
426    }
427
428    // ── Test 8 ───────────────────────────────────────────────────
429    #[test]
430    fn test_hybrid_engine_fuse_weighted_sum() {
431        let engine = HybridEngine::new(0.7, FusionStrategy::WeightedSum, 5);
432        let dense = vec![
433            ("doc1".to_string(), 0.95_f32),
434            ("doc2".to_string(), 0.7),
435            ("doc3".to_string(), 0.3),
436        ];
437        let sparse = vec![
438            ("doc2".to_string(), 0.9_f32),
439            ("doc1".to_string(), 0.4),
440            ("doc3".to_string(), 0.2),
441        ];
442        let results = engine.fuse(dense, sparse, 3).unwrap();
443        assert_eq!(results.len(), 3);
444        // All results have both dense and sparse scores.
445        for r in &results {
446            assert!(r.dense_score.is_some(), "dense_score must be Some");
447            assert!(r.sparse_score.is_some(), "sparse_score must be Some");
448        }
449        // Sorted descending.
450        for i in 1..results.len() {
451            assert!(
452                results[i - 1].score >= results[i].score,
453                "results not sorted: {} < {}",
454                results[i - 1].score,
455                results[i].score
456            );
457        }
458    }
459
460    // ── Test 9 ───────────────────────────────────────────────────
461    #[test]
462    fn test_hybrid_engine_fuse_empty_sparse() {
463        let engine = HybridEngine::default();
464        let dense = vec![("doc1".to_string(), 0.9_f32), ("doc2".to_string(), 0.5)];
465        let sparse: Vec<(VectorId, f32)> = vec![];
466        let results = engine.fuse(dense, sparse, 2).unwrap();
467        assert_eq!(results.len(), 2);
468        for r in &results {
469            assert!(r.dense_score.is_some());
470            assert!(
471                r.sparse_score.is_none(),
472                "pure dense path must have sparse_score=None"
473            );
474        }
475    }
476
477    // ── Test 10 ──────────────────────────────────────────────────
478    #[test]
479    fn test_hybrid_engine_fuse_empty_dense() {
480        let engine = HybridEngine::default();
481        let dense: Vec<(VectorId, f32)> = vec![];
482        let sparse = vec![("doc1".to_string(), 8.5_f32), ("doc2".to_string(), 3.2)];
483        let results = engine.fuse(dense, sparse, 2).unwrap();
484        assert_eq!(results.len(), 2);
485        for r in &results {
486            assert!(
487                r.dense_score.is_none(),
488                "pure sparse path must have dense_score=None"
489            );
490            assert!(r.sparse_score.is_some());
491        }
492    }
493}