Raise matching floor: boundary tokenizer, stemming/folding/stopwords, boosts, snippets, engine seam

This commit is contained in:
George Coles
2026-09-15 08:11:55 -04:00
parent 0d35036168
commit 382acc36a9
10 changed files with 294 additions and 42 deletions
+131
View File
@@ -0,0 +1,131 @@
use tantivy::tokenizer::{
AsciiFoldingFilter, Language, LowerCaser, RemoveLongFilter, Stemmer, StopWordFilter,
TextAnalyzer, Token, TokenStream, Tokenizer,
};
#[derive(Clone, Default)]
pub struct BoundaryTokenizer {
token: Token,
}
impl BoundaryTokenizer {
pub fn new() -> Self {
Self {
token: Token::default(),
}
}
}
impl Tokenizer for BoundaryTokenizer {
type TokenStream<'a> = BoundaryTokenStream<'a>;
fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> {
self.token.reset();
BoundaryTokenStream {
text,
chars: text.char_indices().peekable(),
token: &mut self.token,
position: 0,
}
}
}
pub struct BoundaryTokenStream<'a> {
text: &'a str,
chars: std::iter::Peekable<std::str::CharIndices<'a>>,
token: &'a mut Token,
position: usize,
}
impl TokenStream for BoundaryTokenStream<'_> {
fn advance(&mut self) -> bool {
let mut start = None;
let mut alphabetic = None;
while let Some(&(index, ch)) = self.chars.peek() {
if !ch.is_alphanumeric() {
if start.is_none() {
self.chars.next();
continue;
}
break;
}
match alphabetic {
None => {
start = Some(index);
alphabetic = Some(ch.is_alphabetic());
self.chars.next();
}
Some(prev) if prev == ch.is_alphabetic() => {
self.chars.next();
}
Some(_) => break,
}
}
let Some(start) = start else {
return false;
};
let end = self
.chars
.peek()
.map(|&(index, _)| index)
.unwrap_or(self.text.len());
self.token.text = self.text[start..end].to_string();
self.token.offset_from = start;
self.token.offset_to = end;
self.token.position = self.position;
self.position += 1;
true
}
fn token(&self) -> &Token {
self.token
}
fn token_mut(&mut self) -> &mut Token {
self.token
}
}
pub fn analyzer() -> TextAnalyzer {
TextAnalyzer::builder(BoundaryTokenizer::new())
.filter(LowerCaser)
.filter(AsciiFoldingFilter)
.filter(StopWordFilter::new(Language::English).expect("english stopwords"))
.filter(Stemmer::new(Language::English))
.filter(RemoveLongFilter::limit(255))
.build()
}
pub const TOKENIZER_NAME: &str = "frx";
#[cfg(test)]
mod tests {
use super::*;
fn tokens(text: &str) -> Vec<String> {
let mut analyzer = analyzer();
let mut stream = analyzer.token_stream(text);
let mut out = Vec::new();
while let Some(token) = stream.next() {
out.push(token.text.clone());
}
out
}
#[test]
fn splits_model_numbers_and_folds_case() {
let tokens = tokens("DLEX5555 Café");
assert!(tokens.contains(&"dlex".to_string()), "{tokens:?}");
assert!(tokens.contains(&"5555".to_string()), "{tokens:?}");
assert!(tokens.contains(&"cafe".to_string()), "{tokens:?}");
}
#[test]
fn stems_and_drops_stopwords() {
let tokens = tokens("the dryers are searching");
assert!(!tokens.contains(&"the".to_string()), "{tokens:?}");
assert!(!tokens.contains(&"are".to_string()), "{tokens:?}");
assert!(tokens.iter().any(|t| t.starts_with("dryer")), "{tokens:?}");
assert!(tokens.iter().any(|t| t.starts_with("search")), "{tokens:?}");
}
}