1pub trait Tokenizer {
21 fn tokens(&self, text: &[u8]) -> Vec<Vec<u8>>;
23}
24
25#[derive(Debug, Clone, Copy, Default)]
34pub struct KevyTokenizer;
35
36impl Tokenizer for KevyTokenizer {
37 fn tokens(&self, text: &[u8]) -> Vec<Vec<u8>> {
38 tokenize(text)
39 }
40}
41
42fn is_cjk(c: char) -> bool {
43 matches!(c,
44 '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{AC00}'..='\u{D7AF}' )
50}
51
52pub fn tokenize(text: &[u8]) -> Vec<Vec<u8>> {
72 let s = String::from_utf8_lossy(text);
73 let mut out = Vec::new();
74 let mut word = String::new();
75 let mut prev_cjk: Option<char> = None;
76 let mut cjk_run = 0usize;
77
78 let flush_word = |word: &mut String, out: &mut Vec<Vec<u8>>| {
79 if word.chars().count() >= 2 {
80 out.push(word.to_lowercase().into_bytes());
81 }
82 word.clear();
83 };
84
85 for c in s.chars() {
86 if is_cjk(c) {
87 flush_word(&mut word, &mut out);
88 if let Some(p) = prev_cjk {
89 let mut bi = String::with_capacity(8);
90 bi.push(p);
91 bi.push(c);
92 out.push(bi.into_bytes());
93 }
94 prev_cjk = Some(c);
95 cjk_run += 1;
96 } else {
97 if cjk_run == 1
99 && let Some(p) = prev_cjk
100 {
101 out.push(p.to_string().into_bytes());
102 }
103 prev_cjk = None;
104 cjk_run = 0;
105 if c.is_alphanumeric() {
106 word.push(c);
107 } else {
108 flush_word(&mut word, &mut out);
109 }
110 }
111 }
112 if cjk_run == 1
113 && let Some(p) = prev_cjk
114 {
115 out.push(p.to_string().into_bytes());
116 }
117 flush_word(&mut word, &mut out);
118 out
119}
120
121pub fn tokenize_spans(text: &[u8]) -> Vec<(Vec<u8>, usize, usize)> {
142 let Ok(s) = core::str::from_utf8(text) else {
143 return Vec::new();
144 };
145 let mut out = Vec::new();
146 let mut word = String::new();
147 let mut word_start = 0usize;
148 let mut prev_cjk: Option<(char, usize)> = None;
149 let mut cjk_run = 0usize;
150 let flush = |w: &mut String, ws: usize, we: usize, out: &mut Vec<(Vec<u8>, usize, usize)>| {
151 if w.chars().count() >= 2 {
152 out.push((w.to_lowercase().into_bytes(), ws, we));
153 }
154 w.clear();
155 };
156 for (idx, c) in s.char_indices() {
157 if is_cjk(c) {
158 flush(&mut word, word_start, idx, &mut out);
159 if let Some((p, ps)) = prev_cjk {
160 out.push((format!("{p}{c}").into_bytes(), ps, idx + c.len_utf8()));
161 }
162 prev_cjk = Some((c, idx));
163 cjk_run += 1;
164 } else {
165 if cjk_run == 1
166 && let Some((p, ps)) = prev_cjk
167 {
168 out.push((p.to_string().into_bytes(), ps, ps + p.len_utf8()));
169 }
170 prev_cjk = None;
171 cjk_run = 0;
172 if c.is_alphanumeric() {
173 if word.is_empty() {
174 word_start = idx;
175 }
176 word.push(c);
177 } else {
178 flush(&mut word, word_start, idx, &mut out);
179 }
180 }
181 }
182 if cjk_run == 1
183 && let Some((p, ps)) = prev_cjk
184 {
185 out.push((p.to_string().into_bytes(), ps, ps + p.len_utf8()));
186 }
187 flush(&mut word, word_start, s.len(), &mut out);
188 out
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 fn toks(s: &str) -> Vec<String> {
196 tokenize(s.as_bytes()).into_iter().map(|t| String::from_utf8(t).unwrap()).collect()
197 }
198
199 #[test]
200 fn latin_words_lowercased_min_two() {
201 assert_eq!(toks("Hello, Rust world! a I"), vec!["hello", "rust", "world"]);
202 assert_eq!(toks("v2.7-alpha"), vec!["v2", "alpha"]);
203 }
204
205 #[test]
206 fn cjk_bigrams_no_dictionary() {
207 assert_eq!(toks("全文检索"), vec!["全文", "文检", "检索"]);
208 assert_eq!(toks("猫"), vec!["猫"], "lone CJK char = unigram");
209 assert_eq!(toks("ひらがな"), vec!["ひら", "らが", "がな"]);
210 assert_eq!(toks("한국어"), vec!["한국", "국어"]);
211 }
212
213 #[test]
214 fn mixed_scripts_never_cross() {
215 assert_eq!(
216 toks("Rust搜索engine引擎x"),
217 vec!["rust", "搜索", "engine", "引擎"],
218 "boundaries split; trailing single latin char dropped"
219 );
220 assert_eq!(toks("日本語abc漢字"), vec!["日本", "本語", "abc", "漢字"]);
221 }
222
223 #[test]
224 fn junk_and_empty() {
225 assert!(toks("").is_empty());
226 assert!(toks("!!! ...").is_empty());
227 let bad = tokenize(&[0xFF, 0xFE, b'o', b'k', b'a', b'y']);
228 assert_eq!(bad, vec![b"okay".to_vec()]);
229 }
230
231 #[test]
237 fn spans_point_at_the_source() {
238 let text = "a Quick brown 全文 fox";
239 let spans = tokenize_spans(text.as_bytes());
240 let got: Vec<(&str, &str)> = spans
241 .iter()
242 .map(|(t, s, e)| (std::str::from_utf8(t).unwrap(), &text[*s..*e]))
243 .collect();
244 assert_eq!(
245 got,
246 vec![
247 ("quick", "Quick"), ("brown", "brown"),
249 ("全文", "全文"), ("fox", "fox"),
251 ],
252 "single-char 'a' dropped; every span slices its source"
253 );
254 }
255
256 #[test]
259 fn spans_tokens_match_tokenize() {
260 for s in [
261 "rust full text search",
262 "全文检索引擎 rust 実装",
263 "Rust搜索engine引擎x",
264 "a I v2.7-alpha",
265 ] {
266 let plain = tokenize(s.as_bytes());
267 let with_spans: Vec<Vec<u8>> =
268 tokenize_spans(s.as_bytes()).into_iter().map(|(t, _, _)| t).collect();
269 assert_eq!(plain, with_spans, "token stream diverged for {s:?}");
270 }
271 }
272
273 #[test]
274 fn spans_invalid_utf8_is_empty() {
275 assert!(tokenize_spans(&[0xFF, 0xFE, b'o', b'k']).is_empty());
276 }
277}