1use std::cell::RefCell;
10
11use rusqlite::Connection;
12
13pub const FIELDS: [(&str, f64); 4] = [
16 ("name", 10.0),
17 ("path", 3.0),
18 ("heading", 4.0),
19 ("body", 1.0),
20];
21
22const 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 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
48pub 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 tx.rollback().expect("discard tokenizer scratch row");
64 tokens
65 })
66}
67
68pub fn order_free_text(text: &str) -> String {
70 let mut tokens = tokenize(text);
71 tokens.sort_unstable();
72 tokens.join(" ")
73}
74
75#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct LexicalQuery {
79 pub groups: Vec<Vec<String>>,
80}
81
82impl LexicalQuery {
83 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 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 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#[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 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
189pub 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 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 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 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"], ["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}