khive_fusion/strategy.rs
1//! Fusion strategy types.
2
3use serde::{Deserialize, Serialize};
4
5/// Default RRF constant k=60, standard in literature (Craswell et al., 2009).
6pub const DEFAULT_RRF_K: usize = 60;
7
8/// Fusion strategy for combining ranked result lists.
9///
10/// See module-level docs for algorithm details.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum FusionStrategy {
14 /// Reciprocal Rank Fusion (default, recommended).
15 ///
16 /// Uses only ranks, making it robust to different score distributions.
17 /// Formula: score(d) = Σ 1/(k + rank_i(d))
18 #[serde(alias = "Rrf")]
19 Rrf {
20 /// Smoothing constant. Higher values reduce impact of rank differences.
21 /// Default: 60 (standard in literature).
22 k: usize,
23 },
24
25 /// Weighted linear combination of scores.
26 ///
27 /// Requires score normalization for different score scales (e.g., vector
28 /// similarity 0-1 vs BM25 0-∞).
29 ///
30 /// Weights are normalized to sum to 1.0 internally.
31 #[serde(alias = "Weighted")]
32 Weighted {
33 /// Weights for each source (will be normalized).
34 weights: Vec<f64>,
35 },
36
37 /// Take union with max score per ID.
38 ///
39 /// Useful when you want the best score from any source.
40 #[serde(alias = "Union")]
41 Union,
42
43 /// Skip BM25 entirely — return only vector (HNSW) results.
44 ///
45 /// Use when keyword search degrades quality (short queries, code search).
46 /// The result list is the raw HNSW output with no fusion step.
47 #[serde(alias = "VectorOnly")]
48 VectorOnly,
49
50 /// Skip HNSW entirely — return only BM25 keyword results.
51 ///
52 /// Use for exact-match retrieval (medication names, identifiers, slugs).
53 /// The result list is the raw BM25 output with no fusion step.
54 #[serde(alias = "KeywordOnly")]
55 KeywordOnly,
56}
57
58impl Default for FusionStrategy {
59 fn default() -> Self {
60 Self::Rrf { k: DEFAULT_RRF_K }
61 }
62}
63
64impl FusionStrategy {
65 /// Create an RRF strategy with default k=60.
66 #[inline]
67 pub fn rrf() -> Self {
68 Self::Rrf { k: DEFAULT_RRF_K }
69 }
70
71 /// Create an RRF strategy with custom k value.
72 #[inline]
73 pub fn rrf_with_k(k: usize) -> Self {
74 Self::Rrf { k: k.max(1) } // Ensure k >= 1
75 }
76
77 /// Create a weighted strategy with given weights.
78 #[inline]
79 pub fn weighted(weights: Vec<f64>) -> Self {
80 Self::Weighted { weights }
81 }
82
83 /// Create a union strategy.
84 #[inline]
85 pub fn union() -> Self {
86 Self::Union
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn test_fusion_strategy_default() {
96 let default = FusionStrategy::default();
97 assert_eq!(default, FusionStrategy::Rrf { k: 60 });
98 }
99
100 #[test]
101 fn test_fusion_strategy_builders() {
102 assert_eq!(FusionStrategy::rrf(), FusionStrategy::Rrf { k: 60 });
103 assert_eq!(
104 FusionStrategy::rrf_with_k(20),
105 FusionStrategy::Rrf { k: 20 }
106 );
107 assert_eq!(FusionStrategy::rrf_with_k(0), FusionStrategy::Rrf { k: 1 }); // min enforced
108 assert_eq!(
109 FusionStrategy::weighted(vec![0.5, 0.5]),
110 FusionStrategy::Weighted {
111 weights: vec![0.5, 0.5]
112 }
113 );
114 assert_eq!(FusionStrategy::union(), FusionStrategy::Union);
115 }
116}