Skip to main content

vecdb_core/sparse/
tokenizer.rs

1use std::collections::{HashMap, HashSet};
2
3/// Common English stopwords.
4static ENGLISH_STOPWORDS: &[&str] = &[
5    "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
6    "from", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does",
7    "did", "will", "would", "could", "should", "may", "might", "shall", "can", "not", "no", "nor",
8    "so", "yet", "both", "either", "neither", "each", "few", "more", "most", "other", "some",
9    "such", "than", "then", "too", "very", "just", "also", "as", "if", "its", "it", "this", "that",
10    "these", "those", "i", "me", "my", "we", "our", "you", "your", "he", "she", "they", "them",
11    "his", "her", "their", "what", "which", "who", "whom", "how", "when", "where", "why", "all",
12    "any", "into", "through", "during", "before", "after", "above", "below", "up", "down", "out",
13    "off", "over", "under", "again", "further", "once", "here", "there", "while", "about",
14    "against", "between", "own", "same", "only", "s", "t", "re", "ve", "ll", "d", "m",
15];
16
17/// Configuration for the tokenizer pipeline.
18#[derive(Debug, Clone)]
19pub struct TokenizerConfig {
20    /// Convert tokens to lowercase (default: true).
21    pub lowercase: bool,
22    /// Remove stopwords from token stream (default: true).
23    pub remove_stopwords: bool,
24    /// Minimum token length to keep (default: 2).
25    pub min_token_length: usize,
26    /// Maximum token length to keep (default: 64).
27    pub max_token_length: usize,
28    /// Apply simple suffix stripping (default: false).
29    pub strip_suffixes: bool,
30}
31
32impl Default for TokenizerConfig {
33    fn default() -> Self {
34        Self {
35            lowercase: true,
36            remove_stopwords: true,
37            min_token_length: 2,
38            max_token_length: 64,
39            strip_suffixes: false,
40        }
41    }
42}
43
44/// Unicode-aware tokenizer with configurable pipeline:
45/// split → lowercase → clean → length filter → stopword filter → suffix strip.
46#[derive(Debug, Clone)]
47pub struct Tokenizer {
48    config: TokenizerConfig,
49    stopwords: HashSet<String>,
50}
51
52impl Tokenizer {
53    /// Create a tokenizer with the given config.
54    pub fn new(config: TokenizerConfig) -> Self {
55        let stopwords: HashSet<String> = ENGLISH_STOPWORDS.iter().map(|s| s.to_string()).collect();
56        Self { config, stopwords }
57    }
58
59    /// Tokenize document text — applies all filters including stopword removal.
60    pub fn tokenize(&self, text: &str) -> Vec<String> {
61        self.run_pipeline(text, self.config.remove_stopwords)
62    }
63
64    /// Tokenize query text — stopwords are KEPT (critical for recall).
65    pub fn tokenize_query(&self, text: &str) -> Vec<String> {
66        self.run_pipeline(text, false)
67    }
68
69    fn run_pipeline(&self, text: &str, remove_stopwords: bool) -> Vec<String> {
70        // 1. Split on non-alphanumeric boundaries (simple but effective for ASCII/Latin text).
71        let mut tokens: Vec<String> = text
72            .split(|c: char| !c.is_alphanumeric() && c != '\'')
73            .map(|s| s.trim_matches('\''))
74            .filter(|s| !s.is_empty())
75            .map(|s| s.to_string())
76            .collect();
77
78        // 2. Lowercase.
79        if self.config.lowercase {
80            tokens = tokens.into_iter().map(|t| t.to_lowercase()).collect();
81        }
82
83        // 3. Length filter.
84        tokens.retain(|t| {
85            t.len() >= self.config.min_token_length && t.len() <= self.config.max_token_length
86        });
87
88        // 4. Stopword filter.
89        if remove_stopwords {
90            tokens.retain(|t| !self.stopwords.contains(t.as_str()));
91        }
92
93        // 5. Suffix stripping.
94        if self.config.strip_suffixes {
95            tokens = tokens.into_iter().map(Self::strip_suffix).collect();
96        }
97
98        tokens
99    }
100
101    /// Simple English suffix stripping (no stemmer dependency).
102    fn strip_suffix(token: String) -> String {
103        // Try suffixes longest-first so "tion" wins over "on".
104        const SUFFIXES: &[&str] = &["tion", "ness", "ment", "ing", "ed", "er", "es", "ly"];
105        for &suffix in SUFFIXES {
106            // Keep at least 3 characters after stripping.
107            if token.len() > suffix.len() + 3 && token.ends_with(suffix) {
108                return token[..token.len() - suffix.len()].to_string();
109            }
110        }
111        token
112    }
113
114    /// Compute raw term → count frequencies from a token slice.
115    pub fn compute_term_frequencies(tokens: &[String]) -> HashMap<String, usize> {
116        let mut freq: HashMap<String, usize> = HashMap::new();
117        for token in tokens {
118            *freq.entry(token.clone()).or_insert(0) += 1;
119        }
120        freq
121    }
122}
123
124impl Default for Tokenizer {
125    fn default() -> Self {
126        Self::new(TokenizerConfig::default())
127    }
128}
129
130// ──────────────────────────────────────────────
131// Tests
132// ──────────────────────────────────────────────
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_basic_tokenize() {
140        let tok = Tokenizer::default();
141        let tokens = tok.tokenize("The quick brown fox jumps over the lazy dog");
142        // "the", "over" are stopwords; all lowercase
143        assert!(!tokens.contains(&"the".to_string()));
144        assert!(tokens.contains(&"quick".to_string()));
145        assert!(tokens.contains(&"brown".to_string()));
146        assert!(tokens.contains(&"fox".to_string()));
147    }
148
149    #[test]
150    fn test_query_keeps_stopwords() {
151        let tok = Tokenizer::default();
152        let doc_tokens = tok.tokenize("to be or not to be");
153        let query_tokens = tok.tokenize_query("to be or not to be");
154        // Document tokenization removes stopwords → empty or near-empty
155        assert!(doc_tokens.is_empty() || doc_tokens.len() < query_tokens.len());
156        // Query keeps stopwords
157        assert!(query_tokens.contains(&"to".to_string()));
158        assert!(query_tokens.contains(&"be".to_string()));
159    }
160
161    #[test]
162    fn test_min_length_filter() {
163        let tok = Tokenizer::default(); // min_token_length = 2
164        let tokens = tok.tokenize("a I go running");
165        // "a", "I" filtered by length (< 2); "go" kept; "I" is also a stopword
166        assert!(!tokens.contains(&"a".to_string()));
167        assert!(tokens.contains(&"go".to_string()));
168        assert!(tokens.contains(&"running".to_string()));
169    }
170
171    #[test]
172    fn test_compute_term_frequencies() {
173        let tokens = vec!["dog".to_string(), "cat".to_string(), "dog".to_string()];
174        let freq = Tokenizer::compute_term_frequencies(&tokens);
175        assert_eq!(freq["dog"], 2);
176        assert_eq!(freq["cat"], 1);
177    }
178
179    #[test]
180    fn test_strip_suffixes() {
181        let config = TokenizerConfig {
182            strip_suffixes: true,
183            remove_stopwords: false,
184            ..Default::default()
185        };
186        let tok = Tokenizer::new(config);
187        let tokens = tok.tokenize("running jumped faster");
188        // "running" → "runn" (ing stripped), "jumped" → "jump" (ed stripped)
189        assert!(tokens.contains(&"runn".to_string()) || tokens.contains(&"running".to_string()));
190        assert!(tokens.contains(&"jump".to_string()) || tokens.contains(&"jumped".to_string()));
191    }
192
193    #[test]
194    fn test_punctuation_split() {
195        let tok = Tokenizer::default();
196        let tokens = tok.tokenize("hello, world! foo-bar baz.qux");
197        assert!(tokens.contains(&"hello".to_string()));
198        assert!(tokens.contains(&"world".to_string()));
199        assert!(tokens.contains(&"foo".to_string()));
200        assert!(tokens.contains(&"bar".to_string()));
201        assert!(tokens.contains(&"baz".to_string()));
202        assert!(tokens.contains(&"qux".to_string()));
203    }
204}