Skip to main content

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 =
146                stats.and_then(|s| s.df.get(t)).map(|&d| f64::from(d)).unwrap_or(list.len() as f64);
147            let max_tf = f64::from(list.max_tf());
148            lists.push((list, df, bm25_upper(max_tf, df, n_docs)));
149        }
150        lists.sort_by(|a, b| b.2.total_cmp(&a.2));
151        lists
152    }
153
154    /// Walk one list bucket-by-bucket (tf descending), with the
155    /// bucket-level and within-bucket (single-list) early stops.
156    fn walk_list(
157        &self,
158        list: &Buckets,
159        df: f64,
160        tail_next: f64,
161        single: bool,
162        ctx: &QueryCtx,
163        scores: &mut HashMap<u32, f64>,
164    ) {
165        let QueryCtx { n_docs, limit, .. } = *ctx;
166        let groups = list.tf_groups();
167        for (bi, (tf, bands)) in groups.iter().enumerate() {
168            // Bucket-level early stop: buckets are tf-descending,
169            // so once even the dl-free bound of THIS tf (plus
170            // everything later lists could add) can't reach the
171            // kth floor, no NEW doc from here on can enter. Docs
172            // already accumulated still need this list's
173            // contribution — the remaining buckets are PROBED for
174            // them (a key has exactly one tf per token, so no
175            // double count with earlier buckets).
176            if scores.len() >= limit {
177                let bound = bm25_upper(f64::from(*tf), df, n_docs);
178                if bound + tail_next < kth_of(scores, limit) {
179                    let walked_tfs: Vec<u32> = groups[..bi].iter().map(|(t, _)| *t).collect();
180                    self.probe_list(list, df, &walked_tfs, ctx, scores);
181                    break;
182                }
183            }
184            self.walk_bucket(*tf, bands, df, single, ctx, scores);
185        }
186    }
187
188    /// Walk one tf bucket's bands (dl ascending), scoring every id.
189    ///
190    /// Single-list within-bucket cut: bands are dl-ASCENDING and
191    /// BM25 falls as dl rises, so the band's LOWER dl edge bounds
192    /// every score inside it from above. On a one-list query — each
193    /// doc appears exactly ONCE in the whole list, no later
194    /// contribution to lose — the first band whose bound can't beat
195    /// the kth floor ends the bucket exactly. Scoring stays per-id
196    /// exact via the id_dl table; bands only gate the cut.
197    fn walk_bucket(
198        &self,
199        tf: u32,
200        bands: &BandsView<'_>,
201        df: f64,
202        single: bool,
203        ctx: &QueryCtx,
204        scores: &mut HashMap<u32, f64>,
205    ) {
206        let QueryCtx { n_docs, avgdl, limit } = *ctx;
207        for (b, band) in bands.iter() {
208            if band.is_empty() {
209                continue;
210            }
211            let bound =
212                bm25_score(f64::from(tf), df, n_docs, f64::from(BAND_MIN_DL[b as usize]), avgdl);
213            if single && scores.len() >= limit && bound < kth_of(scores, limit) {
214                break;
215            }
216            for &id in band {
217                let dl = f64::from(self.id_dl[id as usize]);
218                *scores.entry(id).or_insert(0.0) +=
219                    bm25_score(f64::from(tf), df, n_docs, dl, avgdl);
220            }
221        }
222    }
223
224    /// Contribute `list` to every ALREADY-ACCUMULATED doc via O(1)
225    /// list-level probes (never a walk). A doc whose tf sits in
226    /// `skip_tfs` already got this list's contribution from a WALKED
227    /// bucket (tf is unique per (token, doc)) and is skipped.
228    fn probe_list(
229        &self,
230        list: &Buckets,
231        df: f64,
232        skip_tfs: &[u32],
233        ctx: &QueryCtx,
234        scores: &mut HashMap<u32, f64>,
235    ) {
236        let ids: Vec<u32> = scores.keys().copied().collect();
237        for &id in &ids {
238            if let Some(tf) = list.get(id)
239                && !skip_tfs.contains(&tf)
240            {
241                let dl = f64::from(self.id_dl[id as usize]);
242                *scores.get_mut(&id).expect("this id was inserted by the first term's pass") +=
243                    bm25_score(f64::from(tf), df, ctx.n_docs, dl, ctx.avgdl);
244            }
245        }
246    }
247
248    /// Bounded selection: only the winners get cloned. Ids resolve
249    /// to keys here — the tiebreak (key ascending) is unchanged.
250    pub(crate) fn select_top(
251        &self,
252        scores: &HashMap<u32, f64>,
253        limit: usize,
254        filter: &[crate::Filter],
255        sort: Option<crate::Sort>,
256        distinct: Option<crate::Distinct>,
257    ) -> Vec<TextMatch> {
258        let order = Order { desc: sort.is_some_and(|s| s.desc), sorted: sort.is_some() };
259        let mut top = TopK::new(limit, order);
260        match distinct {
261            // The plain path stays streaming: a candidate that loses is
262            // dropped, never collected.
263            None => {
264                for (id, score) in scores {
265                    if let Some(c) = self.candidate(*id, *score, filter, sort) {
266                        top.push(c);
267                    }
268                }
269            }
270            Some(d) => {
271                for c in self.collapse(scores, filter, sort, d, order) {
272                    top.push(c);
273                }
274            }
275        }
276        top.finish()
277    }
278
279    /// One candidate, or `None` when a predicate rejects it.
280    ///
281    /// The candidate set is walked exactly once, so this is the cheapest
282    /// correct place to test a predicate and to build a sort key: testing
283    /// inside each term's accumulation would retest a document once per
284    /// query term.
285    fn candidate(
286        &self,
287        id: u32,
288        score: f64,
289        filter: &[crate::Filter],
290        sort: Option<crate::Sort>,
291    ) -> Option<Cand<'_>> {
292        if !self.passes(id, filter) {
293            return None;
294        }
295        Some(Cand {
296            score,
297            key: self.id_key[id as usize].as_deref().expect("a live id always has a key"),
298            okey: sort.and_then(|s| self.stored(id, s.field).and_then(s.key)),
299        })
300    }
301
302    /// One document's stored value for a field, by id.
303    fn stored(&self, id: u32, field: usize) -> Option<&[u8]> {
304        self.values.as_ref().and_then(|dv| dv.get(id, field))
305    }
306
307    /// One field's value counts over the match set, most frequent first.
308    ///
309    /// Predicates apply — a filtered-out document did not match — but
310    /// nothing else does: not the top-K, and not `DISTINCT`. The question
311    /// a facet answers is how many documents matched per value, and
312    /// collapsing decides which of them are shown, not which matched.
313    ///
314    /// Documents with no value for the field are in no bucket. A facet
315    /// reports the values that occur; absence is not one of them.
316    pub(crate) fn count_facet(
317        &self,
318        scores: &HashMap<u32, f64>,
319        filter: &[crate::Filter],
320        facet: crate::Facet,
321    ) -> Vec<crate::Bucket> {
322        let mut counts: HashMap<Vec<u8>, (Vec<u8>, u64)> = HashMap::new();
323        for id in scores.keys() {
324            if !self.passes(*id, filter) {
325                continue;
326            }
327            let Some(raw) = self.stored(*id, facet.field) else { continue };
328            let Some(k) = (facet.key)(raw) else { continue };
329            let e = counts.entry(k).or_insert_with(|| (raw.to_vec(), 0));
330            e.1 += 1;
331        }
332        let mut out: Vec<crate::Bucket> =
333            counts.into_iter().map(|(k, (label, n))| (k, label, n)).collect();
334        // Most frequent first, label breaking ties so two shards counting
335        // the same corpus report the same order.
336        out.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
337        out
338    }
339
340    /// The candidates with duplicates removed: at most one document per
341    /// value of the distinct field, the best of them by the page's order.
342    ///
343    /// A document with **no** value for the field is its own group.
344    /// `DISTINCT` removes documents shown to share a value; one that has
345    /// no value has not been shown to share anything, and collapsing them
346    /// together would hide rows on the strength of a value none of them
347    /// has.
348    fn collapse(
349        &self,
350        scores: &HashMap<u32, f64>,
351        filter: &[crate::Filter],
352        sort: Option<crate::Sort>,
353        distinct: crate::Distinct,
354        order: Order,
355    ) -> Vec<Cand<'_>> {
356        let mut best: HashMap<Vec<u8>, Cand> = HashMap::new();
357        let mut ungrouped: Vec<Cand> = Vec::new();
358        for (id, score) in scores {
359            let Some(c) = self.candidate(*id, *score, filter, sort) else { continue };
360            match self.stored(*id, distinct.field).and_then(distinct.key) {
361                Some(k) => match best.entry(k) {
362                    std::collections::hash_map::Entry::Occupied(mut e) => {
363                        if order.better(&c, e.get()) {
364                            e.insert(c);
365                        }
366                    }
367                    std::collections::hash_map::Entry::Vacant(v) => {
368                        v.insert(c);
369                    }
370                },
371                None => ungrouped.push(c),
372            }
373        }
374        best.into_values().chain(ungrouped).collect()
375    }
376
377    /// Whether `id` satisfies every predicate (they are ANDed). A
378    /// document with no value for a filtered field never passes: absent
379    /// is not a value, and treating it as one would let rows that simply
380    /// lack the field slip through a range test.
381    fn passes(&self, id: u32, filter: &[crate::Filter]) -> bool {
382        if filter.is_empty() {
383            return true;
384        }
385        let Some(dv) = self.values.as_ref() else { return false };
386        filter.iter().all(|f| dv.get(id, f.field).is_some_and(|v| (f.test)(v)))
387    }
388}
389
390/// The `limit`-th best score currently accumulated (the MaxScore
391/// entry floor). O(n) selection, called only between list walks.
392fn kth_of(scores: &HashMap<u32, f64>, limit: usize) -> f64 {
393    let mut v: Vec<f64> = scores.values().copied().collect();
394    let idx = limit - 1;
395    v.select_nth_unstable_by(idx, |a, b| b.total_cmp(a));
396    v[idx]
397}
398
399/// `tail_ub[i]` = Σ upper bounds of `lists[i..]`.
400fn tail_bounds(lists: &[ScoredList<'_>]) -> Vec<f64> {
401    let mut acc = 0.0;
402    let mut v: Vec<f64> = lists
403        .iter()
404        .rev()
405        .map(|l| {
406            acc += l.2;
407            acc
408        })
409        .collect();
410    v.reverse();
411    v
412}