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