use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct Bm25Params {
pub k1: f64,
pub b: f64,
}
impl Default for Bm25Params {
fn default() -> Self {
Self { k1: 1.2, b: 0.75 }
}
}
#[derive(Clone, Debug)]
pub struct Bm25Index {
pub num_docs: usize,
pub avg_doc_len: f64,
pub doc_lengths: Vec<usize>,
pub doc_freqs: HashMap<String, usize>,
pub term_freqs: Vec<HashMap<String, usize>>,
pub params: Bm25Params,
pub total_tokens: usize,
}
impl Bm25Index {
#[inline]
pub fn build(documents: &[Vec<String>], params: Bm25Params) -> Self {
let num_docs = documents.len();
let mut doc_lengths = Vec::with_capacity(num_docs);
let mut term_freqs = Vec::with_capacity(num_docs);
let mut doc_freqs: HashMap<String, usize> = HashMap::new();
let mut total_tokens = 0;
for doc in documents {
let mut tf = HashMap::new();
let doc_len = doc.len();
for token in doc {
let counter = tf.entry(token.clone()).or_insert(0);
*counter += 1;
}
for token in tf.keys() {
*doc_freqs.entry(token.clone()).or_insert(0) += 1;
}
doc_lengths.push(doc_len);
total_tokens += doc_len;
term_freqs.push(tf);
}
let avg_doc_len = if num_docs > 0 {
total_tokens as f64 / num_docs as f64
} else {
0.0
};
Self {
num_docs,
avg_doc_len,
doc_lengths,
doc_freqs,
term_freqs,
params,
total_tokens,
}
}
#[inline]
pub fn score(&self, query: &[String], doc_index: usize) -> f64 {
let doc_len = self.doc_lengths[doc_index] as f64;
let tf = &self.term_freqs[doc_index];
let mut score = 0.0;
for term in query {
let df = self.doc_freqs.get(term).copied().unwrap_or(0);
if df == 0 {
continue;
}
let idf = ((self.num_docs as f64 - df as f64 + 0.5) / (df as f64 + 0.5) + 1.0).ln();
let term_freq = tf.get(term).copied().unwrap_or(0) as f64;
let numerator = term_freq * (self.params.k1 + 1.0);
let denominator = term_freq
+ self.params.k1
* (1.0 - self.params.b + self.params.b * doc_len / self.avg_doc_len);
let tf_component = numerator / denominator;
score += idf * tf_component;
}
score
}
#[inline]
pub fn score_all(&self, query: &[String]) -> Vec<(usize, f64)> {
(0..self.num_docs)
.map(|i| (i, self.score(query, i)))
.collect()
}
#[inline]
pub fn normalized_score(&self, query: &[String], doc_index: usize) -> f64 {
let raw = self.score(query, doc_index);
let max = self
.score_all(query)
.into_iter()
.map(|(_, s)| s)
.fold(0.0_f64, f64::max);
if max > 0.0 {
raw / max
} else {
0.0
}
}
}
#[inline]
pub fn tokenize(text: &str) -> Vec<String> {
text.split(|c: char| c.is_whitespace() || c.is_ascii_punctuation())
.filter(|s| !s.is_empty())
.map(|s| s.to_lowercase())
.collect()
}
#[inline]
pub fn similarity(a: &str, b: &str) -> f64 {
let tokens_a = tokenize(a);
let tokens_b = tokenize(b);
let docs = vec![tokens_a.clone(), tokens_b];
let index = Bm25Index::build(&docs, Bm25Params::default());
index.normalized_score(&tokens_a, 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identical_texts() {
let s = similarity("the quick brown fox", "the quick brown fox");
assert!((s - 1.0).abs() < 0.01, "identical should be ~1.0, got {s}");
}
#[test]
fn completely_different() {
let s = similarity("hello world", "completely unrelated");
assert!(s < 0.3, "expected low similarity, got {s}");
}
#[test]
fn partial_match() {
let s = similarity("the quick brown fox", "the quick blue fox");
assert!(s > 0.3 && s < 1.0, "expected moderate, got {s}");
}
#[test]
fn empty_strings() {
let s = similarity("", "");
assert!((s - 0.0).abs() < f64::EPSILON);
}
#[test]
fn index_builds() {
let docs = vec![
tokenize("the cat sat on the mat"),
tokenize("the dog sat on the log"),
];
let index = Bm25Index::build(&docs, Bm25Params::default());
assert_eq!(index.num_docs, 2);
assert!(index.avg_doc_len > 0.0);
}
}