use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct BM25Index {
documents: Vec<Document>,
idf: HashMap<String, f32>,
avgdl: f32,
k1: f32,
b: f32,
dirty: bool,
}
#[derive(Debug, Clone)]
struct Document {
id: String,
text: String,
term_freqs: HashMap<String, u32>,
length: u32,
}
#[derive(Debug, Clone)]
pub struct BM25Result {
pub id: String,
pub text: String,
pub score: f32,
}
impl Default for BM25Index {
fn default() -> Self {
Self::new()
}
}
impl BM25Index {
pub fn new() -> Self {
Self::with_params(1.5, 0.75)
}
pub fn with_params(k1: f32, b: f32) -> Self {
Self {
documents: Vec::new(),
idf: HashMap::new(),
avgdl: 0.0,
k1,
b,
dirty: false,
}
}
pub fn add(&mut self, id: impl Into<String>, text: impl Into<String>) {
let id = id.into();
let text = text.into();
self.remove_all(&id);
let tokens = Self::tokenize(&text);
let length = tokens.len() as u32;
let mut term_freqs: HashMap<String, u32> = HashMap::new();
for token in tokens {
*term_freqs.entry(token).or_insert(0) += 1;
}
self.documents.push(Document {
id,
text,
term_freqs,
length,
});
self.dirty = true;
}
pub fn add_batch(&mut self, docs: impl IntoIterator<Item = (String, String)>) {
for (id, text) in docs {
let tokens = Self::tokenize(&text);
let length = tokens.len() as u32;
let mut term_freqs: HashMap<String, u32> = HashMap::new();
for token in tokens {
*term_freqs.entry(token).or_insert(0) += 1;
}
self.documents.push(Document {
id,
text,
term_freqs,
length,
});
}
self.dirty = true;
}
pub fn remove(&mut self, id: &str) -> bool {
if let Some(pos) = self.documents.iter().position(|d| d.id == id) {
self.documents.remove(pos);
self.dirty = true;
true
} else {
false
}
}
pub fn remove_all(&mut self, id: &str) -> usize {
let before = self.documents.len();
self.documents.retain(|d| d.id != id);
let removed = before - self.documents.len();
if removed > 0 {
self.dirty = true;
}
removed
}
pub fn clear(&mut self) {
self.documents.clear();
self.idf.clear();
self.avgdl = 0.0;
self.dirty = false;
}
pub fn rebuild(&mut self) {
if self.documents.is_empty() {
self.idf.clear();
self.avgdl = 0.0;
self.dirty = false;
return;
}
let n = self.documents.len() as f32;
let total_length: u32 = self.documents.iter().map(|d| d.length).sum();
self.avgdl = total_length as f32 / n;
let mut doc_freq: HashMap<String, u32> = HashMap::new();
for doc in &self.documents {
for term in doc.term_freqs.keys() {
*doc_freq.entry(term.clone()).or_insert(0) += 1;
}
}
self.idf.clear();
for (term, df) in doc_freq {
let df = df as f32;
let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
self.idf.insert(term, idf);
}
self.dirty = false;
}
pub fn search(&mut self, query: &str, limit: usize) -> Vec<BM25Result> {
if self.dirty {
self.rebuild();
}
if self.documents.is_empty() {
return Vec::new();
}
let query_tokens = Self::tokenize(query);
if query_tokens.is_empty() {
return Vec::new();
}
let mut scores: Vec<(usize, f32)> = self
.documents
.iter()
.enumerate()
.map(|(i, doc)| (i, self.score_document(doc, &query_tokens)))
.filter(|(_, score)| *score > 0.0)
.collect();
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scores
.into_iter()
.take(limit)
.map(|(i, score)| {
let doc = &self.documents[i];
BM25Result {
id: doc.id.clone(),
text: doc.text.clone(),
score,
}
})
.collect()
}
fn score_document(&self, doc: &Document, query_tokens: &[String]) -> f32 {
let mut score = 0.0;
let dl = doc.length as f32;
let avgdl = self.avgdl;
if dl <= 0.0 || avgdl <= 0.0 {
return 0.0;
}
for token in query_tokens {
if let Some(&idf) = self.idf.get(token) {
let tf = *doc.term_freqs.get(token).unwrap_or(&0) as f32;
if tf > 0.0 {
let numerator = tf * (self.k1 + 1.0);
let denominator = tf + self.k1 * (1.0 - self.b + self.b * (dl / avgdl));
if denominator <= 0.0 {
continue;
}
score += idf * (numerator / denominator);
}
}
}
score
}
fn tokenize(text: &str) -> Vec<String> {
let mut tokens = Vec::new();
for word in text
.split(|c: char| c.is_whitespace() || ".,;:!?()[]{}\"'`<>=+-*/\\|&^%$#@~".contains(c))
{
if word.is_empty() {
continue;
}
for part in word.split('_') {
if part.is_empty() {
continue;
}
let camel_parts = Self::split_camel_case(part);
for p in camel_parts {
let lower = p.to_lowercase();
if !lower.is_empty() && lower.len() >= 2 {
tokens.push(lower);
}
}
}
}
tokens
}
fn split_camel_case(s: &str) -> Vec<&str> {
if s.is_empty() {
return vec![s];
}
let mut parts = Vec::new();
let mut last_byte = 0;
let indexed: Vec<(usize, char)> = s.char_indices().collect();
for i in 1..indexed.len() {
let (prev_byte, prev_char) = indexed[i - 1];
let (curr_byte, curr_char) = indexed[i];
if prev_char.is_lowercase() && curr_char.is_uppercase() {
if last_byte < curr_byte {
parts.push(&s[last_byte..curr_byte]);
}
last_byte = curr_byte;
}
else if i >= 2 {
let (prev2_byte, prev2_char) = indexed[i - 2];
if prev2_char.is_uppercase() && prev_char.is_uppercase() && curr_char.is_lowercase()
{
if last_byte < prev_byte {
parts.push(&s[last_byte..prev_byte]);
}
last_byte = prev_byte;
let _ = prev2_byte; }
}
}
if last_byte < s.len() {
parts.push(&s[last_byte..]);
}
if parts.is_empty() {
parts.push(s);
}
parts
}
pub fn len(&self) -> usize {
self.documents.len()
}
pub fn is_empty(&self) -> bool {
self.documents.is_empty()
}
pub fn terms(&self) -> Vec<&str> {
self.idf.keys().map(|s| s.as_str()).collect()
}
pub fn contains(&self, id: &str) -> bool {
self.documents.iter().any(|d| d.id == id)
}
pub fn get(&self, id: &str) -> Option<&str> {
self.documents
.iter()
.find(|d| d.id == id)
.map(|d| d.text.as_str())
}
}
#[cfg(test)]
#[path = "../../tests/unit/analysis/bm25/bm25_test.rs"]
mod tests;