Skip to main content

velesdb_core/fusion/
strategy.rs

1//! Fusion strategies for combining multi-query search results.
2
3#![allow(clippy::unnecessary_wraps)]
4
5use std::collections::HashMap;
6
7/// Error type for fusion operations.
8#[derive(Debug, Clone, PartialEq)]
9#[non_exhaustive]
10pub enum FusionError {
11    /// Weights do not sum to 1.0 (within tolerance).
12    InvalidWeightSum {
13        /// The actual sum of weights.
14        sum: f32,
15    },
16    /// Negative weight provided.
17    NegativeWeight {
18        /// The negative weight value.
19        weight: f32,
20    },
21    /// Weight slice length does not match the number of result branches.
22    WeightCountMismatch {
23        /// Number of weights provided.
24        weights: usize,
25        /// Number of branches passed to fuse.
26        branches: usize,
27    },
28}
29
30impl std::fmt::Display for FusionError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::InvalidWeightSum { sum } => {
34                write!(f, "Weights must sum to 1.0, got {sum:.4}")
35            }
36            Self::NegativeWeight { weight } => {
37                write!(f, "Weights must be non-negative, got {weight:.4}")
38            }
39            Self::WeightCountMismatch { weights, branches } => write!(
40                f,
41                "WeightedRRF requires one weight per branch: {weights} weights for {branches} branches",
42            ),
43        }
44    }
45}
46
47impl std::error::Error for FusionError {}
48
49/// Strategy for fusing results from multiple vector searches.
50///
51/// Each strategy combines results differently, optimizing for various use cases:
52/// - `Average`: Good for general-purpose fusion
53/// - `Maximum`: Emphasizes documents that score very high in any query
54/// - `RRF`: Position-based fusion, robust to score scale differences
55/// - `Weighted`: Custom combination with explicit control over factors
56#[derive(Debug, Clone, PartialEq)]
57#[non_exhaustive]
58pub enum FusionStrategy {
59    /// Average score across all queries where the document appears.
60    ///
61    /// Score = mean(scores for this document across queries)
62    Average,
63
64    /// Maximum score across all queries.
65    ///
66    /// Score = max(scores for this document across queries)
67    Maximum,
68
69    /// Reciprocal Rank Fusion.
70    ///
71    /// Score = Σ 1/(k + `rank_i`) for each query where document appears.
72    /// Standard k=60 provides good balance between emphasizing top ranks
73    /// while still considering lower-ranked results.
74    RRF {
75        /// Ranking constant (default: 60).
76        k: u32,
77    },
78
79    /// Weighted combination of average, maximum, and hit ratio.
80    ///
81    /// Score = `avg_weight` × `avg_score` + `max_weight` × `max_score` + `hit_weight` × `hit_ratio`
82    /// where `hit_ratio` = (number of queries containing doc) / (total queries)
83    Weighted {
84        /// Weight for average score component.
85        avg_weight: f32,
86        /// Weight for maximum score component.
87        max_weight: f32,
88        /// Weight for hit ratio component.
89        hit_weight: f32,
90    },
91
92    /// Relative Score Fusion for dense + sparse hybrid search.
93    ///
94    /// Each branch is min-max normalized independently, then combined via
95    /// weighted sum: `final = dense_weight * norm_dense + sparse_weight * norm_sparse`.
96    /// Docs appearing in only one branch get 0 for the missing branch.
97    RelativeScore {
98        /// Weight for the dense (vector) branch.
99        dense_weight: f32,
100        /// Weight for the sparse branch.
101        sparse_weight: f32,
102    },
103
104    /// Weighted Reciprocal Rank Fusion with 0-based ranks.
105    ///
106    /// Score for document `d` = Σᵢ `weights[i] / (rank_i(d) + k)` where
107    /// `rank_i` is the 0-based position of `d` in branch `i`, and `k` is a
108    /// smoothing constant (default 60.0).  Documents absent from a branch
109    /// contribute nothing from that branch.
110    ///
111    /// Unlike [`FusionStrategy::RRF`] (which is unweighted and uses 1-based
112    /// ranks), this variant gives explicit per-branch control and is the
113    /// correct strategy for hybrid dense + text search where branches carry
114    /// different retrieval precision characteristics.
115    WeightedRRF {
116        /// Per-branch non-negative weights; must equal the number of branches
117        /// passed to [`FusionStrategy::fuse`].
118        weights: Vec<f32>,
119        /// Smoothing constant (default 60.0). Higher values dampen the
120        /// advantage of the top rank.
121        k: f32,
122    },
123}
124
125impl FusionStrategy {
126    /// Creates an RRF strategy with the standard k=60 parameter.
127    #[must_use]
128    pub fn rrf_default() -> Self {
129        Self::RRF { k: 60 }
130    }
131
132    /// Creates a `WeightedRRF` strategy with validation.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if any weight is negative or `k` ≤ 0.
137    pub fn weighted_rrf(weights: Vec<f32>, k: f32) -> Result<Self, FusionError> {
138        validate_non_negative(&weights)?;
139        if k <= 0.0 {
140            return Err(FusionError::NegativeWeight { weight: k });
141        }
142        Ok(Self::WeightedRRF { weights, k })
143    }
144
145    /// Creates a `RelativeScore` strategy with validation.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if:
150    /// - Weights do not sum to 1.0 (within 0.001 tolerance)
151    /// - Any weight is negative
152    pub fn relative_score(dense_weight: f32, sparse_weight: f32) -> Result<Self, FusionError> {
153        validate_non_negative(&[dense_weight, sparse_weight])?;
154        validate_weight_sum(dense_weight + sparse_weight)?;
155        Ok(Self::RelativeScore {
156            dense_weight,
157            sparse_weight,
158        })
159    }
160
161    /// Creates a Weighted strategy with validation.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if:
166    /// - Weights do not sum to 1.0 (within 0.001 tolerance)
167    /// - Any weight is negative
168    pub fn weighted(
169        avg_weight: f32,
170        max_weight: f32,
171        hit_weight: f32,
172    ) -> Result<Self, FusionError> {
173        validate_non_negative(&[avg_weight, max_weight, hit_weight])?;
174        validate_weight_sum(avg_weight + max_weight + hit_weight)?;
175
176        Ok(Self::Weighted {
177            avg_weight,
178            max_weight,
179            hit_weight,
180        })
181    }
182
183    /// Fuses results from multiple queries into a single ranked list.
184    ///
185    /// # Arguments
186    ///
187    /// * `results` - Vec of search results, one per query. Each inner Vec
188    ///   contains `(document_id, score)` tuples, assumed sorted by score descending.
189    ///
190    /// # Returns
191    ///
192    /// A single Vec of `(document_id, fused_score)` sorted by score descending.
193    ///
194    /// # Errors
195    ///
196    /// Currently infallible, but returns Result for future extensibility.
197    pub fn fuse(&self, results: Vec<Vec<(u64, f32)>>) -> Result<Vec<(u64, f32)>, FusionError> {
198        if results.is_empty() {
199            return Ok(Vec::new());
200        }
201
202        // Filter out empty query results for counting
203        let non_empty_count = results.iter().filter(|r| !r.is_empty()).count();
204        if non_empty_count == 0 {
205            return Ok(Vec::new());
206        }
207
208        let total_queries = results.len();
209
210        match self {
211            Self::Average => Self::fuse_average(results),
212            Self::Maximum => Self::fuse_maximum(results),
213            Self::RRF { k } => Self::fuse_rrf(results, *k),
214            Self::Weighted {
215                avg_weight,
216                max_weight,
217                hit_weight,
218            } => Self::fuse_weighted(
219                results,
220                *avg_weight,
221                *max_weight,
222                *hit_weight,
223                total_queries,
224            ),
225            Self::RelativeScore {
226                dense_weight,
227                sparse_weight,
228            } => Self::fuse_relative_score(&results, *dense_weight, *sparse_weight),
229            Self::WeightedRRF { weights, k } => Self::fuse_weighted_rrf(results, weights, *k),
230        }
231    }
232
233    /// Collects per-document best scores across queries (deduplicates within each query).
234    ///
235    /// Returns a map from document ID to the list of its best scores (one per query
236    /// where it appeared).
237    fn collect_doc_scores(results: Vec<Vec<(u64, f32)>>) -> HashMap<u64, Vec<f32>> {
238        let mut doc_scores: HashMap<u64, Vec<f32>> = HashMap::new();
239
240        for query_results in results {
241            let mut query_best: HashMap<u64, f32> = HashMap::new();
242            for (id, score) in query_results {
243                query_best
244                    .entry(id)
245                    .and_modify(|s| *s = s.max(score))
246                    .or_insert(score);
247            }
248
249            for (id, score) in query_best {
250                doc_scores.entry(id).or_default().push(score);
251            }
252        }
253
254        doc_scores
255    }
256
257    /// Sorts a fused result set by score descending.
258    fn sort_descending(fused: &mut [(u64, f32)]) {
259        fused.sort_unstable_by(|a, b| b.1.total_cmp(&a.1));
260    }
261
262    /// Average fusion: mean of scores for each document.
263    #[allow(clippy::cast_precision_loss)]
264    // Reason: scores.len() is the number of queries a document appeared in;
265    // this is a small count that fits exactly in f32.
266    fn fuse_average(results: Vec<Vec<(u64, f32)>>) -> Result<Vec<(u64, f32)>, FusionError> {
267        let mut fused: Vec<(u64, f32)> = Self::collect_doc_scores(results)
268            .into_iter()
269            .map(|(id, scores)| {
270                let avg = scores.iter().sum::<f32>() / scores.len() as f32;
271                (id, avg)
272            })
273            .collect();
274
275        Self::sort_descending(&mut fused);
276        Ok(fused)
277    }
278
279    /// Maximum fusion: best score for each document.
280    fn fuse_maximum(results: Vec<Vec<(u64, f32)>>) -> Result<Vec<(u64, f32)>, FusionError> {
281        let mut doc_max: HashMap<u64, f32> = HashMap::new();
282
283        for query_results in results {
284            for (id, score) in query_results {
285                doc_max
286                    .entry(id)
287                    .and_modify(|s| *s = s.max(score))
288                    .or_insert(score);
289            }
290        }
291
292        let mut fused: Vec<(u64, f32)> = doc_max.into_iter().collect();
293        Self::sort_descending(&mut fused);
294        Ok(fused)
295    }
296
297    /// RRF fusion: reciprocal rank fusion.
298    #[allow(clippy::cast_precision_loss)]
299    // Reason: k (u32, typically 60) and rank+1 (small loop index) both fit
300    // exactly in f32 (exact up to 2^24).
301    fn fuse_rrf(results: Vec<Vec<(u64, f32)>>, k: u32) -> Result<Vec<(u64, f32)>, FusionError> {
302        let mut doc_rrf: HashMap<u64, f32> = HashMap::new();
303        // Reason: k is the RRF constant (default 60, max u32); u32 → f32 is
304        // exact for values <= 16_777_216, so no precision loss in practice.
305        let k_f32 = k as f32;
306
307        for query_results in results {
308            // Deduplicate and get rank order
309            let mut seen: HashMap<u64, usize> = HashMap::new();
310            for (rank, (id, _score)) in query_results.into_iter().enumerate() {
311                // Only count first occurrence (best rank) for each doc in this query
312                seen.entry(id).or_insert(rank);
313            }
314
315            for (id, rank) in seen {
316                let rrf_score = 1.0 / (k_f32 + (rank + 1) as f32);
317                *doc_rrf.entry(id).or_insert(0.0) += rrf_score;
318            }
319        }
320
321        let mut fused: Vec<(u64, f32)> = doc_rrf.into_iter().collect();
322        Self::sort_descending(&mut fused);
323        Ok(fused)
324    }
325
326    /// Weighted fusion: combination of avg, max, and hit ratio.
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if the weights are negative or do not sum to 1.0
331    /// (within 0.001 tolerance). Validation runs here as well as in the
332    /// `weighted()` constructor so that direct enum-literal construction
333    /// (e.g. from server/CLI request fields) cannot bypass it.
334    #[allow(clippy::cast_precision_loss)]
335    // Reason: total_queries and scores.len() are small counts (number of
336    // queries/hits per document); both fit exactly in f32.
337    fn fuse_weighted(
338        results: Vec<Vec<(u64, f32)>>,
339        avg_weight: f32,
340        max_weight: f32,
341        hit_weight: f32,
342        total_queries: usize,
343    ) -> Result<Vec<(u64, f32)>, FusionError> {
344        validate_non_negative(&[avg_weight, max_weight, hit_weight])?;
345        validate_weight_sum(avg_weight + max_weight + hit_weight)?;
346
347        let total_q = total_queries as f32;
348
349        let mut fused: Vec<(u64, f32)> = Self::collect_doc_scores(results)
350            .into_iter()
351            .map(|(id, scores)| {
352                let avg = scores.iter().sum::<f32>() / scores.len() as f32;
353                let max = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max);
354                let hit_ratio = scores.len() as f32 / total_q;
355
356                let combined = avg_weight * avg + max_weight * max + hit_weight * hit_ratio;
357                (id, combined)
358            })
359            .collect();
360
361        Self::sort_descending(&mut fused);
362        Ok(fused)
363    }
364
365    /// Relative Score Fusion: per-branch min-max normalization + weighted sum.
366    ///
367    /// Expects exactly two branches in `results`: index 0 = dense, index 1 = sparse.
368    /// If more branches are provided, only the first two are used; the extras
369    /// are silently discarded. A warning is emitted so callers can detect the
370    /// accidental multi-branch case during development.
371    ///
372    /// # Errors
373    ///
374    /// Returns an error if the weights are negative or do not sum to 1.0
375    /// (within 0.001 tolerance). Validation runs here as well as in the
376    /// `relative_score()` constructor so that direct enum-literal construction
377    /// (e.g. from server/CLI request fields) cannot bypass it.
378    fn fuse_relative_score(
379        results: &[Vec<(u64, f32)>],
380        dense_weight: f32,
381        sparse_weight: f32,
382    ) -> Result<Vec<(u64, f32)>, FusionError> {
383        validate_non_negative(&[dense_weight, sparse_weight])?;
384        validate_weight_sum(dense_weight + sparse_weight)?;
385
386        if results.len() > 2 {
387            tracing::warn!(
388                branch_count = results.len(),
389                "RelativeScore fusion received {} branches but only supports 2 (dense + sparse). \
390                 Branches beyond index 1 are ignored.",
391                results.len(),
392            );
393        }
394
395        let dense = results.first().map_or(&[][..], |v| v.as_slice());
396        let sparse = results.get(1).map_or(&[][..], |v| v.as_slice());
397
398        let norm_dense = min_max_normalize(dense);
399        let norm_sparse = min_max_normalize(sparse);
400
401        // Collect all doc IDs — capacity upper-bounds total unique docs.
402        let mut all_ids: HashMap<u64, f32> =
403            HashMap::with_capacity(norm_dense.len() + norm_sparse.len());
404        for (&id, &nd) in &norm_dense {
405            let ns = norm_sparse.get(&id).copied().unwrap_or(0.0);
406            all_ids.insert(id, dense_weight * nd + sparse_weight * ns);
407        }
408        // For sparse-only IDs (not in norm_dense), dense contribution is 0.
409        for (&id, &ns) in &norm_sparse {
410            all_ids.entry(id).or_insert(sparse_weight * ns);
411        }
412
413        let mut fused: Vec<(u64, f32)> = all_ids.into_iter().collect();
414        Self::sort_descending(&mut fused);
415        Ok(fused)
416    }
417
418    /// Weighted 0-based RRF: Σᵢ `weight_i / (rank_i + k)`.
419    ///
420    /// Rank is 0-based (top result has rank 0). Duplicate document IDs within a
421    /// branch are deduplicated — only the best (lowest) rank is used.
422    ///
423    /// # Errors
424    ///
425    /// Returns [`FusionError::WeightCountMismatch`] if `weights.len()` ≠
426    /// `branches.len()`, or [`FusionError::NegativeWeight`] if any weight is
427    /// negative or `k` ≤ 0. Validation runs here as well as in the
428    /// `weighted_rrf()` constructor so that direct enum-literal construction
429    /// cannot bypass it (same rationale as `fuse_weighted`) — `k = 0` with a
430    /// rank-0 hit would otherwise produce an infinite score.
431    #[allow(clippy::cast_precision_loss)]
432    // Reason: rank and k are small positive values; f32 is sufficient.
433    fn fuse_weighted_rrf(
434        branches: Vec<Vec<(u64, f32)>>,
435        weights: &[f32],
436        k: f32,
437    ) -> Result<Vec<(u64, f32)>, FusionError> {
438        validate_non_negative(weights)?;
439        if k <= 0.0 {
440            return Err(FusionError::NegativeWeight { weight: k });
441        }
442        if weights.len() != branches.len() {
443            return Err(FusionError::WeightCountMismatch {
444                weights: weights.len(),
445                branches: branches.len(),
446            });
447        }
448
449        let mut doc_scores: HashMap<u64, f32> = HashMap::new();
450
451        for (branch, &weight) in branches.into_iter().zip(weights.iter()) {
452            // Deduplicate within branch: keep only the best (first) rank.
453            let mut best_rank: HashMap<u64, usize> = HashMap::new();
454            for (rank, (id, _)) in branch.into_iter().enumerate() {
455                best_rank.entry(id).or_insert(rank);
456            }
457            for (id, rank) in best_rank {
458                let contribution = weight / (rank as f32 + k);
459                *doc_scores.entry(id).or_insert(0.0) += contribution;
460            }
461        }
462
463        let mut fused: Vec<(u64, f32)> = doc_scores.into_iter().collect();
464        Self::sort_descending(&mut fused);
465        Ok(fused)
466    }
467}
468
469impl Default for FusionStrategy {
470    fn default() -> Self {
471        Self::RRF { k: 60 }
472    }
473}
474
475// ---------------------------------------------------------------------------
476// Shared validation helpers (extracted from `relative_score` / `weighted`)
477// ---------------------------------------------------------------------------
478
479/// Validates that no weight in the slice is negative.
480fn validate_non_negative(weights: &[f32]) -> Result<(), FusionError> {
481    for &w in weights {
482        if w < 0.0 {
483            return Err(FusionError::NegativeWeight { weight: w });
484        }
485    }
486    Ok(())
487}
488
489/// Validates that a weight sum is 1.0 (within 0.001 tolerance).
490fn validate_weight_sum(sum: f32) -> Result<(), FusionError> {
491    if (sum - 1.0).abs() > 0.001 {
492        return Err(FusionError::InvalidWeightSum { sum });
493    }
494    Ok(())
495}
496
497/// Min-max normalize a branch of `(id, score)` pairs.
498///
499/// If the score range is smaller than `f32::EPSILON`, all items receive 0.5.
500fn min_max_normalize(branch: &[(u64, f32)]) -> HashMap<u64, f32> {
501    if branch.is_empty() {
502        return HashMap::new();
503    }
504    // Single pass to find both min and max.
505    let (min, max) = branch
506        .iter()
507        .fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), &(_, s)| {
508            (lo.min(s), hi.max(s))
509        });
510    let range = max - min;
511    let mut out = HashMap::with_capacity(branch.len());
512    for &(id, s) in branch {
513        let norm = if range < f32::EPSILON {
514            0.5
515        } else {
516            (s - min) / range
517        };
518        out.insert(id, norm);
519    }
520    out
521}