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