Skip to main content

hayson_engine/registry/
fulltext_index.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::{Mutex, MutexGuard, PoisonError};
3
4use super::tokenize::{parse_search_terms, SearchTerm};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum SearchMode {
8    All,
9    Any,
10}
11
12#[derive(Debug, Default)]
13pub struct CorpusDelta {
14    pub added: HashMap<String, Vec<String>>,
15    pub removed: HashSet<String>,
16}
17
18struct StagedDocument {
19    length: usize,
20    frequencies: HashMap<String, u32>,
21}
22
23struct Corpus {
24    documents: i64,
25    total_tokens: i64,
26    staged: HashMap<String, StagedDocument>,
27    hidden: HashSet<String>,
28}
29
30struct Posting {
31    key: String,
32    frequency: u32,
33}
34
35const K1: f64 = 1.2;
36const B: f64 = 0.75;
37const SPLICED_TOKENS: usize = 16;
38
39fn distinct_terms(query: &str) -> Vec<SearchTerm> {
40    let mut seen = HashSet::new();
41    let mut terms = Vec::new();
42    for term in parse_search_terms(query) {
43        let identity = if term.prefix {
44            format!("{}*", term.token)
45        } else {
46            term.token.clone()
47        };
48        if seen.insert(identity) {
49            terms.push(term);
50        }
51    }
52    terms
53}
54
55/// Whether a document made of `tokens` is one [`FulltextIndex::search`] would answer `query` with —
56/// the membership half of a search, decided on one document alone. What a caller holding a record
57/// the index has never been told about (a unit of work's staged put) has to ask instead of looking
58/// the key up in the postings, which cannot know it. Relevance is not part of the answer: a staged
59/// record joins the result set, it does not join the ranking.
60#[allow(dead_code)]
61pub fn matches_terms(tokens: &[String], query: &str, mode: SearchMode) -> bool {
62    let terms = distinct_terms(query);
63    if terms.is_empty() {
64        return false;
65    }
66    let mut hits = 0;
67    for term in &terms {
68        let hit = tokens.iter().any(|token| {
69            if term.prefix {
70                token.starts_with(&term.token)
71            } else {
72                token == &term.token
73            }
74        });
75        if hit {
76            hits += 1;
77        } else if mode == SearchMode::All {
78            return false;
79        }
80    }
81    hits > 0
82}
83
84/// A poisoned cache lock is recovered rather than propagated: the two vectors behind it are a
85/// derived prefix cache, so the worst a panic mid-merge can leave behind is a prefix search that
86/// misses a token until it is indexed again — never a wrong record, and never a wrong count.
87fn cached<T>(cell: &Mutex<T>) -> MutexGuard<'_, T> {
88    cell.lock().unwrap_or_else(PoisonError::into_inner)
89}
90
91// NOTE: vocabulary/fresh_tokens are a lazy sorted-prefix cache, mutated by `&self` searches.
92#[derive(Default)]
93pub struct FulltextIndex {
94    postings: HashMap<String, HashMap<String, u32>>,
95    lengths: HashMap<String, usize>,
96    vocabulary: Mutex<Vec<String>>,
97    fresh_tokens: Mutex<Vec<String>>,
98    total_tokens: i64,
99}
100
101impl FulltextIndex {
102    pub fn new() -> Self {
103        FulltextIndex::default()
104    }
105
106    #[allow(dead_code)]
107    pub fn documents(&self) -> usize {
108        self.lengths.len()
109    }
110
111    pub fn index(&mut self, key: &str, tokens: &[String]) {
112        self.remove(key);
113        if tokens.is_empty() {
114            return;
115        }
116        for token in tokens {
117            match self.postings.get_mut(token) {
118                Some(bucket) => {
119                    *bucket.entry(key.to_string()).or_insert(0) += 1;
120                }
121                None => {
122                    let mut bucket = HashMap::new();
123                    bucket.insert(key.to_string(), 1);
124                    self.postings.insert(token.clone(), bucket);
125                    cached(&self.fresh_tokens).push(token.clone());
126                }
127            }
128        }
129        self.lengths.insert(key.to_string(), tokens.len());
130        self.total_tokens += tokens.len() as i64;
131    }
132
133    pub fn remove(&mut self, key: &str) {
134        let Some(length) = self.lengths.remove(key) else { return };
135        self.total_tokens -= length as i64;
136        let mut emptied = HashSet::new();
137        self.postings.retain(|token, bucket| {
138            if bucket.remove(key).is_some() && bucket.is_empty() {
139                emptied.insert(token.clone());
140                false
141            } else {
142                true
143            }
144        });
145        if emptied.is_empty() {
146            return;
147        }
148        cached(&self.vocabulary).retain(|token| !emptied.contains(token));
149        let mut fresh = cached(&self.fresh_tokens);
150        if !fresh.is_empty() {
151            fresh.retain(|token| !emptied.contains(token));
152        }
153    }
154
155    pub fn search(&self, query: &str, mode: SearchMode, delta: Option<&CorpusDelta>) -> HashMap<String, f64> {
156        let terms = distinct_terms(query);
157        let mut scores = HashMap::new();
158        if terms.is_empty() {
159            return scores;
160        }
161
162        let corpus = self.corpus_with(delta);
163        if corpus.documents == 0 {
164            return scores;
165        }
166
167        let average_length = corpus.total_tokens as f64 / corpus.documents as f64;
168        let mut hits: HashMap<String, u32> = HashMap::new();
169
170        for term in &terms {
171            self.accumulate_term(term, &corpus, average_length, &mut scores, &mut hits);
172        }
173
174        if mode == SearchMode::All {
175            let terms_len = terms.len() as u32;
176            scores.retain(|key, _| hits.get(key).copied().unwrap_or(0) >= terms_len);
177        }
178        scores
179    }
180
181    fn accumulate_term(
182        &self,
183        term: &SearchTerm,
184        corpus: &Corpus,
185        average_length: f64,
186        scores: &mut HashMap<String, f64>,
187        hits: &mut HashMap<String, u32>,
188    ) {
189        let tokens = if term.prefix {
190            self.expand(&term.token, corpus)
191        } else {
192            vec![term.token.clone()]
193        };
194        if tokens.len() == 1 {
195            let mut add = |key: String, score: f64| {
196                *scores.entry(key.clone()).or_insert(0.0) += score;
197                *hits.entry(key).or_insert(0) += 1;
198            };
199            self.score_token(&tokens[0], corpus, average_length, &mut add);
200            return;
201        }
202
203        let mut best: HashMap<String, f64> = HashMap::new();
204        for token in &tokens {
205            let mut take = |key: String, score: f64| {
206                let current = best.get(&key).copied();
207                if current.is_none_or(|value| score > value) {
208                    best.insert(key, score);
209                }
210            };
211            self.score_token(token, corpus, average_length, &mut take);
212        }
213        for (key, score) in best {
214            *scores.entry(key.clone()).or_insert(0.0) += score;
215            *hits.entry(key).or_insert(0) += 1;
216        }
217    }
218
219    fn score_token(&self, token: &str, corpus: &Corpus, average_length: f64, take: &mut dyn FnMut(String, f64)) {
220        let postings = self.postings_of(token, corpus);
221        if postings.is_empty() {
222            return;
223        }
224        let documents = corpus.documents as f64;
225        let df = postings.len() as f64;
226        let idf = (1.0 + (documents - df + 0.5) / (df + 0.5)).ln();
227        for posting in &postings {
228            let length = corpus
229                .staged
230                .get(&posting.key)
231                .map(|doc| doc.length as f64)
232                .or_else(|| self.lengths.get(&posting.key).map(|l| *l as f64))
233                .unwrap_or(average_length);
234            let frequency = posting.frequency as f64;
235            let denominator = frequency + K1 * (1.0 - B + (B * length) / average_length);
236            take(posting.key.clone(), (idf * (frequency * (K1 + 1.0))) / denominator);
237        }
238    }
239
240    fn expand(&self, prefix: &str, corpus: &Corpus) -> Vec<String> {
241        // NOTE: the range lookup below is valid only once tokens indexed since the last search merge.
242        self.merge_fresh_tokens();
243        let mut tokens = HashSet::new();
244        let mut at = self.lower_bound(prefix);
245        let vocabulary = cached(&self.vocabulary);
246        while at < vocabulary.len() {
247            let token = &vocabulary[at];
248            if !token.starts_with(prefix) {
249                break;
250            }
251            tokens.insert(token.clone());
252            at += 1;
253        }
254        drop(vocabulary);
255        for document in corpus.staged.values() {
256            for token in document.frequencies.keys() {
257                if token.starts_with(prefix) {
258                    tokens.insert(token.clone());
259                }
260            }
261        }
262        tokens.into_iter().collect()
263    }
264
265    fn merge_fresh_tokens(&self) {
266        // NOTE: taken under its own guard — `lower_bound` below locks `vocabulary`, never this one.
267        let mut fresh = std::mem::take(&mut *cached(&self.fresh_tokens));
268        if fresh.is_empty() {
269            return;
270        }
271
272        if fresh.len() <= SPLICED_TOKENS {
273            for token in fresh {
274                let at = self.lower_bound(&token);
275                cached(&self.vocabulary).insert(at, token);
276            }
277            return;
278        }
279
280        fresh.sort();
281        let sorted = std::mem::take(&mut *cached(&self.vocabulary));
282        let mut merged = Vec::with_capacity(sorted.len() + fresh.len());
283        let mut left = 0;
284        let mut right = 0;
285        while left < sorted.len() || right < fresh.len() {
286            if right == fresh.len() || (left < sorted.len() && sorted[left] <= fresh[right]) {
287                merged.push(sorted[left].clone());
288                left += 1;
289            } else {
290                merged.push(fresh[right].clone());
291                right += 1;
292            }
293        }
294        *cached(&self.vocabulary) = merged;
295    }
296
297    fn lower_bound(&self, token: &str) -> usize {
298        let vocabulary = cached(&self.vocabulary);
299        let mut low = 0;
300        let mut high = vocabulary.len();
301        while low < high {
302            let middle = (low + high) / 2;
303            if vocabulary[middle].as_str() < token {
304                low = middle + 1;
305            } else {
306                high = middle;
307            }
308        }
309        low
310    }
311
312    fn corpus_with(&self, delta: Option<&CorpusDelta>) -> Corpus {
313        let mut staged = HashMap::new();
314        let mut hidden = HashSet::new();
315        let mut documents = self.lengths.len() as i64;
316        let mut total_tokens = self.total_tokens;
317        let Some(delta) = delta else {
318            return Corpus {
319                documents,
320                total_tokens,
321                staged,
322                hidden,
323            };
324        };
325
326        for key in &delta.removed {
327            hidden.insert(key.clone());
328        }
329        for (key, tokens) in &delta.added {
330            hidden.insert(key.clone());
331            if tokens.is_empty() {
332                continue;
333            }
334            let mut frequencies = HashMap::new();
335            for token in tokens {
336                *frequencies.entry(token.clone()).or_insert(0) += 1;
337            }
338            staged.insert(
339                key.clone(),
340                StagedDocument {
341                    length: tokens.len(),
342                    frequencies,
343                },
344            );
345        }
346
347        for key in &hidden {
348            if let Some(length) = self.lengths.get(key) {
349                documents -= 1;
350                total_tokens -= *length as i64;
351            }
352        }
353        for document in staged.values() {
354            documents += 1;
355            total_tokens += document.length as i64;
356        }
357        Corpus {
358            documents,
359            total_tokens,
360            staged,
361            hidden,
362        }
363    }
364
365    fn postings_of(&self, token: &str, corpus: &Corpus) -> Vec<Posting> {
366        let mut postings = Vec::new();
367        if let Some(bucket) = self.postings.get(token) {
368            for (key, frequency) in bucket {
369                if corpus.hidden.contains(key) {
370                    continue;
371                }
372                postings.push(Posting {
373                    key: key.clone(),
374                    frequency: *frequency,
375                });
376            }
377        }
378        for (key, document) in &corpus.staged {
379            if let Some(frequency) = document.frequencies.get(token) {
380                postings.push(Posting {
381                    key: key.clone(),
382                    frequency: *frequency,
383                });
384            }
385        }
386        postings
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::registry::tokenize::tokenize;
394
395    fn strings(tokens: &[&str]) -> Vec<String> {
396        tokens.iter().map(|t| t.to_string()).collect()
397    }
398
399    #[test]
400    fn requires_every_query_token_by_default_and_ranks_the_closer_document_higher() {
401        let mut index = FulltextIndex::new();
402        index.index("a", &tokenize("backend developer node"));
403        index.index("b", &tokenize("frontend developer"));
404
405        let hits: Vec<String> = index
406            .search("developer backend", SearchMode::All, None)
407            .into_keys()
408            .collect();
409        assert_eq!(hits, vec!["a".to_string()]);
410
411        let any = index.search("developer frontend", SearchMode::Any, None);
412        let mut keys: Vec<&String> = any.keys().collect();
413        keys.sort();
414        assert_eq!(keys, vec!["a", "b"]);
415        assert!(any["b"] > any["a"]);
416    }
417
418    #[test]
419    fn forgets_a_removed_document() {
420        let mut index = FulltextIndex::new();
421        index.index("a", &strings(&["backend"]));
422        index.remove("a");
423        assert_eq!(index.documents(), 0);
424        assert!(index.search("backend", SearchMode::All, None).is_empty());
425    }
426
427    #[test]
428    fn scores_a_staged_document_against_the_corpus_it_is_added_to() {
429        let mut index = FulltextIndex::new();
430        index.index("a", &strings(&["backend", "developer"]));
431        let alone = index.search("backend", SearchMode::All, None)["a"];
432
433        let delta = CorpusDelta {
434            added: HashMap::from([
435                ("b".to_string(), strings(&["backend", "developer"])),
436                ("c".to_string(), strings(&["backend", "architect"])),
437            ]),
438            removed: HashSet::new(),
439        };
440        let scores = index.search("backend", SearchMode::All, Some(&delta));
441
442        let mut keys: Vec<&String> = scores.keys().collect();
443        keys.sort();
444        assert_eq!(keys, vec!["a", "b", "c"]);
445        assert!(scores["a"] < alone);
446    }
447
448    #[test]
449    fn leaves_a_removed_document_out_of_the_scores_and_statistics() {
450        let mut index = FulltextIndex::new();
451        index.index("a", &strings(&["backend", "developer"]));
452        index.index("b", &strings(&["frontend", "developer"]));
453        let with_both = index.search("developer", SearchMode::All, None)["a"];
454
455        let delta = CorpusDelta {
456            added: HashMap::new(),
457            removed: HashSet::from(["b".to_string()]),
458        };
459        let scores = index.search("developer", SearchMode::All, Some(&delta));
460
461        let keys: Vec<&String> = scores.keys().collect();
462        assert_eq!(keys, vec!["a"]);
463        assert_ne!(scores["a"], with_both);
464    }
465
466    #[test]
467    fn matches_a_token_that_only_begins_with_the_query_term_when_marked() {
468        let mut index = FulltextIndex::new();
469        index.index("a", &strings(&["wybor", "silnika", "skladowania"]));
470        index.index("b", &strings(&["wybor", "formatu"]));
471
472        assert!(index.search("silnik", SearchMode::All, None).is_empty());
473        let hits: Vec<String> = index.search("silnik*", SearchMode::All, None).into_keys().collect();
474        assert_eq!(hits, vec!["a".to_string()]);
475    }
476
477    #[test]
478    fn scores_each_expanded_token_by_its_own_document_frequency() {
479        let mut index = FulltextIndex::new();
480        index.index("rare", &strings(&["silnikowego"]));
481        index.index("common", &strings(&["silnik"]));
482        for at in 0..20 {
483            index.index(&format!("filler_{at}"), &strings(&["silnik"]));
484        }
485
486        let scores = index.search("silnik*", SearchMode::All, None);
487        assert!(scores["rare"] > scores["common"]);
488        assert_eq!(scores["common"], scores["filler_0"]);
489    }
490
491    #[test]
492    fn judges_an_unindexed_document_by_the_same_membership_rule_as_the_postings() {
493        let mut index = FulltextIndex::new();
494        let tokens = tokenize("Backend architecture");
495        index.index("indexed", &tokens);
496
497        for (query, mode) in [
498            ("backend", SearchMode::All),
499            ("architec*", SearchMode::All),
500            ("architek* backend", SearchMode::All),
501            ("architek* backend", SearchMode::Any),
502            ("frontend", SearchMode::All),
503            ("", SearchMode::All),
504        ] {
505            let indexed = index.search(query, mode, None).contains_key("indexed");
506            assert_eq!(
507                matches_terms(&tokens, query, mode),
508                indexed,
509                "query \"{query}\" disagreed"
510            );
511        }
512    }
513
514    #[test]
515    fn forgets_a_token_from_the_vocabulary_when_its_last_document_goes() {
516        let mut index = FulltextIndex::new();
517        index.index("a", &strings(&["silnika"]));
518        index.index("b", &strings(&["silnikowego"]));
519        index.remove("a");
520
521        let hits: Vec<String> = index.search("silnik*", SearchMode::All, None).into_keys().collect();
522        assert_eq!(hits, vec!["b".to_string()]);
523        index.remove("b");
524        assert!(index.search("silnik*", SearchMode::All, None).is_empty());
525    }
526}