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}