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