use std::collections::HashMap;
use anyhow::{Context, Result};
use bm25::{DefaultTokenizer, Embedder, EmbedderBuilder, Embedding, Scorer, TokenEmbedder, Tokenizer};
use parking_lot::RwLock;
type LexicalEmbedding = Embedding;
pub struct Bm25Index {
embedder: Embedder<u32, DefaultTokenizer>,
scorer: RwLock<Scorer<String, u32>>,
embeddings: RwLock<HashMap<String, LexicalEmbedding>>,
texts: RwLock<HashMap<String, String>>,
}
impl Bm25Index {
#[must_use]
pub fn fit(corpus: &[&str]) -> Self {
let embedder = EmbedderBuilder::with_tokenizer_and_fit_to_corpus(
DefaultTokenizer::default(),
corpus,
)
.build();
Self {
embedder,
scorer: RwLock::new(Scorer::new()),
embeddings: RwLock::new(HashMap::new()),
texts: RwLock::new(HashMap::new()),
}
}
#[must_use]
pub fn with_avgdl(avgdl: f32) -> Self {
let embedder = EmbedderBuilder::with_avgdl(avgdl).build();
Self {
embedder,
scorer: RwLock::new(Scorer::new()),
embeddings: RwLock::new(HashMap::new()),
texts: RwLock::new(HashMap::new()),
}
}
#[must_use]
pub fn avgdl(&self) -> f32 {
self.embedder.avgdl()
}
#[must_use]
pub fn len(&self) -> usize {
self.embeddings.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn upsert(&self, id: &str, text: &str) {
let embedding = self.embedder.embed(text);
self.scorer.write().upsert(&id.to_string(), embedding.clone());
self.embeddings.write().insert(id.to_string(), embedding);
self.texts.write().insert(id.to_string(), text.to_string());
}
pub fn upsert_batch(&self, items: &[(String, String)]) {
for (id, text) in items {
self.upsert(id, text);
}
}
pub fn remove(&self, id: &str) {
self.scorer.write().remove(&id.to_string());
self.embeddings.write().remove(id);
self.texts.write().remove(id);
}
#[must_use]
pub fn search(&self, query: &str, limit: usize) -> Vec<(String, f32)> {
let query_embedding = self.embedder.embed(query);
let mut scored: Vec<(String, f32)> = self
.scorer
.read()
.matches(&query_embedding)
.into_iter()
.map(|doc| (doc.id, doc.score))
.collect();
scored.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
scored.truncate(limit);
scored
}
#[must_use]
pub fn embedding_of(&self, id: &str) -> Option<Vec<(String, f32)>> {
let embeddings = self.embeddings.read();
let embedding = embeddings.get(id)?;
let texts = self.texts.read();
let text = texts.get(id)?;
let mut hash_to_term: HashMap<u32, String> = HashMap::new();
for token in DefaultTokenizer::default().tokenize(text) {
let hash = <u32 as TokenEmbedder>::embed(&token);
hash_to_term.entry(hash).or_insert(token);
}
Some(
embedding
.iter()
.filter_map(|t| {
hash_to_term
.get(&t.index)
.map(|term| (term.clone(), t.value))
})
.collect(),
)
}
pub fn refit(&self) -> f32 {
let texts = self.texts.read();
let corpus: Vec<&str> = texts.values().map(String::as_str).collect();
if corpus.is_empty() {
return self.embedder.avgdl();
}
let refitted: Embedder<u32, DefaultTokenizer> =
EmbedderBuilder::with_tokenizer_and_fit_to_corpus(
DefaultTokenizer::default(),
&corpus,
)
.build();
let avgdl = refitted.avgdl();
let mut scorer = self.scorer.write();
let mut embeddings = self.embeddings.write();
*scorer = Scorer::new();
for (id, text) in texts.iter() {
let embedding = refitted.embed(text);
scorer.upsert(id, embedding.clone());
embeddings.insert(id.clone(), embedding);
}
avgdl
}
}
impl Default for Bm25Index {
fn default() -> Self {
Self::with_avgdl(0.0)
}
}
pub fn encode_sparse(embedding: &[(String, f32)]) -> Result<(Vec<u8>, Vec<u8>)> {
let mut terms = Vec::new();
let mut weights = Vec::with_capacity(embedding.len() * 4);
let count = u32::try_from(embedding.len())
.context("sparse embedding has more terms than u32::MAX")?;
terms.extend_from_slice(&count.to_le_bytes());
for (term, weight) in embedding {
let len = u32::try_from(term.len()).context("term longer than u32::MAX bytes")?;
terms.extend_from_slice(&len.to_le_bytes());
terms.extend_from_slice(term.as_bytes());
weights.extend_from_slice(&weight.to_le_bytes());
}
Ok((terms, weights))
}
pub fn decode_sparse(terms: &[u8], weights: &[u8]) -> Result<Vec<(String, f32)>> {
let mut out = Vec::new();
if terms.len() < 4 {
return Ok(out);
}
let count = u32::from_le_bytes(
terms[..4]
.try_into()
.context("sparse term blob is shorter than its header")?,
) as usize;
let mut cursor = 4usize;
let mut weight_cursor = 0usize;
for _ in 0..count {
if cursor + 4 > terms.len() {
anyhow::bail!("sparse term blob truncated at term length");
}
let len = u32::from_le_bytes(
terms[cursor..cursor + 4]
.try_into()
.context("sparse term length is malformed")?,
) as usize;
cursor += 4;
if cursor + len > terms.len() {
anyhow::bail!("sparse term blob truncated at term body");
}
let term = String::from_utf8(terms[cursor..cursor + len].to_vec())
.context("sparse term is not valid UTF-8")?;
cursor += len;
if weight_cursor + 4 > weights.len() {
anyhow::bail!("sparse weight blob truncated");
}
let weight = f32::from_le_bytes(
weights[weight_cursor..weight_cursor + 4]
.try_into()
.context("sparse weight is malformed")?,
);
weight_cursor += 4;
out.push((term, weight));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn corpus() -> Vec<&'static str> {
vec![
"the rare token appears here",
"the common token appears here",
"the common token appears here too",
"the common token appears here as well",
"the common token appears here again",
]
}
#[test]
fn idf_prefers_rare_terms_over_common_ones() {
let index = Bm25Index::fit(&corpus());
for (i, text) in corpus().iter().enumerate() {
index.upsert(&format!("doc-{i}"), text);
}
let rare_hits = index.search("rare", 5);
assert_eq!(rare_hits[0].0, "doc-0");
let common_hits = index.search("common", 5);
assert!(
common_hits.iter().all(|(id, _)| id != "doc-0"),
"doc-0 does not contain 'common' and must not match"
);
}
#[test]
fn empty_index_returns_no_hits() {
let index = Bm25Index::fit(&[]);
assert!(index.is_empty());
assert!(index.search("anything", 10).is_empty());
}
#[test]
fn upsert_replaces_rather_than_duplicates() {
let index = Bm25Index::fit(&["alpha beta"]);
index.upsert("doc", "alpha beta");
index.upsert("doc", "gamma delta");
assert_eq!(index.len(), 1);
assert!(index.search("alpha", 5).is_empty(), "old text must be gone");
assert_eq!(index.search("gamma", 5).len(), 1);
}
#[test]
fn remove_drops_the_document() {
let index = Bm25Index::fit(&["alpha beta"]);
index.upsert("doc", "alpha beta");
index.remove("doc");
assert!(index.is_empty());
assert!(index.search("alpha", 5).is_empty());
}
#[test]
fn refit_reindexes_every_document() {
let index = Bm25Index::with_avgdl(1.0);
for (i, text) in corpus().iter().enumerate() {
index.upsert(&format!("doc-{i}"), text);
}
let avgdl = index.refit();
assert!(avgdl > 0.0, "fitted avgdl must be positive");
assert_eq!(index.len(), corpus().len(), "refit must not lose documents");
assert_eq!(index.search("rare", 5)[0].0, "doc-0");
}
#[test]
fn sparse_encoding_round_trips() {
let original = vec![
("alpha".to_string(), 1.5f32),
("beta".to_string(), 0.25f32),
("gamma".to_string(), 3.0f32),
];
let (terms, weights) = encode_sparse(&original).expect("encode");
let decoded = decode_sparse(&terms, &weights).expect("decode");
assert_eq!(decoded, original);
}
#[test]
fn sparse_decoding_rejects_truncated_blobs() {
let original = vec![("alpha".to_string(), 1.0f32)];
let (terms, weights) = encode_sparse(&original).expect("encode");
let truncated = &terms[..terms.len() - 1];
assert!(decode_sparse(truncated, &weights).is_err());
}
#[test]
fn embedding_of_exposes_term_weight_pairs() {
let index = Bm25Index::fit(&["alpha beta"]);
index.upsert("doc", "alpha beta");
let embedding = index.embedding_of("doc").expect("indexed doc");
assert!(!embedding.is_empty());
assert!(
embedding.iter().any(|(term, _)| term == "alpha"),
"term strings must be recovered, got {embedding:?}"
);
assert!(embedding.iter().all(|(_, weight)| *weight > 0.0));
}
#[test]
fn search_is_deterministic_on_ties() {
let index = Bm25Index::fit(&["alpha", "alpha"]);
index.upsert("b-doc", "alpha");
index.upsert("a-doc", "alpha");
let first = index.search("alpha", 5);
let second = index.search("alpha", 5);
assert_eq!(first, second, "tied scores must break deterministically");
}
}