Skip to main content

kevy_text/
segment_phrase.rs

1//! What a query *means* — clause parsing plus the phrase / prefix /
2//! typo / field-scoped entry points. A child module of `segment`
3//! (declared via `#[path]`), so it reaches `TextSegment`'s private
4//! fields and helpers. What each clause *contributes* to a score lives
5//! next door in `segment_scope`.
6//!
7//! A phrase is an AND of its terms with an adjacency constraint: it
8//! scores with the same BM25 sum an AND query would use, restricted to
9//! documents where the tokens occur consecutively and in order. Without
10//! positions (`WITH POSITIONS` was not set) nothing is verifiable, so a
11//! phrase query returns empty rather than silently degrading to OR.
12
13use crate::clauses::parse_clauses;
14use std::collections::{HashMap, HashSet};
15
16use super::segment_scope::Scope;
17use super::{CorpusStats, QueryOpts, TextMatch, TextSegment};
18use crate::positions::{Positions, walk};
19use crate::token::{tokenize, tokenize_spans};
20
21/// Whether the query is the ordinary pruned term query — no phrase, prefix,
22/// typo, field, filter, sort, distinct or facet clause.
23///
24/// Split out of `matches_query_faceted` for the 50-line rule. It reads
25/// better as a name than as eight `&&`s, and the name is the thing the
26/// comment underneath it used to have to say.
27fn is_plain_term_query(
28    phrases: &[Vec<Vec<u8>>],
29    prefixes: &[Vec<u8>],
30    want: &[usize],
31    opts: &QueryOpts,
32    facets: &[crate::Facet],
33) -> bool {
34    phrases.is_empty()
35        && prefixes.is_empty()
36        && opts.typo == 0
37        && want.is_empty()
38        && opts.filter.is_empty()
39        && opts.sort.is_none()
40        && opts.distinct.is_none()
41        && facets.is_empty()
42}
43
44impl TextSegment {
45    /// BM25-ranked documents that contain `phrase`'s tokens **adjacent
46    /// and in order**, best `limit` hits, score-descending.
47    ///
48    /// A single-token phrase is an ordinary term query (adjacency is
49    /// trivial). A multi-token phrase needs the positional side-channel:
50    /// on a segment created without positions it returns empty. `stats`
51    /// injects global corpus statistics (the two-pass cross-shard path);
52    /// `None` scores shard-local.
53    pub fn phrase_matches(
54        &self,
55        phrase: &[u8],
56        limit: usize,
57        stats: Option<&CorpusStats>,
58    ) -> Vec<TextMatch> {
59        if limit == 0 {
60            return Vec::new();
61        }
62        let toks = tokenize(phrase);
63        match toks.len() {
64            0 => return Vec::new(),
65            1 => return self.matches_scored(phrase, limit, stats),
66            _ => {}
67        }
68        if self.positions.is_none() {
69            return Vec::new();
70        }
71        let (n_docs, avgdl) = self.corpus_stats(stats);
72        let sc = Scope { stats, n_docs, avgdl, want: &[] };
73        let mut scores: HashMap<u32, f64> = HashMap::new();
74        self.add_phrase(&toks, &mut scores, &sc);
75        self.select_top(&scores, limit, &[], None, None)
76    }
77
78    /// BM25-ranked matches for a query `text` that may mix bare terms and
79    /// double-quoted phrases (`foo "quick brown" bar`), best `limit` hits.
80    ///
81    /// The query is the OR of its clauses — each bare term and each
82    /// phrase — scored by the summed BM25 an OR query would give, with a
83    /// phrase clause contributing only to documents where its tokens are
84    /// adjacent. With no quoted phrase in `text` this is byte-identical
85    /// to [`TextSegment::matches_scored`] (the pruned hot path); the
86    /// phrase branch trades that pruning for exactness and is what the
87    /// positional side-channel exists for. `stats` injects global corpus
88    /// statistics (the cross-shard path); `None` scores shard-local.
89    pub fn matches_query(
90        &self,
91        text: &[u8],
92        limit: usize,
93        stats: Option<&CorpusStats>,
94    ) -> Vec<TextMatch> {
95        self.matches_query_typo(text, limit, stats, 0)
96    }
97
98    /// [`TextSegment::matches_query`] with a typo budget: each bare term
99    /// also matches the dictionary terms within `typo` edits of it
100    /// (`TYPO n`). A budget of 0 is the exact query, byte-identical.
101    ///
102    /// Only bare terms are fuzzed — a phrase asks for those exact tokens
103    /// adjacent, and a prefix is already an inexact match, so widening
104    /// either would answer a question the user did not ask.
105    pub fn matches_query_typo(
106        &self,
107        text: &[u8],
108        limit: usize,
109        stats: Option<&CorpusStats>,
110        typo: u32,
111    ) -> Vec<TextMatch> {
112        self.matches_query_with(text, limit, QueryOpts { stats, typo, ..QueryOpts::default() })
113    }
114
115    /// [`TextSegment::matches_query`] with every option a MATCH carries:
116    /// injected corpus statistics, a typo budget, the field positions the
117    /// query is restricted to (`IN <field…>`, empty = every field), and
118    /// the non-scoring predicates it must satisfy (`FILTER`).
119    ///
120    /// A scoped query is a *field-scoped BM25*, not a filter over
121    /// whole-document scores: frequency, length and document frequency
122    /// all come from the wanted fields alone, so a match in a short title
123    /// is not diluted by a long body that never mentioned the term.
124    pub fn matches_query_with(&self, text: &[u8], limit: usize, opts: QueryOpts) -> Vec<TextMatch> {
125        self.matches_query_faceted(text, limit, opts, &[]).hits
126    }
127
128    /// [`TextSegment::matches_query_with`], additionally counting the
129    /// values of stored fields over the **whole match set**.
130    ///
131    /// Counted before the top-K, because a facet is about what matched
132    /// and the page is only `limit` of it. `FILTER` restricts the count —
133    /// a filtered-out document did not match — but `DISTINCT` does not:
134    /// collapsing decides which documents are shown, not which matched.
135    pub fn matches_query_faceted(
136        &self,
137        text: &[u8],
138        limit: usize,
139        opts: QueryOpts,
140        facets: &[crate::Facet],
141    ) -> crate::FacetedMatches {
142        let empty =
143            || crate::FacetedMatches { hits: Vec::new(), facets: vec![Vec::new(); facets.len()] };
144        if limit == 0 {
145            return empty();
146        }
147        let Some(want) = self.normalize_scope(opts.fields) else {
148            return empty();
149        };
150        let (bare, phrases, prefixes) = parse_clauses(text);
151        if is_plain_term_query(&phrases, &prefixes, &want, &opts, facets) {
152            return crate::FacetedMatches {
153                hits: self.matches_scored(text, limit, opts.stats),
154                facets: Vec::new(),
155            };
156        }
157        // A filtered or sorted query takes the full walk deliberately.
158        // MaxScore
159        // prunes against the k-th best score SO FAR, computed over
160        // unfiltered candidates: if the unfiltered leaders are the ones
161        // the predicate rejects, the qualifying documents behind them may
162        // never be accumulated at all. Pruning would not merely rank them
163        // wrongly — it would lose them. A sort is the same hazard read
164        // the other way: the buckets are ordered by score, which under
165        // SORT is not what decides the page at all.
166        if self.docs.is_empty() {
167            return empty();
168        }
169        let scores = self.accumulate_clauses(bare, &phrases, &prefixes, &want, &opts);
170        crate::FacetedMatches {
171            facets: facets.iter().map(|f| self.count_facet(&scores, opts.filter, *f)).collect(),
172            hits: self.select_top(&scores, limit, opts.filter, opts.sort, opts.distinct),
173        }
174    }
175
176    /// Every clause's BM25 contribution, accumulated over the whole
177    /// candidate set — the un-pruned walk the phrase, prefix, typo,
178    /// field-scoped, filtered and sorted paths all share.
179    fn accumulate_clauses(
180        &self,
181        bare: Vec<Vec<u8>>,
182        phrases: &[Vec<Vec<u8>>],
183        prefixes: &[Vec<u8>],
184        want: &[usize],
185        opts: &QueryOpts,
186    ) -> HashMap<u32, f64> {
187        let (n_docs, avgdl) = self.scope_stats(opts.stats, want);
188        let sc = Scope { stats: opts.stats, n_docs, avgdl, want };
189        let mut terms = bare;
190        terms.sort();
191        terms.dedup();
192        let mut scores: HashMap<u32, f64> = HashMap::new();
193        for t in &terms {
194            self.add_typo(t, opts.typo, &mut scores, &sc);
195        }
196        for phrase in phrases {
197            self.add_phrase(phrase, &mut scores, &sc);
198        }
199        for pfx in prefixes {
200            self.add_prefix(pfx, &mut scores, &sc);
201        }
202        scores
203    }
204
205    /// BM25-ranked documents holding any indexed term that begins with
206    /// `prefix` — a search-as-you-type `prefix*` query, scored as the OR
207    /// of its expansion terms, best `limit` hits.
208    ///
209    /// `prefix` is ASCII-lowercased first so it matches the stored token
210    /// form (Latin tokens are lowercased on the way in). This scans the
211    /// term dictionary; an ordered dictionary would binary-search to the
212    /// prefix range instead — the cost it trades is one linear pass over
213    /// the distinct terms, weighed against the write-path cost of keeping
214    /// the dictionary ordered.
215    pub fn matches_prefix(
216        &self,
217        prefix: &[u8],
218        limit: usize,
219        stats: Option<&CorpusStats>,
220    ) -> Vec<TextMatch> {
221        if limit == 0 || prefix.is_empty() || self.docs.is_empty() {
222            return Vec::new();
223        }
224        let pfx: Vec<u8> = prefix.iter().map(u8::to_ascii_lowercase).collect();
225        let (n_docs, avgdl) = self.corpus_stats(stats);
226        let sc = Scope { stats, n_docs, avgdl, want: &[] };
227        let mut scores: HashMap<u32, f64> = HashMap::new();
228        self.add_prefix(&pfx, &mut scores, &sc);
229        self.select_top(&scores, limit, &[], None, None)
230    }
231
232    /// The terms whose document frequency a cross-shard query aggregates
233    /// for global BM25: the bare tokens, every phrase's tokens, and every
234    /// expansion of a `word*` prefix (expanded against THIS shard's
235    /// dictionary, since which terms share the prefix is shard-local).
236    /// Deduplicated. For a query with no prefix this is exactly the
237    /// tokenized query, so pass 1 is unchanged.
238    pub fn query_df_terms(&self, text: &[u8]) -> Vec<Vec<u8>> {
239        self.query_df_terms_typo(text, 0)
240    }
241
242    /// [`TextSegment::query_df_terms`] with a typo budget, so a fuzzed
243    /// term's neighbours get their df aggregated globally too.
244    pub fn query_df_terms_typo(&self, text: &[u8], typo: u32) -> Vec<Vec<u8>> {
245        let (bare, phrases, prefixes) = parse_clauses(text);
246        let mut terms: Vec<Vec<u8>> = Vec::new();
247        for t in &bare {
248            if typo == 0 {
249                terms.push(t.clone());
250            } else {
251                terms.extend(self.expand_typo(t, typo).into_iter().map(<[u8]>::to_vec));
252            }
253        }
254        for phrase in &phrases {
255            terms.extend(phrase.iter().cloned());
256        }
257        for pfx in &prefixes {
258            terms.extend(self.expand_prefix(pfx).into_iter().map(<[u8]>::to_vec));
259        }
260        terms.sort();
261        terms.dedup();
262        terms
263    }
264
265    /// The document frequency this shard contributes for each of a
266    /// query's terms, over the query's field scope.
267    ///
268    /// Unscoped this is the ordinary posting-list length. Scoped it is
269    /// the number of documents holding the term *in the wanted fields* —
270    /// counted by the same walk that would score them, because summing
271    /// stored per-field counts would count a document twice when it holds
272    /// the term in two of the fields.
273    pub fn query_df_in(&self, text: &[u8], opts: QueryOpts) -> Vec<(Vec<u8>, u32)> {
274        let want = self.normalize_scope(opts.fields).unwrap_or_default();
275        self.query_df_terms_typo(text, opts.typo)
276            .into_iter()
277            .map(|t| {
278                let df = match self.fields.as_ref() {
279                    Some(fs) if !want.is_empty() => fs.docs_in(&t, &want).len(),
280                    _ => self.postings.get(&t).map_or(0, super::Buckets::len),
281                };
282                (t, df as u32)
283            })
284            .collect()
285    }
286
287    /// The field positions a query is really scoped to, or `None` when
288    /// the scope cannot match anything in this segment.
289    ///
290    /// A single-field segment keeps no per-field channel because it needs
291    /// none: scoping to its only field *is* the unscoped query, and
292    /// scoping to any other position matches nothing.
293    fn normalize_scope(&self, want: &[usize]) -> Option<Vec<usize>> {
294        let mut w = want.to_vec();
295        w.sort_unstable();
296        w.dedup();
297        if w.is_empty() {
298            return Some(Vec::new());
299        }
300        if self.fields.is_none() {
301            return (w == [0]).then(Vec::new);
302        }
303        Some(w)
304    }
305
306    /// Add one phrase clause's contribution: for every document whose
307    /// positions place the phrase adjacently, the BM25 sum of its tokens.
308    /// A segment without positions can verify nothing, so the clause
309    /// contributes to no document.
310    fn add_phrase(&self, toks: &[Vec<u8>], scores: &mut HashMap<u32, f64>, sc: &Scope) {
311        let Some(pos) = self.positions.as_ref() else { return };
312        let Some(anchor) = self.rarest_anchor(toks) else { return };
313        let distinct = distinct_tokens(toks);
314        for id in pos.ids(anchor) {
315            if self.phrase_hit(pos, toks, id, sc) {
316                *scores.entry(id).or_insert(0.0) += self.clause_score(&distinct, id, sc);
317            }
318        }
319    }
320
321    /// Whether `id` contains the phrase — and, when the query is scoped,
322    /// contains it *inside* one of the wanted fields rather than
323    /// somewhere else in the document.
324    fn phrase_hit(&self, pos: &Positions, toks: &[Vec<u8>], id: u32, sc: &Scope) -> bool {
325        // Unscoped only needs "does it occur", which is answerable without
326        // materialising where.
327        if !sc.scoped() {
328            return phrase_occurs(pos, toks, id);
329        }
330        let starts = phrase_starts(pos, toks, id);
331        let len = toks.len() as u32;
332        starts.iter().any(|&s| self.phrase_in_scope(id, s, len, sc.want))
333    }
334}
335
336impl TextSegment {
337    /// Byte spans in `key`'s stored fields where `query` matched: a bare
338    /// term highlights every occurrence, a phrase only its adjacent runs.
339    /// Returns `(field_index, spans)` for each field with a match, each
340    /// span list sorted and de-duplicated. Empty when `key` is not
341    /// indexed.
342    ///
343    /// It re-analyses the winning document's own text — the fields are
344    /// stored for re-indexing already — so it needs no positional
345    /// side-channel: highlighting a handful of hits is cheap.
346    pub fn highlight_spans(&self, key: &[u8], query: &[u8]) -> Vec<(usize, Vec<(usize, usize)>)> {
347        let Some((_, _, fields)) = self.docs.get(key) else {
348            return Vec::new();
349        };
350        let (bare, phrases, prefixes) = parse_clauses(query);
351        let terms: HashSet<&[u8]> = bare.iter().map(Vec::as_slice).collect();
352        let mut out = Vec::new();
353        for (fi, (text, _weight)) in fields.iter().enumerate() {
354            let mut spans = field_spans(&tokenize_spans(text), &terms, &phrases, &prefixes);
355            if !spans.is_empty() {
356                spans.sort_unstable();
357                spans.dedup();
358                out.push((fi, spans));
359            }
360        }
361        out
362    }
363}
364
365/// Highlight spans within one field's tokens: every bare-term token, every
366/// token matching a query prefix, plus the tokens of each phrase
367/// occurrence (a consecutive, in-order match).
368pub(crate) fn field_spans(
369    toks: &[(Vec<u8>, usize, usize)],
370    terms: &HashSet<&[u8]>,
371    phrases: &[Vec<Vec<u8>>],
372    prefixes: &[Vec<u8>],
373) -> Vec<(usize, usize)> {
374    let mut spans = Vec::new();
375    for (t, s, e) in toks {
376        if terms.contains(t.as_slice()) || prefixes.iter().any(|p| t.starts_with(p.as_slice())) {
377            spans.push((*s, *e));
378        }
379    }
380    for phrase in phrases {
381        let last = toks.len().saturating_sub(phrase.len() - 1);
382        for start in 0..last {
383            if (0..phrase.len()).all(|k| toks[start + k].0 == phrase[k]) {
384                for (_, s, e) in &toks[start..start + phrase.len()] {
385                    spans.push((*s, *e));
386                }
387            }
388        }
389    }
390    spans
391}
392
393/// The phrase's distinct tokens (dedup for scoring — a repeated word
394/// must not be counted twice in the BM25 sum).
395pub(crate) fn distinct_tokens(toks: &[Vec<u8>]) -> Vec<Vec<u8>> {
396    let mut d = toks.to_vec();
397    d.sort();
398    d.dedup();
399    d
400}
401
402/// Whether `id`'s positions place `toks` consecutively and in order at
403/// least once — the same question [`phrase_starts`] answers, without
404/// building any of the answer.
405///
406/// Allocation-free on purpose: `Positions::get` decodes a blob into a
407/// fresh `Vec` once per candidate document per token, and walking the
408/// bytes in place removes that. Worth a measured 6.2% of phrase p95.
409///
410/// The claim this comment used to make — that a profile put 87% of query
411/// time in the allocator — was wrong. That profile had caught the shard
412/// tearing down, where freeing a million positional blobs does dominate.
413/// The real query profile puts the whole phrase check at a few percent.
414///
415/// Re-walking a later token's blob per candidate start looks quadratic
416/// and is not, in the shape that matters: a blob holds ONE document's
417/// occurrences of ONE token, which is almost always one or two. The scan
418/// short-circuits on the first occurrence found.
419fn phrase_occurs(pos: &Positions, toks: &[Vec<u8>], id: u32) -> bool {
420    let Some(first) = pos.blob(&toks[0], id) else {
421        return false;
422    };
423    walk(first).any(|start| {
424        toks.iter()
425            .enumerate()
426            .skip(1)
427            .all(|(i, t)| pos.blob(t, id).is_some_and(|b| walk(b).any(|p| p == start + i as u32)))
428    })
429}
430
431/// Where in `id`'s token stream `toks` occur consecutively and in order.
432/// Shift each token's offsets left by its phrase index and intersect: a
433/// surviving offset is where one occurrence begins. Empty = no
434/// occurrence, which is also what a scoped query filters further.
435fn phrase_starts(pos: &Positions, toks: &[Vec<u8>], id: u32) -> HashSet<u32> {
436    let Some(first) = pos.get(&toks[0], id) else {
437        return HashSet::new();
438    };
439    let mut starts: HashSet<u32> = first.into_iter().collect();
440    for (i, t) in toks.iter().enumerate().skip(1) {
441        let Some(offs) = pos.get(t, id) else {
442            return HashSet::new();
443        };
444        let shifted: HashSet<u32> = offs.iter().filter_map(|&p| p.checked_sub(i as u32)).collect();
445        starts.retain(|s| shifted.contains(s));
446        if starts.is_empty() {
447            return starts;
448        }
449    }
450    starts
451}