Skip to main content

docling_rag/retrieve/
bm25.rs

1//! Pure-Rust Okapi BM25 over the store's chunk corpus.
2//!
3//! The index is an **inverted index**: one postings list per term, so a query
4//! only ever touches the chunks that actually contain one of its terms instead
5//! of scoring the whole corpus. Building it is the expensive part (it reads
6//! every chunk), which is why [`Bm25Cache`] keeps one alive across queries —
7//! hybrid and multi-query retrieval issue several searches per question.
8//!
9//! Keyword search stays in Rust rather than delegating to a backend's
10//! full-text engine so scores are identical on SQLite, Postgres and the
11//! in-memory store.
12
13use 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/// Okapi BM25 tuning parameters.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct Bm25Params {
23    /// Term-frequency saturation: higher rewards repeated terms for longer.
24    pub k1: f32,
25    /// Length normalization, 0..=1: 0 ignores chunk length, 1 normalizes fully.
26    pub b: f32,
27}
28
29impl Default for Bm25Params {
30    /// The classic Okapi defaults (also Lucene's): k1 = 1.2, b = 0.75.
31    fn default() -> Self {
32        Bm25Params { k1: 1.2, b: 0.75 }
33    }
34}
35
36/// One term occurrence inside a chunk: `(chunk index, term frequency)`.
37type Posting = (u32, u32);
38
39/// An in-memory BM25 index over a fixed set of chunks.
40pub struct Bm25Index {
41    chunks: Vec<Chunk>,
42    /// Postings per term, each sorted by chunk index. `df` is `postings.len()`.
43    postings: HashMap<String, Vec<Posting>>,
44    doc_len: Vec<f32>,
45    avgdl: f32,
46    params: Bm25Params,
47}
48
49/// Split `text` into lowercase alphanumeric terms.
50///
51/// Case folding is Unicode-aware (`str::to_lowercase`), so `Постгрес` and
52/// `постгрес` are the same term — an ASCII-only fold would silently make
53/// keyword search case-sensitive for every non-Latin corpus.
54pub fn tokenize(text: &str) -> Vec<String> {
55    text.split(|c: char| !c.is_alphanumeric())
56        .filter(|s| !s.is_empty())
57        .map(|s| {
58            // Fast path: the overwhelmingly common all-ASCII term.
59            if s.is_ascii() {
60                s.to_ascii_lowercase()
61            } else {
62                s.to_lowercase()
63            }
64        })
65        .collect()
66}
67
68impl Bm25Index {
69    /// Build an index over `chunks` with the default parameters.
70    pub fn build(chunks: Vec<Chunk>) -> Self {
71        Self::build_with(chunks, Bm25Params::default())
72    }
73
74    /// Build an index over `chunks` with explicit BM25 parameters.
75    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            // Sorting turns "count each term's occurrences" into a run scan over
83            // the tokens, which are then moved into the index rather than cloned.
84            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                // Chunks are visited in order, so every postings list stays sorted.
93                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    /// Number of indexed chunks.
113    pub fn len(&self) -> usize {
114        self.chunks.len()
115    }
116
117    /// Whether the index holds no chunks.
118    pub fn is_empty(&self) -> bool {
119        self.chunks.is_empty()
120    }
121
122    /// Robertson–Spärck-Jones IDF with the usual `+0.5` smoothing, in the
123    /// `ln(1 + …)` form that stays positive for every term (Lucene's).
124    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    /// Score every chunk that shares a term with `query` and return the top `k`
131    /// (score > 0), best first. Ties keep corpus order, so results are stable.
132    pub fn search(&self, query: &str, k: usize) -> Vec<Scored> {
133        if self.chunks.is_empty() || k == 0 {
134            return Vec::new();
135        }
136        // Query-term frequencies: a term repeated in the query weighs that much
137        // more, matching the plain sum-over-query-occurrences formulation.
138        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        // Sort by score, then by corpus position: `acc` is a HashMap, so without
161        // the second key equal-scoring chunks would come back in random order.
162        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
174/// A lazily-built, shared [`Bm25Index`] over a store's whole chunk corpus.
175///
176/// Building the index reads every chunk, so a retriever that rebuilt it per
177/// query paid that cost several times for a single question — hybrid runs one
178/// keyword search per query, multi-query one per rewrite. The cache builds it
179/// once and hands out an `Arc`.
180///
181/// Freshness is checked on every use against a cheap store fingerprint (the
182/// document and chunk counts, one `COUNT(*)` each) plus a generation counter
183/// that [`Bm25Cache::invalidate`] bumps — the pipeline calls that on every
184/// write, which is what catches an edit that happens to leave the counts
185/// unchanged. A different process writing to the same database is only caught
186/// by the counts, so a shared store can serve one stale result after such a
187/// same-count edit.
188pub struct Bm25Cache {
189    /// `None` until the first search; replaced whenever the fingerprint moves.
190    cached: tokio::sync::Mutex<Option<(Fingerprint, Arc<Bm25Index>)>>,
191    generation: AtomicU64,
192}
193
194/// What the cached index was built from. Any change rebuilds it.
195#[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    /// An empty cache.
211    pub fn new() -> Self {
212        Bm25Cache {
213            cached: tokio::sync::Mutex::new(None),
214            generation: AtomicU64::new(0),
215        }
216    }
217
218    /// Drop the cached index. Call after any write to the store.
219    pub fn invalidate(&self) {
220        self.generation.fetch_add(1, Ordering::Relaxed);
221    }
222
223    /// The index for `store`, rebuilding it if the corpus or `params` moved.
224    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        // Held across the build so concurrent searches wait for one index
236        // instead of each building their own.
237        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        // Tokenizing a whole corpus is CPU-bound; keep it off the async worker.
245        let index = tokio::task::spawn_blocking(move || Bm25Index::build_with(chunks, params))
246            .await
247            // A blocking task is never cancelled, so the only way to fail the
248            // join is a panic inside the build — re-raise it as it would have
249            // been raised had the build run inline.
250            .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    /// Straight transcription of the BM25 formula, scoring every chunk with a
268    /// linear scan — what the index is expected to reproduce exactly.
269    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        // b = 0 ignores length, so the long chunk `e` is no longer penalized for
355        // its filler; with the default b = 0.75 the short chunk `c` wins.
356        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        // k1 -> 0 collapses term frequency to a single occurrence, so the chunk
363        // that just repeats "database" loses its edge over one plain mention.
364        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        // Three identical chunks score identically; the ranking must not depend
385        // on hash iteration order.
386        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            // A new chunk moves the count fingerprint.
447            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            // Replace the corpus with a same-sized one: only the explicit
463            // invalidation can catch this, the counts are identical.
464            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}