Skip to main content

plugmem_core/index/
bm25.rs

1//! The lexical index: classic BM25 over delta-encoded postings
2//!
3//! Scoring is the standard formula with the Robertson idf:
4//!
5//! ```text
6//! idf(t)      = ln(1 + (N - df + 0.5) / (df + 0.5))
7//! tf_norm(d)  = tf · (k1 + 1) / (tf + k1 · (1 - b + b · len(d) / avg_len))
8//! score(d, q) = Σ_t idf(t) · tf_norm(d, t)
9//! ```
10//!
11//! Query cost is O(Σ df) — a full decode of every query term's postings
12//! with accumulation in a reusable scratch map. No WAND-style pruning in
13//! v1: at the capacity passport's scale decoding is microseconds, and the
14//! deterministic `postings_decoded` counter gates it in CI.
15//!
16//! Deletions never touch the postings: tombstoned facts are filtered per
17//! candidate by the caller's `live` predicate and fall out physically
18//! when `maintain` rebuilds the index.
19
20use alloc::vec::Vec;
21
22#[cfg(feature = "counters")]
23use core::cell::Cell;
24
25use plugmem_arena::{Arena, ArenaCfg, ShardMode, Slot, key};
26
27use crate::error::Error;
28use crate::id::FactId;
29use crate::index::postings::PostingStore;
30
31/// Per-document length record: `[fact 4 | len u16 | pad 2]`, Uniform
32/// arena.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub struct DocLenSlot {
36    /// The document (fact) id — the key.
37    pub fact: FactId,
38    /// Token count of the document, saturated at `u16::MAX`.
39    pub len: u16,
40}
41
42impl Slot for DocLenSlot {
43    const SIZE: usize = 8;
44    const KEY_LEN: usize = 4;
45
46    fn write(&self, out: &mut [u8]) {
47        key::write_u32(out, self.fact.0);
48        out[4..6].copy_from_slice(&self.len.to_be_bytes());
49        out[6..8].copy_from_slice(&[0, 0]);
50    }
51
52    fn read(bytes: &[u8]) -> Self {
53        Self {
54            fact: FactId(key::read_u32(bytes)),
55            len: u16::from_be_bytes(bytes[4..6].try_into().unwrap()),
56        }
57    }
58}
59
60/// Reusable query scratch: accumulator and top-k selection buffer. One
61/// per engine; after warm-up a query allocates nothing (the zero-alloc
62/// recall invariant).
63#[derive(Debug, Default)]
64pub struct Bm25Scratch {
65    /// fact id → accumulated score. The xxh3 hasher is explicit:
66    /// hashbrown's default hasher is behind a feature we do not enable,
67    /// and a fixed hasher keeps scratch behavior deterministic.
68    acc: hashbrown::HashMap<u32, f32, xxhash_rust::xxh3::Xxh3Builder>,
69    /// Selection buffer for the top-k extraction.
70    top: Vec<(f32, u32)>,
71}
72
73impl Bm25Scratch {
74    /// Empty scratch buffers.
75    pub fn new() -> Self {
76        Self::default()
77    }
78}
79
80/// The BM25 index: postings with term frequencies plus per-document
81/// lengths and corpus statistics.
82#[derive(Debug)]
83pub struct Bm25Index<'a> {
84    postings: PostingStore<'a, true>,
85    doc_len: Arena<'a, DocLenSlot>,
86    total_docs: u64,
87    total_len: u64,
88    /// Posting entries decoded by queries (feature `counters`) — the
89    /// deterministic cost metric of the lexical source.
90    #[cfg(feature = "counters")]
91    decoded: Cell<u64>,
92}
93
94impl<'a> Bm25Index<'a> {
95    /// Creates an empty index; `shards` per the engine config
96    /// (`shards_postings`), `max_bytes` bounds each underlying pool.
97    pub fn new(shards: usize, max_bytes: usize) -> Result<Self, Error> {
98        Ok(Self {
99            postings: PostingStore::new(shards, max_bytes)?,
100            doc_len: Arena::new(
101                ArenaCfg::new(shards, ShardMode::Uniform).with_max_bytes(max_bytes),
102            )?,
103            total_docs: 0,
104            total_len: 0,
105            #[cfg(feature = "counters")]
106            decoded: Cell::new(0),
107        })
108    }
109
110    /// Indexes one document given its `(term, tf)` pairs (the caller
111    /// tokenizes and counts; pairs may arrive in any order, terms must be
112    /// unique). Documents must arrive in ascending fact-id order.
113    ///
114    /// # Errors
115    ///
116    /// [`Error::Arena`] when a pool hits its byte ceiling; the index may
117    /// then hold the document partially — the engine treats that as fatal
118    /// for the whole operation (journal replay rebuilds consistently).
119    pub fn index_doc(&mut self, fact: FactId, term_tfs: &[(u32, u8)]) -> Result<(), Error> {
120        let mut len = 0u32;
121        for &(term, tf) in term_tfs {
122            self.postings.push(term, fact, tf)?;
123            len += u32::from(tf);
124        }
125        self.doc_len.insert(&DocLenSlot {
126            fact,
127            len: u16::try_from(len).unwrap_or(u16::MAX),
128        })?;
129        self.total_docs += 1;
130        self.total_len += u64::from(len);
131        Ok(())
132    }
133
134    /// Document frequency of a term.
135    pub fn df(&self, term: u32) -> u32 {
136        self.postings.count(term)
137    }
138
139    /// Number of indexed documents.
140    pub fn docs(&self) -> u64 {
141        self.total_docs
142    }
143
144    /// Robertson idf for a term with document frequency `df` in this
145    /// corpus (monotonically decreasing in `df`, always positive).
146    pub fn idf(&self, df: u32) -> f32 {
147        let n = self.total_docs as f32;
148        let df = df as f32;
149        libm::logf(1.0 + (n - df + 0.5) / (df + 0.5))
150    }
151
152    /// Scores `terms` against the corpus and writes the top `k` live
153    /// documents into `out` (descending score, ties by ascending id).
154    /// `live` filters candidates (tombstones, as-of, tag allow-sets) —
155    /// filtered documents cost their posting decode but never rank.
156    ///
157    /// Duplicate query terms are the caller's choice: each occurrence
158    /// accumulates again (a term repeated in the query weighs more).
159    pub fn search(
160        &self,
161        (k1, b): (f32, f32),
162        terms: &[u32],
163        k: usize,
164        live: &mut dyn FnMut(FactId) -> bool,
165        scratch: &mut Bm25Scratch,
166        out: &mut Vec<(FactId, f32)>,
167    ) {
168        out.clear();
169        if self.total_docs == 0 || k == 0 {
170            return;
171        }
172        scratch.acc.clear();
173        let avg_len = self.total_len as f32 / self.total_docs as f32;
174        #[cfg(feature = "counters")]
175        let mut decoded = 0u64;
176        for &term in terms {
177            let df = self.postings.count(term);
178            if df == 0 {
179                continue;
180            }
181            let idf = self.idf(df);
182            for (fact, tf) in self.postings.entries(term) {
183                #[cfg(feature = "counters")]
184                {
185                    decoded += 1;
186                }
187                let Some(doc) = self.doc_len.get(&fact.0.to_be_bytes()) else {
188                    continue;
189                };
190                let tf = f32::from(tf);
191                let norm =
192                    tf * (k1 + 1.0) / (tf + k1 * (1.0 - b + b * f32::from(doc.len) / avg_len));
193                *scratch.acc.entry(fact.0).or_insert(0.0) += idf * norm;
194            }
195        }
196        #[cfg(feature = "counters")]
197        self.decoded.set(self.decoded.get() + decoded);
198
199        // Top-k: collect survivors, sort the (small) buffer. k is ≤ 64 in
200        // the engine; a heap would not buy anything at these sizes.
201        scratch.top.clear();
202        for (&id, &score) in &scratch.acc {
203            if live(FactId(id)) {
204                scratch.top.push((score, id));
205            }
206        }
207        scratch
208            .top
209            .sort_unstable_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
210        for &(score, id) in scratch.top.iter().take(k) {
211            out.push((FactId(id), score));
212        }
213    }
214
215    /// Bytes held by the underlying pools.
216    pub fn pool_bytes(&self) -> usize {
217        self.postings.pool_bytes() + self.doc_len.pool_bytes()
218    }
219
220    /// Total token count across the corpus (persisted in the engine
221    /// state).
222    pub fn total_len(&self) -> u64 {
223        self.total_len
224    }
225
226    /// The underlying posting store (the persistence composer dumps it).
227    pub(crate) fn postings(&self) -> &PostingStore<'a, true> {
228        &self.postings
229    }
230
231    /// The per-document length arena (the persistence composer dumps it).
232    pub(crate) fn doc_len_arena(&self) -> &Arena<'a, DocLenSlot> {
233        &self.doc_len
234    }
235
236    /// Assembles an index from already-validated parts (the load path).
237    pub(crate) fn from_parts(
238        postings: PostingStore<'a, true>,
239        doc_len: Arena<'a, DocLenSlot>,
240        total_docs: u64,
241        total_len: u64,
242    ) -> Self {
243        Self {
244            postings,
245            doc_len,
246            total_docs,
247            total_len,
248            #[cfg(feature = "counters")]
249            decoded: Cell::new(0),
250        }
251    }
252
253    /// Posting entries decoded so far (feature `counters`).
254    #[cfg(feature = "counters")]
255    pub fn decoded(&self) -> u64 {
256        self.decoded.get()
257    }
258
259    /// Resets the decode counter (feature `counters`).
260    #[cfg(feature = "counters")]
261    pub fn reset_decoded(&self) {
262        self.decoded.set(0);
263    }
264}