Skip to main content

hermes_core/query/
term.rs

1//! Term query - matches documents containing a specific term
2
3use std::sync::Arc;
4
5use crate::dsl::Field;
6use crate::segment::SegmentReader;
7use crate::structures::BlockPostingList;
8use crate::structures::TERMINATED;
9use crate::{DocId, Score};
10
11use super::{CountFuture, EmptyScorer, GlobalStats, Query, Scorer, ScorerFuture, TermQueryInfo};
12
13/// Term query - matches documents containing a specific term
14#[derive(Clone)]
15pub struct TermQuery {
16    pub field: Field,
17    pub term: Vec<u8>,
18    /// Optional global statistics for cross-segment IDF
19    global_stats: Option<Arc<GlobalStats>>,
20}
21
22impl std::fmt::Debug for TermQuery {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        f.debug_struct("TermQuery")
25            .field("field", &self.field)
26            .field("term", &String::from_utf8_lossy(&self.term))
27            .field("has_global_stats", &self.global_stats.is_some())
28            .finish()
29    }
30}
31
32impl std::fmt::Display for TermQuery {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        write!(
35            f,
36            "Term({}:\"{}\")",
37            self.field.0,
38            String::from_utf8_lossy(&self.term)
39        )
40    }
41}
42
43impl TermQuery {
44    pub fn new(field: Field, term: impl Into<Vec<u8>>) -> Self {
45        Self {
46            field,
47            term: term.into(),
48            global_stats: None,
49        }
50    }
51
52    pub fn text(field: Field, text: &str) -> Self {
53        Self {
54            field,
55            term: text.to_lowercase().into_bytes(),
56            global_stats: None,
57        }
58    }
59
60    /// Create with global statistics for cross-segment IDF
61    pub fn with_global_stats(field: Field, text: &str, stats: Arc<GlobalStats>) -> Self {
62        Self {
63            field,
64            term: text.to_lowercase().into_bytes(),
65            global_stats: Some(stats),
66        }
67    }
68
69    /// Set global statistics for cross-segment IDF
70    pub fn set_global_stats(&mut self, stats: Arc<GlobalStats>) {
71        self.global_stats = Some(stats);
72    }
73}
74
75/// Compute (idf, avg_field_len) from a posting list, using global stats when available.
76fn compute_term_idf(
77    posting_list: &BlockPostingList,
78    field: Field,
79    reader: &SegmentReader,
80    global_stats: Option<&Arc<GlobalStats>>,
81    term: &[u8],
82) -> (f32, f32) {
83    if let Some(stats) = global_stats {
84        let term_str = String::from_utf8_lossy(term);
85        let global_idf = stats.text_idf(field, &term_str);
86        if global_idf > 0.0 {
87            return (global_idf, stats.avg_field_len(field));
88        }
89    }
90    let num_docs = reader.num_docs() as f32;
91    let doc_freq = posting_list.doc_count() as f32;
92    (
93        super::bm25_idf(doc_freq, num_docs),
94        reader.avg_field_len(field),
95    )
96}
97
98// ── Unified term scorer macro ────────────────────────────────────────────
99//
100// Parameterised on:
101//   $get_postings_fn – get_postings | get_postings_sync
102//   $get_positions_fn – get_positions | get_positions_sync
103//   $($aw)*          – .await  (present for async, absent for sync)
104macro_rules! term_plan {
105    ($field:expr, $term:expr, $global_stats:expr, $reader:expr, $limit:expr,
106     $load_positions:expr, $budget:expr, $get_postings_fn:ident, $get_positions_fn:ident
107     $(, $aw:tt)*) => {{
108        let field: Field = $field;
109        let term: &[u8] = $term;
110        let global_stats: Option<&Arc<GlobalStats>> = $global_stats;
111        let reader: &SegmentReader = $reader;
112        let limit: usize = $limit;
113        let budget: Option<&super::SharedThreshold> = $budget;
114        if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
115            return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
116        }
117
118        // Non-indexed fields → fast-field-only path
119        let is_indexed = reader.schema().get_field_entry(field).is_none_or(|e| e.indexed);
120        if !is_indexed {
121            let term_str = String::from_utf8_lossy(term);
122            if let Some(scorer) = FastFieldTextScorer::try_new(reader, field, &term_str) {
123                return Ok(Box::new(scorer) as Box<dyn Scorer + '_>);
124            }
125            return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
126        }
127
128        let postings = reader.$get_postings_fn(field, term) $(. $aw)* ?;
129
130        match postings {
131            // Chunked field: postings are keyed by virtual chunk id. Score the
132            // chunks, fold them back to documents and report the ordinals.
133            Some(posting_list) if reader.is_chunked_field(field) => {
134                let num_chunks = reader.text_corpus_size(field);
135                let idf = super::bm25_idf(posting_list.doc_count() as f32, num_chunks);
136                super::planner::finish_chunked_text_maxscore(
137                    vec![(posting_list, idf)],
138                    limit,
139                    reader,
140                    field,
141                    None,
142                    None,
143                    1.0,
144                    budget,
145                )
146            }
147            Some(posting_list) => {
148                let (idf, avg_field_len) =
149                    compute_term_idf(&posting_list, field, reader, global_stats, term);
150
151                let positions = if $load_positions {
152                    reader.$get_positions_fn(field, term) $(. $aw)* ?
153                } else {
154                    None
155                };
156
157                let mut scorer = TermScorer::new(posting_list, idf, avg_field_len, 1.0)
158                    .with_params(super::Bm25Params::for_field(reader.schema(), field));
159                scorer.budget = budget.filter(|b| b.deadline().is_some()).cloned();
160                if let Some(lengths) = reader.doc_lengths(field) {
161                    scorer = scorer.with_doc_lengths(lengths.clone());
162                }
163                if let Some(pos) = positions {
164                    scorer = scorer.with_positions(field.0, pos);
165                }
166                Ok(Box::new(scorer) as Box<dyn Scorer + '_>)
167            }
168            None => {
169                let term_str = String::from_utf8_lossy(term);
170                if let Some(scorer) = FastFieldTextScorer::try_new(reader, field, &term_str) {
171                    Ok(Box::new(scorer) as Box<dyn Scorer + '_>)
172                } else {
173                    Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>)
174                }
175            }
176        }
177    }};
178}
179
180impl Query for TermQuery {
181    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
182        self.scorer_with_options(reader, limit, super::ScorerOptions::with_positions())
183    }
184
185    fn scorer_with_options<'a>(
186        &self,
187        reader: &'a SegmentReader,
188        limit: usize,
189        options: super::ScorerOptions,
190    ) -> ScorerFuture<'a> {
191        let field = self.field;
192        let term = self.term.clone();
193        let global_stats = self
194            .global_stats
195            .clone()
196            .or_else(|| options.global_stats.clone());
197        let load_positions = options.collect_positions;
198        Box::pin(async move {
199            term_plan!(
200                field,
201                &term,
202                global_stats.as_ref(),
203                reader,
204                limit,
205                load_positions,
206                options.shared_threshold.as_ref(),
207                get_postings,
208                get_positions,
209                await
210            )
211        })
212    }
213
214    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
215        let field = self.field;
216        let term = self.term.clone();
217        Box::pin(async move {
218            match reader.get_postings(field, &term).await? {
219                Some(list) => Ok(list.doc_count()),
220                None => Ok(0),
221            }
222        })
223    }
224
225    #[cfg(feature = "sync")]
226    fn scorer_sync<'a>(
227        &self,
228        reader: &'a SegmentReader,
229        limit: usize,
230    ) -> crate::Result<Box<dyn Scorer + 'a>> {
231        self.scorer_sync_with_options(reader, limit, super::ScorerOptions::with_positions())
232    }
233
234    #[cfg(feature = "sync")]
235    fn scorer_sync_with_options<'a>(
236        &self,
237        reader: &'a SegmentReader,
238        limit: usize,
239        options: super::ScorerOptions,
240    ) -> crate::Result<Box<dyn Scorer + 'a>> {
241        let global_stats = self
242            .global_stats
243            .clone()
244            .or_else(|| options.global_stats.clone());
245        term_plan!(
246            self.field,
247            &self.term,
248            global_stats.as_ref(),
249            reader,
250            limit,
251            options.collect_positions,
252            options.shared_threshold.as_ref(),
253            get_postings_sync,
254            get_positions_sync
255        )
256    }
257
258    fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<super::DocPredicate<'a>> {
259        let fast_field = reader.fast_field(self.field.0)?;
260        let term_str = String::from_utf8_lossy(&self.term);
261        match fast_field.text_ordinal(&term_str) {
262            Some(target_ordinal) => Some(Box::new(move |doc_id: DocId| -> bool {
263                fast_field.get_u64(doc_id) == target_ordinal
264            })),
265            // Term doesn't exist in this segment — no doc can match.
266            None => Some(Box::new(|_| false)),
267        }
268    }
269
270    #[cfg(feature = "sync")]
271    fn bitset_cardinality_estimate(&self, reader: &SegmentReader) -> Option<u64> {
272        // Chunked postings count chunks, not documents.
273        if reader.is_chunked_field(self.field) {
274            return None;
275        }
276        // Exact: the posting list header carries the doc count.
277        let pl = reader.get_postings_sync(self.field, &self.term).ok()??;
278        Some(pl.doc_count() as u64)
279    }
280
281    #[cfg(feature = "sync")]
282    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<super::DocBitset> {
283        // Chunked postings are keyed by virtual chunk ids, not document ids;
284        // a bitset over them would filter the wrong documents.
285        if reader.is_chunked_field(self.field) {
286            return None;
287        }
288        // Build bitset from posting list: O(M) where M = matching doc count.
289        // Much faster than O(N) fast-field scan for selective terms.
290        let pl = reader.get_postings_sync(self.field, &self.term).ok()??;
291        let mut bitset = super::DocBitset::new(reader.num_docs());
292        let mut iter = pl.iterator();
293        loop {
294            let doc = iter.doc();
295            if doc == crate::structures::TERMINATED {
296                break;
297            }
298            bitset.set(doc);
299            iter.advance();
300        }
301        Some(bitset)
302    }
303
304    fn text_terms(&self, out: &mut Vec<(Field, Vec<u8>)>) {
305        out.push((self.field, self.term.clone()));
306    }
307
308    fn decompose(&self) -> super::QueryDecomposition {
309        super::QueryDecomposition::TextTerm(TermQueryInfo {
310            weight: 1.0,
311            field: self.field,
312            term: self.term.clone(),
313        })
314    }
315}
316
317struct TermScorer {
318    budget: Option<super::SharedThreshold>,
319    iterator: crate::structures::BlockPostingIterator<'static>,
320    idf: f32,
321    /// Average field length for this field
322    avg_field_len: f32,
323    /// Field boost/weight for BM25F
324    field_boost: f32,
325    /// Field ID for position reporting
326    field_id: u32,
327    /// Positions of the term (if positions are enabled)
328    positions: Option<crate::structures::TermPositions>,
329    /// Persisted per-document field lengths; `None` keeps `tf` as the length.
330    lengths: Option<crate::segment::chunk_map::DocLengths>,
331    /// Per-field k1/b.
332    params: super::Bm25Params,
333}
334
335impl TermScorer {
336    pub fn new(
337        posting_list: BlockPostingList,
338        idf: f32,
339        avg_field_len: f32,
340        field_boost: f32,
341    ) -> Self {
342        Self {
343            budget: None,
344            iterator: posting_list.into_iterator(),
345            idf,
346            avg_field_len,
347            field_boost,
348            field_id: 0,
349            positions: None,
350            lengths: None,
351            params: super::Bm25Params::default(),
352        }
353    }
354
355    /// Score with the field's BM25 parameters.
356    pub fn with_params(mut self, params: super::Bm25Params) -> Self {
357        self.params = params;
358        self
359    }
360
361    /// Score with the field's persisted per-document lengths.
362    pub fn with_doc_lengths(mut self, lengths: crate::segment::chunk_map::DocLengths) -> Self {
363        self.lengths = Some(lengths);
364        self
365    }
366
367    pub fn with_positions(
368        mut self,
369        field_id: u32,
370        positions: crate::structures::TermPositions,
371    ) -> Self {
372        self.field_id = field_id;
373        self.positions = Some(positions);
374        self
375    }
376}
377
378impl super::docset::DocSet for TermScorer {
379    fn doc(&self) -> DocId {
380        if self
381            .budget
382            .as_ref()
383            .is_some_and(super::SharedThreshold::stop_if_expired)
384        {
385            return TERMINATED;
386        }
387        self.iterator.doc()
388    }
389
390    fn advance(&mut self) -> DocId {
391        if self.doc() == TERMINATED {
392            return TERMINATED;
393        }
394        self.iterator.advance()
395    }
396
397    fn seek(&mut self, target: DocId) -> DocId {
398        if self.doc() == TERMINATED {
399            return TERMINATED;
400        }
401        self.iterator.seek(target)
402    }
403
404    fn size_hint(&self) -> u32 {
405        0
406    }
407}
408
409// ── Fast field text equality scorer ──────────────────────────────────────
410
411/// Scorer that scans a text fast field for exact string equality.
412/// Used as fallback when a TermQuery targets a fast-only text field (no inverted index).
413/// Returns score 1.0 for matching docs (filter-style, like RangeScorer).
414struct FastFieldTextScorer<'a> {
415    fast_field: &'a crate::structures::fast_field::FastFieldReader,
416    target_ordinal: u64,
417    current: u32,
418    num_docs: u32,
419}
420
421impl<'a> FastFieldTextScorer<'a> {
422    fn try_new(reader: &'a SegmentReader, field: Field, text: &str) -> Option<Self> {
423        let fast_field = reader.fast_field(field.0)?;
424        let target_ordinal = fast_field.text_ordinal(text)?;
425        let num_docs = reader.num_docs();
426        let mut scorer = Self {
427            fast_field,
428            target_ordinal,
429            current: 0,
430            num_docs,
431        };
432        // Position on first matching doc
433        if num_docs > 0 && fast_field.get_u64(0) != target_ordinal {
434            scorer.scan_forward();
435        }
436        Some(scorer)
437    }
438
439    fn scan_forward(&mut self) {
440        loop {
441            self.current += 1;
442            if self.current >= self.num_docs {
443                self.current = self.num_docs;
444                return;
445            }
446            if self.fast_field.get_u64(self.current) == self.target_ordinal {
447                return;
448            }
449        }
450    }
451}
452
453impl super::docset::DocSet for FastFieldTextScorer<'_> {
454    fn doc(&self) -> DocId {
455        if self.current >= self.num_docs {
456            TERMINATED
457        } else {
458            self.current
459        }
460    }
461
462    fn advance(&mut self) -> DocId {
463        self.scan_forward();
464        self.doc()
465    }
466
467    fn seek(&mut self, target: DocId) -> DocId {
468        if target > self.current {
469            self.current = target;
470            if self.current < self.num_docs
471                && self.fast_field.get_u64(self.current) != self.target_ordinal
472            {
473                self.scan_forward();
474            }
475        }
476        self.doc()
477    }
478
479    fn size_hint(&self) -> u32 {
480        0
481    }
482}
483
484impl Scorer for FastFieldTextScorer<'_> {
485    fn score(&self) -> Score {
486        1.0
487    }
488}
489
490impl Scorer for TermScorer {
491    fn score(&self) -> Score {
492        let tf = self.iterator.term_freq() as f32;
493        // Persisted field length when the segment has norms; otherwise `tf`
494        // stands in for the length (legacy segments).
495        let doc_len = self
496            .lengths
497            .as_ref()
498            .map(|lengths| lengths.length(self.iterator.doc()) as f32)
499            .filter(|len| *len > 0.0)
500            .unwrap_or(tf);
501        self.params
502            .score_boosted(tf, self.idf, doc_len, self.avg_field_len, self.field_boost)
503    }
504
505    fn matched_positions(&self) -> Option<super::MatchedPositions> {
506        let positions = self.positions.as_ref()?;
507        let doc_id = self.iterator.doc();
508        let pos = positions.positions(
509            doc_id,
510            self.iterator.position_cursor(),
511            self.iterator.term_freq(),
512        )?;
513        let score = self.score();
514        // Each position contributes equally to the term score
515        let per_position_score = if pos.is_empty() {
516            0.0
517        } else {
518            score / pos.len() as f32
519        };
520        let scored_positions: Vec<super::ScoredPosition> = pos
521            .iter()
522            .map(|&p| super::ScoredPosition::new(p, per_position_score))
523            .collect();
524        Some(vec![(self.field_id, scored_positions)])
525    }
526}