kevy_text/segment_scope.rs
1//! What one query clause contributes to a document's score — the
2//! accumulation half of the query engine, shared by the whole-document
3//! path and the field-scoped (`IN <field…>`) one. A child module of
4//! `segment` (declared via `#[path]`), so it reaches `TextSegment`'s
5//! private fields.
6//!
7//! Both paths run the *same* clause engine; only where a frequency and a
8//! length come from differs. Unscoped, they come from the merged
9//! postings, exactly as before this module existed. Scoped, they come
10//! from the per-field channel ([`crate::fields`]): the term frequency is
11//! the sum over the wanted fields only, the length is those fields'
12//! length only, and the document frequency counts documents holding the
13//! term *in those fields* — a real field-scoped BM25, not a filter over
14//! whole-document scores.
15
16use std::collections::HashMap;
17
18use super::{CorpusStats, TextSegment};
19use crate::bm25::bm25_score;
20
21/// The context a clause accumulates against: the corpus statistics to
22/// score with, and which fields the query is restricted to.
23#[derive(Clone, Copy)]
24pub(crate) struct Scope<'a> {
25 pub(crate) stats: Option<&'a CorpusStats>,
26 pub(crate) n_docs: f64,
27 pub(crate) avgdl: f64,
28 /// Field positions the query is restricted to (`IN`); empty = the
29 /// whole document, scored from the merged postings.
30 pub(crate) want: &'a [usize],
31}
32
33impl Scope<'_> {
34 /// Whether this query is restricted to a subset of the fields.
35 pub(crate) fn scoped(&self) -> bool {
36 !self.want.is_empty()
37 }
38
39 /// `t`'s document frequency: the corpus-wide value when the query
40 /// carries one (the cross-shard second pass), else this segment's own
41 /// count.
42 fn df(&self, t: &[u8], local: usize) -> f64 {
43 self.stats.and_then(|s| s.df.get(t)).map(|&d| f64::from(d)).unwrap_or(local as f64)
44 }
45}
46
47impl TextSegment {
48 /// The corpus size and average document length to score with, over
49 /// the scope's fields.
50 ///
51 /// Injected statistics are used as given: the first pass computed
52 /// them over the same field scope, so a scoped query's `avgdl` is
53 /// already the average length *of those fields*.
54 pub(crate) fn scope_stats(&self, stats: Option<&CorpusStats>, want: &[usize]) -> (f64, f64) {
55 if want.is_empty() || stats.is_some() {
56 return self.corpus_stats(stats);
57 }
58 let n = self.docs.len() as f64;
59 (n, self.total_len_in(want) as f64 / n)
60 }
61
62 /// Corpus token total over `fields` — the numerator of a field-scoped
63 /// average document length, and what each shard reports in the first
64 /// pass of a scoped cross-shard query. Empty `fields` = the whole
65 /// document.
66 pub fn total_len_in(&self, fields: &[usize]) -> u64 {
67 match self.fields.as_ref() {
68 Some(fs) if !fields.is_empty() => fs.total_len_in(fields),
69 _ => self.total_len,
70 }
71 }
72
73 /// Add one term's contribution with typo tolerance: the OR of every
74 /// dictionary term within `budget` edits. With `budget` 0 this is
75 /// exactly [`Self::add_term`], so the exact path stays untouched.
76 pub(crate) fn add_typo(
77 &self,
78 t: &[u8],
79 budget: u32,
80 scores: &mut HashMap<u32, f64>,
81 sc: &Scope,
82 ) {
83 if budget == 0 {
84 self.add_term(t, scores, sc);
85 return;
86 }
87 for cand in self.expand_typo(t, budget) {
88 self.add_term(cand, scores, sc);
89 }
90 }
91
92 /// Add one prefix clause's contribution: the OR of every expansion
93 /// term (already-lowercased `pfx` matched against the stored token
94 /// form). Scanning the dictionary is the cost an ordered dictionary
95 /// would replace with a binary search.
96 pub(crate) fn add_prefix(&self, pfx: &[u8], scores: &mut HashMap<u32, f64>, sc: &Scope) {
97 for t in self.expand_prefix(pfx) {
98 self.add_term(t, scores, sc);
99 }
100 }
101
102 /// Add one bare term's BM25 contribution to every document that holds
103 /// it — a full-list walk (no MaxScore pruning), used only on the
104 /// phrase path where a phrase clause could otherwise boost a document
105 /// pruning would have dropped.
106 pub(crate) fn add_term(&self, t: &[u8], scores: &mut HashMap<u32, f64>, sc: &Scope) {
107 if sc.scoped() {
108 self.add_term_scoped(t, scores, sc);
109 return;
110 }
111 let Some(list) = self.postings.get(t) else { return };
112 let df = sc.df(t, list.len());
113 for (tf, bands) in list.tf_groups() {
114 for (_b, band) in bands.iter() {
115 for &id in band {
116 let dl = f64::from(self.id_dl[id as usize]);
117 *scores.entry(id).or_insert(0.0) +=
118 bm25_score(f64::from(tf), df, sc.n_docs, dl, sc.avgdl);
119 }
120 }
121 }
122 }
123
124 /// The field-scoped counterpart: walk the per-field channel, keeping
125 /// documents that hold `t` in one of the wanted fields and scoring
126 /// each by those fields' frequency and length alone.
127 fn add_term_scoped(&self, t: &[u8], scores: &mut HashMap<u32, f64>, sc: &Scope) {
128 let Some(fs) = self.fields.as_ref() else { return };
129 let docs = fs.docs_in(t, sc.want);
130 let df = sc.df(t, docs.len());
131 for (id, tf) in docs {
132 let dl = f64::from(fs.doc_len_in(id, sc.want));
133 *scores.entry(id).or_insert(0.0) +=
134 bm25_score(f64::from(tf), df, sc.n_docs, dl, sc.avgdl);
135 }
136 }
137
138 /// One document's summed BM25 over a set of distinct tokens — what an
139 /// AND query over them would score it, and so what a phrase
140 /// occurrence is worth.
141 pub(crate) fn clause_score(&self, distinct: &[Vec<u8>], id: u32, sc: &Scope) -> f64 {
142 if sc.scoped() {
143 return self.clause_score_scoped(distinct, id, sc);
144 }
145 let dl = f64::from(self.id_dl[id as usize]);
146 let mut score = 0.0;
147 for t in distinct {
148 let Some(list) = self.postings.get(t) else { continue };
149 let Some(tf) = list.get(id) else { continue };
150 score += bm25_score(f64::from(tf), sc.df(t, list.len()), sc.n_docs, dl, sc.avgdl);
151 }
152 score
153 }
154
155 /// The field-scoped counterpart of [`Self::clause_score`].
156 fn clause_score_scoped(&self, distinct: &[Vec<u8>], id: u32, sc: &Scope) -> f64 {
157 let Some(fs) = self.fields.as_ref() else { return 0.0 };
158 let dl = f64::from(fs.doc_len_in(id, sc.want));
159 let mut score = 0.0;
160 for t in distinct {
161 let docs = fs.docs_in(t, sc.want);
162 let Some(&(_, tf)) = docs.iter().find(|(d, _)| *d == id) else { continue };
163 score += bm25_score(f64::from(tf), sc.df(t, docs.len()), sc.n_docs, dl, sc.avgdl);
164 }
165 score
166 }
167
168 /// The dictionary terms beginning with `pfx` (already lowercased),
169 /// sorted for a deterministic ranking tiebreak.
170 ///
171 /// The first byte is checked inline before `starts_with`, which for a
172 /// runtime-length needle is a `memcmp` **call** per term. This scan
173 /// visits the whole dictionary, so that call is made once per indexed
174 /// term per prefix clause, and a profile of the prefix workload puts
175 /// 55% of query time inside `__memcmp_avx2_movbe`. One byte rejects
176 /// nearly all of them without the call.
177 pub(crate) fn expand_prefix(&self, pfx: &[u8]) -> Vec<&[u8]> {
178 // An empty prefix matches everything, which is what `starts_with`
179 // did and what the head-byte filter would not: callers never send
180 // one, but the shortcut must not be where that stops being true.
181 let mut e: Vec<&[u8]> = match pfx.first() {
182 None => self.postings.keys().map(Vec::as_slice).collect(),
183 Some(&head) => self
184 .postings
185 .keys()
186 .map(Vec::as_slice)
187 .filter(|t| t.first() == Some(&head) && t.starts_with(pfx))
188 .collect(),
189 };
190 e.sort_unstable();
191 e
192 }
193
194 /// The dictionary terms within `budget` edits of `t` — the typo
195 /// tolerance expansion, including `t` itself when it is indexed.
196 /// Sorted for a deterministic ranking tiebreak.
197 pub(crate) fn expand_typo(&self, t: &[u8], budget: u32) -> Vec<&[u8]> {
198 let mut e: Vec<&[u8]> = self
199 .postings
200 .keys()
201 .map(Vec::as_slice)
202 .filter(|cand| crate::edit::edit_within(t, cand, budget).is_some())
203 .collect();
204 e.sort_unstable();
205 e
206 }
207
208 /// The phrase token with the fewest postings — the tightest candidate
209 /// set, since every phrase token must appear in a matching document,
210 /// so anchoring the scan on the rarest one avoids walking a head
211 /// term's whole list. `None` if any token is absent (the phrase then
212 /// matches nothing).
213 pub(crate) fn rarest_anchor<'a>(&self, toks: &'a [Vec<u8>]) -> Option<&'a [u8]> {
214 let mut best: Option<(&'a [u8], usize)> = None;
215 for t in toks {
216 let df = self.postings.get(t)?.len();
217 if best.is_none_or(|(_, b)| df < b) {
218 best = Some((t, df));
219 }
220 }
221 best.map(|(t, _)| t)
222 }
223
224 /// Whether a phrase occupying `[start, start + len)` of `id`'s token
225 /// stream lies entirely inside one of the scope's fields.
226 ///
227 /// Positions are ordinals over the document's concatenated fields, so
228 /// a field is a contiguous range of them: a scoped phrase must fall
229 /// within a single wanted field's range, which also rejects a
230 /// "phrase" that only reads as adjacent because one field's last
231 /// token abuts the next field's first.
232 pub(crate) fn phrase_in_scope(&self, id: u32, start: u32, len: u32, want: &[usize]) -> bool {
233 let Some(fs) = self.fields.as_ref() else { return false };
234 let lens = fs.doc_lens(id);
235 let mut base = 0u32;
236 for (f, &n) in lens.iter().enumerate() {
237 if want.contains(&f) && start >= base && start + len <= base + n {
238 return true;
239 }
240 base += n;
241 }
242 false
243 }
244}