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};
8pub(crate) use super::opts::MatchOpts;
9use super::{FieldSpans, HighlightedHit, sync_segs};
10use crate::store::{Store, lock_write};
11use crate::{KevyError, KevyResult};
12
13impl Store {
14    /// [`Self::idx_match`] with every optional clause: highlight spans,
15    /// a typo budget, an offset, and a field scope.
16    ///
17    /// A scoped query is a field-scoped BM25 — frequency, length and
18    /// document frequency all come from the named fields alone — so
19    /// naming a field the index does not declare is an error rather than
20    /// an empty result that would look like a working query.
21    pub fn idx_match_with(
22        &self,
23        name: &[u8],
24        query: &[u8],
25        limit: usize,
26        opts: MatchOpts<'_>,
27    ) -> KevyResult<Vec<HighlightedHit>> {
28        self.idx_match_faceted(name, query, limit, opts).map(|p| p.hits)
29    }
30
31    // The public entry is [`Store::idx_match_faceted`] in the advise
32    // module, which wraps this run with the refusal/usage observation.
33    pub(crate) fn match_faceted_run(
34        &self,
35        name: &[u8],
36        query: &[u8],
37        limit: usize,
38        opts: MatchOpts<'_>,
39    ) -> KevyResult<MatchPage> {
40        let (limit, offset) = (limit.clamp(1, 1000), opts.offset.min(10_000));
41        // Deep enough to skip OFFSET and still fill LIMIT post-merge.
42        let fetch = limit + offset;
43        super::text_cold::cold_refusal(self.text_has_cold(name), query, opts.typo, opts.scope)?;
44        let (scope, tests) = self.resolve_clauses(name, opts.scope, opts.filters)?;
45        let sorted = self.sort_field(name, opts.sort)?;
46        let fkeys = self.facet_keys(name, opts.facets)?;
47        let fac: Vec<kevy_text::Facet> =
48            fkeys.iter().map(|(field, k)| kevy_text::Facet::new(*field, k.as_ref())).collect();
49        let grouped = self.value_field("DISTINCT", name, opts.distinct)?;
50        let dkey = grouped.map(|(_, ty)| move |raw: &[u8]| kevy_index::order_key(ty, raw));
51        let distinct =
52            grouped.zip(dkey.as_ref()).map(|((field, _), k)| kevy_text::Distinct::new(field, k));
53        let key = sorted.map(|(_, _, ty)| move |raw: &[u8]| kevy_index::order_key(ty, raw));
54        let sort = sorted
55            .zip(key.as_ref())
56            .map(|((field, order, _), k)| kevy_text::Sort::new(field, k).with_order(order));
57        let boxed = box_tests(tests);
58        let filter: Vec<kevy_text::Filter> =
59            boxed.iter().map(|(f, t)| kevy_text::Filter::new(*f, t.as_ref())).collect();
60        let stats = self.text_corpus_stats_in(name, query, opts.typo, &scope)?;
61        let mut q = kevy_text::QueryOpts::default()
62            .with_stats(&stats)
63            .with_typo(opts.typo)
64            .with_fields(&scope)
65            .with_filter(&filter);
66        q.sort = sort;
67        q.distinct = distinct;
68        let (mut all, facets, cold_vals) =
69            self.gather_hits(name, query, fetch, q, &stats, opts.highlight, &fac);
70        self.order_page(name, &mut all, sorted, &cold_vals);
71        self.collapse_union(name, &mut all, grouped, &cold_vals);
72        if offset > 0 {
73            all.drain(..offset.min(all.len()));
74        }
75        all.truncate(limit);
76        Ok(MatchPage { hits: all, facets })
77    }
78
79    /// Resolve the clauses that need the index spec: `IN` names onto
80    /// field positions, `FILTER` predicates onto stored-value positions
81    /// and typed tests.
82    ///
83    /// One catalog read for both, and both fail loudly on a name the
84    /// index does not offer — an unknown field could just as easily match
85    /// nothing, but then a typo would return a result indistinguishable
86    /// from a working query with no hits.
87    fn resolve_clauses(
88        &self,
89        name: &[u8],
90        scope: &[Vec<u8>],
91        filters: &[ValueFilter<'_>],
92    ) -> KevyResult<ResolvedClauses> {
93        if scope.is_empty() && filters.is_empty() {
94            return Ok((Vec::new(), Vec::new()));
95        }
96        let guard = self.indexes.catalog.read().unwrap_or_else(|e| e.into_inner());
97        let Some((spec, _)) = guard.1.get(name) else {
98            return Err(KevyError::NotFound("no such text index".into()));
99        };
100        let mut positions = Vec::with_capacity(scope.len());
101        for want in scope {
102            let names = || spec.fields().iter().map(|f| f.name.as_slice()).collect::<Vec<_>>();
103            let i = spec
104                .fields()
105                .iter()
106                .position(|f| f.name == *want)
107                .ok_or_else(|| unknown_field("IN", want, "index", &names()))?;
108            positions.push(i);
109        }
110        let tests = filters.iter().map(|f| value_test(spec, f)).collect::<KevyResult<Vec<_>>>()?;
111        Ok((positions, tests))
112    }
113}
114
115/// What the spec-dependent clauses resolve to: `IN`'s field positions,
116/// and `FILTER`'s (stored-value position, typed test) pairs.
117type ResolvedClauses = (Vec<usize>, Vec<(usize, kevy_index::ValueTest)>);
118
119/// Each resolved test boxed as the closure the segment takes. The boxes
120/// must outlive the borrowed `Filter` list, so they are returned rather
121/// than built inline.
122type ValuePred = Box<dyn Fn(&[u8]) -> bool>;
123
124fn box_tests(tests: Vec<(usize, kevy_index::ValueTest)>) -> Vec<(usize, ValuePred)> {
125    tests
126        .into_iter()
127        .map(|(f, t)| {
128            let b: ValuePred = Box::new(move |v: &[u8]| t.passes(v));
129            (f, b)
130        })
131        .collect()
132}
133
134impl Store {
135    /// Every shard's page for this query, unmerged, with the facet
136    /// buckets summed by identity as the shards report them.
137    #[allow(clippy::too_many_arguments)]
138    fn gather_hits(
139        &self,
140        name: &[u8],
141        query: &[u8],
142        fetch: usize,
143        q: kevy_text::QueryOpts<'_>,
144        stats: &kevy_text::CorpusStats,
145        highlight: Option<&[Vec<u8>]>,
146        facets: &[kevy_text::Facet],
147    ) -> (Vec<HighlightedHit>, Vec<FacetCounts>, super::text_cold::ColdVals) {
148        let mut all = Vec::new();
149        let mut buckets: Vec<Vec<RawBucket>> = vec![Vec::new(); facets.len()];
150        #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
151        let mut cold_vals = super::text_cold::ColdVals::new();
152        for shard in self.shards.iter() {
153            let mut g = lock_write(shard);
154            let inner = &mut *g;
155            sync_segs(&self.indexes, &mut inner.idx_segs, &mut inner.store);
156            if let Some((spec, ts)) = inner.idx_segs.text.iter().find(|(s, _)| s.name() == name) {
157                // `matches_query_with` parses quoted phrases out of the
158                // raw query text; with none it is the ordinary term query.
159                let r = ts.matches_query_faceted(query, fetch, q, facets);
160                absorb_live(r, ts, spec, query, highlight, &mut all, &mut buckets);
161            }
162            // The shard's frozen buckets join the union like one more
163            // ranked contributor — the origin merge below re-orders,
164            // re-collapses and truncates the lot.
165            #[cfg(not(target_arch = "wasm32"))]
166            gather_cold(
167                inner,
168                name,
169                query,
170                fetch,
171                stats,
172                &q,
173                facets,
174                highlight,
175                &mut all,
176                &mut buckets,
177                &mut cold_vals,
178            );
179            #[cfg(target_arch = "wasm32")]
180            let _ = stats;
181        }
182        (all, finish_buckets(buckets), cold_vals)
183    }
184
185    /// Put the merged hits in the page's order: by the sort key when the
186    /// query gave one — the same definition each shard selected by — else
187    /// by score.
188    fn order_page(
189        &self,
190        name: &[u8],
191        all: &mut Vec<HighlightedHit>,
192        sorted: Option<(usize, kevy_index::SortOrder, kevy_index::ValType)>,
193        cold_vals: &super::text_cold::ColdVals,
194    ) {
195        let Some((field, order, ty)) = sorted else {
196            all.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
197            return;
198        };
199        let mut keyed: Vec<(Option<Vec<u8>>, HighlightedHit)> = std::mem::take(all)
200            .into_iter()
201            .map(|h| (self.stored_order_key(name, &h.0, field, ty, cold_vals), h))
202            .collect();
203        keyed.sort_by(|a, b| {
204            kevy_text::sorted_order((a.0.as_deref(), &a.1.0), (b.0.as_deref(), &b.1.0), order)
205        });
206        *all = keyed.into_iter().map(|(_, h)| h).collect();
207    }
208
209    /// Collapse the union of the shards' pages: two shards can each hold
210    /// a document with the same value, and only the better survives.
211    ///
212    /// `all` is already in the page's order, so the first occurrence of a
213    /// value is its best and a stable retain keeps exactly that.
214    /// Documents with no value are their own group and all survive — the
215    /// same rule each shard collapsed by.
216    fn collapse_union(
217        &self,
218        name: &[u8],
219        all: &mut Vec<HighlightedHit>,
220        grouped: Option<(usize, kevy_index::ValType)>,
221        cold_vals: &super::text_cold::ColdVals,
222    ) {
223        let Some((field, ty)) = grouped else { return };
224        let mut seen: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
225        all.retain(|h| match self.stored_order_key(name, &h.0, field, ty, cold_vals) {
226            Some(k) => seen.insert(k),
227            None => true,
228        });
229    }
230
231    /// Resolve a `SORT <field> ASC|DESC` clause to the stored-value
232    /// position, the direction, and that field's declared type.
233    fn sort_field(
234        &self,
235        name: &[u8],
236        sort: Option<(&[u8], kevy_index::SortOrder)>,
237    ) -> KevyResult<Option<(usize, kevy_index::SortOrder, kevy_index::ValType)>> {
238        let Some((field, order)) = sort else { return Ok(None) };
239        let Some((pos, ty)) = self.value_field("SORT", name, Some(field))? else {
240            return Ok(None);
241        };
242        Ok(Some((pos, order, ty)))
243    }
244
245    /// Each `FACET` field's position paired with the order-preserving
246    /// encoding of its declared type — the identity buckets are grouped
247    /// by. Returned rather than built inline because the borrowed
248    /// `Facet` list points at these closures.
249    fn facet_keys(&self, name: &[u8], facets: &[Vec<u8>]) -> KevyResult<Vec<FacetKey>> {
250        Ok(self
251            .facet_fields(name, facets)?
252            .into_iter()
253            .map(|(field, ty)| -> FacetKey {
254                (field, Box::new(move |raw: &[u8]| kevy_index::order_key(ty, raw)))
255            })
256            .collect())
257    }
258
259    /// Each `FACET` field's stored-value position and declared type.
260    fn facet_fields(
261        &self,
262        name: &[u8],
263        facets: &[Vec<u8>],
264    ) -> KevyResult<Vec<(usize, kevy_index::ValType)>> {
265        facets
266            .iter()
267            .map(|f| {
268                Ok(self
269                    .value_field("FACET", name, Some(f))?
270                    .expect("a named field always resolves or errors"))
271            })
272            .collect()
273    }
274
275    /// A clause's named stored-value field, as a position and its
276    /// declared type. Errors naming what the index does store.
277    fn value_field(
278        &self,
279        clause: &str,
280        name: &[u8],
281        field: Option<&[u8]>,
282    ) -> KevyResult<Option<(usize, kevy_index::ValType)>> {
283        let Some(field) = field else { return Ok(None) };
284        let guard = self.indexes.catalog.read().unwrap_or_else(|e| e.into_inner());
285        let Some((spec, _)) = guard.1.get(name) else {
286            return Err(KevyError::NotFound("no such text index".into()));
287        };
288        let stored: Vec<&[u8]> = spec.values().iter().map(|v| v.name.as_slice()).collect();
289        let pos = spec
290            .values()
291            .iter()
292            .position(|v| v.name == field)
293            .ok_or_else(|| unknown_field(clause, field, "store", &stored))?;
294        Ok(Some((pos, spec.values()[pos].ty)))
295    }
296
297    /// One row's sort key: its stored value, in the order-preserving
298    /// encoding of that field's declared type.
299    /// One row's sort key: its stored value from a hot segment, or —
300    /// for a cold hit — from its frozen doc record.
301    fn stored_order_key(
302        &self,
303        name: &[u8],
304        key: &[u8],
305        field: usize,
306        ty: kevy_index::ValType,
307        cold_vals: &super::text_cold::ColdVals,
308    ) -> Option<Vec<u8>> {
309        for shard in self.shards.iter() {
310            let g = lock_write(shard);
311            if let Some((_, ts)) = g.idx_segs.text.iter().find(|(s, _)| s.name() == name)
312                && let Some(raw) = ts.stored_value(key, field)
313            {
314                return kevy_index::order_key(ty, raw);
315            }
316        }
317        let raw = cold_vals.get(key)?.get(field)?.as_deref()?;
318        kevy_index::order_key(ty, raw)
319    }
320}
321
322/// One shard's cold contribution to the union: page hits (each with
323/// its row-read highlight when asked), facet buckets folded by
324/// identity, and the frozen values the union's sort/distinct keys
325/// will need.
326#[cfg(not(target_arch = "wasm32"))]
327#[allow(clippy::too_many_arguments)]
328fn gather_cold(
329    inner: &mut crate::store_inner::Inner,
330    name: &[u8],
331    query: &[u8],
332    fetch: usize,
333    stats: &kevy_text::CorpusStats,
334    q: &kevy_text::QueryOpts<'_>,
335    facets: &[kevy_text::Facet],
336    highlight: Option<&[Vec<u8>]>,
337    all: &mut Vec<HighlightedHit>,
338    buckets: &mut [Vec<RawBucket>],
339    cold_vals: &mut super::text_cold::ColdVals,
340) {
341    let Some(dir) = inner.idx_segs.cold_text_of(name).filter(|d| d.has_cold()) else {
342        return;
343    };
344    let mut cq = kevy_window::ColdPageQuery::parse(query, stats, fetch)
345        .with_filter(q.filter)
346        .with_facets(facets);
347    cq.sort = q.sort.as_ref();
348    cq.distinct = q.distinct.as_ref();
349    let page = dir.cold_page(&cq);
350    let spec = inner.idx_segs.text.iter().find(|(s, _)| s.name() == name).map(|(s, _)| s.clone());
351    for h in page.hits {
352        let hl = highlight.map_or_else(Vec::new, |w| {
353            spec.as_ref().map_or_else(Vec::new, |sp| {
354                super::text_cold::cold_hit_highlight(&mut inner.store, sp, &h.key, query, w)
355            })
356        });
357        all.push((h.key, h.score, hl));
358    }
359    for (into, from) in buckets.iter_mut().zip(page.facets) {
360        fold_raw(into, from);
361    }
362    cold_vals.extend(page.values);
363}
364
365/// Fold one contributor's facet buckets into the running totals by
366/// identity (two spellings of one value sum; the first label wins).
367fn fold_raw(into: &mut Vec<RawBucket>, from: Vec<RawBucket>) {
368    for (key, label, n) in from {
369        match into.iter_mut().find(|(k, _, _)| *k == key) {
370            Some(e) => e.2 += n,
371            None => into.push((key, label, n)),
372        }
373    }
374}
375
376/// A facet field's position paired with the order-preserving encoding of
377/// its declared type — the identity its buckets are grouped by.
378type FacetKey = (usize, Box<dyn Fn(&[u8]) -> Option<Vec<u8>>>);
379
380/// One facet field's reported buckets: `(value, count)`, most frequent
381/// first.
382///
383/// ```
384/// use kevy_embedded::*;
385/// let s = Store::open(Config::default())?;
386/// s.idx_create_text(b"ft", b"d:", &[(b"body", 1.0)], TokenPositions::Omit, &[(b"lang", IndexValType::Str)])?;
387/// for (k, lang) in [(&b"d:1"[..], &b"en"[..]), (b"d:2", b"en"), (b"d:3", b"ja")] {
388///     s.hset(k, &[(b"body", b"rust"), (b"lang", lang)])?;
389/// }
390/// let fields = [b"lang".to_vec()];
391/// let page = s.idx_match_faceted(b"ft", b"rust", 10, MatchOpts::default().with_facets(&fields))?;
392/// let langs: &FacetCounts = &page.facets[0];
393/// assert_eq!(langs, &[(b"en".to_vec(), 2), (b"ja".to_vec(), 1)]);
394/// # Ok::<(), kevy_embedded::KevyError>(())
395/// ```
396pub type FacetCounts = Vec<(Vec<u8>, u64)>;
397
398/// One facet bucket in flight, before the grouping identity is dropped.
399type RawBucket = (Vec<u8>, Vec<u8>, u64);
400
401/// A faceted query's answer: the page, and per requested `FACET` field
402/// its `(value, count)` buckets over the whole match set.
403///
404/// ```
405/// use kevy_embedded::{Config, MatchOpts, Store, TokenPositions};
406///
407/// let s = Store::open(Config::default())?;
408/// s.idx_create_text(b"ft", b"doc:", &[(b"body", 1.0)], TokenPositions::Omit, &[])?;
409/// s.hset(b"doc:1", &[(b"body", b"rust engine")])?;
410/// let page = s.idx_match_faceted(b"ft", b"rust", 10, MatchOpts::default())?;
411/// assert_eq!(page.hits.len(), 1);
412/// # Ok::<(), kevy_embedded::KevyError>(())
413/// ```
414#[derive(Debug, Clone, PartialEq)]
415#[non_exhaustive]
416pub struct MatchPage {
417    /// The ranked page — exactly what [`Store::idx_match_with`] returns.
418    ///
419    /// ```
420    /// # use kevy_embedded::*;
421    /// # let s = Store::open(Config::default())?;
422    /// # s.idx_create_text(b"ft", b"d:", &[(b"body", 1.0)], TokenPositions::Omit, &[])?;
423    /// s.hset(b"d:1", &[(b"body", b"rust engine")])?;
424    /// s.hset(b"d:2", &[(b"body", b"go server")])?;
425    /// let page = s.idx_match_faceted(b"ft", b"rust", 10, MatchOpts::default())?;
426    /// assert_eq!(page.hits.iter().map(|h| &h.0[..]).collect::<Vec<_>>(), [&b"d:1"[..]]);
427    /// # Ok::<(), kevy_embedded::KevyError>(())
428    /// ```
429    pub hits: Vec<HighlightedHit>,
430    /// One entry per requested facet field, most frequent first.
431    ///
432    /// ```
433    /// # use kevy_embedded::*;
434    /// # let s = Store::open(Config::default())?;
435    /// # s.idx_create_text(b"ft", b"d:", &[(b"body", 1.0)], TokenPositions::Omit, &[(b"lang", IndexValType::Str)])?;
436    /// s.hset(b"d:1", &[(b"body", b"rust"), (b"lang", b"en")])?;
437    /// let (none, lang) = (MatchOpts::default(), [b"lang".to_vec()]);
438    /// assert!(s.idx_match_faceted(b"ft", b"rust", 10, none)?.facets.is_empty());
439    /// let page = s.idx_match_faceted(b"ft", b"rust", 10, none.with_facets(&lang))?;
440    /// assert_eq!(page.facets, [vec![(b"en".to_vec(), 1)]]);
441    /// # Ok::<(), kevy_embedded::KevyError>(())
442    /// ```
443    pub facets: Vec<FacetCounts>,
444}
445
446/// Drop the grouping identity and order the buckets for reporting: most
447/// frequent first, the label breaking ties so the order is stable.
448fn finish_buckets(buckets: Vec<Vec<RawBucket>>) -> Vec<FacetCounts> {
449    buckets
450        .into_iter()
451        .map(|mut field| {
452            field.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
453            field.into_iter().map(|(_, label, n)| (label, n)).collect()
454        })
455        .collect()
456}
457
458/// Fold one live segment's answer into the running union: each hit with its
459/// highlight spans, each facet's raw buckets into the matching accumulator.
460///
461/// Split from `gather_hits` for the 50-line rule.
462fn absorb_live(
463    r: kevy_text::FacetedMatches,
464    ts: &kevy_text::TextSegment,
465    spec: &IndexSpec,
466    query: &[u8],
467    highlight: Option<&[Vec<u8>]>,
468    all: &mut Vec<(Vec<u8>, f64, Vec<FieldSpans>)>,
469    buckets: &mut [Vec<RawBucket>],
470) {
471    for m in r.hits {
472        let hl = highlight.map_or_else(Vec::new, |w| hit_highlight(ts, spec, &m.key, query, w));
473        all.push((m.key, m.score, hl));
474    }
475    for (into, from) in buckets.iter_mut().zip(r.facets) {
476        fold_raw(into, from);
477    }
478}
479
480/// One hit's highlight spans as `(field name, [(start, end)])`, filtered
481/// to the requested fields (`want` empty = every field with a match).
482fn hit_highlight(
483    ts: &kevy_text::TextSegment,
484    spec: &IndexSpec,
485    key: &[u8],
486    query: &[u8],
487    want: &[Vec<u8>],
488) -> Vec<FieldSpans> {
489    ts.highlight_spans(key, query)
490        .into_iter()
491        .filter_map(|(fi, spans)| {
492            let name = spec.fields().get(fi)?.name.clone();
493            if !want.is_empty() && !want.contains(&name) {
494                return None;
495            }
496            let ranges = spans.into_iter().map(|(s, e)| (s as u32, e as u32)).collect();
497            Some((name, ranges))
498        })
499        .collect()
500}