use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::types::Chunk;
const K1: f64 = 1.5;
const B: f64 = 0.75;
fn stem_of(base: &str) -> &str {
match base.rfind('.') {
Some(0) | None => base,
Some(i) => &base[..i],
}
}
pub fn enrich_for_bm25(chunk: &Chunk) -> String {
let normalized = chunk.file_path.replace('\\', "/");
let (dir, base) = match normalized.rfind('/') {
Some(i) => (&normalized[..i], &normalized[i + 1..]),
None => ("", normalized.as_str()),
};
let stem = stem_of(base);
let parts: Vec<&str> = dir
.split('/')
.filter(|p| !p.is_empty() && *p != ".")
.collect();
let start = parts.len().saturating_sub(3);
let dir_text = parts[start..].join(" ");
format!("{} {stem} {stem} {dir_text}", chunk.content)
}
pub fn selector_to_mask(selector: Option<&[u32]>, size: usize) -> Option<Vec<u8>> {
selector.map(|sel| {
let mut mask = vec![0u8; size];
for &idx in sel {
if (idx as usize) < size {
mask[idx as usize] = 1;
}
}
mask
})
}
#[derive(Debug, Clone)]
struct Doc {
terms: Vec<(String, u32)>,
length: usize,
}
#[derive(Debug, Clone, Default)]
pub struct Bm25Index {
ids: HashMap<String, u32>,
docs: Vec<Option<Doc>>,
free_slots: Vec<u32>,
postings: HashMap<String, HashMap<u32, u32>>,
total_doc_length: usize,
doc_order: Vec<String>,
order_positions: Vec<Option<usize>>,
}
impl Bm25Index {
pub fn new() -> Self {
Self::default()
}
pub fn build(documents: &[Vec<String>]) -> Self {
let mut index = Self::new();
let mut order = Vec::with_capacity(documents.len());
for (i, tokens) in documents.iter().enumerate() {
let chunk_id = i.to_string();
index
.add_document(&chunk_id, tokens)
.expect("positional ids are unique");
order.push(chunk_id);
}
index.set_doc_order(order);
index
}
pub fn add_document(&mut self, chunk_id: &str, tokens: &[String]) -> Result<(), String> {
let mut terms: Vec<(String, u32)> = Vec::new();
let mut positions: HashMap<&str, usize> = HashMap::new();
for token in tokens {
match positions.get(token.as_str()) {
Some(&i) => terms[i].1 += 1,
None => {
positions.insert(token.as_str(), terms.len());
terms.push((token.clone(), 1));
}
}
}
self.insert_document(chunk_id, terms, tokens.len())
}
fn insert_document(
&mut self,
chunk_id: &str,
terms: Vec<(String, u32)>,
length: usize,
) -> Result<(), String> {
if self.ids.contains_key(chunk_id) {
return Err(format!("chunk_id already indexed: {chunk_id}"));
}
let slot = match self.free_slots.pop() {
Some(slot) => slot,
None => {
self.docs.push(None);
self.order_positions.push(None);
(self.docs.len() - 1) as u32
}
};
for (term, freq) in &terms {
self.postings
.entry(term.clone())
.or_default()
.insert(slot, *freq);
}
self.total_doc_length += length;
self.ids.insert(chunk_id.to_string(), slot);
self.order_positions[slot as usize] = None;
self.docs[slot as usize] = Some(Doc { terms, length });
Ok(())
}
pub fn remove_document(&mut self, chunk_id: &str) {
let Some(slot) = self.ids.remove(chunk_id) else {
return;
};
let Some(doc) = self.docs[slot as usize].take() else {
return;
};
self.total_doc_length -= doc.length;
for (term, _) in &doc.terms {
if let Some(docs) = self.postings.get_mut(term) {
docs.remove(&slot);
if docs.is_empty() {
self.postings.remove(term);
}
}
}
self.order_positions[slot as usize] = None;
self.free_slots.push(slot);
}
pub fn set_doc_order(&mut self, chunk_ids: Vec<String>) {
for position in self.order_positions.iter_mut() {
*position = None;
}
for (i, chunk_id) in chunk_ids.iter().enumerate() {
if let Some(&slot) = self.ids.get(chunk_id) {
self.order_positions[slot as usize] = Some(i);
}
}
self.doc_order = chunk_ids;
}
pub fn doc_order(&self) -> &[String] {
&self.doc_order
}
pub fn num_docs(&self) -> usize {
self.doc_order.len()
}
pub fn corpus_size(&self) -> usize {
self.ids.len()
}
pub fn get_scores(&self, query_tokens: &[String], weight_mask: Option<&[u8]>) -> Vec<f32> {
let mut scores = vec![0f32; self.doc_order.len()];
let corpus_size = self.corpus_size();
if query_tokens.is_empty() || corpus_size == 0 {
return scores;
}
let mut seen: HashSet<&str> = HashSet::new();
let mut unique: Vec<&str> = Vec::new();
for token in query_tokens {
if seen.insert(token.as_str()) {
unique.push(token.as_str());
}
}
let avg = self.total_doc_length as f64 / corpus_size as f64;
let avg = if avg != 0.0 { avg } else { 1.0 };
for term in unique {
let Some(docs) = self.postings.get(term) else {
continue;
};
let df = docs.len() as f64;
let idf = (1.0 + (corpus_size as f64 - df + 0.5) / (df + 0.5)).ln();
for (&slot, &freq) in docs {
let Some(position) = self.order_positions[slot as usize] else {
continue;
};
if let Some(mask) = weight_mask {
if mask.get(position).copied().unwrap_or(0) == 0 {
continue;
}
}
let dl = self.docs[slot as usize]
.as_ref()
.map_or(0.0, |doc| doc.length as f64);
let denom = freq as f64 + K1 * (1.0 - B + (B * dl) / avg);
let denom = if denom != 0.0 { denom } else { 1.0 };
let contrib = (idf * (freq as f64 * (K1 + 1.0))) / denom;
scores[position] = ((scores[position] as f64) + contrib) as f32;
}
}
scores
}
pub fn save(&self, dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let documents: BTreeMap<&str, BTreeMap<&str, u32>> = self
.ids
.iter()
.filter_map(|(chunk_id, &slot)| {
let doc = self.docs[slot as usize].as_ref()?;
let counts = doc
.terms
.iter()
.map(|(term, freq)| (term.as_str(), *freq))
.collect();
Some((chunk_id.as_str(), counts))
})
.collect();
let serialized = Bm25Serialized {
version: BM25_FORMAT_VERSION,
documents,
doc_order: self.doc_order.iter().map(String::as_str).collect(),
};
let json = serde_json::to_string(&serialized)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(dir.join("bm25.json"), json)
}
pub fn load(dir: &Path) -> std::io::Result<Self> {
let raw = std::fs::read_to_string(dir.join("bm25.json"))?;
let parsed: Bm25Serialized<String> = serde_json::from_str(&raw)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg);
if parsed.version != BM25_FORMAT_VERSION {
return Err(invalid(&format!(
"Unsupported BM25 format {}; expected {BM25_FORMAT_VERSION}",
parsed.version
)));
}
let order_set: HashSet<&str> = parsed.doc_order.iter().map(String::as_str).collect();
let document_set: HashSet<&str> = parsed.documents.keys().map(String::as_str).collect();
if order_set.len() != parsed.doc_order.len() || order_set != document_set {
return Err(invalid("Persisted BM25 document state is inconsistent"));
}
let mut index = Self::new();
for (chunk_id, counts) in parsed.documents {
let mut length = 0u64;
let mut terms: Vec<(String, u32)> = Vec::with_capacity(counts.len());
for (term, freq) in counts {
if freq == 0 {
return Err(invalid("Persisted BM25 term frequencies must be positive"));
}
length += u64::from(freq);
terms.push((term, freq));
}
let length = usize::try_from(length)
.map_err(|_| invalid("Persisted BM25 document length is out of range"))?;
index
.insert_document(&chunk_id, terms, length)
.map_err(|e| invalid(&e))?;
}
index.set_doc_order(parsed.doc_order);
Ok(index)
}
}
const BM25_FORMAT_VERSION: u32 = 2;
#[derive(Serialize, Deserialize)]
struct Bm25Serialized<S: Ord> {
version: u32,
documents: BTreeMap<S, BTreeMap<S, u32>>,
#[serde(rename = "docOrder")]
doc_order: Vec<S>,
}
#[cfg(test)]
mod tests;