Skip to main content

semtree_rag/
lexical.rs

1use std::collections::HashMap;
2
3use semtree_core::Chunk;
4
5// BM25 tuning constants (Robertson/Zaragoza defaults).
6const K1: f32 = 1.2;
7const B: f32 = 0.75;
8// A chunk's name is repeated this many times so identifier matches rank high.
9const NAME_BOOST: usize = 3;
10
11/// In-memory BM25 lexical index over indexed chunks.
12///
13/// Built fresh from the registry at query time - no extra persistence. The
14/// tokenizer is code-aware: it splits on non-alphanumeric characters (so
15/// `rate_limit` → `rate`, `limit`) and on camelCase boundaries (so
16/// `TokenBucket` → `token`, `bucket`), which is what lets a lexical query
17/// match identifiers a pure substring grep would phrase differently.
18pub struct LexicalIndex {
19    ids: Vec<String>,
20    doc_len: Vec<f32>,
21    avgdl: f32,
22    /// term -> list of (document index, term frequency)
23    postings: HashMap<String, Vec<(usize, u32)>>,
24}
25
26impl LexicalIndex {
27    /// Builds the index from an iterator of chunks (typically `registry.iter()`).
28    pub fn from_chunks<'a>(chunks: impl Iterator<Item = &'a Chunk>) -> Self {
29        let mut ids = Vec::new();
30        let mut doc_len = Vec::new();
31        let mut postings: HashMap<String, Vec<(usize, u32)>> = HashMap::new();
32
33        for chunk in chunks {
34            let doc_idx = ids.len();
35            ids.push(chunk.id.clone());
36
37            let mut text = String::new();
38            if let Some(name) = &chunk.name {
39                for _ in 0..NAME_BOOST {
40                    text.push_str(name);
41                    text.push(' ');
42                }
43            }
44            text.push_str(&chunk.content);
45
46            let tokens = tokenize(&text);
47            doc_len.push(tokens.len() as f32);
48
49            let mut tf: HashMap<String, u32> = HashMap::new();
50            for t in tokens {
51                *tf.entry(t).or_insert(0) += 1;
52            }
53            for (term, count) in tf {
54                postings.entry(term).or_default().push((doc_idx, count));
55            }
56        }
57
58        let n = ids.len().max(1) as f32;
59        let avgdl = doc_len.iter().sum::<f32>() / n;
60        let avgdl = if avgdl == 0.0 { 1.0 } else { avgdl };
61
62        Self {
63            ids,
64            doc_len,
65            avgdl,
66            postings,
67        }
68    }
69
70    pub fn len(&self) -> usize {
71        self.ids.len()
72    }
73
74    pub fn is_empty(&self) -> bool {
75        self.ids.is_empty()
76    }
77
78    /// Returns up to `limit` chunk ids ranked by BM25 score (descending).
79    pub fn search(&self, query: &str, limit: usize) -> Vec<(String, f32)> {
80        let n = self.ids.len() as f32;
81        let mut q_terms = tokenize(query);
82        q_terms.sort();
83        q_terms.dedup();
84
85        let mut scores: HashMap<usize, f32> = HashMap::new();
86        for term in q_terms {
87            let Some(postings) = self.postings.get(&term) else {
88                continue;
89            };
90            let df = postings.len() as f32;
91            let idf = (1.0 + (n - df + 0.5) / (df + 0.5)).ln();
92            for &(doc, tf) in postings {
93                let tf = tf as f32;
94                let dl = self.doc_len[doc];
95                let denom = tf + K1 * (1.0 - B + B * dl / self.avgdl);
96                *scores.entry(doc).or_insert(0.0) += idf * (tf * (K1 + 1.0)) / denom;
97            }
98        }
99
100        let mut ranked: Vec<(usize, f32)> = scores.into_iter().collect();
101        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
102        ranked.truncate(limit);
103        ranked
104            .into_iter()
105            .map(|(doc, score)| (self.ids[doc].clone(), score))
106            .collect()
107    }
108}
109
110/// Splits text into lowercase tokens, breaking on non-alphanumeric characters
111/// and camelCase boundaries. Tokens shorter than 2 chars are dropped.
112fn tokenize(text: &str) -> Vec<String> {
113    let mut tokens = Vec::new();
114    let mut buf = String::new();
115    for ch in text.chars() {
116        if ch.is_alphanumeric() {
117            buf.push(ch);
118        } else if !buf.is_empty() {
119            split_camel(&buf, &mut tokens);
120            buf.clear();
121        }
122    }
123    if !buf.is_empty() {
124        split_camel(&buf, &mut tokens);
125    }
126    tokens
127}
128
129fn split_camel(word: &str, out: &mut Vec<String>) {
130    let chars: Vec<char> = word.chars().collect();
131    let mut start = 0;
132    for i in 1..chars.len() {
133        // lower -> upper transition marks a camelCase boundary
134        if chars[i - 1].is_lowercase() && chars[i].is_uppercase() {
135            push_token(&chars[start..i], out);
136            start = i;
137        }
138    }
139    push_token(&chars[start..], out);
140}
141
142fn push_token(chars: &[char], out: &mut Vec<String>) {
143    if chars.len() >= 2 {
144        out.push(chars.iter().collect::<String>().to_lowercase());
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use semtree_core::{ChunkKind, Language, Span};
152    use std::path::PathBuf;
153
154    fn chunk(id: &str, name: &str, content: &str) -> Chunk {
155        Chunk {
156            id: id.to_string(),
157            path: PathBuf::from(format!("{id}.rs")),
158            language: Language::Rust,
159            kind: ChunkKind::Function,
160            name: Some(name.to_string()),
161            content: content.to_string(),
162            span: Span::new(0, content.len(), 0, 0),
163            doc: None,
164        }
165    }
166
167    #[test]
168    fn tokenizer_splits_camel_and_snake() {
169        let toks = tokenize("TokenBucket rate_limit HTTPServer");
170        assert!(toks.contains(&"token".to_string()));
171        assert!(toks.contains(&"bucket".to_string()));
172        assert!(toks.contains(&"rate".to_string()));
173        assert!(toks.contains(&"limit".to_string()));
174    }
175
176    #[test]
177    fn bm25_ranks_identifier_match_first() {
178        let chunks = [
179            chunk(
180                "a",
181                "TokenBucket",
182                "fn refill(&mut self) { self.tokens += 1; }",
183            ),
184            chunk("b", "parse_config", "fn parse_config() { read_file(); }"),
185            chunk(
186                "c",
187                "log_message",
188                "fn log_message(msg: &str) { println!(); }",
189            ),
190        ];
191        let idx = LexicalIndex::from_chunks(chunks.iter());
192
193        // Query phrased differently than the substring, but shares the token.
194        let hits = idx.search("token bucket throttling", 3);
195        assert_eq!(hits.first().map(|(id, _)| id.as_str()), Some("a"));
196    }
197
198    #[test]
199    fn empty_query_returns_nothing() {
200        let chunks = [chunk("a", "foo", "bar")];
201        let idx = LexicalIndex::from_chunks(chunks.iter());
202        assert!(idx.search("", 5).is_empty());
203    }
204}