use tantivy::tokenizer::{Token, TokenStream, Tokenizer};
fn is_cjk(ch: char) -> bool {
matches!(ch as u32, 0x4E00..=0x9FFF | 0x3400..=0x4DBF)
}
#[derive(Clone)]
pub(crate) struct MixedTokenizer {
jieba: tantivy_jieba::JiebaTokenizer,
}
impl MixedTokenizer {
pub(crate) fn new() -> Self {
Self {
jieba: tantivy_jieba::JiebaTokenizer::new(),
}
}
fn emit_cjk(&mut self, run: &str, base: usize, out: &mut Vec<Token>, position: &mut usize) {
let mut stream = self.jieba.token_stream(run);
while stream.advance() {
let tok = stream.token();
out.push(Token {
offset_from: base + tok.offset_from,
offset_to: base + tok.offset_to,
position: *position,
text: tok.text.to_lowercase(),
position_length: 1,
});
*position += 1;
}
}
fn emit_non_cjk(run: &str, base: usize, out: &mut Vec<Token>, position: &mut usize) {
let mut span_start: Option<usize> = None;
for (i, ch) in run.char_indices() {
if ch.is_alphanumeric() {
if span_start.is_none() {
span_start = Some(i);
}
} else if let Some(start) = span_start.take() {
out.push(Token {
offset_from: base + start,
offset_to: base + i,
position: *position,
text: run[start..i].to_lowercase(),
position_length: 1,
});
*position += 1;
}
}
if let Some(start) = span_start {
out.push(Token {
offset_from: base + start,
offset_to: base + run.len(),
position: *position,
text: run[start..].to_lowercase(),
position_length: 1,
});
*position += 1;
}
}
}
impl Tokenizer for MixedTokenizer {
type TokenStream<'a> = VecTokenStream;
fn token_stream(&mut self, text: &str) -> VecTokenStream {
let mut tokens: Vec<Token> = Vec::new();
let mut position: usize = 0;
let mut run_start = 0usize;
let mut run_is_cjk: Option<bool> = None;
for (idx, ch) in text.char_indices() {
let cjk = is_cjk(ch);
match run_is_cjk {
None => run_is_cjk = Some(cjk),
Some(prev) if prev != cjk => {
let run = &text[run_start..idx];
if prev {
self.emit_cjk(run, run_start, &mut tokens, &mut position);
} else {
Self::emit_non_cjk(run, run_start, &mut tokens, &mut position);
}
run_start = idx;
run_is_cjk = Some(cjk);
}
_ => {}
}
}
if let Some(prev) = run_is_cjk {
let run = &text[run_start..];
if prev {
self.emit_cjk(run, run_start, &mut tokens, &mut position);
} else {
Self::emit_non_cjk(run, run_start, &mut tokens, &mut position);
}
}
VecTokenStream { tokens, index: 0 }
}
}
pub(crate) struct VecTokenStream {
tokens: Vec<Token>,
index: usize,
}
impl TokenStream for VecTokenStream {
fn advance(&mut self) -> bool {
if self.index < self.tokens.len() {
self.index += 1;
true
} else {
false
}
}
fn token(&self) -> &Token {
&self.tokens[self.index - 1]
}
fn token_mut(&mut self) -> &mut Token {
&mut self.tokens[self.index - 1]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn collect(text: &str) -> Vec<Token> {
let mut tokenizer = MixedTokenizer::new();
let mut stream = tokenizer.token_stream(text);
let mut out = Vec::new();
while stream.advance() {
out.push(stream.token().clone());
}
out
}
#[test]
fn cjk_then_latin_offsets_are_byte_based_not_char_based() {
let text = "报告api";
let tokens = collect(text);
let api = tokens
.iter()
.find(|t| t.text == "api")
.expect("应该切出 api");
assert_eq!(
api.offset_from,
"报告".len(),
"拉丁段基址应在汉字段字节数之后"
);
assert_eq!(api.offset_to, text.len());
assert_eq!(&text[api.offset_from..api.offset_to], "api");
for (i, t) in tokens.iter().enumerate() {
assert_eq!(t.position, i, "position 应顺序递增: {tokens:?}");
assert_eq!(t.position_length, 1);
}
}
#[test]
fn hyphenated_string_splits_into_subwords_with_exact_offsets() {
let text = "glimmerfrost-9931-unique-marker";
let tokens = collect(text);
let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
assert_eq!(texts, vec!["glimmerfrost", "9931", "unique", "marker"]);
for t in &tokens {
assert_eq!(&text[t.offset_from..t.offset_to], t.text);
}
}
#[test]
fn dotted_version_and_case_folding() {
assert_eq!(
collect("v1.2.3")
.iter()
.map(|t| t.text.clone())
.collect::<Vec<_>>(),
vec!["v1", "2", "3"]
);
assert_eq!(
collect("GPT-4")
.iter()
.map(|t| t.text.clone())
.collect::<Vec<_>>(),
vec!["gpt", "4"]
);
}
#[test]
fn empty_input_produces_no_tokens() {
assert!(collect("").is_empty());
}
#[test]
fn punctuation_and_whitespace_only_produce_no_tokens() {
assert!(collect(" \t\r\n").is_empty());
assert!(collect(" !@#$%^&*()_+-=[]{};:'\",.<>/?\\|`~ ").is_empty());
}
#[test]
fn cjk_punctuation_only_produces_no_tokens() {
assert!(collect(",。!?;:、()《》「」【】").is_empty());
}
#[test]
fn pure_digits_form_a_single_token() {
let tokens = collect("1234567890");
let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
assert_eq!(texts, vec!["1234567890"]);
assert_eq!(tokens[0].offset_from, 0);
assert_eq!(tokens[0].offset_to, "1234567890".len());
}
#[test]
fn very_long_single_line_does_not_panic_and_tokenizes_each_run() {
let unit = "alpha-99 ";
let reps = 20_000;
let text = unit.repeat(reps);
let tokens = collect(&text);
assert_eq!(tokens.len(), reps * 2);
assert_eq!(tokens.first().unwrap().text, "alpha");
assert_eq!(tokens.last().unwrap().text, "99");
for (i, t) in tokens.iter().enumerate() {
assert_eq!(t.position, i);
}
}
}