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}