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}