1use crate::model::{Chunk, Scored};
14use crate::store::VectorStore;
15use crate::Result;
16use std::collections::HashMap;
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::Arc;
19
20#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct Bm25Params {
23 pub k1: f32,
25 pub b: f32,
27}
28
29impl Default for Bm25Params {
30 fn default() -> Self {
32 Bm25Params { k1: 1.2, b: 0.75 }
33 }
34}
35
36type Posting = (u32, u32);
38
39pub struct Bm25Index {
41 chunks: Vec<Chunk>,
42 postings: HashMap<String, Vec<Posting>>,
44 doc_len: Vec<f32>,
45 avgdl: f32,
46 params: Bm25Params,
47}
48
49pub fn tokenize(text: &str) -> Vec<String> {
55 text.split(|c: char| !c.is_alphanumeric())
56 .filter(|s| !s.is_empty())
57 .map(|s| {
58 if s.is_ascii() {
60 s.to_ascii_lowercase()
61 } else {
62 s.to_lowercase()
63 }
64 })
65 .collect()
66}
67
68impl Bm25Index {
69 pub fn build(chunks: Vec<Chunk>) -> Self {
71 Self::build_with(chunks, Bm25Params::default())
72 }
73
74 pub fn build_with(chunks: Vec<Chunk>, params: Bm25Params) -> Self {
76 let mut postings: HashMap<String, Vec<Posting>> = HashMap::new();
77 let mut doc_len = Vec::with_capacity(chunks.len());
78
79 for (i, chunk) in chunks.iter().enumerate() {
80 let mut tokens = tokenize(&chunk.text);
81 doc_len.push(tokens.len() as f32);
82 tokens.sort_unstable();
85 let mut tokens = tokens.into_iter().peekable();
86 while let Some(term) = tokens.next() {
87 let mut freq = 1u32;
88 while tokens.peek() == Some(&term) {
89 tokens.next();
90 freq += 1;
91 }
92 postings.entry(term).or_default().push((i as u32, freq));
94 }
95 }
96
97 let n = chunks.len();
98 let avgdl = if n == 0 {
99 0.0
100 } else {
101 doc_len.iter().sum::<f32>() / n as f32
102 };
103 Bm25Index {
104 chunks,
105 postings,
106 doc_len,
107 avgdl,
108 params,
109 }
110 }
111
112 pub fn len(&self) -> usize {
114 self.chunks.len()
115 }
116
117 pub fn is_empty(&self) -> bool {
119 self.chunks.is_empty()
120 }
121
122 fn idf(&self, df: usize) -> f32 {
125 let n = self.chunks.len() as f32;
126 let df = df as f32;
127 (((n - df + 0.5) / (df + 0.5)) + 1.0).ln()
128 }
129
130 pub fn search(&self, query: &str, k: usize) -> Vec<Scored> {
133 if self.chunks.is_empty() || k == 0 {
134 return Vec::new();
135 }
136 let mut q_tf: HashMap<String, f32> = HashMap::new();
139 for t in tokenize(query) {
140 *q_tf.entry(t).or_insert(0.0) += 1.0;
141 }
142
143 let (k1, b) = (self.params.k1, self.params.b);
144 let avgdl = self.avgdl.max(1e-6);
145 let mut acc: HashMap<u32, f32> = HashMap::new();
146 for (term, qf) in &q_tf {
147 let Some(list) = self.postings.get(term) else {
148 continue;
149 };
150 let idf = self.idf(list.len()) * qf;
151 for &(doc, freq) in list {
152 let f = freq as f32;
153 let dl = self.doc_len[doc as usize];
154 let denom = f + k1 * (1.0 - b + b * dl / avgdl);
155 *acc.entry(doc).or_insert(0.0) += idf * (f * (k1 + 1.0)) / denom;
156 }
157 }
158
159 let mut hits: Vec<(u32, f32)> = acc.into_iter().filter(|&(_, s)| s > 0.0).collect();
160 hits.sort_by(|a, b| {
163 b.1.partial_cmp(&a.1)
164 .unwrap_or(std::cmp::Ordering::Equal)
165 .then(a.0.cmp(&b.0))
166 });
167 hits.truncate(k);
168 hits.into_iter()
169 .map(|(doc, score)| Scored::new(self.chunks[doc as usize].clone(), score))
170 .collect()
171 }
172}
173
174pub struct Bm25Cache {
189 cached: tokio::sync::Mutex<Option<(Fingerprint, Arc<Bm25Index>)>>,
191 generation: AtomicU64,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq)]
196struct Fingerprint {
197 documents: usize,
198 chunks: usize,
199 generation: u64,
200 params: Bm25Params,
201}
202
203impl Default for Bm25Cache {
204 fn default() -> Self {
205 Self::new()
206 }
207}
208
209impl Bm25Cache {
210 pub fn new() -> Self {
212 Bm25Cache {
213 cached: tokio::sync::Mutex::new(None),
214 generation: AtomicU64::new(0),
215 }
216 }
217
218 pub fn invalidate(&self) {
220 self.generation.fetch_add(1, Ordering::Relaxed);
221 }
222
223 pub async fn index(
225 &self,
226 store: &Arc<dyn VectorStore>,
227 params: Bm25Params,
228 ) -> Result<Arc<Bm25Index>> {
229 let fingerprint = Fingerprint {
230 documents: store.count_documents().await?,
231 chunks: store.count_chunks().await?,
232 generation: self.generation.load(Ordering::Relaxed),
233 params,
234 };
235 let mut cached = self.cached.lock().await;
238 if let Some((fp, index)) = cached.as_ref() {
239 if *fp == fingerprint {
240 return Ok(index.clone());
241 }
242 }
243 let chunks = store.all_chunks().await?;
244 let index = tokio::task::spawn_blocking(move || Bm25Index::build_with(chunks, params))
246 .await
247 .unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic()));
251 let index = Arc::new(index);
252 *cached = Some((fingerprint, index.clone()));
253 Ok(index)
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn chunk(id: &str, text: &str) -> Chunk {
262 let mut c = Chunk::new("doc", 0, text, 0);
263 c.id = id.to_string();
264 c
265 }
266
267 fn reference(chunks: &[Chunk], query: &str, p: Bm25Params, k: usize) -> Vec<(String, f32)> {
270 let tokens: Vec<Vec<String>> = chunks.iter().map(|c| tokenize(&c.text)).collect();
271 let n = chunks.len() as f32;
272 let avgdl = tokens.iter().map(|t| t.len() as f32).sum::<f32>() / n;
273 let mut out: Vec<(String, f32)> = Vec::new();
274 for (i, toks) in tokens.iter().enumerate() {
275 let mut score = 0.0f32;
276 for term in tokenize(query) {
277 let f = toks.iter().filter(|t| **t == term).count() as f32;
278 if f == 0.0 {
279 continue;
280 }
281 let df = tokens.iter().filter(|d| d.contains(&term)).count() as f32;
282 let idf = (((n - df + 0.5) / (df + 0.5)) + 1.0).ln();
283 let dl = toks.len() as f32;
284 let denom = f + p.k1 * (1.0 - p.b + p.b * dl / avgdl);
285 score += idf * (f * (p.k1 + 1.0)) / denom;
286 }
287 if score > 0.0 {
288 out.push((chunks[i].id.clone(), score));
289 }
290 }
291 out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
292 out.truncate(k);
293 out
294 }
295
296 fn corpus() -> Vec<Chunk> {
297 vec![
298 chunk("a", "the postgres database stores vectors"),
299 chunk("b", "a banana smoothie recipe with yogurt"),
300 chunk("c", "vector search over a database index"),
301 chunk("d", "database database database, a very database chunk"),
302 chunk(
303 "e",
304 "a long chunk about databases and vector search and search engines and \
305 index structures that goes on for a while so that length normalization \
306 has something to bite on",
307 ),
308 ]
309 }
310
311 #[test]
312 fn ranks_exact_term_matches_first() {
313 let index = Bm25Index::build(corpus());
314 let hits = index.search("database vector", 3);
315 assert!(!hits.is_empty());
316 let top_ids: Vec<&str> = hits.iter().map(|h| h.chunk.id.as_str()).collect();
317 assert!(top_ids.contains(&"a") && top_ids.contains(&"c"));
318 assert!(!top_ids.contains(&"b"), "the smoothie chunk shares no term");
319 }
320
321 #[test]
322 fn matches_the_reference_implementation() {
323 for params in [
324 Bm25Params::default(),
325 Bm25Params { k1: 0.5, b: 0.0 },
326 Bm25Params { k1: 2.0, b: 1.0 },
327 ] {
328 let index = Bm25Index::build_with(corpus(), params);
329 for query in [
330 "database",
331 "database vector search",
332 "search search",
333 "banana yogurt smoothie",
334 "nothing here matches",
335 ] {
336 let got = index.search(query, 10);
337 let want = reference(&corpus(), query, params, 10);
338 assert_eq!(got.len(), want.len(), "{query} @ {params:?}");
339 for (g, w) in got.iter().zip(&want) {
340 assert_eq!(g.chunk.id, w.0, "{query} @ {params:?}");
341 assert!(
342 (g.score - w.1).abs() < 1e-4,
343 "{query} @ {params:?}: {} vs {}",
344 g.score,
345 w.1
346 );
347 }
348 }
349 }
350 }
351
352 #[test]
353 fn saturation_and_length_normalization_follow_k1_and_b() {
354 let normalized = Bm25Index::build(corpus()).search("vector search index", 5);
357 let flat = Bm25Index::build_with(corpus(), Bm25Params { k1: 1.2, b: 0.0 })
358 .search("vector search index", 5);
359 assert_eq!(normalized[0].chunk.id, "c");
360 assert_eq!(flat[0].chunk.id, "e");
361
362 let saturated =
365 Bm25Index::build_with(corpus(), Bm25Params { k1: 0.0, b: 0.75 }).search("database", 5);
366 let repeated = Bm25Index::build(corpus()).search("database", 5);
367 assert_eq!(repeated[0].chunk.id, "d");
368 assert_ne!(saturated[0].chunk.id, "d");
369 }
370
371 #[test]
372 fn case_folding_is_unicode_aware() {
373 let index = Bm25Index::build(vec![
374 chunk("ru", "Постгрес хранит векторы"),
375 chunk("de", "Größe der Straße"),
376 ]);
377 assert_eq!(index.search("постгрес", 5)[0].chunk.id, "ru");
378 assert_eq!(index.search("ПОСТГРЕС", 5)[0].chunk.id, "ru");
379 assert_eq!(index.search("straße", 5)[0].chunk.id, "de");
380 }
381
382 #[test]
383 fn ties_keep_corpus_order() {
384 let chunks: Vec<Chunk> = ["x", "y", "z"]
387 .iter()
388 .map(|id| chunk(id, "identical text about vectors"))
389 .collect();
390 for _ in 0..8 {
391 let hits = Bm25Index::build(chunks.clone()).search("vectors", 3);
392 let ids: Vec<&str> = hits.iter().map(|h| h.chunk.id.as_str()).collect();
393 assert_eq!(ids, ["x", "y", "z"]);
394 }
395 }
396
397 #[test]
398 fn empty_index_and_no_match() {
399 let empty = Bm25Index::build(vec![]);
400 assert!(empty.is_empty());
401 assert!(empty.search("x", 5).is_empty());
402 let index = Bm25Index::build(vec![chunk("a", "hello world")]);
403 assert_eq!(index.len(), 1);
404 assert!(index.search("nonexistent", 5).is_empty());
405 assert!(index.search("hello", 0).is_empty());
406 }
407
408 mod cache {
409 use super::*;
410 use crate::store::memory::MemoryStore;
411
412 async fn store_with(texts: &[&str]) -> Arc<dyn VectorStore> {
413 let store: Arc<dyn VectorStore> = Arc::new(MemoryStore::new());
414 let doc = crate::model::Document::new("mem://t", "T", "h");
415 store.upsert_document(&doc).await.unwrap();
416 add_chunks(&store, &doc.id, texts).await;
417 store
418 }
419
420 async fn add_chunks(store: &Arc<dyn VectorStore>, doc_id: &str, texts: &[&str]) {
421 let chunks: Vec<Chunk> = texts
422 .iter()
423 .enumerate()
424 .map(|(i, t)| {
425 let mut c = Chunk::new(doc_id, i as i64, *t, 0);
426 c.embedding = Some(vec![0.0; 4]);
427 c
428 })
429 .collect();
430 store.insert_chunks(&chunks).await.unwrap();
431 }
432
433 #[tokio::test]
434 async fn reuses_one_index_until_the_corpus_moves() {
435 let store = store_with(&["vector database", "banana smoothie"]).await;
436 let cache = Bm25Cache::new();
437 let p = Bm25Params::default();
438
439 let first = cache.index(&store, p).await.unwrap();
440 let second = cache.index(&store, p).await.unwrap();
441 assert!(
442 Arc::ptr_eq(&first, &second),
443 "an unchanged corpus must not be re-tokenized"
444 );
445
446 let doc_id = store.list_documents().await.unwrap()[0].id.clone();
448 add_chunks(&store, &doc_id, &["tokio async runtime"]).await;
449 let third = cache.index(&store, p).await.unwrap();
450 assert!(!Arc::ptr_eq(&second, &third));
451 assert_eq!(third.len(), 3);
452 assert_eq!(third.search("tokio", 5).len(), 1, "new chunk is searchable");
453 }
454
455 #[tokio::test]
456 async fn invalidate_rebuilds_even_at_an_unchanged_count() {
457 let store = store_with(&["vector database"]).await;
458 let cache = Bm25Cache::new();
459 let p = Bm25Params::default();
460 let first = cache.index(&store, p).await.unwrap();
461
462 store.clear().await.unwrap();
465 let doc = crate::model::Document::new("mem://t2", "T2", "h2");
466 store.upsert_document(&doc).await.unwrap();
467 add_chunks(&store, &doc.id, &["tokio async runtime"]).await;
468 assert!(
469 Arc::ptr_eq(&first, &cache.index(&store, p).await.unwrap()),
470 "counts alone cannot see a same-sized replacement"
471 );
472
473 cache.invalidate();
474 let rebuilt = cache.index(&store, p).await.unwrap();
475 assert_eq!(rebuilt.search("tokio", 5).len(), 1);
476 assert!(rebuilt.search("vector", 5).is_empty());
477 }
478
479 #[tokio::test]
480 async fn changed_params_rebuild() {
481 let store = store_with(&["vector database"]).await;
482 let cache = Bm25Cache::new();
483 let first = cache.index(&store, Bm25Params::default()).await.unwrap();
484 let tuned = cache
485 .index(&store, Bm25Params { k1: 2.0, b: 0.4 })
486 .await
487 .unwrap();
488 assert!(!Arc::ptr_eq(&first, &tuned));
489 }
490 }
491}