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;