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