Skip to main content

kevy_embedded/
ops_index_highlight.rs

1//! Clause-carrying MATCH for the embedded API, split from
2//! `ops_index.rs` for the 500-LOC house rule. A `#[path]` child module,
3//! so it reaches `Store`'s crate-private index methods and fields.
4
5use kevy_index::IndexSpec;
6
7use super::claused::{ValueFilter, unknown_field, value_test};
8use super::{FieldSpans, HighlightedHit, sync_segs};
9use crate::store::{Store, lock_write};
10use crate::{KevyError, KevyResult};
11
12/// Everything a text MATCH carries beyond its index, query text and
13/// result limit — the embedded twin of the wire's optional clauses.
14///
15/// Grouping them keeps one entry point instead of one per clause, and
16/// [`MatchOpts::default`] is the plain query, so a caller opts into
17/// exactly the clauses it names.
18#[derive(Clone, Copy, Default)]
19pub struct MatchOpts<'a> {
20    /// `HIGHLIGHT`: `None` = not requested, `Some(&[])` = every indexed
21    /// field, `Some(names)` = only those.
22    pub highlight: Option<&'a [Vec<u8>]>,
23    /// `TYPO n`: edit budget for each bare term; 0 = exact.
24    pub typo: u32,
25    /// `OFFSET n`: hits to skip before `limit` takes effect.
26    pub offset: usize,
27    /// `IN <field…>`: the declared field names to score within; empty =
28    /// the whole document.
29    pub scope: &'a [Vec<u8>],
30    /// `FILTER …`: non-scoring predicates over stored values, ANDed.
31    /// They decide which documents are eligible, not what a term is
32    /// worth, so the corpus statistics stay whole-corpus.
33    pub filters: &'a [ValueFilter<'a>],
34    /// `SORT <field> ASC|DESC`: select by a stored value instead of by
35    /// score. Selecting, not re-ordering — a document that wins on the
36    /// key is chosen even when its score would never have reached the
37    /// page.
38    pub sort: Option<(&'a [u8], bool)>,
39    /// `DISTINCT <field>`: at most one hit per value of a stored field,
40    /// applied during selection so the page holds `limit` distinct
41    /// documents rather than `limit` that then collapse.
42    pub distinct: Option<&'a [u8]>,
43    /// `FACET <field…>`: count each field's values over the whole match
44    /// set. Reported alongside the page rather than shaping it.
45    pub facets: &'a [Vec<u8>],
46}
47
48impl Store {
49    /// [`Self::idx_match`] with every optional clause: highlight spans,
50    /// a typo budget, an offset, and a field scope.
51    ///
52    /// A scoped query is a field-scoped BM25 — frequency, length and
53    /// document frequency all come from the named fields alone — so
54    /// naming a field the index does not declare is an error rather than
55    /// an empty result that would look like a working query.
56    pub fn idx_match_with(
57        &self,
58        name: &[u8],
59        query: &[u8],
60        limit: usize,
61        opts: MatchOpts<'_>,
62    ) -> KevyResult<Vec<HighlightedHit>> {
63        self.idx_match_faceted(name, query, limit, opts).map(|p| p.hits)
64    }
65
66    /// [`Self::idx_match_with`], additionally counting the values of the
67    /// `FACET` fields over the whole match set — not just the page, which
68    /// is why the counts cannot be derived from the hits.
69    pub fn idx_match_faceted(
70        &self,
71        name: &[u8],
72        query: &[u8],
73        limit: usize,
74        opts: MatchOpts<'_>,
75    ) -> KevyResult<MatchPage> {
76        let limit = limit.clamp(1, 1000);
77        let offset = opts.offset.min(10_000);
78        // Fetch deep enough to skip OFFSET and still fill LIMIT after the
79        // cross-shard merge.
80        let fetch = limit + offset;
81        let (scope, tests) = self.resolve_clauses(name, opts.scope, opts.filters)?;
82        let sorted = self.sort_field(name, opts.sort)?;
83        let fkeys = self.facet_keys(name, opts.facets)?;
84        let fac: Vec<kevy_text::Facet> =
85            fkeys.iter().map(|(field, k)| kevy_text::Facet { field: *field, key: k.as_ref() }).collect();
86        let grouped = self.value_field("DISTINCT", name, opts.distinct)?;
87        let dkey = grouped.map(|(_, ty)| move |raw: &[u8]| kevy_index::order_key(ty, raw));
88        let distinct = grouped
89            .zip(dkey.as_ref())
90            .map(|((field, _), k)| kevy_text::Distinct { field, key: k });
91        let key = sorted.map(|(_, _, ty)| move |raw: &[u8]| kevy_index::order_key(ty, raw));
92        let sort = sorted
93            .zip(key.as_ref())
94            .map(|((field, desc, _), k)| kevy_text::Sort { field, desc, key: k });
95        let boxed = box_tests(tests);
96        let filter: Vec<kevy_text::Filter> = boxed
97            .iter()
98            .map(|(f, t)| kevy_text::Filter { field: *f, test: t.as_ref() })
99            .collect();
100        let stats = self.text_corpus_stats_in(name, query, opts.typo, &scope)?;
101        let q = kevy_text::QueryOpts {
102            stats: Some(&stats),
103            typo: opts.typo,
104            fields: &scope,
105            filter: &filter,
106            sort,
107            distinct,
108        };
109        let (mut all, facets) =
110            self.gather_hits(name, query, fetch, q, opts.highlight, &fac);
111        self.order_page(name, &mut all, sorted);
112        self.collapse_union(name, &mut all, grouped);
113        if offset > 0 {
114            all.drain(..offset.min(all.len()));
115        }
116        all.truncate(limit);
117        Ok(MatchPage { hits: all, facets })
118    }
119
120    /// Resolve the clauses that need the index spec: `IN` names onto
121    /// field positions, `FILTER` predicates onto stored-value positions
122    /// and typed tests.
123    ///
124    /// One catalog read for both, and both fail loudly on a name the
125    /// index does not offer — an unknown field could just as easily match
126    /// nothing, but then a typo would return a result indistinguishable
127    /// from a working query with no hits.
128    fn resolve_clauses(
129        &self,
130        name: &[u8],
131        scope: &[Vec<u8>],
132        filters: &[ValueFilter<'_>],
133    ) -> KevyResult<ResolvedClauses> {
134        if scope.is_empty() && filters.is_empty() {
135            return Ok((Vec::new(), Vec::new()));
136        }
137        let guard = self.indexes.catalog.read().unwrap_or_else(|e| e.into_inner());
138        let Some((spec, _)) = guard.1.get(name) else {
139            return Err(KevyError::NotFound("no such text index".into()));
140        };
141        let mut positions = Vec::with_capacity(scope.len());
142        for want in scope {
143            let names = || spec.fields.iter().map(|f| f.name.as_slice()).collect::<Vec<_>>();
144            let i = spec
145                .fields
146                .iter()
147                .position(|f| f.name == *want)
148                .ok_or_else(|| unknown_field("IN", want, "index", &names()))?;
149            positions.push(i);
150        }
151        let tests =
152            filters.iter().map(|f| value_test(spec, f)).collect::<KevyResult<Vec<_>>>()?;
153        Ok((positions, tests))
154    }
155}
156
157/// What the spec-dependent clauses resolve to: `IN`'s field positions,
158/// and `FILTER`'s (stored-value position, typed test) pairs.
159type ResolvedClauses = (Vec<usize>, Vec<(usize, kevy_index::ValueTest)>);
160
161/// Each resolved test boxed as the closure the segment takes. The boxes
162/// must outlive the borrowed `Filter` list, so they are returned rather
163/// than built inline.
164type ValuePred = Box<dyn Fn(&[u8]) -> bool>;
165
166fn box_tests(tests: Vec<(usize, kevy_index::ValueTest)>) -> Vec<(usize, ValuePred)> {
167    tests
168        .into_iter()
169        .map(|(f, t)| {
170            let b: ValuePred = Box::new(move |v: &[u8]| t.passes(v));
171            (f, b)
172        })
173        .collect()
174}
175
176
177impl Store {
178    /// Every shard's page for this query, unmerged, with the facet
179    /// buckets summed by identity as the shards report them.
180    fn gather_hits(
181        &self,
182        name: &[u8],
183        query: &[u8],
184        fetch: usize,
185        q: kevy_text::QueryOpts<'_>,
186        highlight: Option<&[Vec<u8>]>,
187        facets: &[kevy_text::Facet],
188    ) -> (Vec<HighlightedHit>, Vec<FacetCounts>) {
189        let mut all = Vec::new();
190        let mut buckets: Vec<Vec<RawBucket>> = vec![Vec::new(); facets.len()];
191        for shard in self.shards.iter() {
192            let mut g = lock_write(shard);
193            let inner = &mut *g;
194            sync_segs(&self.indexes, &mut inner.idx_segs, &mut inner.store);
195            if let Some((spec, ts)) = inner.idx_segs.text.iter().find(|(s, _)| s.name == name) {
196                // `matches_query_with` parses quoted phrases out of the
197                // raw query text; with none it is the ordinary term query.
198                let r = ts.matches_query_faceted(query, fetch, q, facets);
199                for m in r.hits {
200                    let hl = highlight
201                        .map_or_else(Vec::new, |w| hit_highlight(ts, spec, &m.key, query, w));
202                    all.push((m.key, m.score, hl));
203                }
204                for (into, from) in buckets.iter_mut().zip(r.facets) {
205                    for (key, label, n) in from {
206                        match into.iter_mut().find(|(k, _, _)| *k == key) {
207                            Some(e) => e.2 += n,
208                            None => into.push((key, label, n)),
209                        }
210                    }
211                }
212            }
213        }
214        (all, finish_buckets(buckets))
215    }
216
217    /// Put the merged hits in the page's order: by the sort key when the
218    /// query gave one — the same definition each shard selected by — else
219    /// by score.
220    fn order_page(
221        &self,
222        name: &[u8],
223        all: &mut Vec<HighlightedHit>,
224        sorted: Option<(usize, bool, kevy_index::ValType)>,
225    ) {
226        let Some((field, desc, ty)) = sorted else {
227            all.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
228            return;
229        };
230        let mut keyed: Vec<(Option<Vec<u8>>, HighlightedHit)> = std::mem::take(all)
231            .into_iter()
232            .map(|h| (self.stored_order_key(name, &h.0, field, ty), h))
233            .collect();
234        keyed.sort_by(|a, b| {
235            kevy_text::sorted_order((a.0.as_deref(), &a.1.0), (b.0.as_deref(), &b.1.0), desc)
236        });
237        *all = keyed.into_iter().map(|(_, h)| h).collect();
238    }
239
240    /// Collapse the union of the shards' pages: two shards can each hold
241    /// a document with the same value, and only the better survives.
242    ///
243    /// `all` is already in the page's order, so the first occurrence of a
244    /// value is its best and a stable retain keeps exactly that.
245    /// Documents with no value are their own group and all survive — the
246    /// same rule each shard collapsed by.
247    fn collapse_union(
248        &self,
249        name: &[u8],
250        all: &mut Vec<HighlightedHit>,
251        grouped: Option<(usize, kevy_index::ValType)>,
252    ) {
253        let Some((field, ty)) = grouped else { return };
254        let mut seen: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
255        all.retain(|h| match self.stored_order_key(name, &h.0, field, ty) {
256            Some(k) => seen.insert(k),
257            None => true,
258        });
259    }
260
261    /// Resolve a `SORT <field> ASC|DESC` clause to the stored-value
262    /// position, the direction, and that field's declared type.
263    fn sort_field(
264        &self,
265        name: &[u8],
266        sort: Option<(&[u8], bool)>,
267    ) -> KevyResult<Option<(usize, bool, kevy_index::ValType)>> {
268        let Some((field, desc)) = sort else { return Ok(None) };
269        let Some((pos, ty)) = self.value_field("SORT", name, Some(field))? else {
270            return Ok(None);
271        };
272        Ok(Some((pos, desc, ty)))
273    }
274
275    /// Each `FACET` field's position paired with the order-preserving
276    /// encoding of its declared type — the identity buckets are grouped
277    /// by. Returned rather than built inline because the borrowed
278    /// `Facet` list points at these closures.
279    fn facet_keys(
280        &self,
281        name: &[u8],
282        facets: &[Vec<u8>],
283    ) -> KevyResult<Vec<FacetKey>> {
284        Ok(self
285            .facet_fields(name, facets)?
286            .into_iter()
287            .map(|(field, ty)| -> FacetKey {
288                (field, Box::new(move |raw: &[u8]| kevy_index::order_key(ty, raw)))
289            })
290            .collect())
291    }
292
293    /// Each `FACET` field's stored-value position and declared type.
294    fn facet_fields(
295        &self,
296        name: &[u8],
297        facets: &[Vec<u8>],
298    ) -> KevyResult<Vec<(usize, kevy_index::ValType)>> {
299        facets
300            .iter()
301            .map(|f| {
302                Ok(self
303                    .value_field("FACET", name, Some(f))?
304                    .expect("a named field always resolves or errors"))
305            })
306            .collect()
307    }
308
309    /// A clause's named stored-value field, as a position and its
310    /// declared type. Errors naming what the index does store.
311    fn value_field(
312        &self,
313        clause: &str,
314        name: &[u8],
315        field: Option<&[u8]>,
316    ) -> KevyResult<Option<(usize, kevy_index::ValType)>> {
317        let Some(field) = field else { return Ok(None) };
318        let guard = self.indexes.catalog.read().unwrap_or_else(|e| e.into_inner());
319        let Some((spec, _)) = guard.1.get(name) else {
320            return Err(KevyError::NotFound("no such text index".into()));
321        };
322        let stored: Vec<&[u8]> = spec.values.iter().map(|v| v.name.as_slice()).collect();
323        let pos = spec
324            .values
325            .iter()
326            .position(|v| v.name == field)
327            .ok_or_else(|| unknown_field(clause, field, "store", &stored))?;
328        Ok(Some((pos, spec.values[pos].ty)))
329    }
330
331    /// One row's sort key: its stored value, in the order-preserving
332    /// encoding of that field's declared type.
333    fn stored_order_key(
334        &self,
335        name: &[u8],
336        key: &[u8],
337        field: usize,
338        ty: kevy_index::ValType,
339    ) -> Option<Vec<u8>> {
340        for shard in self.shards.iter() {
341            let g = lock_write(shard);
342            if let Some((_, ts)) = g.idx_segs.text.iter().find(|(s, _)| s.name == name)
343                && let Some(raw) = ts.stored_value(key, field)
344            {
345                return kevy_index::order_key(ty, raw);
346            }
347        }
348        None
349    }
350}
351
352/// A facet field's position paired with the order-preserving encoding of
353/// its declared type — the identity its buckets are grouped by.
354type FacetKey = (usize, Box<dyn Fn(&[u8]) -> Option<Vec<u8>>>);
355
356/// One facet field's reported buckets: `(value, count)`, most frequent
357/// first.
358pub type FacetCounts = Vec<(Vec<u8>, u64)>;
359
360/// One facet bucket in flight, before the grouping identity is dropped.
361type RawBucket = (Vec<u8>, Vec<u8>, u64);
362
363/// A faceted query's answer: the page, and per requested `FACET` field
364/// its `(value, count)` buckets over the whole match set.
365#[derive(Debug)]
366pub struct MatchPage {
367    /// The ranked page — exactly what [`Store::idx_match_with`] returns.
368    pub hits: Vec<HighlightedHit>,
369    /// One entry per requested facet field, most frequent first.
370    pub facets: Vec<FacetCounts>,
371}
372
373/// Drop the grouping identity and order the buckets for reporting: most
374/// frequent first, the label breaking ties so the order is stable.
375fn finish_buckets(buckets: Vec<Vec<RawBucket>>) -> Vec<FacetCounts> {
376    buckets
377        .into_iter()
378        .map(|mut field| {
379            field.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
380            field.into_iter().map(|(_, label, n)| (label, n)).collect()
381        })
382        .collect()
383}
384
385
386/// One hit's highlight spans as `(field name, [(start, end)])`, filtered
387/// to the requested fields (`want` empty = every field with a match).
388fn hit_highlight(
389    ts: &kevy_text::TextSegment,
390    spec: &IndexSpec,
391    key: &[u8],
392    query: &[u8],
393    want: &[Vec<u8>],
394) -> Vec<FieldSpans> {
395    ts.highlight_spans(key, query)
396        .into_iter()
397        .filter_map(|(fi, spans)| {
398            let name = spec.fields.get(fi)?.name.clone();
399            if !want.is_empty() && !want.contains(&name) {
400                return None;
401            }
402            let ranges = spans.into_iter().map(|(s, e)| (s as u32, e as u32)).collect();
403            Some((name, ranges))
404        })
405        .collect()
406}