1pub trait Tokenizer {
12 fn tokens(&self, text: &[u8]) -> Vec<Vec<u8>>;
14}
15
16#[derive(Debug, Clone, Copy, Default)]
18pub struct KevyTokenizer;
19
20impl Tokenizer for KevyTokenizer {
21 fn tokens(&self, text: &[u8]) -> Vec<Vec<u8>> {
22 tokenize(text)
23 }
24}
25
26fn is_cjk(c: char) -> bool {
27 matches!(c,
28 '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{AC00}'..='\u{D7AF}' )
34}
35
36pub fn tokenize(text: &[u8]) -> Vec<Vec<u8>> {
38 let s = String::from_utf8_lossy(text);
39 let mut out = Vec::new();
40 let mut word = String::new();
41 let mut prev_cjk: Option<char> = None;
42 let mut cjk_run = 0usize;
43
44 let flush_word = |word: &mut String, out: &mut Vec<Vec<u8>>| {
45 if word.chars().count() >= 2 {
46 out.push(word.to_lowercase().into_bytes());
47 }
48 word.clear();
49 };
50
51 for c in s.chars() {
52 if is_cjk(c) {
53 flush_word(&mut word, &mut out);
54 if let Some(p) = prev_cjk {
55 let mut bi = String::with_capacity(8);
56 bi.push(p);
57 bi.push(c);
58 out.push(bi.into_bytes());
59 }
60 prev_cjk = Some(c);
61 cjk_run += 1;
62 } else {
63 if cjk_run == 1
65 && let Some(p) = prev_cjk
66 {
67 out.push(p.to_string().into_bytes());
68 }
69 prev_cjk = None;
70 cjk_run = 0;
71 if c.is_alphanumeric() {
72 word.push(c);
73 } else {
74 flush_word(&mut word, &mut out);
75 }
76 }
77 }
78 if cjk_run == 1
79 && let Some(p) = prev_cjk
80 {
81 out.push(p.to_string().into_bytes());
82 }
83 flush_word(&mut word, &mut out);
84 out
85}
86
87pub fn tokenize_spans(text: &[u8]) -> Vec<(Vec<u8>, usize, usize)> {
97 let Ok(s) = core::str::from_utf8(text) else {
98 return Vec::new();
99 };
100 let mut out = Vec::new();
101 let mut word = String::new();
102 let mut word_start = 0usize;
103 let mut prev_cjk: Option<(char, usize)> = None;
104 let mut cjk_run = 0usize;
105 let flush = |w: &mut String, ws: usize, we: usize, out: &mut Vec<(Vec<u8>, usize, usize)>| {
106 if w.chars().count() >= 2 {
107 out.push((w.to_lowercase().into_bytes(), ws, we));
108 }
109 w.clear();
110 };
111 for (idx, c) in s.char_indices() {
112 if is_cjk(c) {
113 flush(&mut word, word_start, idx, &mut out);
114 if let Some((p, ps)) = prev_cjk {
115 out.push((format!("{p}{c}").into_bytes(), ps, idx + c.len_utf8()));
116 }
117 prev_cjk = Some((c, idx));
118 cjk_run += 1;
119 } else {
120 if cjk_run == 1
121 && let Some((p, ps)) = prev_cjk
122 {
123 out.push((p.to_string().into_bytes(), ps, ps + p.len_utf8()));
124 }
125 prev_cjk = None;
126 cjk_run = 0;
127 if c.is_alphanumeric() {
128 if word.is_empty() {
129 word_start = idx;
130 }
131 word.push(c);
132 } else {
133 flush(&mut word, word_start, idx, &mut out);
134 }
135 }
136 }
137 if cjk_run == 1
138 && let Some((p, ps)) = prev_cjk
139 {
140 out.push((p.to_string().into_bytes(), ps, ps + p.len_utf8()));
141 }
142 flush(&mut word, word_start, s.len(), &mut out);
143 out
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn toks(s: &str) -> Vec<String> {
151 tokenize(s.as_bytes())
152 .into_iter()
153 .map(|t| String::from_utf8(t).unwrap())
154 .collect()
155 }
156
157 #[test]
158 fn latin_words_lowercased_min_two() {
159 assert_eq!(toks("Hello, Rust world! a I"), vec!["hello", "rust", "world"]);
160 assert_eq!(toks("v2.7-alpha"), vec!["v2", "alpha"]);
161 }
162
163 #[test]
164 fn cjk_bigrams_no_dictionary() {
165 assert_eq!(toks("全文检索"), vec!["全文", "文检", "检索"]);
166 assert_eq!(toks("猫"), vec!["猫"], "lone CJK char = unigram");
167 assert_eq!(toks("ひらがな"), vec!["ひら", "らが", "がな"]);
168 assert_eq!(toks("한국어"), vec!["한국", "국어"]);
169 }
170
171 #[test]
172 fn mixed_scripts_never_cross() {
173 assert_eq!(
174 toks("Rust搜索engine引擎x"),
175 vec!["rust", "搜索", "engine", "引擎"],
176 "boundaries split; trailing single latin char dropped"
177 );
178 assert_eq!(toks("日本語abc漢字"), vec!["日本", "本語", "abc", "漢字"]);
179 }
180
181 #[test]
182 fn junk_and_empty() {
183 assert!(toks("").is_empty());
184 assert!(toks("!!! ...").is_empty());
185 let bad = tokenize(&[0xFF, 0xFE, b'o', b'k', b'a', b'y']);
186 assert_eq!(bad, vec![b"okay".to_vec()]);
187 }
188
189 #[test]
195 fn spans_point_at_the_source() {
196 let text = "a Quick brown 全文 fox";
197 let spans = tokenize_spans(text.as_bytes());
198 let got: Vec<(&str, &str)> = spans
199 .iter()
200 .map(|(t, s, e)| (std::str::from_utf8(t).unwrap(), &text[*s..*e]))
201 .collect();
202 assert_eq!(
203 got,
204 vec![
205 ("quick", "Quick"), ("brown", "brown"),
207 ("全文", "全文"), ("fox", "fox"),
209 ],
210 "single-char 'a' dropped; every span slices its source"
211 );
212 }
213
214 #[test]
217 fn spans_tokens_match_tokenize() {
218 for s in ["rust full text search", "全文检索引擎 rust 実装", "Rust搜索engine引擎x", "a I v2.7-alpha"] {
219 let plain = tokenize(s.as_bytes());
220 let with_spans: Vec<Vec<u8>> =
221 tokenize_spans(s.as_bytes()).into_iter().map(|(t, _, _)| t).collect();
222 assert_eq!(plain, with_spans, "token stream diverged for {s:?}");
223 }
224 }
225
226 #[test]
227 fn spans_invalid_utf8_is_empty() {
228 assert!(tokenize_spans(&[0xFF, 0xFE, b'o', b'k']).is_empty());
229 }
230}