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}