Skip to main content

seyk_core/
lexical.rs

1//! Lexical (keyword) matching, the half of hybrid search that embeddings miss:
2//! exact names, identifiers and rare words.
3//!
4//! Both caches use SQLite FTS5's `unicode61 remove_diacritics 2` tokenizer.
5//! Persistent fields contain sorted tokens, including duplicates: frequencies
6//! and field lengths survive, but positions reveal no source word order.
7//! Identifier groups require all their tokens in the same field, in any order.
8
9use std::cell::RefCell;
10
11use rusqlite::Connection;
12
13/// The fields a segment is matched on, with their BM25 weights. A query word in
14/// the file name counts ten times as much as one in the body text.
15pub const FIELDS: [(&str, f64); 4] = [
16    ("name", 10.0),
17    ("path", 3.0),
18    ("heading", 4.0),
19    ("body", 1.0),
20];
21
22/// Function words that would match nearly every segment and only add noise to a
23/// natural-language question. Identifiers and names never consist of these.
24const STOPWORDS: &[&str] = &[
25    "a", "about", "after", "all", "an", "and", "any", "are", "as", "at", "be", "been", "but", "by",
26    "can", "did", "do", "does", "for", "from", "had", "has", "have", "he", "her", "his", "how",
27    "i", "if", "in", "into", "is", "it", "its", "me", "my", "of", "on", "or", "our", "she", "so",
28    "some", "that", "the", "their", "them", "there", "they", "this", "to", "was", "we", "were",
29    "what", "when", "where", "which", "who", "why", "will", "with", "would", "you", "your",
30];
31
32thread_local! {
33    // Reuse SQLite's own tokenizer instead of approximating its Unicode 6.1
34    // character classes and case folding with Rust's newer Unicode tables.
35    // This scratch index is memory-only, including for MemoryCache users.
36    static TOKENIZER: RefCell<Connection> = RefCell::new({
37        let conn = Connection::open_in_memory().expect("open in-memory tokenizer");
38        conn.execute_batch(
39            "PRAGMA temp_store = MEMORY;
40             CREATE VIRTUAL TABLE tokens USING fts5(text, content='', contentless_delete=1,
41                 tokenize='unicode61 remove_diacritics 2');
42             CREATE VIRTUAL TABLE vocabulary USING fts5vocab(tokens, instance);"
43        ).expect("initialize bundled FTS5 tokenizer");
44        conn
45    });
46}
47
48/// Tokens normalized by the same tokenizer that indexes persistent fields.
49pub fn tokenize(text: &str) -> Vec<String> {
50    TOKENIZER.with(|cell| {
51        let mut conn = cell.borrow_mut();
52        let tx = conn.transaction().expect("begin tokenization");
53        tx.execute("INSERT INTO tokens(rowid, text) VALUES (1, ?1)", [text])
54            .expect("tokenize text");
55        let tokens = tx
56            .prepare_cached("SELECT term FROM vocabulary ORDER BY offset")
57            .expect("prepare token scan")
58            .query_map([], |row| row.get(0))
59            .expect("scan tokens")
60            .collect::<rusqlite::Result<Vec<String>>>()
61            .expect("read tokens");
62        // Roll back the scratch row and its positions even if reading panics.
63        tx.rollback().expect("discard tokenizer scratch row");
64        tokens
65    })
66}
67
68/// FTS input preserving term frequencies but no original token positions.
69pub fn order_free_text(text: &str) -> String {
70    let mut tokens = tokenize(text);
71    tokens.sort_unstable();
72    tokens.join(" ")
73}
74
75/// OR-ed groups of tokens. All tokens of a group must occur in one field,
76/// without requiring adjacency or source order; plain words are groups of one.
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct LexicalQuery {
79    pub groups: Vec<Vec<String>>,
80}
81
82impl LexicalQuery {
83    /// Build from one or more phrasings. Whitespace separates groups; within a
84    /// group, punctuation joins tokens into a conjunction. Single-token stopwords
85    /// are dropped, so a question matches on its content words.
86    pub fn new<'a>(queries: impl IntoIterator<Item = &'a str>) -> Self {
87        let mut groups: Vec<Vec<String>> = Vec::new();
88        for q in queries {
89            for word in q.split_whitespace() {
90                let tokens = tokenize(word);
91                let keep = match tokens.as_slice() {
92                    [] => false,
93                    [t] => !STOPWORDS.contains(&t.as_str()),
94                    _ => true,
95                };
96                if keep && !groups.contains(&tokens) {
97                    groups.push(tokens);
98                }
99            }
100        }
101        Self { groups }
102    }
103
104    pub fn is_empty(&self) -> bool {
105        self.groups.is_empty()
106    }
107
108    /// An FTS5 `MATCH` expression: quoted tokens AND-ed within each field,
109    /// groups OR-ed. Only our fixed field names are parsed as FTS syntax.
110    pub fn fts5(&self) -> Option<String> {
111        if self.is_empty() {
112            return None;
113        }
114        Some(
115            self.groups
116                .iter()
117                .map(|g| {
118                    let terms = g
119                        .iter()
120                        .map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
121                        .collect::<Vec<_>>()
122                        .join(" AND ");
123                    if g.len() == 1 {
124                        terms
125                    } else {
126                        // A bare AND could satisfy an identifier across unrelated fields.
127                        format!(
128                            "({})",
129                            FIELDS
130                                .iter()
131                                .map(|(field, _)| format!("{field}:({terms})"))
132                                .collect::<Vec<_>>()
133                                .join(" OR ")
134                        )
135                    }
136                })
137                .collect::<Vec<_>>()
138                .join(" OR "),
139        )
140    }
141}
142
143/// One indexed segment's fields, already tokenized, in [`FIELDS`] order.
144#[derive(Debug, Clone, Default, PartialEq, Eq)]
145pub struct LexicalDoc {
146    pub fields: [Vec<String>; 4],
147}
148
149impl LexicalDoc {
150    pub fn new(name: &str, path: &str, heading: Option<&str>, body: &str) -> Self {
151        Self {
152            fields: [
153                tokenize(name),
154                tokenize(path),
155                heading.map(tokenize).unwrap_or_default(),
156                tokenize(body),
157            ],
158        }
159    }
160
161    /// Return only query tokens whose complete group matches a single field.
162    pub fn matched_terms(&self, query: &LexicalQuery) -> Vec<String> {
163        let mut terms = Vec::new();
164        for group in &query.groups {
165            if (0..FIELDS.len()).any(|field| self.matches_group(field, group)) {
166                for term in group {
167                    if !terms.contains(term) {
168                        terms.push(term.clone());
169                    }
170                }
171            }
172        }
173        terms
174    }
175
176    fn term_count(&self, field: usize, term: &str) -> usize {
177        self.fields[field].iter().filter(|t| *t == term).count()
178    }
179
180    fn matches_group(&self, field: usize, group: &[String]) -> bool {
181        !group.is_empty() && group.iter().all(|t| self.term_count(field, t) > 0)
182    }
183
184    fn len(&self) -> usize {
185        self.fields.iter().map(Vec::len).sum()
186    }
187}
188
189/// BM25 with FTS5's k1, b, IDF floor, weighted term frequencies and total
190/// document-length normalization. Each quoted token is an FTS5 phrase; a
191/// multi-token group's field-filtered phrases contribute only in matching fields.
192pub fn bm25<'d, K: Clone>(
193    docs: impl IntoIterator<Item = (K, &'d LexicalDoc)>,
194    query: &LexicalQuery,
195) -> Vec<(K, f64)> {
196    const K1: f64 = 1.2;
197    const B: f64 = 0.75;
198    let docs: Vec<(K, &LexicalDoc)> = docs.into_iter().collect();
199    let n = docs.len() as f64;
200    if n == 0.0 || query.is_empty() {
201        return Vec::new();
202    }
203    let avg_len = docs.iter().map(|(_, d)| d.len()).sum::<usize>() as f64 / n;
204    // (group, token, optional column restriction, inverse document frequency)
205    let mut phrases = Vec::new();
206    for group in &query.groups {
207        let fields: Vec<Option<usize>> = if group.len() == 1 {
208            vec![None]
209        } else {
210            (0..FIELDS.len()).map(Some).collect()
211        };
212        for field in fields {
213            for term in group {
214                let df = docs
215                    .iter()
216                    .filter(|(_, d)| match field {
217                        Some(f) => d.term_count(f, term) > 0,
218                        None => (0..FIELDS.len()).any(|f| d.term_count(f, term) > 0),
219                    })
220                    .count() as f64;
221                let idf = ((n - df + 0.5) / (df + 0.5)).ln().max(1e-6);
222                phrases.push((group, term, field, idf));
223            }
224        }
225    }
226    let mut scored: Vec<(K, f64)> = docs
227        .iter()
228        .filter_map(|(key, doc)| {
229            let mut score = 0.0;
230            for (group, term, field, idf) in &phrases {
231                let tf = match field {
232                    Some(f) if doc.matches_group(*f, group) => {
233                        FIELDS[*f].1 * doc.term_count(*f, term) as f64
234                    }
235                    Some(_) => 0.0,
236                    None => FIELDS
237                        .iter()
238                        .enumerate()
239                        .map(|(f, (_, w))| w * doc.term_count(f, term) as f64)
240                        .sum(),
241                };
242                if tf > 0.0 {
243                    let norm = 1.0 - B + B * doc.len() as f64 / avg_len;
244                    score += idf * tf * (K1 + 1.0) / (tf + K1 * norm);
245                }
246            }
247            (score > 0.0).then(|| (key.clone(), score))
248        })
249        .collect();
250    scored.sort_by(|a, b| b.1.total_cmp(&a.1));
251    scored
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn tokens_ignore_case_accents_and_punctuation() {
260        assert_eq!(
261            tokenize("Café-Zürich, INC-18372!"),
262            ["cafe", "zurich", "inc", "18372"]
263        );
264        assert_eq!(tokenize("Session::restore()"), ["session", "restore"]);
265        assert_eq!(tokenize("invoice_38192"), ["invoice", "38192"]);
266    }
267
268    #[test]
269    fn queries_keep_identifiers_as_conjunctions_and_drop_stopwords() {
270        let q = LexicalQuery::new(["where is INC-18372 and the garden.md"]);
271        assert_eq!(
272            q.groups,
273            [vec!["inc", "18372"], vec!["garden", "md"]]
274                .map(|g| g.iter().map(|s| s.to_string()).collect::<Vec<_>>())
275        );
276        assert_eq!(
277            q.fts5().unwrap(),
278            concat!(
279                "(name:(\"inc\" AND \"18372\") OR path:(\"inc\" AND \"18372\") OR ",
280                "heading:(\"inc\" AND \"18372\") OR body:(\"inc\" AND \"18372\")) OR ",
281                "(name:(\"garden\" AND \"md\") OR path:(\"garden\" AND \"md\") OR ",
282                "heading:(\"garden\" AND \"md\") OR body:(\"garden\" AND \"md\"))"
283            )
284        );
285        assert!(LexicalQuery::new(["where is the"]).fts5().is_none());
286    }
287
288    #[test]
289    fn quotes_in_queries_cannot_break_out_of_a_phrase() {
290        // Tokens never contain quotes or FTS5 operators.
291        let q = LexicalQuery::new([r#"a" OR body:x NEAR(y)"#]);
292        let conn = Connection::open_in_memory().unwrap();
293        conn.execute_batch(
294            "CREATE VIRTUAL TABLE f USING fts5(name,path,heading,body);
295            INSERT INTO f VALUES('','','','unrelated');",
296        )
297        .unwrap();
298        let count: i64 = conn
299            .query_row(
300                "SELECT count(*) FROM f WHERE f MATCH ?1",
301                [q.fts5().unwrap()],
302                |r| r.get(0),
303            )
304            .unwrap();
305        assert_eq!(count, 0);
306    }
307
308    #[test]
309    fn sorted_fields_round_trip_through_unicode61() {
310        // Greek final sigma, Indic combining marks, Roman numerals and accents
311        // expose differences from Rust is_alphanumeric() + lowercase + NFD.
312        let text = "Zürich Café κόσμος नमस्ते 東京 Привет Straße ẞ aⅣ2 v2alpha INC-18372 Café";
313        let sorted = order_free_text(text);
314        let mut expected = tokenize(text);
315        expected.sort_unstable();
316        assert_eq!(tokenize(&sorted), expected);
317        assert_eq!(order_free_text(&sorted), sorted);
318        assert_eq!(
319            order_free_text("zeta alpha ZETA beta"),
320            "alpha beta zeta zeta"
321        );
322        assert_eq!(tokenize("κόσμος नमस्ते aⅣ2"), ["κόσμοσ", "नमस", "त", "aⅳ2"]);
323    }
324
325    #[test]
326    fn memory_scores_match_fts5_conjunctions_and_field_weights() {
327        let conn = Connection::open_in_memory().unwrap();
328        conn.execute_batch(
329            "CREATE VIRTUAL TABLE docs USING fts5(name,path,heading,body,
330            content='',contentless_delete=1,tokenize='unicode61 remove_diacritics 2');",
331        )
332        .unwrap();
333        let fields = [
334            ["session", "", "", "restore"], // Must not join across fields.
335            ["session restore", "", "", "session"],
336            ["", "", "", "restore extra session session"],
337            ["", "session restore", "", "different"],
338            ["", "", "restore session", "garden"],
339            ["garden", "", "", "unrelated unrelated"],
340            ["other", "", "", "other other"],
341        ];
342        let mut docs = Vec::new();
343        for (i, f) in fields.iter().enumerate() {
344            docs.push(LexicalDoc::new(f[0], f[1], Some(f[2]), f[3]));
345            conn.execute(
346                "INSERT INTO docs(rowid,name,path,heading,body) VALUES(?1,?2,?3,?4,?5)",
347                rusqlite::params![
348                    i as i64,
349                    order_free_text(f[0]),
350                    order_free_text(f[1]),
351                    order_free_text(f[2]),
352                    order_free_text(f[3])
353                ],
354            )
355            .unwrap();
356        }
357        for text in [
358            "Session::restore()",
359            "Session::restore() garden",
360            "session",
361            "session-session",
362            "missing-restoration",
363        ] {
364            let q = LexicalQuery::new([text]);
365            let memory = bm25(docs.iter().enumerate(), &q);
366            let sqlite = conn
367                .prepare(
368                    "SELECT rowid, -bm25(docs,10,3,4,1) FROM docs
369                WHERE docs MATCH ?1 ORDER BY bm25(docs,10,3,4,1)",
370                )
371                .unwrap()
372                .query_map([q.fts5().unwrap()], |r| {
373                    Ok((r.get::<_, i64>(0)? as usize, r.get::<_, f64>(1)?))
374                })
375                .unwrap()
376                .collect::<rusqlite::Result<Vec<_>>>()
377                .unwrap();
378            assert_eq!(memory.len(), sqlite.len(), "{text}");
379            for ((mi, ms), (si, ss)) in memory.iter().zip(&sqlite) {
380                assert_eq!(mi, si, "{text}");
381                assert!((ms - ss).abs() < 1e-12, "{text}: {ms} != {ss}");
382            }
383        }
384    }
385
386    #[test]
387    fn contentless_reduced_detail_loses_bm25_and_phrases() {
388        for detail in ["none", "column", "full"] {
389            let conn = Connection::open_in_memory().unwrap();
390            conn.execute_batch(&format!(
391                "CREATE VIRTUAL TABLE docs USING fts5(name,body,
392                content='',contentless_delete=1,detail={detail});
393                INSERT INTO docs VALUES('alpha beta','');
394                INSERT INTO docs VALUES('','alpha beta');
395                INSERT INTO docs VALUES('','unrelated');
396                INSERT INTO docs VALUES('','unrelated');
397                INSERT INTO docs VALUES('','unrelated');"
398            ))
399            .unwrap();
400            let scores = conn
401                .prepare("SELECT -bm25(docs,10,1) FROM docs WHERE docs MATCH 'alpha'")
402                .unwrap()
403                .query_map([], |r| r.get::<_, f64>(0))
404                .unwrap()
405                .collect::<rusqlite::Result<Vec<_>>>()
406                .unwrap();
407            let phrase = conn.query_row(
408                r#"SELECT count(*) FROM docs WHERE docs MATCH '"alpha beta"'"#,
409                [],
410                |r| r.get::<_, i64>(0),
411            );
412            let column = conn.query_row(
413                "SELECT count(*) FROM docs WHERE docs MATCH 'name:alpha'",
414                [],
415                |r| r.get::<_, i64>(0),
416            );
417            if detail == "full" {
418                assert!(scores[0] > scores[1] && scores[1] > 0.0);
419                assert_eq!(phrase.unwrap(), 2);
420            } else {
421                assert_eq!(scores, [0.0, 0.0]);
422                assert!(
423                    phrase
424                        .unwrap_err()
425                        .to_string()
426                        .contains("phrase queries are not supported")
427                );
428            }
429            assert_eq!(column.is_err(), detail == "none");
430        }
431    }
432
433    #[test]
434    fn filename_matches_outrank_body_mentions() {
435        let named = LexicalDoc::new("garden.md", "book/garden.md", None, "Notes on it.");
436        let mentioned = LexicalDoc::new(
437            "ideas.md",
438            "book/ideas.md",
439            None,
440            "My garden and the md file.",
441        );
442        let ranked = bm25(
443            [("named", &named), ("mentioned", &mentioned)],
444            &LexicalQuery::new(["garden.md"]),
445        );
446        assert_eq!(ranked[0].0, "named");
447    }
448}