Skip to main content

kevy_text/
segment.rs

1//! [`TextSegment`] — one shard's inverted slice of one text index
2//! (index-follows-key, same discipline as kevy-index's `Segment`).
3//! Maintained synchronously with writes; queried with BM25 ranking
4//! over shard-local statistics (RFC D2: per-shard df/avgdl — global
5//! statistics would need cross-shard write coordination).
6//!
7//! The impact-bucketed posting-list structure lives in
8//! [`crate::buckets`].
9
10use std::collections::HashMap;
11
12use crate::bm25::bm25_score;
13use crate::buckets::{BAND_MIN_DL, Buckets};
14use crate::token::tokenize;
15
16/// One ranked hit.
17#[derive(Debug, Clone, PartialEq)]
18pub struct TextMatch {
19    /// Row key.
20    pub key: Vec<u8>,
21    /// Shard-local BM25 score.
22    pub score: f64,
23}
24
25/// Sizing counters (memory formula + IDX.LIST).
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
27pub struct TextStats {
28    /// Indexed documents.
29    pub docs: u64,
30    /// Distinct tokens.
31    pub tokens: u64,
32    /// Total postings.
33    pub postings: u64,
34    /// Approximate heap bytes (RFC D4 formula's measured side).
35    pub approx_bytes: u64,
36}
37
38/// One scoring candidate list: (postings, df, MaxScore upper bound).
39type ScoredList<'s> = (&'s Buckets, f64, f64);
40
41/// Per-query BM25 constants threaded through the walk helpers.
42struct QueryCtx {
43    n_docs: f64,
44    avgdl: f64,
45    limit: usize,
46}
47
48/// One shard's inverted segment.
49///
50/// `postings` maps token → (key → tf) so a pruned list is PROBED per
51/// accumulated candidate (O(candidates)) instead of walked
52/// (O(postings)); `docs` keeps each row's original text so an update
53/// removes exactly its own tokens (re-tokenize the old text) instead
54/// of scanning every posting list.
55#[derive(Debug, Default)]
56pub struct TextSegment {
57    postings: HashMap<Vec<u8>, Buckets>,
58    /// key → (doc id, dl, original text).
59    docs: HashMap<Vec<u8>, (u32, u32, Vec<u8>)>,
60    /// id → key (None = freed slot, id on the free list).
61    id_key: Vec<Option<Vec<u8>>>,
62    /// id → dl (valid while id_key[id].is_some()).
63    id_dl: Vec<u32>,
64    free_ids: Vec<u32>,
65    total_len: u64,
66}
67
68impl TextSegment {
69    /// Empty segment.
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// (Re-)index one row's text (`None` = row removed / excluded).
75    pub fn apply(&mut self, key: &[u8], text: Option<&[u8]>) {
76        if let Some((old_id, old_len, old_text)) = self.docs.remove(key) {
77            self.total_len -= u64::from(old_len);
78            // Remove exactly this doc's tokens (re-derive tf from the
79            // old text — O(doc), not O(index)).
80            for (t, tf) in tf_of(&tokenize(&old_text)) {
81                if let Some(list) = self.postings.get_mut(&t) {
82                    list.remove(tf, old_len, old_id);
83                    if list.is_empty() {
84                        self.postings.remove(&t);
85                    }
86                }
87            }
88            self.id_key[old_id as usize] = None;
89            self.free_ids.push(old_id);
90        }
91        let Some(text) = text else { return };
92        let toks = tokenize(text);
93        if toks.is_empty() {
94            return;
95        }
96        let dl = toks.len() as u32;
97        let id = if let Some(id) = self.free_ids.pop() {
98            self.id_key[id as usize] = Some(key.to_vec());
99            self.id_dl[id as usize] = dl;
100            id
101        } else {
102            self.id_key.push(Some(key.to_vec()));
103            self.id_dl.push(dl);
104            (self.id_key.len() - 1) as u32
105        };
106        self.docs.insert(key.to_vec(), (id, dl, text.to_vec()));
107        self.total_len += u64::from(dl);
108        for (t, tf) in tf_of(&toks) {
109            match self.postings.entry(t) {
110                std::collections::hash_map::Entry::Occupied(mut e) => {
111                    e.get_mut().insert(tf, dl, id);
112                }
113                std::collections::hash_map::Entry::Vacant(v) => {
114                    v.insert(Buckets::new_one(tf, dl, id));
115                }
116            }
117        }
118    }
119
120    /// BM25-ranked matches for `query` (tokenized with the same rules;
121    /// OR semantics), best `limit` hits, score-descending.
122    ///
123    /// MaxScore pruning: query tokens process rarest-first; once the
124    /// running top-`limit` threshold exceeds the summed upper bounds
125    /// of the remaining (commoner) tokens, documents seen ONLY in
126    /// those lists can no longer enter — their lists are then probed
127    /// per accumulated doc instead of walked. Selection is a bounded
128    /// heap over borrowed keys (no per-candidate allocation).
129    pub fn matches(&self, query: &[u8], limit: usize) -> Vec<TextMatch> {
130        // Top-0 of anything is empty (same convention as kevy-vector's
131        // `knn` with k = 0). Also keeps the MaxScore floor well-defined:
132        // `kth_of` indexes `limit - 1`.
133        if limit == 0 {
134            return Vec::new();
135        }
136        let mut q_tokens = tokenize(query);
137        q_tokens.sort();
138        q_tokens.dedup();
139        if q_tokens.is_empty() || self.docs.is_empty() {
140            return Vec::new();
141        }
142        let n_docs = self.docs.len() as f64;
143        let avgdl = self.total_len as f64 / n_docs;
144        let lists = self.scored_lists(&q_tokens, n_docs);
145        if lists.is_empty() {
146            return Vec::new();
147        }
148        let tail_ub = tail_bounds(&lists);
149        let ctx = QueryCtx { n_docs, avgdl, limit };
150        let mut scores: HashMap<u32, f64> = HashMap::new();
151        let mut kth_threshold = 0.0_f64;
152        let mut walked = 0usize;
153        for (i, (list, df, _ub)) in lists.iter().enumerate() {
154            // Docs appearing only in the remaining lists can't reach
155            // the current top-limit floor → stop WALKING; the loop
156            // below PROBES these lists for already-seen docs.
157            if i > 0 && scores.len() >= limit && tail_ub[i] < kth_threshold {
158                break;
159            }
160            walked = i + 1;
161            let tail_next = tail_ub.get(i + 1).copied().unwrap_or(0.0);
162            self.walk_list(list, *df, tail_next, lists.len() == 1, &ctx, &mut scores);
163            if scores.len() >= limit && i + 1 < lists.len() {
164                kth_threshold = kth_of(&scores, limit);
165            }
166        }
167        // Probe un-walked lists PER ACCUMULATED DOC — O(candidates)
168        // hash gets, never a walk of the common list (walking here
169        // was the measured 30ms p95: a pruned 500k-posting head list
170        // still cost a full scan).
171        for (list, df, _) in &lists[walked..] {
172            self.probe_list(list, *df, &[], &ctx, &mut scores);
173        }
174        self.select_top(&scores, limit)
175    }
176
177    /// The candidate lists for a query, rarest (highest upper bound)
178    /// first. The bound is dl-independent: denom ≥ tf + k1(1-b), so
179    /// score ≤ idf·tf(k1+1)/(tf + k1(1-b)).
180    fn scored_lists<'s>(&'s self, q_tokens: &[Vec<u8>], n_docs: f64) -> Vec<ScoredList<'s>> {
181        let mut lists: Vec<ScoredList<'s>> = Vec::new();
182        for t in q_tokens {
183            let Some(list) = self.postings.get(t) else { continue };
184            let df = list.len() as f64;
185            let max_tf = f64::from(list.max_tf());
186            lists.push((list, df, crate::bm25::bm25_upper(max_tf, df, n_docs)));
187        }
188        lists.sort_by(|a, b| b.2.total_cmp(&a.2));
189        lists
190    }
191
192    /// Walk one list bucket-by-bucket (tf descending), with the
193    /// bucket-level and within-bucket (single-list) early stops.
194    fn walk_list(
195        &self,
196        list: &Buckets,
197        df: f64,
198        tail_next: f64,
199        single: bool,
200        ctx: &QueryCtx,
201        scores: &mut HashMap<u32, f64>,
202    ) {
203        let QueryCtx { n_docs, limit, .. } = *ctx;
204        let groups = list.tf_groups();
205        for (bi, (tf, bands)) in groups.iter().enumerate() {
206            // Bucket-level early stop: buckets are tf-descending,
207            // so once even the dl-free bound of THIS tf (plus
208            // everything later lists could add) can't reach the
209            // kth floor, no NEW doc from here on can enter. Docs
210            // already accumulated still need this list's
211            // contribution — the remaining buckets are PROBED for
212            // them (a key has exactly one tf per token, so no
213            // double count with earlier buckets).
214            if scores.len() >= limit {
215                let bound = crate::bm25::bm25_upper(f64::from(*tf), df, n_docs);
216                if bound + tail_next < kth_of(scores, limit) {
217                    let walked_tfs: Vec<u32> =
218                        groups[..bi].iter().map(|(t, _)| *t).collect();
219                    self.probe_list(list, df, &walked_tfs, ctx, scores);
220                    break;
221                }
222            }
223            self.walk_bucket(*tf, bands, df, single, ctx, scores);
224        }
225    }
226
227    /// Walk one tf bucket's bands (dl ascending), scoring every id.
228    ///
229    /// v3.5 single-list within-bucket cut: bands are dl-ASCENDING and
230    /// BM25 falls as dl rises, so the band's LOWER dl edge bounds
231    /// every score inside it from above. On a one-list query — each
232    /// doc appears exactly ONCE in the whole list, no later
233    /// contribution to lose — the first band whose bound can't beat
234    /// the kth floor ends the bucket exactly. Scoring stays per-id
235    /// exact via the id_dl table; bands only gate the cut.
236    fn walk_bucket(
237        &self,
238        tf: u32,
239        bands: &crate::buckets::BandsView<'_>,
240        df: f64,
241        single: bool,
242        ctx: &QueryCtx,
243        scores: &mut HashMap<u32, f64>,
244    ) {
245        let QueryCtx { n_docs, avgdl, limit } = *ctx;
246        for (b, band) in bands.iter() {
247            if band.is_empty() {
248                continue;
249            }
250            let bound = bm25_score(
251                f64::from(tf),
252                df,
253                n_docs,
254                f64::from(BAND_MIN_DL[b as usize]),
255                avgdl,
256            );
257            if single && scores.len() >= limit && bound < kth_of(scores, limit) {
258                break;
259            }
260            for &id in band {
261                let dl = f64::from(self.id_dl[id as usize]);
262                *scores.entry(id).or_insert(0.0) +=
263                    bm25_score(f64::from(tf), df, n_docs, dl, avgdl);
264            }
265        }
266    }
267
268    /// Contribute `list` to every ALREADY-ACCUMULATED doc via O(1)
269    /// list-level probes (never a walk). A doc whose tf sits in
270    /// `skip_tfs` already got this list's contribution from a WALKED
271    /// bucket (tf is unique per (token, doc)) and is skipped.
272    fn probe_list(
273        &self,
274        list: &Buckets,
275        df: f64,
276        skip_tfs: &[u32],
277        ctx: &QueryCtx,
278        scores: &mut HashMap<u32, f64>,
279    ) {
280        let ids: Vec<u32> = scores.keys().copied().collect();
281        for &id in &ids {
282            if let Some(tf) = list.get(id)
283                && !skip_tfs.contains(&tf)
284            {
285                let dl = f64::from(self.id_dl[id as usize]);
286                *scores.get_mut(&id).expect("accumulated") +=
287                    bm25_score(f64::from(tf), df, ctx.n_docs, dl, ctx.avgdl);
288            }
289        }
290    }
291
292    /// Bounded selection: only the winners get cloned. Ids resolve
293    /// to keys here — the tiebreak (key ascending) is unchanged.
294    fn select_top(&self, scores: &HashMap<u32, f64>, limit: usize) -> Vec<TextMatch> {
295        let key_of = |id: u32| -> &[u8] {
296            self.id_key[id as usize].as_deref().expect("live posting id")
297        };
298        let mut top: Vec<(f64, &[u8])> = Vec::with_capacity(limit + 1);
299        for (id, score) in scores {
300            let cand = (*score, key_of(*id));
301            if top.len() < limit {
302                top.push(cand);
303                if top.len() == limit {
304                    top.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(b.1)));
305                }
306            } else if better(cand, top[limit - 1]) {
307                let pos = top.partition_point(|e| better(*e, cand));
308                top.insert(pos, cand);
309                top.pop();
310            }
311        }
312        if top.len() < limit {
313            top.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(b.1)));
314        }
315        top.into_iter()
316            .map(|(score, k)| TextMatch { key: k.to_vec(), score })
317            .collect()
318    }
319
320    /// Live counters.
321    pub fn stats(&self) -> TextStats {
322        let postings: u64 = self.postings.values().map(|l| l.len() as u64).sum();
323        // Hapax lists are INLINE (enum One) — no heap beyond their
324        // postings-map slot; only Many lists pay the per-posting
325        // band-vec + index costs.
326        let many_postings: u64 = self
327            .postings
328            .values()
329            .map(|l| match l {
330                Buckets::One { .. } => 0,
331                Buckets::Many(m) => m.index.len() as u64,
332            })
333            .sum();
334        let token_bytes: u64 = self.postings.keys().map(|t| (t.len() + 48) as u64).sum();
335        // docs table + the id→key/id→dl tables (key stored twice).
336        let doc_bytes: u64 = self
337            .docs
338            .iter()
339            .map(|(k, (_, _, text))| (2 * k.len() + text.len() + 110) as u64)
340            .sum();
341        TextStats {
342            docs: self.docs.len() as u64,
343            tokens: self.postings.len() as u64,
344            postings,
345            // per-Many-posting ≈ 4B band-vec slot + ~26B list-index
346            // entry (doc-id postings + log2 dl bands, v3.5); hapax
347            // lists are inline. Docs keep their original text
348            // (update path re-derives tokens).
349            approx_bytes: token_bytes + many_postings * 30 + doc_bytes,
350        }
351    }
352
353    /// Verify hook: is `key` indexed here?
354    pub fn contains(&self, key: &[u8]) -> bool {
355        self.docs.contains_key(key)
356    }
357}
358
359/// Aggregate token counts for one document's token stream.
360fn tf_of(toks: &[Vec<u8>]) -> HashMap<Vec<u8>, u32> {
361    let mut tf = HashMap::new();
362    for t in toks {
363        *tf.entry(t.clone()).or_insert(0) += 1;
364    }
365    tf
366}
367
368/// Strict "ranks ahead of" for (score, key) — higher score first,
369/// key ascending as the tiebreak.
370// float_cmp: exact equality is the tiebreak trigger — an epsilon here would
371// make ranking non-deterministic for genuinely equal BM25 scores.
372#[allow(clippy::float_cmp)]
373fn better(a: (f64, &[u8]), b: (f64, &[u8])) -> bool {
374    a.0 > b.0 || (a.0 == b.0 && a.1 < b.1)
375}
376
377/// The `limit`-th best score currently accumulated (the MaxScore
378/// entry floor). O(n) selection, called only between list walks.
379fn kth_of(scores: &HashMap<u32, f64>, limit: usize) -> f64 {
380    let mut v: Vec<f64> = scores.values().copied().collect();
381    let idx = limit - 1;
382    v.select_nth_unstable_by(idx, |a, b| b.total_cmp(a));
383    v[idx]
384}
385
386/// `tail_ub[i]` = Σ upper bounds of `lists[i..]`.
387fn tail_bounds(lists: &[ScoredList<'_>]) -> Vec<f64> {
388    let mut acc = 0.0;
389    let mut v: Vec<f64> = lists
390        .iter()
391        .rev()
392        .map(|l| {
393            acc += l.2;
394            acc
395        })
396        .collect();
397    v.reverse();
398    v
399}
400
401#[cfg(test)]
402#[path = "segment_tests.rs"]
403mod tests;