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 = 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}