1use std::collections::HashMap;
9
10#[derive(Debug, Clone, PartialEq)]
12pub struct ScoredHit {
13 pub id: String,
14 pub score: f32,
15}
16
17impl ScoredHit {
18 pub fn new(id: impl Into<String>, score: f32) -> Self {
19 Self {
20 id: id.into(),
21 score,
22 }
23 }
24}
25
26#[derive(Debug, Clone, PartialEq)]
28pub struct FusedHit {
29 pub id: String,
30 pub score: f32,
31}
32
33pub fn rrf_fuse(lists: &[Vec<ScoredHit>], k: u32, limit: usize) -> Vec<FusedHit> {
39 let shift = k as f32;
40 let mut totals: HashMap<&str, f32> = HashMap::new();
41 for list in lists {
42 for (position, hit) in list.iter().enumerate() {
43 let rank = position as f32 + 1.0;
44 let entry = totals.entry(hit.id.as_str()).or_insert(0.0);
45 *entry += 1.0 / (shift + rank);
46 }
47 }
48 let mut fused: Vec<FusedHit> = totals
49 .into_iter()
50 .map(|(id, score)| FusedHit {
51 id: id.to_string(),
52 score,
53 })
54 .collect();
55 fused.sort_by(|a, b| b.score.total_cmp(&a.score));
56 fused.truncate(limit);
57 fused
58}
59
60pub fn weighted_fuse(
66 vector: &[ScoredHit],
67 fulltext: &[ScoredHit],
68 alpha: f32,
69 limit: usize,
70) -> Vec<FusedHit> {
71 let weight = alpha.clamp(0.0, 1.0);
72 let vector_norm = normalize(vector);
73 let fulltext_norm = normalize(fulltext);
74 let mut totals: HashMap<&str, f32> = HashMap::new();
75 for (id, score) in &vector_norm {
76 let entry = totals.entry(id.as_str()).or_insert(0.0);
77 *entry += weight * score;
78 }
79 for (id, score) in &fulltext_norm {
80 let entry = totals.entry(id.as_str()).or_insert(0.0);
81 *entry += (1.0 - weight) * score;
82 }
83 let mut fused: Vec<FusedHit> = totals
84 .into_iter()
85 .map(|(id, score)| FusedHit {
86 id: id.to_string(),
87 score,
88 })
89 .collect();
90 fused.sort_by(|a, b| b.score.total_cmp(&a.score));
91 fused.truncate(limit);
92 fused
93}
94
95fn normalize(hits: &[ScoredHit]) -> Vec<(String, f32)> {
96 if hits.is_empty() {
97 return Vec::new();
98 }
99 let mut min = f32::INFINITY;
100 let mut max = f32::NEG_INFINITY;
101 for hit in hits {
102 if hit.score < min {
103 min = hit.score;
104 }
105 if hit.score > max {
106 max = hit.score;
107 }
108 }
109 let span = max - min;
110 hits.iter()
111 .map(|hit| {
112 let normalized = if span <= f32::EPSILON {
113 1.0
114 } else {
115 (hit.score - min) / span
116 };
117 (hit.id.clone(), normalized)
118 })
119 .collect()
120}
121
122pub fn normalize_vector_point_id(point_id: &str, tag: &str, field: &str) -> String {
130 let suffix = format!("#{}#{}", tag, field);
131 let encoded = point_id.strip_suffix(&suffix).unwrap_or(point_id);
132 encoded.replace("%23", "#").replace("%25", "%")
133}
134
135pub fn normalize_vector_hits(hits: &[ScoredHit], tag: &str, field: &str) -> Vec<ScoredHit> {
138 let suffix = format!("#{}#{}", tag, field);
139 hits.iter()
140 .filter(|hit| hit.id.ends_with(&suffix))
141 .map(|hit| ScoredHit::new(normalize_vector_point_id(&hit.id, tag, field), hit.score))
142 .collect()
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn rrf_prefers_hits_present_in_both_lists() {
151 let vector = vec![ScoredHit::new("a", 0.9), ScoredHit::new("b", 0.5)];
152 let fulltext = vec![ScoredHit::new("b", 2.0), ScoredHit::new("c", 1.0)];
153 let fused = rrf_fuse(&[vector, fulltext], 60, 10);
154 assert_eq!(fused.len(), 3);
155 assert_eq!(fused[0].id, "b");
156 }
157
158 #[test]
159 fn rrf_respects_limit() {
160 let first = vec![ScoredHit::new("a", 1.0), ScoredHit::new("b", 0.5)];
161 let fused = rrf_fuse(&[first], 60, 1);
162 assert_eq!(fused.len(), 1);
163 assert_eq!(fused[0].id, "a");
164 }
165
166 #[test]
167 fn weighted_fuse_blends_both_sources() {
168 let vector = vec![ScoredHit::new("a", 1.0), ScoredHit::new("b", 0.0)];
169 let fulltext = vec![ScoredHit::new("b", 5.0), ScoredHit::new("c", 1.0)];
170 let fused = weighted_fuse(&vector, &fulltext, 0.3, 10);
174 assert_eq!(fused.len(), 3);
175 assert_eq!(fused[0].id, "b");
176 }
177
178 #[test]
179 fn weighted_fuse_handles_degenerate_lists() {
180 let vector = vec![ScoredHit::new("a", 2.0)];
181 let fulltext = vec![ScoredHit::new("a", 7.0)];
182 let fused = weighted_fuse(&vector, &fulltext, 0.5, 10);
183 assert_eq!(fused.len(), 1);
184 assert_eq!(fused[0].id, "a");
185 assert!((fused[0].score - 1.0).abs() < 1e-6);
186 }
187
188 #[test]
189 fn vector_point_id_normalization_strips_suffix() {
190 let normalized = normalize_vector_point_id("v1#Tag#bio", "Tag", "bio");
191 assert_eq!(normalized, "v1");
192 let escaped = normalize_vector_point_id("a%23b#Tag#bio", "Tag", "bio");
193 assert_eq!(escaped, "a#b");
194 }
195}