use std::borrow::Cow;
use std::collections::HashSet;
use std::sync::LazyLock;
use qql_core::error::QqlError;
use rust_stemmers::Algorithm;
use rust_stemmers::Stemmer as SnowballStemmer;
use super::bm25_fold::fold_to_ascii_cow;
use super::bm25_lang::Language;
use super::bm25_stopwords::stopwords_for;
use super::sparse::{Bm25Params, SparseVector, token_id};
fn config_error(message: String) -> QqlError {
QqlError::validation("QQL-VALIDATION-CONFIG", message, None)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tokenizer {
#[default]
Word,
Whitespace,
Prefix,
Multilingual,
}
impl Tokenizer {
pub fn parse(name: &str) -> Result<Self, QqlError> {
match name.to_ascii_lowercase().as_str() {
"word" => Ok(Self::Word),
"whitespace" => Ok(Self::Whitespace),
"prefix" => Ok(Self::Prefix),
"multilingual" => Ok(Self::Multilingual),
_ => Err(config_error(format!(
"unsupported bm25 tokenizer: {name:?}"
))),
}
}
pub fn name(self) -> &'static str {
match self {
Self::Word => "word",
Self::Whitespace => "whitespace",
Self::Prefix => "prefix",
Self::Multilingual => "multilingual",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stemmer {
Snowball(Language),
Armenian,
Tamil,
Disabled,
}
impl Stemmer {
pub fn parse(name: &str) -> Result<Self, QqlError> {
if name.eq_ignore_ascii_case("none") {
return Ok(Self::Disabled);
}
let lower = name.to_ascii_lowercase();
if lower == "armenian" || lower == "hy" {
return Ok(Self::Armenian);
}
if lower == "tamil" || lower == "ta" {
return Ok(Self::Tamil);
}
let language = Language::parse(name)?;
if language.stem_algorithm().is_none() {
return Err(config_error(format!(
"bm25 stemmer unavailable for language {:?}: no Snowball stemmer (use \"none\" to disable)",
language.name()
)));
}
Ok(Self::Snowball(language))
}
pub fn algorithm(self) -> Option<Algorithm> {
match self {
Self::Snowball(language) => language.stem_algorithm(),
Self::Armenian => Some(Algorithm::Armenian),
Self::Tamil => Some(Algorithm::Tamil),
Self::Disabled => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Stopwords {
pub languages: Vec<Language>,
pub custom: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Bm25TextConfig {
pub params: Bm25Params,
pub tokenizer: Tokenizer,
pub language: Language,
pub lowercase: bool,
pub ascii_folding: bool,
pub stopwords: Option<Stopwords>,
pub stemmer: Option<Stemmer>,
pub min_token_len: Option<usize>,
pub max_token_len: Option<usize>,
}
impl Default for Bm25TextConfig {
fn default() -> Self {
Self {
params: Bm25Params::default(),
tokenizer: Tokenizer::Word,
language: Language::English,
lowercase: true,
ascii_folding: false,
stopwords: None,
stemmer: None,
min_token_len: None,
max_token_len: None,
}
}
}
impl Bm25TextConfig {
#[allow(clippy::too_many_arguments)]
pub fn resolve(
k1: Option<f64>,
b: Option<f64>,
avg_len: Option<f64>,
language: Option<&str>,
tokenizer: Option<&str>,
lowercase: Option<bool>,
ascii_folding: Option<bool>,
stopwords: Option<Vec<String>>,
stemmer: Option<&str>,
min_token_len: Option<usize>,
max_token_len: Option<usize>,
stopwords_languages: Option<Vec<String>>,
) -> Result<Self, QqlError> {
let mut languages = Vec::new();
if let Some(names) = stopwords_languages {
for name in &names {
languages.push(Language::parse(name)?);
}
}
Ok(Self {
params: Bm25Params::resolve(k1, b, avg_len)?,
tokenizer: match tokenizer {
None => Tokenizer::Word,
Some(name) => Tokenizer::parse(name)?,
},
language: match language {
None => Language::English,
Some(name) => Language::parse(name)?,
},
lowercase: lowercase.unwrap_or(true),
ascii_folding: ascii_folding.unwrap_or(false),
stopwords: match (stopwords, languages.is_empty()) {
(None, true) => None,
(custom, _) => Some(Stopwords {
languages,
custom: custom.unwrap_or_default(),
}),
},
stemmer: match stemmer {
None => None,
Some(name) => Some(Stemmer::parse(name)?),
},
min_token_len,
max_token_len,
})
}
#[allow(clippy::too_many_arguments)]
pub fn with_text_options(
&self,
language: Option<&str>,
tokenizer: Option<&str>,
lowercase: Option<bool>,
ascii_folding: Option<bool>,
stopwords: Option<Vec<String>>,
stemmer: Option<&str>,
min_token_len: Option<usize>,
max_token_len: Option<usize>,
stopwords_languages: Option<Vec<String>>,
) -> Result<Self, QqlError> {
let mut next = self.clone();
if let Some(name) = language.filter(|s| !s.is_empty()) {
next.language = Language::parse(name)?;
}
if let Some(name) = tokenizer.filter(|s| !s.is_empty()) {
next.tokenizer = Tokenizer::parse(name)?;
}
if let Some(lowercase) = lowercase {
next.lowercase = lowercase;
}
if let Some(ascii_folding) = ascii_folding {
next.ascii_folding = ascii_folding;
}
if stopwords.is_some() || stopwords_languages.is_some() {
let mut languages = Vec::new();
if let Some(names) = stopwords_languages {
for name in &names {
languages.push(Language::parse(name)?);
}
}
next.stopwords = Some(Stopwords {
languages,
custom: stopwords.unwrap_or_default(),
});
}
if let Some(name) = stemmer.filter(|s| !s.is_empty()) {
next.stemmer = Some(Stemmer::parse(name)?);
}
if min_token_len.is_some() {
next.min_token_len = min_token_len;
}
if max_token_len.is_some() {
next.max_token_len = max_token_len;
}
Ok(next)
}
pub fn pipeline(&self) -> Bm25Pipeline {
let stemmer = match self.stemmer {
Some(Stemmer::Disabled) => None,
Some(stemmer) => stemmer.algorithm().map(SnowballStemmer::create),
None => self.language.stem_algorithm().map(SnowballStemmer::create),
};
let mut stopwords = HashSet::new();
let mut insert = |word: &str| {
if self.lowercase {
stopwords.insert(word.to_lowercase());
} else {
stopwords.insert(word.to_string());
}
};
match &self.stopwords {
None => {
for word in stopwords_for(self.language) {
insert(word);
}
}
Some(selection) => {
for language in &selection.languages {
for word in stopwords_for(*language) {
insert(word);
}
}
for word in &selection.custom {
insert(word.as_str());
}
}
}
Bm25Pipeline {
params: self.params,
tokenizer: self.tokenizer,
lowercase: self.lowercase,
ascii_folding: self.ascii_folding,
stopwords,
stemmer,
min_token_len: self.min_token_len,
max_token_len: self.max_token_len,
}
}
}
pub struct Bm25Pipeline {
params: Bm25Params,
tokenizer: Tokenizer,
lowercase: bool,
ascii_folding: bool,
stopwords: HashSet<String>,
stemmer: Option<SnowballStemmer>,
min_token_len: Option<usize>,
max_token_len: Option<usize>,
}
impl Bm25Pipeline {
pub fn with_params(params: &Bm25Params) -> Self {
Bm25TextConfig {
params: *params,
..Bm25TextConfig::default()
}
.pipeline()
}
fn process_token<'a>(
&self,
raw: &'a str,
is_query: bool,
check_max_len: bool,
) -> Option<Cow<'a, str>> {
if raw.is_empty() {
return None;
}
let mut token: Cow<'a, str> = Cow::Borrowed(raw);
if self.ascii_folding {
token = fold_to_ascii_cow(token);
}
if self.lowercase {
token = Cow::Owned(token.to_lowercase());
}
let prefix_query = is_query && self.tokenizer == Tokenizer::Prefix;
if !prefix_query && self.stopwords.contains(token.as_ref()) {
return None;
}
if let Some(stemmer) = self.stemmer.as_ref() {
token = Cow::Owned(stemmer.stem(token.as_ref()).into_owned());
}
if self
.min_token_len
.is_some_and(|min| token.chars().count() < min)
{
return None;
}
if check_max_len
&& self
.max_token_len
.is_some_and(|max| token.chars().count() > max)
{
return None;
}
Some(token)
}
fn for_each<F>(&self, text: &str, is_query: bool, mut f: F) -> Result<(), QqlError>
where
F: FnMut(&str),
{
match self.tokenizer {
Tokenizer::Word => {
for raw in text.split(|c: char| !c.is_alphanumeric()) {
if let Some(token) = self.process_token(raw, is_query, true) {
f(token.as_ref());
}
}
}
Tokenizer::Whitespace => {
for raw in text.split_whitespace() {
if let Some(token) = self.process_token(raw, is_query, true) {
f(token.as_ref());
}
}
}
Tokenizer::Prefix => {
if is_query {
self.for_each_prefix_query(text, &mut f);
} else {
self.for_each_prefix_doc(text, &mut f);
}
}
Tokenizer::Multilingual => {
return Err(config_error(
"bm25 tokenizer \"multilingual\" needs script-aware segmentation (charabia/vaporetto), which is not compiled in; use \"word\" or \"whitespace\"".to_string(),
));
}
}
Ok(())
}
fn for_each_prefix_doc<F>(&self, text: &str, mut f: F)
where
F: FnMut(&str),
{
let min_ngram = self.min_token_len.unwrap_or(1);
let max_ngram = self.max_token_len.unwrap_or(usize::MAX);
for raw in text.split(|c: char| !c.is_alphanumeric()) {
let Some(word) = self.process_token(raw, false, false) else {
continue;
};
for n in min_ngram..=max_ngram {
match word.char_indices().map(|(i, _)| i).nth(n) {
Some(end) => f(&word[..end]),
None => {
f(word.as_ref());
break;
}
}
}
}
}
fn for_each_prefix_query<F>(&self, text: &str, mut f: F)
where
F: FnMut(&str),
{
let max_ngram = self.max_token_len.unwrap_or(usize::MAX);
for raw in text.split(|c: char| !c.is_alphanumeric()) {
if raw.is_empty() {
continue;
}
let Some(word) = self.process_token(raw, true, false) else {
continue;
};
match word.char_indices().map(|(i, _)| i).nth(max_ngram) {
Some(end) => f(&word[..end]),
None => f(word.as_ref()),
}
}
}
pub fn doc_tokens(&self, text: &str) -> Result<Vec<String>, QqlError> {
let mut tokens = Vec::new();
self.for_each(text, false, |token| {
tokens.push(token.to_string());
})?;
Ok(tokens)
}
pub(crate) fn for_each_query<F>(&self, text: &str, f: F) -> Result<(), QqlError>
where
F: FnMut(&str),
{
self.for_each(text, true, f)
}
pub fn token_count(&self, text: &str) -> Result<usize, QqlError> {
let mut count = 0;
self.for_each(text, false, |_| {
count += 1;
})?;
Ok(count)
}
pub fn embed_query(&self, text: &str) -> Result<SparseVector, QqlError> {
let mut indices = Vec::with_capacity(text.len() / 6 + 1);
self.for_each_query(text, |token| {
indices.push(token_id(token));
})?;
if indices.is_empty() {
return Ok(SparseVector::default());
}
indices.sort_unstable();
indices.dedup();
let values = vec![1.0; indices.len()];
Ok(SparseVector { indices, values })
}
pub fn embed_document(&self, text: &str) -> Result<SparseVector, QqlError> {
self.embed_document_with(
text,
self.params.k1(),
self.params.b(),
self.params.avg_len(),
)
}
pub(crate) fn embed_document_with(
&self,
text: &str,
k1: f64,
b: f64,
avgdl: f64,
) -> Result<SparseVector, QqlError> {
let mut token_ids: Vec<u32> = Vec::with_capacity(text.len() / 6 + 1);
self.for_each(text, false, |token| {
token_ids.push(token_id(token));
})?;
if token_ids.is_empty() {
return Ok(SparseVector::default());
}
let doc_len = token_ids.len() as f64;
let k1p1 = k1 + 1.0;
let norm = 1.0 - b + b * doc_len / avgdl;
token_ids.sort_unstable();
let mut indices = Vec::with_capacity(token_ids.len());
let mut values = Vec::with_capacity(token_ids.len());
let mut i = 0;
while i < token_ids.len() {
let id = token_ids[i];
let mut count = 1u32;
while i + 1 < token_ids.len() && token_ids[i + 1] == id {
count += 1;
i += 1;
}
indices.push(id);
let n = count as f64;
values.push((n * k1p1 / k1.mul_add(norm, n)) as f32);
i += 1;
}
Ok(SparseVector { indices, values })
}
}
static DEFAULT_PIPELINE: LazyLock<Bm25Pipeline> =
LazyLock::new(|| Bm25TextConfig::default().pipeline());
pub fn default_pipeline() -> &'static Bm25Pipeline {
&DEFAULT_PIPELINE
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AvgLenEstimate {
pub mean: f64,
pub docs: usize,
}
pub fn estimate_avg_len<'a, I>(
texts: I,
pipeline: &Bm25Pipeline,
) -> Result<Option<AvgLenEstimate>, QqlError>
where
I: IntoIterator<Item = &'a str>,
{
let mut docs = 0usize;
let mut total = 0usize;
for text in texts {
docs += 1;
total += pipeline.token_count(text)?;
}
if docs == 0 || total == 0 {
return Ok(None);
}
Ok(Some(AvgLenEstimate {
mean: total as f64 / docs as f64,
docs,
}))
}