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