Skip to main content

lean_ctx/core/
splade_retrieval.rs

1//! SPLADE-style sparse expansion layered on BM25 candidate retrieval.
2//!
3//! Stage 1: BM25 top-100. Stage 2: programming-term synonym / association expansion.
4//! Stage 3: additive expansion BM25-like scoring combined with stage-1 scores.
5
6#[cfg(feature = "embeddings")]
7use std::collections::{HashMap, HashSet};
8
9#[cfg(feature = "embeddings")]
10use crate::core::bm25_index::{BM25Index, tokenize_for_index};
11
12/// Result row after hybrid retrieval + re-ranking.
13#[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/// Static programming-term associations (token → related tokens with weights).
24#[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/// BM25 top-100 → SPLADE-like expansion → combined re-rank.
176#[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        // "jwt" matches BM25 stage 1; "auth" drives SPLADE expansion toward login/oauth chunks.
310        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}