Skip to main content

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}