kevy_text/segment_query.rs
1//! The read/query path of [`TextSegment`] — BM25-ranked `matches` with
2//! MaxScore pruning — split from `segment.rs` for the 500-LOC house
3//! rule. A child module (declared via `#[path]` in `segment.rs`), so it
4//! reaches the segment's private fields; `corpus_stats` and `select_top`
5//! are `pub(crate)` because the sibling phrase path (`segment_phrase`)
6//! reuses them.
7//!
8//! The MaxScore machinery is unchanged from when it lived in
9//! `segment.rs` — the split moves code, it does not touch the walk.
10
11use std::collections::HashMap;
12
13#[path = "segment_select.rs"]
14mod segment_select;
15pub use segment_select::sorted_order;
16use segment_select::{Cand, Order, TopK};
17
18use super::{CorpusStats, TextMatch, TextSegment};
19use crate::bm25::{bm25_score, bm25_upper};
20use crate::buckets::{BAND_MIN_DL, BandsView, Buckets};
21
22/// One scoring candidate list: (postings, df, MaxScore upper bound).
23type ScoredList<'s> = (&'s Buckets, f64, f64);
24
25/// Per-query BM25 constants threaded through the walk helpers.
26struct QueryCtx {
27 n_docs: f64,
28 avgdl: f64,
29 limit: usize,
30}
31
32impl TextSegment {
33 /// BM25-ranked matches for `query` (tokenized with the same rules;
34 /// OR semantics), best `limit` hits, score-descending.
35 ///
36 /// MaxScore pruning: query tokens process rarest-first; once the
37 /// running top-`limit` threshold exceeds the summed upper bounds
38 /// of the remaining (commoner) tokens, documents seen ONLY in
39 /// those lists can no longer enter — their lists are then probed
40 /// per accumulated doc instead of walked. Selection is a bounded
41 /// heap over borrowed keys (no per-candidate allocation).
42 pub fn matches(&self, query: &[u8], limit: usize) -> Vec<TextMatch> {
43 self.matches_scored(query, limit, None)
44 }
45
46 /// [`TextSegment::matches`], scored against externally-supplied
47 /// corpus statistics instead of this shard's local ones.
48 ///
49 /// `None` uses the local stats — the shard-local BM25 that `matches`
50 /// has always used, byte-identical. `Some` is the global-BM25 path:
51 /// a cross-shard query aggregates each shard's `n_docs`, `avgdl` and
52 /// per-query-token `df` into one [`CorpusStats`] and scores every
53 /// shard against it, so hits from different shards are comparable.
54 /// The MaxScore upper bound uses the same injected numbers, so
55 /// pruning stays a valid bound.
56 ///
57 /// A query token absent from THIS shard's postings contributes no
58 /// score here regardless — its documents live on other shards — so
59 /// only the idf (via global df) crosses shard boundaries, never a
60 /// posting.
61 pub fn matches_scored(
62 &self,
63 query: &[u8],
64 limit: usize,
65 stats: Option<&CorpusStats>,
66 ) -> Vec<TextMatch> {
67 // Top-0 of anything is empty (same convention as kevy-vector's
68 // `knn` with k = 0). Also keeps the MaxScore floor well-defined:
69 // `kth_of` indexes `limit - 1`.
70 if limit == 0 {
71 return Vec::new();
72 }
73 let mut q_tokens = crate::token::tokenize(query);
74 q_tokens.sort();
75 q_tokens.dedup();
76 if q_tokens.is_empty() || self.docs.is_empty() {
77 return Vec::new();
78 }
79 let (n_docs, avgdl) = self.corpus_stats(stats);
80 let lists = self.scored_lists(&q_tokens, n_docs, stats);
81 if lists.is_empty() {
82 return Vec::new();
83 }
84 let ctx = QueryCtx { n_docs, avgdl, limit };
85 let scores = self.accumulate(&lists, &ctx);
86 self.select_top(&scores, limit, &[], None, None)
87 }
88
89 /// Corpus `(n_docs, avgdl)`: injected global stats when supplied,
90 /// this shard's local totals otherwise.
91 pub(crate) fn corpus_stats(&self, stats: Option<&CorpusStats>) -> (f64, f64) {
92 match stats {
93 Some(s) => (s.n_docs, s.avgdl),
94 None => {
95 let n = self.docs.len() as f64;
96 (n, self.total_len as f64 / n)
97 }
98 }
99 }
100
101 /// MaxScore accumulation: walk lists rarest-first with the tail-bound
102 /// early stop, then probe the un-walked lists per accumulated doc
103 /// (O(candidates) gets, never a walk of the common list — that walk
104 /// was the measured 30ms p95). Returns id → score.
105 fn accumulate(&self, lists: &[ScoredList<'_>], ctx: &QueryCtx) -> HashMap<u32, f64> {
106 let tail_ub = tail_bounds(lists);
107 let mut scores: HashMap<u32, f64> = HashMap::new();
108 let mut kth_threshold = 0.0_f64;
109 let mut walked = 0usize;
110 for (i, (list, df, _ub)) in lists.iter().enumerate() {
111 // A doc seen only in the remaining lists can't reach the
112 // top-limit floor → stop WALKING; the probe loop below still
113 // credits these lists to already-seen docs.
114 if i > 0 && scores.len() >= ctx.limit && tail_ub[i] < kth_threshold {
115 break;
116 }
117 walked = i + 1;
118 let tail_next = tail_ub.get(i + 1).copied().unwrap_or(0.0);
119 self.walk_list(list, *df, tail_next, lists.len() == 1, ctx, &mut scores);
120 if scores.len() >= ctx.limit && i + 1 < lists.len() {
121 kth_threshold = kth_of(&scores, ctx.limit);
122 }
123 }
124 for (list, df, _) in &lists[walked..] {
125 self.probe_list(list, *df, &[], ctx, &mut scores);
126 }
127 scores
128 }
129
130 /// The candidate lists for a query, rarest (highest upper bound)
131 /// first. The bound is dl-independent: denom ≥ tf + k1(1-b), so
132 /// score ≤ idf·tf(k1+1)/(tf + k1(1-b)).
133 fn scored_lists<'s>(
134 &'s self,
135 q_tokens: &[Vec<u8>],
136 n_docs: f64,
137 stats: Option<&CorpusStats>,
138 ) -> Vec<ScoredList<'s>> {
139 let mut lists: Vec<ScoredList<'s>> = Vec::new();
140 for t in q_tokens {
141 let Some(list) = self.postings.get(t) else { continue };
142 // Global df when supplied — the whole point of the injected
143 // stats. Falls back to the local list length, which is what
144 // the shard-local path always used.
145 let df = stats
146 .and_then(|s| s.df.get(t))
147 .map(|&d| f64::from(d))
148 .unwrap_or(list.len() as f64);
149 let max_tf = f64::from(list.max_tf());
150 lists.push((list, df, bm25_upper(max_tf, df, n_docs)));
151 }
152 lists.sort_by(|a, b| b.2.total_cmp(&a.2));
153 lists
154 }
155
156 /// Walk one list bucket-by-bucket (tf descending), with the
157 /// bucket-level and within-bucket (single-list) early stops.
158 fn walk_list(
159 &self,
160 list: &Buckets,
161 df: f64,
162 tail_next: f64,
163 single: bool,
164 ctx: &QueryCtx,
165 scores: &mut HashMap<u32, f64>,
166 ) {
167 let QueryCtx { n_docs, limit, .. } = *ctx;
168 let groups = list.tf_groups();
169 for (bi, (tf, bands)) in groups.iter().enumerate() {
170 // Bucket-level early stop: buckets are tf-descending,
171 // so once even the dl-free bound of THIS tf (plus
172 // everything later lists could add) can't reach the
173 // kth floor, no NEW doc from here on can enter. Docs
174 // already accumulated still need this list's
175 // contribution — the remaining buckets are PROBED for
176 // them (a key has exactly one tf per token, so no
177 // double count with earlier buckets).
178 if scores.len() >= limit {
179 let bound = bm25_upper(f64::from(*tf), df, n_docs);
180 if bound + tail_next < kth_of(scores, limit) {
181 let walked_tfs: Vec<u32> =
182 groups[..bi].iter().map(|(t, _)| *t).collect();
183 self.probe_list(list, df, &walked_tfs, ctx, scores);
184 break;
185 }
186 }
187 self.walk_bucket(*tf, bands, df, single, ctx, scores);
188 }
189 }
190
191 /// Walk one tf bucket's bands (dl ascending), scoring every id.
192 ///
193 /// Single-list within-bucket cut: bands are dl-ASCENDING and
194 /// BM25 falls as dl rises, so the band's LOWER dl edge bounds
195 /// every score inside it from above. On a one-list query — each
196 /// doc appears exactly ONCE in the whole list, no later
197 /// contribution to lose — the first band whose bound can't beat
198 /// the kth floor ends the bucket exactly. Scoring stays per-id
199 /// exact via the id_dl table; bands only gate the cut.
200 fn walk_bucket(
201 &self,
202 tf: u32,
203 bands: &BandsView<'_>,
204 df: f64,
205 single: bool,
206 ctx: &QueryCtx,
207 scores: &mut HashMap<u32, f64>,
208 ) {
209 let QueryCtx { n_docs, avgdl, limit } = *ctx;
210 for (b, band) in bands.iter() {
211 if band.is_empty() {
212 continue;
213 }
214 let bound = bm25_score(
215 f64::from(tf),
216 df,
217 n_docs,
218 f64::from(BAND_MIN_DL[b as usize]),
219 avgdl,
220 );
221 if single && scores.len() >= limit && bound < kth_of(scores, limit) {
222 break;
223 }
224 for &id in band {
225 let dl = f64::from(self.id_dl[id as usize]);
226 *scores.entry(id).or_insert(0.0) +=
227 bm25_score(f64::from(tf), df, n_docs, dl, avgdl);
228 }
229 }
230 }
231
232 /// Contribute `list` to every ALREADY-ACCUMULATED doc via O(1)
233 /// list-level probes (never a walk). A doc whose tf sits in
234 /// `skip_tfs` already got this list's contribution from a WALKED
235 /// bucket (tf is unique per (token, doc)) and is skipped.
236 fn probe_list(
237 &self,
238 list: &Buckets,
239 df: f64,
240 skip_tfs: &[u32],
241 ctx: &QueryCtx,
242 scores: &mut HashMap<u32, f64>,
243 ) {
244 let ids: Vec<u32> = scores.keys().copied().collect();
245 for &id in &ids {
246 if let Some(tf) = list.get(id)
247 && !skip_tfs.contains(&tf)
248 {
249 let dl = f64::from(self.id_dl[id as usize]);
250 *scores.get_mut(&id).expect("accumulated") +=
251 bm25_score(f64::from(tf), df, ctx.n_docs, dl, ctx.avgdl);
252 }
253 }
254 }
255
256 /// Bounded selection: only the winners get cloned. Ids resolve
257 /// to keys here — the tiebreak (key ascending) is unchanged.
258 pub(crate) fn select_top(
259 &self,
260 scores: &HashMap<u32, f64>,
261 limit: usize,
262 filter: &[crate::Filter],
263 sort: Option<crate::Sort>,
264 distinct: Option<crate::Distinct>,
265 ) -> Vec<TextMatch> {
266 let order = Order { desc: sort.is_some_and(|s| s.desc), sorted: sort.is_some() };
267 let mut top = TopK::new(limit, order);
268 match distinct {
269 // The plain path stays streaming: a candidate that loses is
270 // dropped, never collected.
271 None => {
272 for (id, score) in scores {
273 if let Some(c) = self.candidate(*id, *score, filter, sort) {
274 top.push(c);
275 }
276 }
277 }
278 Some(d) => {
279 for c in self.collapse(scores, filter, sort, d, order) {
280 top.push(c);
281 }
282 }
283 }
284 top.finish()
285 }
286
287 /// One candidate, or `None` when a predicate rejects it.
288 ///
289 /// The candidate set is walked exactly once, so this is the cheapest
290 /// correct place to test a predicate and to build a sort key: testing
291 /// inside each term's accumulation would retest a document once per
292 /// query term.
293 fn candidate(
294 &self,
295 id: u32,
296 score: f64,
297 filter: &[crate::Filter],
298 sort: Option<crate::Sort>,
299 ) -> Option<Cand<'_>> {
300 if !self.passes(id, filter) {
301 return None;
302 }
303 Some(Cand {
304 score,
305 key: self.id_key[id as usize].as_deref().expect("live posting id"),
306 okey: sort.and_then(|s| self.stored(id, s.field).and_then(s.key)),
307 })
308 }
309
310 /// One document's stored value for a field, by id.
311 fn stored(&self, id: u32, field: usize) -> Option<&[u8]> {
312 self.values.as_ref().and_then(|dv| dv.get(id, field))
313 }
314
315 /// One field's value counts over the match set, most frequent first.
316 ///
317 /// Predicates apply — a filtered-out document did not match — but
318 /// nothing else does: not the top-K, and not `DISTINCT`. The question
319 /// a facet answers is how many documents matched per value, and
320 /// collapsing decides which of them are shown, not which matched.
321 ///
322 /// Documents with no value for the field are in no bucket. A facet
323 /// reports the values that occur; absence is not one of them.
324 pub(crate) fn count_facet(
325 &self,
326 scores: &HashMap<u32, f64>,
327 filter: &[crate::Filter],
328 facet: crate::Facet,
329 ) -> Vec<crate::Bucket> {
330 let mut counts: HashMap<Vec<u8>, (Vec<u8>, u64)> = HashMap::new();
331 for id in scores.keys() {
332 if !self.passes(*id, filter) {
333 continue;
334 }
335 let Some(raw) = self.stored(*id, facet.field) else { continue };
336 let Some(k) = (facet.key)(raw) else { continue };
337 let e = counts.entry(k).or_insert_with(|| (raw.to_vec(), 0));
338 e.1 += 1;
339 }
340 let mut out: Vec<crate::Bucket> =
341 counts.into_iter().map(|(k, (label, n))| (k, label, n)).collect();
342 // Most frequent first, label breaking ties so two shards counting
343 // the same corpus report the same order.
344 out.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
345 out
346 }
347
348 /// The candidates with duplicates removed: at most one document per
349 /// value of the distinct field, the best of them by the page's order.
350 ///
351 /// A document with **no** value for the field is its own group.
352 /// `DISTINCT` removes documents shown to share a value; one that has
353 /// no value has not been shown to share anything, and collapsing them
354 /// together would hide rows on the strength of a value none of them
355 /// has.
356 fn collapse(
357 &self,
358 scores: &HashMap<u32, f64>,
359 filter: &[crate::Filter],
360 sort: Option<crate::Sort>,
361 distinct: crate::Distinct,
362 order: Order,
363 ) -> Vec<Cand<'_>> {
364 let mut best: HashMap<Vec<u8>, Cand> = HashMap::new();
365 let mut ungrouped: Vec<Cand> = Vec::new();
366 for (id, score) in scores {
367 let Some(c) = self.candidate(*id, *score, filter, sort) else { continue };
368 match self.stored(*id, distinct.field).and_then(distinct.key) {
369 Some(k) => match best.entry(k) {
370 std::collections::hash_map::Entry::Occupied(mut e) => {
371 if order.better(&c, e.get()) {
372 e.insert(c);
373 }
374 }
375 std::collections::hash_map::Entry::Vacant(v) => {
376 v.insert(c);
377 }
378 },
379 None => ungrouped.push(c),
380 }
381 }
382 best.into_values().chain(ungrouped).collect()
383 }
384
385 /// Whether `id` satisfies every predicate (they are ANDed). A
386 /// document with no value for a filtered field never passes: absent
387 /// is not a value, and treating it as one would let rows that simply
388 /// lack the field slip through a range test.
389 fn passes(&self, id: u32, filter: &[crate::Filter]) -> bool {
390 if filter.is_empty() {
391 return true;
392 }
393 let Some(dv) = self.values.as_ref() else { return false };
394 filter.iter().all(|f| dv.get(id, f.field).is_some_and(|v| (f.test)(v)))
395 }
396}
397
398/// The `limit`-th best score currently accumulated (the MaxScore
399/// entry floor). O(n) selection, called only between list walks.
400fn kth_of(scores: &HashMap<u32, f64>, limit: usize) -> f64 {
401 let mut v: Vec<f64> = scores.values().copied().collect();
402 let idx = limit - 1;
403 v.select_nth_unstable_by(idx, |a, b| b.total_cmp(a));
404 v[idx]
405}
406
407/// `tail_ub[i]` = Σ upper bounds of `lists[i..]`.
408fn tail_bounds(lists: &[ScoredList<'_>]) -> Vec<f64> {
409 let mut acc = 0.0;
410 let mut v: Vec<f64> = lists
411 .iter()
412 .rev()
413 .map(|l| {
414 acc += l.2;
415 acc
416 })
417 .collect();
418 v.reverse();
419 v
420}