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