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;