1#[cfg(feature = "embeddings")]
7use std::collections::{HashMap, HashSet};
8
9#[cfg(feature = "embeddings")]
10use crate::core::bm25_index::{BM25Index, tokenize_for_index};
11
12#[derive(Debug, Clone, PartialEq)]
14pub struct SpladeResult {
15 pub chunk_idx: usize,
16 pub file_path: String,
17 pub symbol_name: String,
18 pub combined_score: f64,
19 pub bm25_score: f64,
20 pub expansion_score: f64,
21}
22
23#[cfg(feature = "embeddings")]
25fn expansion_dictionary() -> HashMap<&'static str, Vec<(&'static str, f64)>> {
26 HashMap::from([
27 (
28 "auth",
29 vec![
30 ("authentication", 1.0),
31 ("token", 0.9),
32 ("jwt", 0.85),
33 ("login", 0.8),
34 ("session", 0.85),
35 ("oauth", 0.75),
36 ("credential", 0.7),
37 ],
38 ),
39 (
40 "async",
41 vec![
42 ("await", 1.0),
43 ("future", 0.85),
44 ("promise", 0.75),
45 ("tokio", 0.65),
46 ("concurrent", 0.7),
47 ],
48 ),
49 (
50 "error",
51 vec![
52 ("err", 0.95),
53 ("result", 0.75),
54 ("panic", 0.55),
55 ("exception", 0.65),
56 ("unwrap", 0.5),
57 ],
58 ),
59 (
60 "http",
61 vec![
62 ("request", 0.85),
63 ("response", 0.85),
64 ("rest", 0.65),
65 ("json", 0.7),
66 ("header", 0.6),
67 ],
68 ),
69 (
70 "db",
71 vec![
72 ("database", 1.0),
73 ("sql", 0.85),
74 ("query", 0.75),
75 ("transaction", 0.65),
76 ("migration", 0.55),
77 ],
78 ),
79 (
80 "test",
81 vec![
82 ("mock", 0.75),
83 ("fixture", 0.6),
84 ("assert", 0.85),
85 ("expect", 0.65),
86 ],
87 ),
88 (
89 "config",
90 vec![
91 ("configuration", 1.0),
92 ("env", 0.75),
93 ("setting", 0.65),
94 ("toml", 0.55),
95 ],
96 ),
97 (
98 "cache",
99 vec![
100 ("memo", 0.65),
101 ("redis", 0.55),
102 ("ttl", 0.6),
103 ("invalidate", 0.55),
104 ],
105 ),
106 ])
107}
108
109#[cfg(feature = "embeddings")]
110fn build_expanded_weights(query_tokens: &[String]) -> HashMap<String, f64> {
111 let dict = expansion_dictionary();
112 let mut out: HashMap<String, f64> = HashMap::new();
113
114 for t in query_tokens {
115 let lower = t.to_lowercase();
116 let entry = out.entry(lower.clone()).or_insert(1.0);
117 *entry = (*entry).max(1.0);
118
119 if let Some(rel) = dict.get(lower.as_str()) {
120 for (syn, w) in rel {
121 let le = out.entry((*syn).to_string()).or_insert(0.0);
122 *le = (*le).max(*w);
123 }
124 }
125 }
126 out
127}
128
129#[cfg(feature = "embeddings")]
130fn expansion_bm25_for_chunk(
131 index: &BM25Index,
132 chunk_idx: usize,
133 expanded: &HashMap<String, f64>,
134 original_terms: &HashSet<String>,
135) -> f64 {
136 if index.doc_count == 0 {
137 return 0.0;
138 }
139
140 let doc_len = index.chunks[chunk_idx].token_count as f64;
141 let norm_len = doc_len / index.avg_doc_len.max(1.0);
142
143 const K1: f64 = 1.2;
144 const B: f64 = 0.75;
145
146 let mut sum = 0.0;
147 for (term, ew) in expanded {
148 if original_terms.contains(term) {
149 continue;
150 }
151 let df = *index.doc_freqs.get(term).unwrap_or(&0) as f64;
152 if df == 0.0 {
153 continue;
154 }
155
156 let idf = ((index.doc_count as f64 - df + 0.5) / (df + 0.5) + 1.0).ln();
157 let tf = index.inverted.get(term).map_or(0.0, |postings| {
158 postings
159 .iter()
160 .filter(|(idx, _)| *idx == chunk_idx)
161 .map(|(_, w)| *w)
162 .sum::<f64>()
163 });
164
165 if tf == 0.0 {
166 continue;
167 }
168
169 let bm25_t = idf * (tf * (K1 + 1.0)) / (tf + K1 * (1.0 - B + B * norm_len));
170 sum += ew * bm25_t;
171 }
172 sum
173}
174
175#[cfg(feature = "embeddings")]
177pub fn hybrid_retrieve(query: &str, bm25_index: &BM25Index, top_k: usize) -> Vec<SpladeResult> {
178 if bm25_index.doc_count == 0 || top_k == 0 {
179 return Vec::new();
180 }
181
182 let query_tokens = tokenize_for_index(query);
183 if query_tokens.is_empty() {
184 return Vec::new();
185 }
186
187 let original_terms: HashSet<String> = query_tokens.iter().map(|s| s.to_lowercase()).collect();
188
189 let expanded = build_expanded_weights(&query_tokens);
190
191 let stage1 = bm25_index.search(query, 100.min(bm25_index.doc_count.max(1)));
192
193 let bm25_by_chunk: HashMap<usize, f64> =
194 stage1.iter().map(|r| (r.chunk_idx, r.score)).collect();
195
196 let chunk_indices: Vec<usize> = bm25_by_chunk.keys().copied().collect();
197 if chunk_indices.is_empty() {
198 return Vec::new();
199 }
200
201 let mut expansion_scores: HashMap<usize, f64> = HashMap::new();
202 for &idx in &chunk_indices {
203 let es = expansion_bm25_for_chunk(bm25_index, idx, &expanded, &original_terms);
204 expansion_scores.insert(idx, es);
205 }
206
207 let max_bm25 = bm25_by_chunk.values().copied().fold(0.0_f64, f64::max);
208 let max_exp = expansion_scores.values().copied().fold(0.0_f64, f64::max);
209
210 let norm_bm25 = |s: f64| -> f64 { if max_bm25 > 1e-12 { s / max_bm25 } else { 0.0 } };
211 let norm_exp = |s: f64| -> f64 { if max_exp > 1e-12 { s / max_exp } else { 0.0 } };
212
213 const W_BM25: f64 = 0.65;
214 const W_EXP: f64 = 0.35;
215
216 let mut results: Vec<SpladeResult> = chunk_indices
217 .into_iter()
218 .map(|chunk_idx| {
219 let chunk = &bm25_index.chunks[chunk_idx];
220 let bm25_score = bm25_by_chunk.get(&chunk_idx).copied().unwrap_or(0.0);
221 let expansion_score = expansion_scores.get(&chunk_idx).copied().unwrap_or(0.0);
222 let combined_score = W_BM25 * norm_bm25(bm25_score) + W_EXP * norm_exp(expansion_score);
223
224 SpladeResult {
225 chunk_idx,
226 file_path: chunk.file_path.clone(),
227 symbol_name: chunk.symbol_name.clone(),
228 combined_score,
229 bm25_score,
230 expansion_score,
231 }
232 })
233 .collect();
234
235 results.sort_by(|a, b| {
236 b.combined_score
237 .partial_cmp(&a.combined_score)
238 .unwrap_or(std::cmp::Ordering::Equal)
239 .then_with(|| {
240 b.bm25_score
241 .partial_cmp(&a.bm25_score)
242 .unwrap_or(std::cmp::Ordering::Equal)
243 })
244 .then_with(|| a.file_path.cmp(&b.file_path))
245 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
246 });
247
248 results.truncate(top_k);
249 results
250}
251
252#[cfg(all(test, feature = "embeddings"))]
253mod tests {
254 use super::*;
255 use crate::core::bm25_index::{ChunkKind, CodeChunk};
256
257 fn sample_index() -> BM25Index {
258 BM25Index::from_chunks_for_test(vec![
259 CodeChunk { input_origin: None,
260 file_path: "login.rs".into(),
261 symbol_name: "login_user".into(),
262 kind: ChunkKind::Function,
263 start_line: 1,
264 end_line: 3,
265 content: "pub fn login_user() { session_token authentication jwt oauth login credential }"
266 .into(),
267 tokens: vec![],
268 token_count: 0,
269 },
270 CodeChunk { input_origin: None,
271 file_path: "cache.rs".into(),
272 symbol_name: "memo_cache".into(),
273 kind: ChunkKind::Function,
274 start_line: 1,
275 end_line: 3,
276 content: "pub fn memo_cache() { redis ttl invalidate memo cache }".into(),
277 tokens: vec![],
278 token_count: 0,
279 },
280 CodeChunk { input_origin: None,
281 file_path: "auth.rs".into(),
282 symbol_name: "oauth_flow".into(),
283 kind: ChunkKind::Function,
284 start_line: 1,
285 end_line: 3,
286 content: "pub fn oauth_flow() { credential authentication token jwt session }".into(),
287 tokens: vec![],
288 token_count: 0,
289 },
290 ])
291 }
292
293 #[test]
294 fn hybrid_prefers_expansion_overlap() {
295 let index = sample_index();
296
297 let hits = hybrid_retrieve("auth login", &index, 5);
298 assert!(!hits.is_empty(), "`hits` is empty");
299 let top_path = hits[0].file_path.clone();
300 assert!(
301 top_path.ends_with("login.rs") || top_path.ends_with("auth.rs"),
302 "expected auth-expanded chunk first, got {hits:?}"
303 );
304 }
305
306 #[test]
307 fn expansion_boosts_related_terms() {
308 let index = sample_index();
309 let hybrid = hybrid_retrieve("jwt auth", &index, 10);
311
312 assert!(!hybrid.is_empty(), "`hybrid` is empty");
313 assert!(
314 hybrid[0].expansion_score >= 0.0,
315 "expansion_score should be non-negative"
316 );
317 }
318
319 #[test]
320 fn empty_query_returns_empty() {
321 let index = sample_index();
322 {
323 let found = &hybrid_retrieve("", &index, 5);
324 assert!(found.is_empty(), "{found:?}");
325 }
326 }
327
328 #[test]
329 fn splade_result_fields_populated() {
330 let index = sample_index();
331
332 let hybrid = hybrid_retrieve("auth session", &index, 3);
333 let r = &hybrid[0];
334 assert!(r.chunk_idx < index.chunks.len());
335 assert!(!r.file_path.is_empty(), "`r.file_path` is empty");
336 assert!(r.combined_score.is_finite());
337 assert!(r.bm25_score.is_finite());
338 assert!(r.expansion_score.is_finite());
339 }
340}