Skip to main content

khive_text/
filter.rs

1//! Token filters: lowercase, stop words, length constraints, stemming.
2
3use std::collections::HashSet;
4use std::sync::LazyLock;
5
6use crate::TokenFilter;
7
8/// Lowercases tokens (Unicode-aware).
9#[derive(Debug, Default, Clone)]
10pub struct LowercaseFilter;
11
12impl TokenFilter for LowercaseFilter {
13    fn apply(&self, token: String) -> Option<String> {
14        Some(token.to_lowercase())
15    }
16}
17
18/// Drops tokens shorter than `min` characters.
19#[derive(Debug, Clone)]
20pub struct MinLengthFilter(pub usize);
21
22impl TokenFilter for MinLengthFilter {
23    fn apply(&self, token: String) -> Option<String> {
24        if token.chars().count() >= self.0 {
25            Some(token)
26        } else {
27            None
28        }
29    }
30}
31
32/// Drops tokens longer than `max` characters.
33#[derive(Debug, Clone)]
34pub struct MaxLengthFilter(pub usize);
35
36impl Default for MaxLengthFilter {
37    fn default() -> Self {
38        Self(40)
39    }
40}
41
42impl TokenFilter for MaxLengthFilter {
43    fn apply(&self, token: String) -> Option<String> {
44        if token.chars().count() <= self.0 {
45            Some(token)
46        } else {
47            None
48        }
49    }
50}
51
52/// Drops English stop words. Assumes input is already lowercased.
53#[derive(Debug, Default, Clone)]
54pub struct StopWordFilter;
55
56static EN_STOP_WORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
57    [
58        "a",
59        "about",
60        "above",
61        "after",
62        "again",
63        "against",
64        "all",
65        "am",
66        "an",
67        "and",
68        "any",
69        "are",
70        "as",
71        "at",
72        "be",
73        "because",
74        "been",
75        "before",
76        "being",
77        "below",
78        "between",
79        "both",
80        "but",
81        "by",
82        "can",
83        "could",
84        "did",
85        "do",
86        "does",
87        "doing",
88        "don't",
89        "down",
90        "during",
91        "each",
92        "few",
93        "for",
94        "from",
95        "further",
96        "get",
97        "got",
98        "had",
99        "has",
100        "have",
101        "having",
102        "he",
103        "her",
104        "here",
105        "hers",
106        "herself",
107        "him",
108        "himself",
109        "his",
110        "how",
111        "i",
112        "if",
113        "in",
114        "into",
115        "is",
116        "it",
117        "its",
118        "itself",
119        "just",
120        "me",
121        "more",
122        "most",
123        "my",
124        "myself",
125        "no",
126        "nor",
127        "not",
128        "now",
129        "of",
130        "off",
131        "on",
132        "once",
133        "only",
134        "or",
135        "other",
136        "our",
137        "ours",
138        "ourselves",
139        "out",
140        "over",
141        "own",
142        "same",
143        "she",
144        "should",
145        "so",
146        "some",
147        "such",
148        "than",
149        "that",
150        "the",
151        "their",
152        "theirs",
153        "them",
154        "themselves",
155        "then",
156        "there",
157        "these",
158        "they",
159        "this",
160        "those",
161        "through",
162        "to",
163        "too",
164        "under",
165        "until",
166        "up",
167        "us",
168        "very",
169        "was",
170        "we",
171        "were",
172        "what",
173        "when",
174        "where",
175        "which",
176        "while",
177        "who",
178        "whom",
179        "why",
180        "will",
181        "with",
182        "would",
183        "you",
184        "your",
185        "yours",
186        "yourself",
187        "yourselves",
188    ]
189    .into_iter()
190    .collect()
191});
192
193impl TokenFilter for StopWordFilter {
194    fn apply(&self, token: String) -> Option<String> {
195        if EN_STOP_WORDS.contains(token.as_str()) {
196            None
197        } else {
198            Some(token)
199        }
200    }
201}
202
203/// Drops the same stop words as `khive-bm25`'s `SimpleTokenizer::default()`.
204/// Scoped to `preset::standard()` so the public [`StopWordFilter`] stop list
205/// is unaffected. Assumes input is already lowercased.
206#[derive(Debug, Default, Clone)]
207pub struct Bm25StopWordFilter;
208
209static BM25_STOP_WORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
210    [
211        "a", "an", "and", "are", "as", "at", "be", "been", "being", "but", "by", "can", "did",
212        "do", "does", "doing", "done", "for", "from", "had", "has", "have", "having", "he", "her",
213        "here", "hers", "him", "his", "how", "i", "if", "in", "into", "is", "it", "its", "just",
214        "may", "me", "might", "my", "no", "nor", "not", "of", "on", "or", "our", "out", "own",
215        "say", "she", "should", "so", "some", "such", "than", "that", "the", "their", "them",
216        "then", "there", "these", "they", "this", "those", "through", "to", "too", "up", "us",
217        "very", "was", "we", "were", "what", "when", "where", "which", "while", "who", "whom",
218        "why", "will", "with", "would", "you", "your",
219    ]
220    .into_iter()
221    .collect()
222});
223
224impl TokenFilter for Bm25StopWordFilter {
225    fn apply(&self, token: String) -> Option<String> {
226        if BM25_STOP_WORDS.contains(token.as_str()) {
227            None
228        } else {
229            Some(token)
230        }
231    }
232}
233
234/// Snowball stemmer. Only stems ASCII-alphabetic tokens; others pass through.
235#[cfg(feature = "stem")]
236pub struct SnowballStemmer(rust_stemmers::Stemmer);
237
238#[cfg(feature = "stem")]
239impl SnowballStemmer {
240    /// Creates an English Snowball stemmer using the English algorithm.
241    pub fn english() -> Self {
242        Self(rust_stemmers::Stemmer::create(
243            rust_stemmers::Algorithm::English,
244        ))
245    }
246
247    /// Creates a Snowball stemmer for the specified `rust_stemmers::Algorithm`.
248    pub fn for_algorithm(algo: rust_stemmers::Algorithm) -> Self {
249        Self(rust_stemmers::Stemmer::create(algo))
250    }
251}
252
253#[cfg(feature = "stem")]
254impl TokenFilter for SnowballStemmer {
255    fn apply(&self, token: String) -> Option<String> {
256        if token.chars().all(|c| c.is_ascii_alphabetic()) {
257            Some(self.0.stem(&token).into_owned())
258        } else {
259            Some(token)
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn lowercase_ascii() {
270        assert_eq!(LowercaseFilter.apply("HELLO".into()), Some("hello".into()));
271    }
272
273    #[test]
274    fn lowercase_unicode() {
275        assert_eq!(
276            LowercaseFilter.apply("Straße".into()),
277            Some("straße".into())
278        );
279    }
280
281    #[test]
282    fn min_length_boundary() {
283        let f = MinLengthFilter(3);
284        assert_eq!(f.apply("ab".into()), None);
285        assert_eq!(f.apply("abc".into()), Some("abc".into()));
286    }
287
288    #[test]
289    fn max_length_boundary() {
290        let f = MaxLengthFilter(5);
291        assert_eq!(f.apply("hello".into()), Some("hello".into()));
292        assert_eq!(f.apply("helloo".into()), None);
293    }
294
295    #[test]
296    fn max_length_default_is_40() {
297        let f = MaxLengthFilter::default();
298        assert_eq!(f.0, 40);
299    }
300
301    #[test]
302    fn stop_word_drops() {
303        assert_eq!(StopWordFilter.apply("the".into()), None);
304        assert_eq!(StopWordFilter.apply("is".into()), None);
305        assert_eq!(
306            StopWordFilter.apply("transformer".into()),
307            Some("transformer".into())
308        );
309    }
310
311    #[test]
312    fn stop_word_case_sensitive() {
313        // StopWordFilter expects lowercased input
314        assert_eq!(StopWordFilter.apply("The".into()), Some("The".into()));
315    }
316}