1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::errors::Result;
6use crate::types::{SearchResult, VectorId};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
13pub enum FusionStrategy {
14 #[default]
16 WeightedSum,
17 ReciprocalRankFusion { k: f32 },
19}
20
21pub fn min_max_normalize(scores: &[f32]) -> Vec<f32> {
35 const EPSILON: f32 = 1e-9;
36 if scores.is_empty() {
37 return vec![];
38 }
39 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 return vec![1.0f32; scores.len()];
49 }
50 scores.iter().map(|&s| (s - min) / range).collect()
51}
52
53pub 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
83pub 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 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 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
128pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct HybridEngine {
160 pub alpha: f32,
162 pub strategy: FusionStrategy,
164 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 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 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 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 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 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 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 pub fn compute_oversample_k(&self, k: usize) -> usize {
293 (k * self.oversample_factor).max(k)
294 }
295}
296
297#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
307 fn test_min_max_normalize_basic() {
308 let scores = vec![0.0_f32, 0.5, 1.0];
310 let result = min_max_normalize(&scores);
311 assert_eq!(result.len(), 3);
312 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]
320 fn test_min_max_normalize_all_equal() {
321 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]
335 fn test_min_max_normalize_empty() {
336 let result = min_max_normalize(&[]);
338 assert!(result.is_empty());
339 }
340
341 #[test]
343 fn test_weighted_sum_fusion_pure_dense() {
344 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]
365 fn test_weighted_sum_fusion_pure_sparse() {
366 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]
384 fn test_weighted_sum_fusion_doc_only_in_one_list() {
385 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]
404 fn test_rrf_basic() {
405 let dense = vec![
406 ("a".to_string(), 0.9_f32), ("b".to_string(), 0.5), ("c".to_string(), 0.1), ];
410 let sparse = vec![
412 ("b".to_string(), 0.9_f32), ("c".to_string(), 0.5), ("a".to_string(), 0.1), ];
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]
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 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 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]
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]
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}