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}