Skip to main content

hermes_core/query/
traits.rs

1//! Query and Scorer traits with async support
2//!
3//! Provides the core abstractions for search queries and document scoring.
4
5use std::future::Future;
6use std::pin::Pin;
7
8use crate::segment::SegmentReader;
9use crate::{DocId, Result, Score};
10
11/// BM25 parameters
12#[derive(Debug, Clone, Copy)]
13pub struct Bm25Params {
14    /// Term frequency saturation parameter (typically 1.2-2.0)
15    pub k1: f32,
16    /// Length normalization parameter (typically 0.75)
17    pub b: f32,
18}
19
20impl Default for Bm25Params {
21    fn default() -> Self {
22        Self { k1: 1.2, b: 0.75 }
23    }
24}
25
26/// Future type for scorer creation
27#[cfg(not(target_arch = "wasm32"))]
28pub type ScorerFuture<'a> = Pin<Box<dyn Future<Output = Result<Box<dyn Scorer + 'a>>> + Send + 'a>>;
29#[cfg(target_arch = "wasm32")]
30pub type ScorerFuture<'a> = Pin<Box<dyn Future<Output = Result<Box<dyn Scorer + 'a>>> + 'a>>;
31
32/// Options that affect scorer construction rather than scoring semantics.
33///
34/// Position postings can be much larger than the top-k result itself. Keeping
35/// this explicit lets ID/score-only collectors avoid loading them while query
36/// types that need positions for matching (for example phrases) remain free to
37/// load their own internal data.
38#[derive(Debug, Clone, Default)]
39pub struct ScorerOptions {
40    pub collect_positions: bool,
41    /// Initial top-k score floor to seed into MaxScore/BMP pruning. Used to
42    /// carry the running k-th score across the segments of one query so later
43    /// segments prune from a nonzero threshold (see `SharedThreshold`). 0.0 =
44    /// no seed. Only honored on exact, final-score executor paths.
45    pub initial_threshold: f32,
46    /// Live form of `initial_threshold`. Exact final-score executors may read
47    /// it during traversal so concurrently searched segments benefit as soon
48    /// as another segment establishes a stronger global floor.
49    pub shared_threshold: Option<super::scoring::SharedThreshold>,
50    /// Query-global LSP/0 selection projected onto this segment.
51    pub(crate) lsp_plan: Option<std::sync::Arc<super::bmp::LspSegmentPlan>>,
52}
53
54impl ScorerOptions {
55    pub const fn with_positions() -> Self {
56        Self {
57            collect_positions: true,
58            initial_threshold: 0.0,
59            shared_threshold: None,
60            lsp_plan: None,
61        }
62    }
63
64    /// Preserve collection behavior while preventing a nested/component
65    /// scorer from applying a floor expressed in the outer query's score
66    /// space.
67    pub fn without_threshold(&self) -> Self {
68        Self {
69            collect_positions: self.collect_positions,
70            initial_threshold: 0.0,
71            shared_threshold: None,
72            lsp_plan: None,
73        }
74    }
75}
76
77/// Future type for count estimation
78#[cfg(not(target_arch = "wasm32"))]
79pub type CountFuture<'a> = Pin<Box<dyn Future<Output = Result<u32>> + Send + 'a>>;
80#[cfg(target_arch = "wasm32")]
81pub type CountFuture<'a> = Pin<Box<dyn Future<Output = Result<u32>> + 'a>>;
82
83/// Per-document predicate closure type (platform-aware Send+Sync bounds)
84#[cfg(not(target_arch = "wasm32"))]
85pub type DocPredicate<'a> = Box<dyn Fn(DocId) -> bool + Send + Sync + 'a>;
86#[cfg(target_arch = "wasm32")]
87pub type DocPredicate<'a> = Box<dyn Fn(DocId) -> bool + 'a>;
88
89/// Compact bitset indexed by doc_id. O(1) lookup, ~2.25 MB for 18M docs.
90///
91/// Built from posting lists or predicate scans. Used by BMP filtered queries
92/// for fast per-slot predicate evaluation (~2ns per lookup vs ~30-40ns for
93/// a fast-field closure).
94pub struct DocBitset {
95    pub(crate) bits: Vec<u64>,
96}
97
98impl DocBitset {
99    /// Create an empty bitset for `num_docs` documents.
100    pub fn new(num_docs: u32) -> Self {
101        let num_words = (num_docs as usize).div_ceil(64);
102        Self {
103            bits: vec![0u64; num_words],
104        }
105    }
106
107    /// Set bit for `doc_id`.
108    #[inline]
109    pub fn set(&mut self, doc_id: u32) {
110        let word = doc_id as usize / 64;
111        let bit = doc_id as usize % 64;
112        if word < self.bits.len() {
113            self.bits[word] |= 1u64 << bit;
114        }
115    }
116
117    /// Test if `doc_id` is in the bitset.
118    #[inline(always)]
119    pub fn contains(&self, doc_id: u32) -> bool {
120        let word = doc_id as usize / 64;
121        let bit = doc_id as usize % 64;
122        word < self.bits.len() && self.bits[word] & (1u64 << bit) != 0
123    }
124
125    /// Number of set bits (matching docs).
126    pub fn count(&self) -> u32 {
127        self.bits.iter().map(|w| w.count_ones()).sum()
128    }
129
130    /// Build bitset from a predicate by scanning all docs. O(N).
131    pub fn from_predicate(num_docs: u32, pred: &dyn Fn(DocId) -> bool) -> Self {
132        let mut bs = Self::new(num_docs);
133        for doc_id in 0..num_docs {
134            if pred(doc_id) {
135                bs.set(doc_id);
136            }
137        }
138        bs
139    }
140
141    /// In-place OR (union): `self |= other`.
142    pub fn union_with(&mut self, other: &DocBitset) {
143        for (a, b) in self.bits.iter_mut().zip(other.bits.iter()) {
144            *a |= *b;
145        }
146    }
147
148    /// In-place AND (intersection): `self &= other`.
149    pub fn intersect_with(&mut self, other: &DocBitset) {
150        for (a, b) in self.bits.iter_mut().zip(other.bits.iter()) {
151            *a &= *b;
152        }
153        // Zero out any words beyond `other`'s length
154        for a in self.bits.iter_mut().skip(other.bits.len()) {
155            *a = 0;
156        }
157    }
158
159    /// In-place ANDNOT (subtract): `self &= !other`.
160    pub fn subtract(&mut self, other: &DocBitset) {
161        for (a, b) in self.bits.iter_mut().zip(other.bits.iter()) {
162            *a &= !*b;
163        }
164    }
165
166    /// Keep only the set docs for which `pred` returns true. O(count) probes —
167    /// the planner uses this to refine a small accumulator against a wide
168    /// clause instead of materializing that clause's full bitset.
169    pub fn retain(&mut self, pred: &dyn Fn(DocId) -> bool) {
170        for (w, word) in self.bits.iter_mut().enumerate() {
171            let mut bits = *word;
172            while bits != 0 {
173                let b = bits.trailing_zeros();
174                let doc = (w * 64) as u32 + b;
175                if !pred(doc) {
176                    *word &= !(1u64 << b);
177                }
178                bits &= bits - 1;
179            }
180        }
181    }
182}
183
184/// Info for MaxScore-optimizable term queries
185#[derive(Debug, Clone)]
186pub struct TermQueryInfo {
187    /// Field being searched
188    pub field: crate::dsl::Field,
189    /// Term bytes (lowercase)
190    pub term: Vec<u8>,
191}
192
193/// Info for MaxScore-optimizable sparse term queries
194#[derive(Debug, Clone, Copy)]
195pub struct SparseTermQueryInfo {
196    /// Sparse vector field
197    pub field: crate::dsl::Field,
198    /// Dimension ID in the sparse vector
199    pub dim_id: u32,
200    /// Query weight for this dimension
201    pub weight: f32,
202    /// Whether this term participates in candidate generation. BMP/LSP uses
203    /// the pruned subset for maximum-grid traversal, then scores candidates
204    /// with every term retained in this decomposition.
205    pub candidate: bool,
206    /// MaxScore heap factor (1.0 = exact, lower = approximate)
207    pub heap_factor: f32,
208    /// Multi-value combiner for ordinal deduplication
209    pub combiner: super::MultiValueCombiner,
210    /// Multiplier on executor limit to compensate for ordinal deduplication
211    /// (1.0 = exact, 2.0 = fetch 2x then combine down)
212    pub over_fetch_factor: f32,
213    /// LSP/0 γ. None is depth-derived; Some(0) is exhaustive.
214    pub lsp_gamma: Option<usize>,
215}
216
217/// Decomposition of a query for MaxScore optimization.
218///
219/// The planner inspects this to decide whether to use text MaxScore,
220/// sparse MaxScore, or standard BooleanScorer execution.
221#[derive(Debug, Clone)]
222pub enum QueryDecomposition {
223    /// Single text term — eligible for text MaxScore grouping
224    TextTerm(TermQueryInfo),
225    /// One or more sparse dimensions — eligible for sparse MaxScore
226    SparseTerms(Vec<SparseTermQueryInfo>),
227    /// Not decomposable — falls back to standard execution
228    Opaque,
229}
230
231/// Matched positions for a field (field_id, list of scored positions)
232/// Each position includes its individual score contribution
233pub type MatchedPositions = Vec<(u32, Vec<super::ScoredPosition>)>;
234
235macro_rules! define_query_traits {
236    ($($send_bounds:tt)*) => {
237        /// A search query (async)
238        ///
239        /// Note: `scorer` takes `&self` (not `&'a self`) so that scorers don't borrow the query.
240        /// This enables query composition - queries can create sub-queries locally and get their scorers.
241        /// Implementations must clone/capture any data they need during scorer creation.
242        pub trait Query: std::fmt::Display + $($send_bounds)* {
243            /// Create a scorer for this query against a single segment (async)
244            ///
245            /// The `limit` parameter specifies the maximum number of results to return.
246            /// This is passed from the top-level search limit.
247            ///
248            /// Note: The scorer borrows only the reader, not the query. Implementations
249            /// should capture any needed query data (field, terms, etc.) during creation.
250            fn scorer<'a>(
251                &self,
252                reader: &'a SegmentReader,
253                limit: usize,
254            ) -> ScorerFuture<'a>;
255
256            /// Create a scorer with collector-specific construction options.
257            /// Query implementations that can avoid optional position data
258            /// should override this; the default preserves existing behavior.
259            fn scorer_with_options<'a>(
260                &self,
261                reader: &'a SegmentReader,
262                limit: usize,
263                options: ScorerOptions,
264            ) -> ScorerFuture<'a> {
265                let _ = options;
266                self.scorer(reader, limit)
267            }
268
269            /// Estimated number of matching documents in a segment (async)
270            fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a>;
271
272            /// Create a scorer synchronously (mmap/RAM only).
273            ///
274            /// Available when the `sync` feature is enabled.
275            /// Default implementation returns an error.
276            #[cfg(feature = "sync")]
277            fn scorer_sync<'a>(
278                &self,
279                reader: &'a SegmentReader,
280                limit: usize,
281            ) -> Result<Box<dyn Scorer + 'a>> {
282                let _ = (reader, limit);
283                Err(crate::error::Error::Query(
284                    "sync scorer not supported for this query type".into(),
285                ))
286            }
287
288            /// Synchronous counterpart to [`Query::scorer_with_options`].
289            #[cfg(feature = "sync")]
290            fn scorer_sync_with_options<'a>(
291                &self,
292                reader: &'a SegmentReader,
293                limit: usize,
294                options: ScorerOptions,
295            ) -> Result<Box<dyn Scorer + 'a>> {
296                let _ = options;
297                self.scorer_sync(reader, limit)
298            }
299
300            /// Decompose this query for MaxScore optimization.
301            ///
302            /// Returns `TextTerm` for simple term queries, `SparseTerms` for
303            /// sparse vector queries (single or multi-dim), or `Opaque` if
304            /// the query cannot be decomposed.
305            fn decompose(&self) -> QueryDecomposition {
306                QueryDecomposition::Opaque
307            }
308
309            /// True if this query is a pure filter (always scores 1.0, no positions).
310            /// Used by the planner to convert non-selective MUST filters into predicates.
311            fn is_filter(&self) -> bool {
312                false
313            }
314
315            /// For filter queries: return a cheap per-doc predicate against a segment.
316            /// The predicate does O(1) work per doc (e.g., fast-field lookup).
317            fn as_doc_predicate<'a>(
318                &self,
319                _reader: &'a SegmentReader,
320            ) -> Option<DocPredicate<'a>> {
321                None
322            }
323
324            /// Build a compact bitset of matching doc_ids for this query.
325            ///
326            /// Preferred over `as_doc_predicate` for BMP filtered queries because
327            /// bitset lookup is ~2ns vs ~30-40ns for a fast-field closure.
328            /// Default returns None; TermQuery overrides this to build from its
329            /// posting list in O(M) time.
330            fn as_doc_bitset(
331                &self,
332                _reader: &SegmentReader,
333            ) -> Option<DocBitset> {
334                None
335            }
336
337            /// Cheap estimate of how many docs this filter clause matches in
338            /// the segment. Used by the boolean planner to order MUST/MUST_NOT
339            /// evaluation: the narrowest clause is materialized first and wider
340            /// clauses refine it with per-doc probes instead of being fully
341            /// materialized. `None` = unknown (treated as matching everything).
342            fn bitset_cardinality_estimate(&self, _reader: &SegmentReader) -> Option<u64> {
343                None
344            }
345        }
346
347        /// Scored document stream: a DocSet that also provides scores.
348        pub trait Scorer: super::docset::DocSet + $($send_bounds)* {
349            /// Score for current document
350            fn score(&self) -> Score;
351
352            /// Get matched positions for the current document (if available)
353            /// Returns (field_id, positions) pairs where positions are encoded as per PositionMode
354            fn matched_positions(&self) -> Option<MatchedPositions> {
355                None
356            }
357        }
358    };
359}
360
361#[cfg(not(target_arch = "wasm32"))]
362define_query_traits!(Send + Sync);
363
364#[cfg(target_arch = "wasm32")]
365define_query_traits!();
366
367impl Query for Box<dyn Query> {
368    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
369        (**self).scorer(reader, limit)
370    }
371
372    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
373        (**self).count_estimate(reader)
374    }
375
376    fn scorer_with_options<'a>(
377        &self,
378        reader: &'a SegmentReader,
379        limit: usize,
380        options: ScorerOptions,
381    ) -> ScorerFuture<'a> {
382        (**self).scorer_with_options(reader, limit, options)
383    }
384
385    fn decompose(&self) -> QueryDecomposition {
386        (**self).decompose()
387    }
388
389    fn is_filter(&self) -> bool {
390        (**self).is_filter()
391    }
392
393    fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<DocPredicate<'a>> {
394        (**self).as_doc_predicate(reader)
395    }
396
397    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<DocBitset> {
398        (**self).as_doc_bitset(reader)
399    }
400
401    fn bitset_cardinality_estimate(&self, reader: &SegmentReader) -> Option<u64> {
402        (**self).bitset_cardinality_estimate(reader)
403    }
404
405    #[cfg(feature = "sync")]
406    fn scorer_sync<'a>(
407        &self,
408        reader: &'a SegmentReader,
409        limit: usize,
410    ) -> Result<Box<dyn Scorer + 'a>> {
411        (**self).scorer_sync(reader, limit)
412    }
413
414    #[cfg(feature = "sync")]
415    fn scorer_sync_with_options<'a>(
416        &self,
417        reader: &'a SegmentReader,
418        limit: usize,
419        options: ScorerOptions,
420    ) -> Result<Box<dyn Scorer + 'a>> {
421        (**self).scorer_sync_with_options(reader, limit, options)
422    }
423}
424
425/// Empty scorer for terms that don't exist
426pub struct EmptyScorer;
427
428impl super::docset::DocSet for EmptyScorer {
429    fn doc(&self) -> DocId {
430        crate::structures::TERMINATED
431    }
432
433    fn advance(&mut self) -> DocId {
434        crate::structures::TERMINATED
435    }
436
437    fn seek(&mut self, _target: DocId) -> DocId {
438        crate::structures::TERMINATED
439    }
440
441    fn size_hint(&self) -> u32 {
442        0
443    }
444}
445
446impl Scorer for EmptyScorer {
447    fn score(&self) -> Score {
448        0.0
449    }
450}