llm_kernel/search/
fusion.rs1use std::collections::HashMap;
9
10use crate::search::SearchResult;
11
12pub fn normalize_minmax(results: &mut [SearchResult]) {
22 if results.is_empty() {
23 return;
24 }
25 for r in results.iter_mut() {
26 if !r.score.is_finite() {
27 r.score = 0.0;
28 }
29 }
30 let min = results
31 .iter()
32 .map(|r| r.score)
33 .fold(f32::INFINITY, f32::min);
34 let max = results
35 .iter()
36 .map(|r| r.score)
37 .fold(f32::NEG_INFINITY, f32::max);
38 if (min - max).abs() < f32::EPSILON {
39 for r in results.iter_mut() {
40 r.score = 1.0;
41 }
42 return;
43 }
44 let span = max - min;
45 for r in results.iter_mut() {
46 r.score = (r.score - min) / span;
47 }
48}
49
50pub fn weighted_sum_fuse(result_sets: &[Vec<SearchResult>], weights: &[f32]) -> Vec<SearchResult> {
63 assert_eq!(
64 result_sets.len(),
65 weights.len(),
66 "weighted_sum_fuse: result_sets.len() ({}) must equal weights.len() ({})",
67 result_sets.len(),
68 weights.len(),
69 );
70 let mut scores: HashMap<String, f32> = HashMap::new();
71 let mut texts: HashMap<String, String> = HashMap::new();
72
73 for (results, weight) in result_sets.iter().zip(weights.iter()) {
74 for result in results {
75 *scores.entry(result.id.clone()).or_insert(0.0) += weight * result.score;
76 texts
77 .entry(result.id.clone())
78 .or_insert_with(|| result.text.clone());
79 }
80 }
81
82 let mut fused: Vec<SearchResult> = scores
83 .into_iter()
84 .map(|(id, score)| SearchResult {
85 text: texts.remove(&id).unwrap_or_default(),
86 id,
87 score,
88 })
89 .collect();
90
91 fused.sort_by(|a, b| {
92 b.score
93 .partial_cmp(&a.score)
94 .unwrap_or(std::cmp::Ordering::Equal)
95 });
96 fused
97}
98
99pub fn combmnz_fuse(result_sets: &[Vec<SearchResult>], k: usize) -> Vec<SearchResult> {
120 assert!(
121 k >= 1,
122 "combmnz_fuse: k must be >= 1 (got {k}); k == 0 zeroes every score"
123 );
124 let mut scores: HashMap<String, f32> = HashMap::new();
125 let mut texts: HashMap<String, String> = HashMap::new();
126 let mut topk_counts: HashMap<String, usize> = HashMap::new();
127
128 for results in result_sets {
129 let topk = results.len().min(k);
130 for (rank, result) in results.iter().enumerate() {
131 *scores.entry(result.id.clone()).or_insert(0.0) += result.score;
132 texts
133 .entry(result.id.clone())
134 .or_insert_with(|| result.text.clone());
135 if rank < topk {
136 *topk_counts.entry(result.id.clone()).or_insert(0) += 1;
137 }
138 }
139 }
140
141 let mut fused: Vec<SearchResult> = scores
142 .into_iter()
143 .map(|(id, sum)| SearchResult {
144 text: texts.remove(&id).unwrap_or_default(),
145 score: topk_counts.get(&id).copied().unwrap_or(0) as f32 * sum,
146 id,
147 })
148 .collect();
149
150 fused.sort_by(|a, b| {
151 b.score
152 .partial_cmp(&a.score)
153 .unwrap_or(std::cmp::Ordering::Equal)
154 });
155 fused
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 fn make_results(ids: &[(&str, f32)]) -> Vec<SearchResult> {
163 ids.iter()
164 .map(|(id, score)| SearchResult {
165 id: id.to_string(),
166 score: *score,
167 text: format!("text for {id}"),
168 })
169 .collect()
170 }
171
172 #[test]
173 fn normalize_minmax_maps_extremes() {
174 let mut results = make_results(&[("a", 0.2), ("b", 0.5), ("c", 0.9)]);
175 normalize_minmax(&mut results);
176 let a = results.iter().find(|r| r.id == "a").unwrap().score;
177 let b = results.iter().find(|r| r.id == "b").unwrap().score;
178 let c = results.iter().find(|r| r.id == "c").unwrap().score;
179 assert!((a - 0.0).abs() < 1e-6);
180 assert!((c - 1.0).abs() < 1e-6);
181 assert!((b - (0.5 - 0.2) / (0.9 - 0.2)).abs() < 1e-6);
182 }
183
184 #[test]
185 fn normalize_minmax_all_equal_sets_one() {
186 let mut results = make_results(&[("a", 0.5), ("b", 0.5), ("c", 0.5)]);
187 normalize_minmax(&mut results);
188 for r in &results {
189 assert!((r.score - 1.0).abs() < 1e-6);
190 }
191 }
192
193 #[test]
194 fn normalize_minmax_empty_is_noop() {
195 let mut results: Vec<SearchResult> = vec![];
196 normalize_minmax(&mut results);
197 assert!(results.is_empty());
198 }
199
200 #[test]
201 fn normalize_minmax_clamps_non_finite() {
202 let mut results = make_results(&[("a", f32::NAN), ("b", f32::INFINITY), ("c", 0.5)]);
204 normalize_minmax(&mut results);
205 for r in &results {
206 assert!(r.score.is_finite(), "score {:?} is not finite", r.score);
207 assert!(
208 (0.0..=1.0).contains(&r.score),
209 "score {:?} out of [0,1]",
210 r.score
211 );
212 }
213 }
214
215 #[test]
216 fn weighted_sum_fuse_formula() {
217 let a = make_results(&[("a", 1.0), ("b", 0.5)]);
223 let b = make_results(&[("b", 1.0), ("c", 0.4)]);
224 let fused = weighted_sum_fuse(&[a, b], &[0.7, 0.3]);
225 assert_eq!(fused.len(), 3);
226 assert_eq!(fused[0].id, "a");
227 assert_eq!(fused[1].id, "b");
228 assert_eq!(fused[2].id, "c");
229 let score_a = fused.iter().find(|r| r.id == "a").unwrap().score;
230 let score_b = fused.iter().find(|r| r.id == "b").unwrap().score;
231 let score_c = fused.iter().find(|r| r.id == "c").unwrap().score;
232 assert!((score_a - 0.70).abs() < 1e-6);
233 assert!((score_b - 0.65).abs() < 1e-6);
234 assert!((score_c - 0.12).abs() < 1e-6);
235 }
236
237 #[test]
238 fn combmnz_boosts_multi_list_doc() {
239 let a = make_results(&[("hot", 0.99), ("cold", 0.1)]);
244 let b = make_results(&[("cold", 0.99), ("hot", 0.1)]);
245 let c = make_results(&[("cold", 0.99), ("warm", 0.1)]);
246 let fused = combmnz_fuse(&[a, b, c], 1);
247 assert_eq!(fused[0].id, "cold");
248 let cold = fused.iter().find(|r| r.id == "cold").unwrap().score;
249 let hot = fused.iter().find(|r| r.id == "hot").unwrap().score;
250 assert!(cold > hot);
251 assert!((cold - 4.16).abs() < 1e-5);
252 assert!((hot - 1.09).abs() < 1e-5);
253 }
254
255 #[test]
256 #[should_panic(expected = "must equal weights.len()")]
257 fn weighted_sum_fuse_panics_on_length_mismatch() {
258 let a = make_results(&[("a", 1.0)]);
259 let _ = weighted_sum_fuse(&[a.clone(), a], &[0.5]);
261 }
262}