Skip to main content

nodedb_document/
text_analyzer.rs

1//! Text analysis pipeline for full-text search indexing and querying.
2//!
3//! Pipeline stages (applied at both index time and query time):
4//! 1. Unicode NFD normalization + strip combining marks
5//! 2. Lowercase
6//! 3. Split on non-alphanumeric boundaries (preserving hyphens within words)
7//! 4. Filter empty tokens and single characters
8//! 5. Remove English stop words (binary search on sorted list)
9//! 6. Snowball English stemming
10//!
11//! Produces a `Vec<String>` of normalized, stemmed tokens suitable for
12//! inverted index insertion and BM25 query matching.
13
14use std::collections::HashMap;
15
16use rust_stemmers::{Algorithm, Stemmer};
17use unicode_normalization::UnicodeNormalization;
18
19/// Text analyzer trait: transforms raw text into searchable tokens.
20///
21/// Implementations must produce the same tokens for equivalent text at
22/// both index time and query time (deterministic).
23pub trait TextAnalyzer: Send + Sync {
24    /// Analyze text into tokens.
25    fn analyze(&self, text: &str) -> Vec<String>;
26
27    /// Analyzer name (for serialization and config).
28    fn name(&self) -> &str;
29}
30
31/// Standard English text analyzer (default).
32///
33/// Pipeline: NFD normalize → lowercase → split → filter → stop words → Snowball stem.
34pub struct StandardAnalyzer;
35
36impl TextAnalyzer for StandardAnalyzer {
37    fn analyze(&self, text: &str) -> Vec<String> {
38        analyze(text)
39    }
40
41    fn name(&self) -> &str {
42        "standard"
43    }
44}
45
46/// Simple analyzer: lowercase + split on whitespace. No stemming or stop words.
47///
48/// Useful for exact-match fields (email addresses, tags, identifiers).
49pub struct SimpleAnalyzer;
50
51impl TextAnalyzer for SimpleAnalyzer {
52    fn analyze(&self, text: &str) -> Vec<String> {
53        text.to_lowercase()
54            .split_whitespace()
55            .filter(|w| w.len() > 1)
56            .map(|w| w.to_string())
57            .collect()
58    }
59
60    fn name(&self) -> &str {
61        "simple"
62    }
63}
64
65/// Keyword analyzer: treats entire input as a single token (lowercase).
66///
67/// Used for fields where the entire value is the token (status fields,
68/// enum-like values, exact-match tags).
69pub struct KeywordAnalyzer;
70
71impl TextAnalyzer for KeywordAnalyzer {
72    fn analyze(&self, text: &str) -> Vec<String> {
73        let trimmed = text.trim().to_lowercase();
74        if trimmed.is_empty() {
75            Vec::new()
76        } else {
77            vec![trimmed]
78        }
79    }
80
81    fn name(&self) -> &str {
82        "keyword"
83    }
84}
85
86/// Language-specific analyzer: uses Snowball stemming for the configured language.
87pub struct LanguageAnalyzer {
88    algorithm: Algorithm,
89    lang_name: String,
90}
91
92impl LanguageAnalyzer {
93    pub fn new(language: &str) -> Option<Self> {
94        let algorithm = match language.to_lowercase().as_str() {
95            "english" | "en" => Algorithm::English,
96            "german" | "de" => Algorithm::German,
97            "french" | "fr" => Algorithm::French,
98            "spanish" | "es" => Algorithm::Spanish,
99            "italian" | "it" => Algorithm::Italian,
100            "portuguese" | "pt" => Algorithm::Portuguese,
101            "dutch" | "nl" => Algorithm::Dutch,
102            "swedish" | "sv" => Algorithm::Swedish,
103            "norwegian" | "no" => Algorithm::Norwegian,
104            "danish" | "da" => Algorithm::Danish,
105            "finnish" | "fi" => Algorithm::Finnish,
106            "russian" | "ru" => Algorithm::Russian,
107            "turkish" | "tr" => Algorithm::Turkish,
108            "hungarian" | "hu" => Algorithm::Hungarian,
109            "romanian" | "ro" => Algorithm::Romanian,
110            _ => return None,
111        };
112        Some(Self {
113            algorithm,
114            lang_name: language.to_lowercase(),
115        })
116    }
117}
118
119impl TextAnalyzer for LanguageAnalyzer {
120    fn analyze(&self, text: &str) -> Vec<String> {
121        let stemmer = Stemmer::create(self.algorithm);
122        tokenize_with_stemmer(text, &stemmer)
123    }
124
125    fn name(&self) -> &str {
126        &self.lang_name
127    }
128}
129
130/// N-gram analyzer: generates all character n-grams of sizes min..=max for each token.
131///
132/// Useful for substring matching and partial-word search (e.g., autocomplete).
133/// Example: "database" with min=3, max=4 → ["dat", "ata", "tab", "aba", "bas", "ase", "data", "atab", "taba", "abas", "base"]
134pub struct NgramAnalyzer {
135    min: usize,
136    max: usize,
137}
138
139impl NgramAnalyzer {
140    pub fn new(min: usize, max: usize) -> Self {
141        Self {
142            min: min.max(1),
143            max: max.max(min.max(1)),
144        }
145    }
146}
147
148impl TextAnalyzer for NgramAnalyzer {
149    fn analyze(&self, text: &str) -> Vec<String> {
150        let lower = text.to_lowercase();
151        let mut ngrams = Vec::new();
152        for word in lower.split(|c: char| !c.is_alphanumeric()) {
153            if word.is_empty() {
154                continue;
155            }
156            let chars: Vec<char> = word.chars().collect();
157            for n in self.min..=self.max {
158                if n > chars.len() {
159                    break;
160                }
161                for window in chars.windows(n) {
162                    ngrams.push(window.iter().collect());
163                }
164            }
165        }
166        ngrams
167    }
168
169    fn name(&self) -> &str {
170        "ngram"
171    }
172}
173
174/// Edge n-gram analyzer: generates n-grams anchored to the start of each token.
175///
176/// Useful for prefix/autocomplete search.
177/// Example: "database" with min=2, max=5 → ["da", "dat", "data", "datab"]
178pub struct EdgeNgramAnalyzer {
179    min: usize,
180    max: usize,
181}
182
183impl EdgeNgramAnalyzer {
184    pub fn new(min: usize, max: usize) -> Self {
185        Self {
186            min: min.max(1),
187            max: max.max(min.max(1)),
188        }
189    }
190}
191
192impl TextAnalyzer for EdgeNgramAnalyzer {
193    fn analyze(&self, text: &str) -> Vec<String> {
194        let lower = text.to_lowercase();
195        let mut ngrams = Vec::new();
196        for word in lower.split(|c: char| !c.is_alphanumeric()) {
197            if word.is_empty() {
198                continue;
199            }
200            let chars: Vec<char> = word.chars().collect();
201            for n in self.min..=self.max.min(chars.len()) {
202                ngrams.push(chars[..n].iter().collect());
203            }
204        }
205        ngrams
206    }
207
208    fn name(&self) -> &str {
209        "edge_ngram"
210    }
211}
212
213/// Synonym map: expands query terms with their synonyms at query time.
214///
215/// Each entry maps a term to a set of synonym terms. When a query term
216/// matches a synonym key, all synonym values are added to the query.
217/// Applied after tokenization/stemming, so synonyms should be in
218/// stemmed form (e.g., "db" → ["databas"], not "database").
219///
220/// Example:
221/// ```ignore
222/// let mut synonyms = SynonymMap::new();
223/// synonyms.add("db", &["databas", "rdbms"]);
224/// synonyms.add("ml", &["machin", "learn"]);
225/// ```
226pub struct SynonymMap {
227    /// term → [synonym_terms]. All keys and values are lowercased.
228    entries: HashMap<String, Vec<String>>,
229}
230
231impl SynonymMap {
232    pub fn new() -> Self {
233        Self {
234            entries: HashMap::new(),
235        }
236    }
237
238    /// Add a synonym mapping. Both `term` and `synonyms` are lowercased.
239    pub fn add(&mut self, term: &str, synonyms: &[&str]) {
240        let key = term.to_lowercase();
241        let vals: Vec<String> = synonyms.iter().map(|s| s.to_lowercase()).collect();
242        self.entries.insert(key, vals);
243    }
244
245    /// Expand a list of tokens with their synonyms.
246    ///
247    /// Returns a new token list containing the originals plus any
248    /// synonym expansions. Duplicates are preserved (BM25 handles
249    /// term frequency naturally).
250    pub fn expand(&self, tokens: &[String]) -> Vec<String> {
251        let mut expanded = Vec::with_capacity(tokens.len() * 2);
252        for token in tokens {
253            expanded.push(token.clone());
254            if let Some(synonyms) = self.entries.get(token.as_str()) {
255                expanded.extend(synonyms.iter().cloned());
256            }
257        }
258        expanded
259    }
260
261    /// Number of synonym entries.
262    pub fn len(&self) -> usize {
263        self.entries.len()
264    }
265
266    /// Whether the map is empty.
267    pub fn is_empty(&self) -> bool {
268        self.entries.is_empty()
269    }
270}
271
272impl Default for SynonymMap {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278/// Registry of named text analyzers and synonym maps per collection.
279///
280/// Collections can configure their analyzer via:
281/// `ALTER COLLECTION articles SET text_analyzer = 'german'`
282///
283/// Synonyms are configured via:
284/// `ALTER COLLECTION articles ADD SYNONYM 'DB' => 'database'`
285///
286/// If no analyzer is set, the standard English analyzer is used.
287pub struct AnalyzerRegistry {
288    /// Per-collection analyzer override: collection → analyzer instance.
289    overrides: HashMap<String, Box<dyn TextAnalyzer>>,
290    /// Per-collection synonym maps: collection → SynonymMap.
291    synonyms: HashMap<String, SynonymMap>,
292}
293
294impl AnalyzerRegistry {
295    pub fn new() -> Self {
296        Self {
297            overrides: HashMap::new(),
298            synonyms: HashMap::new(),
299        }
300    }
301
302    /// Set the analyzer for a collection.
303    ///
304    /// Supported names: "standard", "simple", "keyword", or any Snowball
305    /// language ("english", "german", "french", "spanish", etc.).
306    /// Set the analyzer for a collection.
307    ///
308    /// Supported names: "standard", "simple", "keyword", "ngram", "edge_ngram",
309    /// or any Snowball language ("english", "german", "french", etc.).
310    ///
311    /// N-gram analyzers accept optional parameters: "ngram:2:4" (min:max).
312    pub fn set_analyzer(&mut self, collection: &str, analyzer_name: &str) -> bool {
313        let analyzer: Box<dyn TextAnalyzer> = match analyzer_name {
314            "standard" => Box::new(StandardAnalyzer),
315            "simple" => Box::new(SimpleAnalyzer),
316            "keyword" => Box::new(KeywordAnalyzer),
317            "ngram" => Box::new(NgramAnalyzer::new(3, 4)),
318            "edge_ngram" => Box::new(EdgeNgramAnalyzer::new(2, 5)),
319            name if name.starts_with("ngram:") => {
320                let parts: Vec<&str> = name.splitn(3, ':').collect();
321                let min = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(3);
322                let max = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(4);
323                Box::new(NgramAnalyzer::new(min, max))
324            }
325            name if name.starts_with("edge_ngram:") => {
326                let parts: Vec<&str> = name.splitn(3, ':').collect();
327                let min = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(2);
328                let max = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
329                Box::new(EdgeNgramAnalyzer::new(min, max))
330            }
331            lang => match LanguageAnalyzer::new(lang) {
332                Some(a) => Box::new(a),
333                None => return false,
334            },
335        };
336        self.overrides.insert(collection.to_string(), analyzer);
337        true
338    }
339
340    /// Add a synonym for a collection. Both term and synonyms are lowercased.
341    pub fn add_synonym(&mut self, collection: &str, term: &str, synonyms: &[&str]) {
342        self.synonyms
343            .entry(collection.to_string())
344            .or_default()
345            .add(term, synonyms);
346    }
347
348    /// Get the synonym map for a collection (if any).
349    pub fn get_synonyms(&self, collection: &str) -> Option<&SynonymMap> {
350        self.synonyms.get(collection)
351    }
352
353    /// Analyze text for a collection, applying synonym expansion at query time.
354    ///
355    /// For indexing, call `analyze_for_index()` (no synonym expansion).
356    /// For querying, call `analyze()` (with synonym expansion).
357    pub fn analyze(&self, collection: &str, text: &str) -> Vec<String> {
358        let tokens = match self.overrides.get(collection) {
359            Some(analyzer) => analyzer.analyze(text),
360            None => analyze(text),
361        };
362        // Apply synonym expansion.
363        match self.synonyms.get(collection) {
364            Some(syn_map) if !syn_map.is_empty() => syn_map.expand(&tokens),
365            _ => tokens,
366        }
367    }
368
369    /// Analyze text for indexing (no synonym expansion).
370    pub fn analyze_for_index(&self, collection: &str, text: &str) -> Vec<String> {
371        match self.overrides.get(collection) {
372            Some(analyzer) => analyzer.analyze(text),
373            None => analyze(text),
374        }
375    }
376}
377
378impl Default for AnalyzerRegistry {
379    fn default() -> Self {
380        Self::new()
381    }
382}
383
384/// Analyze text into searchable tokens using the standard English analyzer.
385///
386/// Apply the full pipeline: normalize → lowercase → split → filter →
387/// stop words → stem. Used for both indexing and querying.
388pub fn analyze(text: &str) -> Vec<String> {
389    let stemmer = Stemmer::create(Algorithm::English);
390    tokenize_with_stemmer(text, &stemmer)
391}
392
393/// Shared tokenization pipeline used by both the standard `analyze()` function
394/// and `LanguageAnalyzer`. Normalizes Unicode (NFD + strip combining marks +
395/// lowercase), splits on word boundaries, removes stop words, and stems.
396fn tokenize_with_stemmer(text: &str, stemmer: &Stemmer) -> Vec<String> {
397    // Stage 1-2: NFD normalize, strip combining marks, lowercase.
398    let normalized: String = text
399        .nfd()
400        .filter(|c| !c.is_ascii() || !unicode_normalization::char::is_combining_mark(*c))
401        .flat_map(char::to_lowercase)
402        .collect();
403
404    let mut tokens = Vec::new();
405
406    // Stage 3: Split on non-alphanumeric boundaries.
407    // Keep hyphens and underscores within words (e.g., "e-mail" stays together).
408    for word in normalized.split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_') {
409        let trimmed = word.trim_matches(|c: char| c == '-' || c == '_');
410        if trimmed.is_empty() {
411            continue;
412        }
413
414        // Stage 4: Filter single characters.
415        if trimmed.len() <= 1 {
416            continue;
417        }
418
419        // Stage 5: Stop word removal.
420        if is_stop_word(trimmed) {
421            continue;
422        }
423
424        // Stage 6: Snowball stemming.
425        let stemmed = stemmer.stem(trimmed);
426        if !stemmed.is_empty() {
427            tokens.push(stemmed.into_owned());
428        }
429    }
430
431    tokens
432}
433
434/// Check if a word is a common English stop word.
435///
436/// Uses binary search on a sorted static list for O(log n) lookup.
437fn is_stop_word(word: &str) -> bool {
438    STOP_WORDS.binary_search(&word).is_ok()
439}
440
441/// Sorted English stop words for binary search.
442static STOP_WORDS: &[&str] = &[
443    "a", "about", "an", "and", "are", "as", "at", "be", "been", "but", "by", "can", "do", "for",
444    "from", "had", "has", "have", "he", "her", "him", "his", "how", "if", "in", "into", "is", "it",
445    "its", "just", "me", "my", "no", "not", "of", "on", "or", "our", "out", "own", "say", "she",
446    "so", "some", "than", "that", "the", "their", "them", "then", "there", "these", "they", "this",
447    "to", "too", "up", "us", "very", "was", "we", "were", "what", "when", "which", "who", "will",
448    "with", "would", "you", "your",
449];
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn basic_analysis() {
457        let tokens = analyze("The quick Brown FOX jumped over the lazy dog");
458        // "the" is a stop word, removed. All lowercased. "jumped" stemmed to "jump".
459        assert!(tokens.contains(&"quick".to_string()));
460        assert!(tokens.contains(&"brown".to_string()));
461        assert!(tokens.contains(&"fox".to_string()));
462        assert!(tokens.contains(&"jump".to_string())); // stemmed
463        assert!(tokens.contains(&"lazi".to_string())); // stemmed
464        assert!(tokens.contains(&"dog".to_string()));
465        assert!(!tokens.contains(&"the".to_string())); // stop word
466    }
467
468    #[test]
469    fn stop_words_removed() {
470        let tokens = analyze("this is a test of the system");
471        // "this", "is", "a", "of", "the" are stop words.
472        assert_eq!(tokens, vec!["test", "system"]);
473    }
474
475    #[test]
476    fn stemming_works() {
477        let tokens = analyze("running databases distributed systems");
478        assert!(tokens.contains(&"run".to_string()));
479        assert!(tokens.contains(&"databas".to_string()));
480        assert!(tokens.contains(&"distribut".to_string()));
481        assert!(tokens.contains(&"system".to_string()));
482    }
483
484    #[test]
485    fn unicode_normalization() {
486        let tokens = analyze("cafe\u{0301}"); // "café" with combining acute
487        assert_eq!(tokens, vec!["cafe"]); // normalized to "cafe", stemmer keeps it
488    }
489
490    #[test]
491    fn hyphenated_words_preserved() {
492        let tokens = analyze("e-mail real-time");
493        assert!(tokens.contains(&"e-mail".to_string()) || tokens.contains(&"email".to_string()));
494        assert!(
495            tokens.contains(&"real-tim".to_string()) || tokens.contains(&"real-time".to_string())
496        );
497    }
498
499    #[test]
500    fn empty_and_single_char_filtered() {
501        let tokens = analyze("I a x  ");
502        assert!(tokens.is_empty());
503    }
504
505    #[test]
506    fn synonym_expansion() {
507        let mut syn = SynonymMap::new();
508        syn.add("db", &["databas", "rdbms"]);
509        let tokens = vec!["db".to_string(), "query".to_string()];
510        let expanded = syn.expand(&tokens);
511        assert_eq!(expanded.len(), 4); // db, databas, rdbms, query
512        assert!(expanded.contains(&"databas".to_string()));
513        assert!(expanded.contains(&"rdbms".to_string()));
514    }
515
516    #[test]
517    fn analyzer_registry_with_synonyms() {
518        let mut registry = AnalyzerRegistry::new();
519        registry.add_synonym("docs", "db", &["databas"]);
520
521        // Query-time analysis includes synonym expansion.
522        let tokens = registry.analyze("docs", "db query");
523        assert!(tokens.contains(&"databas".to_string()));
524
525        // Index-time analysis does NOT expand synonyms.
526        let index_tokens = registry.analyze_for_index("docs", "db query");
527        assert!(!index_tokens.contains(&"databas".to_string()));
528    }
529
530    #[test]
531    fn simple_analyzer() {
532        let analyzer = SimpleAnalyzer;
533        let tokens = analyzer.analyze("Hello World foo");
534        assert_eq!(tokens, vec!["hello", "world", "foo"]);
535    }
536
537    #[test]
538    fn keyword_analyzer() {
539        let analyzer = KeywordAnalyzer;
540        let tokens = analyzer.analyze("Active Status");
541        assert_eq!(tokens, vec!["active status"]);
542    }
543
544    #[test]
545    fn language_analyzer_german() {
546        let analyzer = LanguageAnalyzer::new("german").unwrap();
547        let tokens = analyzer.analyze("Die Datenbanken sind schnell");
548        // German stemming should apply.
549        assert!(!tokens.is_empty());
550        assert!(tokens.iter().all(|t| t == &t.to_lowercase()));
551    }
552
553    #[test]
554    fn ngram_analyzer() {
555        let analyzer = NgramAnalyzer::new(3, 4);
556        let tokens = analyzer.analyze("hello");
557        // 3-grams: hel, ell, llo  (3)
558        // 4-grams: hell, ello     (2)
559        assert_eq!(tokens.len(), 5);
560        assert!(tokens.contains(&"hel".to_string()));
561        assert!(tokens.contains(&"ell".to_string()));
562        assert!(tokens.contains(&"llo".to_string()));
563        assert!(tokens.contains(&"hell".to_string()));
564        assert!(tokens.contains(&"ello".to_string()));
565    }
566
567    #[test]
568    fn ngram_short_word() {
569        let analyzer = NgramAnalyzer::new(3, 5);
570        let tokens = analyzer.analyze("ab");
571        // "ab" is shorter than min=3, no n-grams produced.
572        assert!(tokens.is_empty());
573    }
574
575    #[test]
576    fn edge_ngram_analyzer() {
577        let analyzer = EdgeNgramAnalyzer::new(2, 5);
578        let tokens = analyzer.analyze("database");
579        // 2: "da", 3: "dat", 4: "data", 5: "datab"
580        assert_eq!(tokens.len(), 4);
581        assert_eq!(tokens[0], "da");
582        assert_eq!(tokens[1], "dat");
583        assert_eq!(tokens[2], "data");
584        assert_eq!(tokens[3], "datab");
585    }
586
587    #[test]
588    fn edge_ngram_multiple_words() {
589        let analyzer = EdgeNgramAnalyzer::new(2, 3);
590        let tokens = analyzer.analyze("foo bar");
591        // "fo", "foo", "ba", "bar"
592        assert_eq!(tokens.len(), 4);
593        assert!(tokens.contains(&"fo".to_string()));
594        assert!(tokens.contains(&"foo".to_string()));
595        assert!(tokens.contains(&"ba".to_string()));
596        assert!(tokens.contains(&"bar".to_string()));
597    }
598
599    #[test]
600    fn registry_ngram_with_params() {
601        let mut registry = AnalyzerRegistry::new();
602        assert!(registry.set_analyzer("col", "ngram:2:3"));
603        let tokens = registry.analyze_for_index("col", "hello");
604        // 2-grams: he, el, ll, lo (4), 3-grams: hel, ell, llo (3) = 7
605        assert_eq!(tokens.len(), 7);
606        assert!(tokens.contains(&"he".to_string()));
607    }
608
609    #[test]
610    fn registry_edge_ngram() {
611        let mut registry = AnalyzerRegistry::new();
612        assert!(registry.set_analyzer("col", "edge_ngram:1:3"));
613        let tokens = registry.analyze_for_index("col", "test");
614        // 1: "t", 2: "te", 3: "tes"
615        assert_eq!(tokens.len(), 3);
616        assert_eq!(tokens[0], "t");
617        assert_eq!(tokens[2], "tes");
618    }
619}