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 (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::buckets::Buckets;
13use crate::docvalues::DocValues;
14use crate::fields::FieldStats;
15use crate::positions::Positions;
16use crate::token::tokenize;
17
18/// One ranked hit.
19#[derive(Debug, Clone, PartialEq)]
20pub struct TextMatch {
21    /// Row key.
22    pub key: Vec<u8>,
23    /// Shard-local BM25 score.
24    pub score: f64,
25}
26
27/// Sizing counters (memory formula + IDX.LIST).
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct TextStats {
30    /// Indexed documents.
31    pub docs: u64,
32    /// Distinct tokens.
33    pub tokens: u64,
34    /// Total postings.
35    pub postings: u64,
36    /// Approximate heap bytes (the measured side of the documented
37    /// memory formula).
38    pub approx_bytes: u64,
39}
40
41/// Corpus statistics supplied from outside a segment, for scoring one
42/// shard's documents against the whole corpus rather than its own slice.
43///
44/// A cross-shard text query builds this by summing each shard's local
45/// `n_docs` / `total_len` and, for each query token, its `df`. `df`
46/// need only carry the query's tokens — the values a query actually
47/// scores with — which is why global BM25 does not need a whole-corpus
48/// df table.
49pub struct CorpusStats {
50    /// Total documents across the corpus.
51    pub n_docs: f64,
52    /// Mean document length (unweighted tokens) across the corpus.
53    pub avgdl: f64,
54    /// Global document frequency per query token; a token missing here
55    /// falls back to the segment's local list length.
56    pub df: std::collections::HashMap<Vec<u8>, u32>,
57}
58
59/// What an index declares, in the terms a segment is built from.
60#[derive(Clone, Copy, Default)]
61pub struct SegmentShape {
62    /// Separately scored fields — `IN <field…>` scopes to these. 0 or 1
63    /// keeps no per-field breakdown, because with one field the
64    /// per-field numbers are the merged ones.
65    pub fields: usize,
66    /// Record token positions (`WITH POSITIONS`) for phrase, proximity
67    /// and adjacency-verified highlight.
68    pub positions: bool,
69    /// Value fields stored per document (`VALUES`), for the clauses that
70    /// read a document's own value rather than a term's postings.
71    pub values: usize,
72}
73
74/// A non-scoring predicate over a document's stored values.
75///
76/// The test takes raw bytes because this crate does not know what a
77/// number or a date is; the caller coerces. A document with no value for
78/// the field never passes — absent is not a value.
79#[derive(Clone, Copy)]
80pub struct Filter<'a> {
81    /// Which declared value field the predicate reads.
82    pub field: usize,
83    /// The test applied to that field's bytes.
84    pub test: &'a dyn Fn(&[u8]) -> bool,
85}
86
87/// A field's text and the BM25 weight it was indexed at. Stored per
88/// document so a removal re-derives exactly the term frequencies the
89/// insert produced.
90type IndexedField = (Vec<u8>, f32);
91
92/// One document's stored form: id, unweighted length, and the fields it
93/// was indexed from.
94type DocRecord = (u32, u32, Vec<IndexedField>);
95
96/// One shard's inverted segment.
97///
98/// `postings` maps token → (key → tf) so a pruned list is PROBED per
99/// accumulated candidate (O(candidates)) instead of walked
100/// (O(postings)); `docs` keeps each row's original text so an update
101/// removes exactly its own tokens (re-tokenize the old text) instead
102/// of scanning every posting list.
103#[derive(Debug, Default)]
104pub struct TextSegment {
105    postings: HashMap<Vec<u8>, Buckets>,
106    /// key → (doc id, dl, the field texts and the weights they were
107    /// indexed with). The weights are stored rather than re-read from
108    /// the spec so a removal re-derives exactly the term frequencies
109    /// the insert produced.
110    docs: HashMap<Vec<u8>, DocRecord>,
111    /// id → key (None = freed slot, id on the free list).
112    id_key: Vec<Option<Vec<u8>>>,
113    /// id → dl (valid while id_key[id].is_some()).
114    id_dl: Vec<u32>,
115    free_ids: Vec<u32>,
116    total_len: u64,
117    /// Positional side-channel, present only when the index was created
118    /// `WITH POSITIONS` (phrase / proximity / highlight). `None` keeps
119    /// the BM25 path byte-identical to the pre-positions structure —
120    /// the ranking hot path never touches it.
121    positions: Option<Positions>,
122    /// Per-field side-channel, present only on a multi-field index — the
123    /// breakdown `IN <field…>` scopes to. With one field the per-field
124    /// numbers are the merged ones, so a single-field index carries
125    /// nothing; an unscoped query never reads it either way.
126    fields: Option<FieldStats>,
127    /// Stored-value side-channel, present only when the index declared
128    /// value fields (`VALUES`). Answers "what is THIS document's price",
129    /// which the postings cannot — see [`crate::docvalues`].
130    values: Option<DocValues>,
131    /// Running counters, so `stats()` never walks the index.
132    /// Each mirrors one walking term of the memory formula in
133    /// `segment_stats.rs` (the walker survives there as the invariant
134    /// the tests hold these to). `postings_total` is the postings
135    /// gauge; the other three are the segment-owned byte terms.
136    postings_total: u64,
137    token_bytes: u64,
138    many_slots: u64,
139    doc_bytes: u64,
140}
141
142impl TextSegment {
143    /// Empty segment (ranking only, no positional postings).
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Empty segment that records token positions for phrase, proximity
149    /// and highlight queries — the `WITH POSITIONS` form. Every other
150    /// operation behaves identically; only the positional side-channel
151    /// (and its memory cost) is added.
152    pub fn with_positions() -> Self {
153        Self::with_shape(SegmentShape { positions: true, ..SegmentShape::default() })
154    }
155
156    /// Empty segment shaped by what an index declares.
157    ///
158    /// Each optional channel exists only when the declaration calls for
159    /// it, so an index pays for what it asked for and nothing else.
160    pub fn with_shape(shape: SegmentShape) -> Self {
161        Self {
162            positions: shape.positions.then(Positions::default),
163            fields: (shape.fields > 1).then(|| FieldStats::new(shape.fields)),
164            values: (shape.values > 0).then(|| DocValues::new(shape.values)),
165            ..Self::default()
166        }
167    }
168
169    /// Whether this segment records token positions.
170    pub fn has_positions(&self) -> bool {
171        self.positions.is_some()
172    }
173
174    /// How many fields this segment scores separately; 1 when it keeps no
175    /// per-field breakdown (a single-field index needs none).
176    pub fn field_arity(&self) -> usize {
177        self.fields.as_ref().map_or(1, FieldStats::arity)
178    }
179
180    /// How many value fields this segment stores per document; 0 when it
181    /// stores none.
182    pub fn value_arity(&self) -> usize {
183        self.values.as_ref().map_or(0, DocValues::arity)
184    }
185
186    /// Every stored value of one document by id, aligned with the
187    /// declared VALUES order — what a freeze carries into a cold doc
188    /// record so the value-reading clauses can serve cold hits.
189    pub(crate) fn doc_values_of(&self, id: u32) -> Vec<Option<&[u8]>> {
190        match self.values.as_ref() {
191            Some(dv) => (0..dv.arity()).map(|f| dv.get(id, f)).collect(),
192            None => Vec::new(),
193        }
194    }
195
196    /// One row's stored value for a declared value field, as raw bytes.
197    /// `None` when the row is not indexed here, the field was not
198    /// declared, or this document has no value for it.
199    ///
200    /// The cross-shard merge needs each returned hit's sort value, and it
201    /// is cheaper to look it up for the handful of hits a shard returns
202    /// than to carry it through the ranking.
203    pub fn stored_value(&self, key: &[u8], field: usize) -> Option<&[u8]> {
204        let (id, _, _) = self.docs.get(key)?;
205        self.values.as_ref()?.get(*id, field)
206    }
207
208    /// (Re-)index one row's text (`None` = row removed / excluded).
209    ///
210    /// Single-field sugar over [`TextSegment::apply_fields`] at neutral
211    /// weight, so the two paths cannot diverge.
212    pub fn apply(&mut self, key: &[u8], text: Option<&[u8]>) {
213        match text {
214            Some(t) => self.apply_fields(key, Some(&[(t.to_vec(), 1.0)])),
215            None => self.apply_fields(key, None),
216        }
217    }
218
219    /// (Re-)index one row from its declared fields, each with its BM25
220    /// weight. `None` removes the row.
221    ///
222    /// A weight scales that field's term frequencies, so a term in a
223    /// weight-3 title counts as if seen three times. Document length is
224    /// summed **unweighted**: length normalisation measures how much
225    /// text there is to dilute a match, and weighting it would make a
226    /// heavily-weighted field penalise itself.
227    pub fn apply_fields(&mut self, key: &[u8], fields: Option<&[IndexedField]>) {
228        self.apply_doc(key, fields, &[]);
229    }
230
231    /// [`TextSegment::apply_fields`], also storing the row's declared
232    /// value fields (`VALUES`) so `FILTER` and friends can read them back
233    /// per document. `values` is positional against the declaration; a
234    /// short slice leaves the rest absent.
235    pub fn apply_doc(
236        &mut self,
237        key: &[u8],
238        fields: Option<&[IndexedField]>,
239        values: &[Option<&[u8]>],
240    ) {
241        self.withdraw(key);
242        let Some(fields) = fields else { return };
243        let (per_field, lens) = field_tf(fields);
244        let (tf_map, dl) = merge_field_tf(&per_field, &lens);
245        if tf_map.is_empty() {
246            return;
247        }
248        let id = self.take_id(key, dl);
249        self.doc_bytes += doc_record_bytes(key, fields);
250        self.docs.insert(key.to_vec(), (id, dl, fields.to_vec()));
251        self.total_len += u64::from(dl);
252        for (t, tf) in tf_map {
253            self.postings_total += 1;
254            match self.postings.entry(t) {
255                std::collections::hash_map::Entry::Occupied(mut e) => {
256                    let before = e.get().index_len();
257                    e.get_mut().insert(tf, dl, id);
258                    self.many_slots += e.get().index_len() - before;
259                }
260                std::collections::hash_map::Entry::Vacant(v) => {
261                    self.token_bytes += v.key().len() as u64 + 48;
262                    v.insert(Buckets::new_one(tf, dl, id));
263                }
264            }
265        }
266        self.index_side_channels(id, fields, &per_field, &lens);
267        if let Some(dv) = self.values.as_mut() {
268            dv.set(id, values);
269        }
270    }
271
272    /// One indexed document's `(id, dl, weighted term→tf)` — the
273    /// freeze's read half, taken BEFORE withdraw consumes the stored
274    /// fields it is derived from.
275    pub(crate) fn doc_terms(&self, key: &[u8]) -> Option<(u32, u32, HashMap<Vec<u8>, u32>)> {
276        let (id, dl, fields) = self.docs.get(key)?;
277        Some((*id, *dl, weighted_tf(fields).0))
278    }
279
280    /// The undecoded positions blob for `(term, id)`, if positions are
281    /// declared and present.
282    pub(crate) fn positions_blob(&self, term: &[u8], id: u32) -> Option<&[u8]> {
283        self.positions.as_ref()?.blob(term, id)
284    }
285
286    /// Claim a document id for `key`, reusing a freed slot when there is
287    /// one.
288    fn take_id(&mut self, key: &[u8], dl: u32) -> u32 {
289        if let Some(id) = self.free_ids.pop() {
290            self.id_key[id as usize] = Some(key.to_vec());
291            self.id_dl[id as usize] = dl;
292            id
293        } else {
294            self.id_key.push(Some(key.to_vec()));
295            self.id_dl.push(dl);
296            (self.id_key.len() - 1) as u32
297        }
298    }
299
300    /// Fill the physical side-channels for a freshly indexed document:
301    /// token offsets for phrase / highlight, and the per-field breakdown
302    /// for field-scoped scoring. Both are derived from the same single
303    /// tokenisation the merged postings came from.
304    fn index_side_channels(
305        &mut self,
306        id: u32,
307        fields: &[IndexedField],
308        per_field: &[HashMap<Vec<u8>, u32>],
309        lens: &[u32],
310    ) {
311        if let Some(pos) = self.positions.as_mut() {
312            for (t, offsets) in token_offsets(fields) {
313                pos.set(&t, id, &offsets);
314            }
315        }
316        let Some(fs) = self.fields.as_mut() else { return };
317        fs.set_doc_len(id, lens);
318        let arity = fs.arity();
319        let mut by_token: HashMap<&[u8], Vec<u32>> = HashMap::new();
320        for (f, m) in per_field.iter().enumerate() {
321            for (t, v) in m {
322                let row = by_token.entry(t).or_insert_with(|| vec![0; arity]);
323                if let Some(slot) = row.get_mut(f) {
324                    *slot = *v;
325                }
326            }
327        }
328        for (t, row) in by_token {
329            fs.set(t, id, &row);
330        }
331    }
332
333    /// Withdraw whatever `key` was last indexed as: strip its postings
334    /// and positions (re-derived from the fields it was stored with,
335    /// O(doc) not O(index)) and free its id. A no-op if `key` is not
336    /// indexed, so it is safe as the first step of every (re-)index.
337    fn withdraw(&mut self, key: &[u8]) {
338        let Some((old_id, old_len, old_fields)) = self.docs.remove(key) else {
339            return;
340        };
341        self.doc_bytes -= doc_record_bytes(key, &old_fields);
342        self.total_len -= u64::from(old_len);
343        for (t, tf) in weighted_tf(&old_fields).0 {
344            if let Some(list) = self.postings.get_mut(&t) {
345                let before = list.index_len();
346                list.remove(tf, old_len, old_id);
347                self.many_slots -= before - list.index_len();
348                self.postings_total -= 1;
349                if list.is_empty() {
350                    self.postings.remove(&t);
351                    self.token_bytes -= t.len() as u64 + 48;
352                }
353            }
354            if let Some(pos) = self.positions.as_mut() {
355                pos.remove(&t, old_id);
356            }
357            if let Some(fs) = self.fields.as_mut() {
358                fs.remove(&t, old_id);
359            }
360        }
361        if let Some(fs) = self.fields.as_mut() {
362            fs.clear_doc_len(old_id);
363        }
364        if let Some(dv) = self.values.as_mut() {
365            dv.clear(old_id);
366        }
367        self.id_key[old_id as usize] = None;
368        self.free_ids.push(old_id);
369    }
370}
371
372/// One document's term of the docs-table byte formula: key stored
373/// twice, each field's text + a small header, and the record fixed
374/// cost — the exact per-entry term `recompute_stats` walks.
375fn doc_record_bytes(key: &[u8], fields: &[IndexedField]) -> u64 {
376    let text: usize = fields.iter().map(|(t, _)| t.len() + 4).sum();
377    (2 * key.len() + text + 110) as u64
378}
379
380/// Aggregate token counts for one document's token stream.
381/// Weighted term frequencies across a document's fields, plus its
382/// unweighted length in tokens.
383///
384/// A weight multiplies the field's raw counts and the result is rounded
385/// up rather than truncated: a term that occurs once in a weight-0.5
386/// field still occurred, and rounding it to zero would delete a match
387/// rather than de-emphasise it.
388fn weighted_tf(fields: &[IndexedField]) -> (HashMap<Vec<u8>, u32>, u32) {
389    let (per_field, lens) = field_tf(fields);
390    merge_field_tf(&per_field, &lens)
391}
392
393/// One document's weighted term frequencies **kept per field**, plus each
394/// field's unweighted length in tokens.
395///
396/// This is the shape the per-field channel stores and the merged postings
397/// are the sum of; deriving both from one call is what guarantees the
398/// scoped and unscoped paths agree on what a field contributed.
399fn field_tf(fields: &[IndexedField]) -> (Vec<HashMap<Vec<u8>, u32>>, Vec<u32>) {
400    let mut per_field = Vec::with_capacity(fields.len());
401    let mut lens = Vec::with_capacity(fields.len());
402    for (text, weight) in fields {
403        let toks = tokenize(text);
404        lens.push(toks.len() as u32);
405        let scaled = tf_of(&toks)
406            .into_iter()
407            .map(|(t, n)| (t, (f64::from(n) * f64::from(*weight)).ceil().max(1.0) as u32))
408            .collect();
409        per_field.push(scaled);
410    }
411    (per_field, lens)
412}
413
414/// Fold a per-field breakdown back into the merged frequencies and length
415/// the ranking postings store.
416fn merge_field_tf(
417    per_field: &[HashMap<Vec<u8>, u32>],
418    lens: &[u32],
419) -> (HashMap<Vec<u8>, u32>, u32) {
420    let mut out: HashMap<Vec<u8>, u32> = HashMap::new();
421    for m in per_field {
422        for (t, v) in m {
423            let slot = out.entry(t.clone()).or_insert(0);
424            *slot = slot.saturating_add(*v);
425        }
426    }
427    let dl = lens.iter().fold(0u32, |a, &b| a.saturating_add(b));
428    (out, dl)
429}
430
431fn tf_of(toks: &[Vec<u8>]) -> HashMap<Vec<u8>, u32> {
432    let mut tf = HashMap::new();
433    for t in toks {
434        *tf.entry(t.clone()).or_insert(0) += 1;
435    }
436    tf
437}
438
439/// Each token's ascending offsets within the document's concatenated
440/// fields (field order). Positions are **unweighted** physical ordinals
441/// — like `dl`, they describe where the text is, not how it is scored —
442/// so a weight-3 title still advances the offset one per token.
443fn token_offsets(fields: &[IndexedField]) -> HashMap<Vec<u8>, Vec<u32>> {
444    let mut out: HashMap<Vec<u8>, Vec<u32>> = HashMap::new();
445    let mut pos = 0u32;
446    for (text, _weight) in fields {
447        for tok in tokenize(text) {
448            out.entry(tok).or_default().push(pos);
449            pos += 1;
450        }
451    }
452    out
453}
454
455#[path = "segment_opts.rs"]
456mod segment_opts;
457pub use segment_opts::{Bucket, Distinct, Facet, FacetedMatches, QueryOpts, Sort};
458
459#[path = "segment_query.rs"]
460mod segment_query;
461pub use segment_query::sorted_order;
462
463#[path = "segment_stats.rs"]
464mod segment_stats;
465
466#[path = "segment_phrase.rs"]
467mod segment_phrase;
468pub use crate::clauses::{Clauses, parse_clauses};
469pub(crate) use segment_phrase::{distinct_tokens, field_spans};
470
471#[path = "segment_scope.rs"]
472mod segment_scope;
473
474#[cfg(test)]
475#[path = "segment_tests.rs"]
476mod tests;