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(
276        &self,
277        key: &[u8],
278    ) -> Option<(u32, u32, HashMap<Vec<u8>, u32>)> {
279        let (id, dl, fields) = self.docs.get(key)?;
280        Some((*id, *dl, weighted_tf(fields).0))
281    }
282
283    /// The undecoded positions blob for `(term, id)`, if positions are
284    /// declared and present.
285    pub(crate) fn positions_blob(&self, term: &[u8], id: u32) -> Option<&[u8]> {
286        self.positions.as_ref()?.blob(term, id)
287    }
288
289    /// Claim a document id for `key`, reusing a freed slot when there is
290    /// one.
291    fn take_id(&mut self, key: &[u8], dl: u32) -> u32 {
292        if let Some(id) = self.free_ids.pop() {
293            self.id_key[id as usize] = Some(key.to_vec());
294            self.id_dl[id as usize] = dl;
295            id
296        } else {
297            self.id_key.push(Some(key.to_vec()));
298            self.id_dl.push(dl);
299            (self.id_key.len() - 1) as u32
300        }
301    }
302
303    /// Fill the physical side-channels for a freshly indexed document:
304    /// token offsets for phrase / highlight, and the per-field breakdown
305    /// for field-scoped scoring. Both are derived from the same single
306    /// tokenisation the merged postings came from.
307    fn index_side_channels(
308        &mut self,
309        id: u32,
310        fields: &[IndexedField],
311        per_field: &[HashMap<Vec<u8>, u32>],
312        lens: &[u32],
313    ) {
314        if let Some(pos) = self.positions.as_mut() {
315            for (t, offsets) in token_offsets(fields) {
316                pos.set(&t, id, &offsets);
317            }
318        }
319        let Some(fs) = self.fields.as_mut() else { return };
320        fs.set_doc_len(id, lens);
321        let arity = fs.arity();
322        let mut by_token: HashMap<&[u8], Vec<u32>> = HashMap::new();
323        for (f, m) in per_field.iter().enumerate() {
324            for (t, v) in m {
325                let row = by_token.entry(t).or_insert_with(|| vec![0; arity]);
326                if let Some(slot) = row.get_mut(f) {
327                    *slot = *v;
328                }
329            }
330        }
331        for (t, row) in by_token {
332            fs.set(t, id, &row);
333        }
334    }
335
336    /// Withdraw whatever `key` was last indexed as: strip its postings
337    /// and positions (re-derived from the fields it was stored with,
338    /// O(doc) not O(index)) and free its id. A no-op if `key` is not
339    /// indexed, so it is safe as the first step of every (re-)index.
340    fn withdraw(&mut self, key: &[u8]) {
341        let Some((old_id, old_len, old_fields)) = self.docs.remove(key) else {
342            return;
343        };
344        self.doc_bytes -= doc_record_bytes(key, &old_fields);
345        self.total_len -= u64::from(old_len);
346        for (t, tf) in weighted_tf(&old_fields).0 {
347            if let Some(list) = self.postings.get_mut(&t) {
348                let before = list.index_len();
349                list.remove(tf, old_len, old_id);
350                self.many_slots -= before - list.index_len();
351                self.postings_total -= 1;
352                if list.is_empty() {
353                    self.postings.remove(&t);
354                    self.token_bytes -= t.len() as u64 + 48;
355                }
356            }
357            if let Some(pos) = self.positions.as_mut() {
358                pos.remove(&t, old_id);
359            }
360            if let Some(fs) = self.fields.as_mut() {
361                fs.remove(&t, old_id);
362            }
363        }
364        if let Some(fs) = self.fields.as_mut() {
365            fs.clear_doc_len(old_id);
366        }
367        if let Some(dv) = self.values.as_mut() {
368            dv.clear(old_id);
369        }
370        self.id_key[old_id as usize] = None;
371        self.free_ids.push(old_id);
372    }
373}
374
375/// One document's term of the docs-table byte formula: key stored
376/// twice, each field's text + a small header, and the record fixed
377/// cost — the exact per-entry term `recompute_stats` walks.
378fn doc_record_bytes(key: &[u8], fields: &[IndexedField]) -> u64 {
379    let text: usize = fields.iter().map(|(t, _)| t.len() + 4).sum();
380    (2 * key.len() + text + 110) as u64
381}
382
383/// Aggregate token counts for one document's token stream.
384/// Weighted term frequencies across a document's fields, plus its
385/// unweighted length in tokens.
386///
387/// A weight multiplies the field's raw counts and the result is rounded
388/// up rather than truncated: a term that occurs once in a weight-0.5
389/// field still occurred, and rounding it to zero would delete a match
390/// rather than de-emphasise it.
391fn weighted_tf(fields: &[IndexedField]) -> (HashMap<Vec<u8>, u32>, u32) {
392    let (per_field, lens) = field_tf(fields);
393    merge_field_tf(&per_field, &lens)
394}
395
396/// One document's weighted term frequencies **kept per field**, plus each
397/// field's unweighted length in tokens.
398///
399/// This is the shape the per-field channel stores and the merged postings
400/// are the sum of; deriving both from one call is what guarantees the
401/// scoped and unscoped paths agree on what a field contributed.
402fn field_tf(fields: &[IndexedField]) -> (Vec<HashMap<Vec<u8>, u32>>, Vec<u32>) {
403    let mut per_field = Vec::with_capacity(fields.len());
404    let mut lens = Vec::with_capacity(fields.len());
405    for (text, weight) in fields {
406        let toks = tokenize(text);
407        lens.push(toks.len() as u32);
408        let scaled = tf_of(&toks)
409            .into_iter()
410            .map(|(t, n)| (t, (f64::from(n) * f64::from(*weight)).ceil().max(1.0) as u32))
411            .collect();
412        per_field.push(scaled);
413    }
414    (per_field, lens)
415}
416
417/// Fold a per-field breakdown back into the merged frequencies and length
418/// the ranking postings store.
419fn merge_field_tf(
420    per_field: &[HashMap<Vec<u8>, u32>],
421    lens: &[u32],
422) -> (HashMap<Vec<u8>, u32>, u32) {
423    let mut out: HashMap<Vec<u8>, u32> = HashMap::new();
424    for m in per_field {
425        for (t, v) in m {
426            let slot = out.entry(t.clone()).or_insert(0);
427            *slot = slot.saturating_add(*v);
428        }
429    }
430    let dl = lens.iter().fold(0u32, |a, &b| a.saturating_add(b));
431    (out, dl)
432}
433
434fn tf_of(toks: &[Vec<u8>]) -> HashMap<Vec<u8>, u32> {
435    let mut tf = HashMap::new();
436    for t in toks {
437        *tf.entry(t.clone()).or_insert(0) += 1;
438    }
439    tf
440}
441
442/// Each token's ascending offsets within the document's concatenated
443/// fields (field order). Positions are **unweighted** physical ordinals
444/// — like `dl`, they describe where the text is, not how it is scored —
445/// so a weight-3 title still advances the offset one per token.
446fn token_offsets(fields: &[IndexedField]) -> HashMap<Vec<u8>, Vec<u32>> {
447    let mut out: HashMap<Vec<u8>, Vec<u32>> = HashMap::new();
448    let mut pos = 0u32;
449    for (text, _weight) in fields {
450        for tok in tokenize(text) {
451            out.entry(tok).or_default().push(pos);
452            pos += 1;
453        }
454    }
455    out
456}
457
458#[path = "segment_opts.rs"]
459mod segment_opts;
460pub use segment_opts::{Bucket, Distinct, Facet, FacetedMatches, QueryOpts, Sort};
461
462#[path = "segment_query.rs"]
463mod segment_query;
464pub use segment_query::sorted_order;
465
466#[path = "segment_stats.rs"]
467mod segment_stats;
468
469#[path = "segment_phrase.rs"]
470mod segment_phrase;
471pub use segment_phrase::{Clauses, parse_clauses};
472pub(crate) use segment_phrase::{distinct_tokens, field_spans};
473
474#[path = "segment_scope.rs"]
475mod segment_scope;
476
477#[cfg(test)]
478#[path = "segment_tests.rs"]
479mod tests;