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, $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
114        // Non-indexed fields → fast-field-only path
115        let is_indexed = reader.schema().get_field_entry(field).is_none_or(|e| e.indexed);
116        if !is_indexed {
117            let term_str = String::from_utf8_lossy(term);
118            if let Some(scorer) = FastFieldTextScorer::try_new(reader, field, &term_str) {
119                return Ok(Box::new(scorer) as Box<dyn Scorer + '_>);
120            }
121            return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
122        }
123
124        let postings = reader.$get_postings_fn(field, term) $(. $aw)* ?;
125
126        match postings {
127            // Chunked field: postings are keyed by virtual chunk id. Score the
128            // chunks, fold them back to documents and report the ordinals.
129            Some(posting_list) if reader.is_chunked_field(field) => {
130                let num_chunks = reader.text_corpus_size(field);
131                let idf = super::bm25_idf(posting_list.doc_count() as f32, num_chunks);
132                super::planner::finish_chunked_text_maxscore(
133                    vec![(posting_list, idf)],
134                    limit,
135                    reader,
136                    field,
137                    None,
138                    None,
139                    1.0,
140                    None,
141                )
142            }
143            Some(posting_list) => {
144                let (idf, avg_field_len) =
145                    compute_term_idf(&posting_list, field, reader, global_stats, term);
146
147                let positions = if $load_positions {
148                    reader.$get_positions_fn(field, term) $(. $aw)* ?
149                } else {
150                    None
151                };
152
153                let mut scorer = TermScorer::new(posting_list, idf, avg_field_len, 1.0)
154                    .with_params(super::Bm25Params::for_field(reader.schema(), field));
155                if let Some(lengths) = reader.doc_lengths(field) {
156                    scorer = scorer.with_doc_lengths(lengths.clone());
157                }
158                if let Some(pos) = positions {
159                    scorer = scorer.with_positions(field.0, pos);
160                }
161                Ok(Box::new(scorer) as Box<dyn Scorer + '_>)
162            }
163            None => {
164                let term_str = String::from_utf8_lossy(term);
165                if let Some(scorer) = FastFieldTextScorer::try_new(reader, field, &term_str) {
166                    Ok(Box::new(scorer) as Box<dyn Scorer + '_>)
167                } else {
168                    Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>)
169                }
170            }
171        }
172    }};
173}
174
175impl Query for TermQuery {
176    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
177        self.scorer_with_options(reader, limit, super::ScorerOptions::with_positions())
178    }
179
180    fn scorer_with_options<'a>(
181        &self,
182        reader: &'a SegmentReader,
183        limit: usize,
184        options: super::ScorerOptions,
185    ) -> ScorerFuture<'a> {
186        let field = self.field;
187        let term = self.term.clone();
188        let global_stats = self
189            .global_stats
190            .clone()
191            .or_else(|| options.global_stats.clone());
192        let load_positions = options.collect_positions;
193        Box::pin(async move {
194            term_plan!(
195                field,
196                &term,
197                global_stats.as_ref(),
198                reader,
199                limit,
200                load_positions,
201                get_postings,
202                get_positions,
203                await
204            )
205        })
206    }
207
208    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
209        let field = self.field;
210        let term = self.term.clone();
211        Box::pin(async move {
212            match reader.get_postings(field, &term).await? {
213                Some(list) => Ok(list.doc_count()),
214                None => Ok(0),
215            }
216        })
217    }
218
219    #[cfg(feature = "sync")]
220    fn scorer_sync<'a>(
221        &self,
222        reader: &'a SegmentReader,
223        limit: usize,
224    ) -> crate::Result<Box<dyn Scorer + 'a>> {
225        self.scorer_sync_with_options(reader, limit, super::ScorerOptions::with_positions())
226    }
227
228    #[cfg(feature = "sync")]
229    fn scorer_sync_with_options<'a>(
230        &self,
231        reader: &'a SegmentReader,
232        limit: usize,
233        options: super::ScorerOptions,
234    ) -> crate::Result<Box<dyn Scorer + 'a>> {
235        let global_stats = self
236            .global_stats
237            .clone()
238            .or_else(|| options.global_stats.clone());
239        term_plan!(
240            self.field,
241            &self.term,
242            global_stats.as_ref(),
243            reader,
244            limit,
245            options.collect_positions,
246            get_postings_sync,
247            get_positions_sync
248        )
249    }
250
251    fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<super::DocPredicate<'a>> {
252        let fast_field = reader.fast_field(self.field.0)?;
253        let term_str = String::from_utf8_lossy(&self.term);
254        match fast_field.text_ordinal(&term_str) {
255            Some(target_ordinal) => Some(Box::new(move |doc_id: DocId| -> bool {
256                fast_field.get_u64(doc_id) == target_ordinal
257            })),
258            // Term doesn't exist in this segment — no doc can match.
259            None => Some(Box::new(|_| false)),
260        }
261    }
262
263    #[cfg(feature = "sync")]
264    fn bitset_cardinality_estimate(&self, reader: &SegmentReader) -> Option<u64> {
265        // Chunked postings count chunks, not documents.
266        if reader.is_chunked_field(self.field) {
267            return None;
268        }
269        // Exact: the posting list header carries the doc count.
270        let pl = reader.get_postings_sync(self.field, &self.term).ok()??;
271        Some(pl.doc_count() as u64)
272    }
273
274    #[cfg(feature = "sync")]
275    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<super::DocBitset> {
276        // Chunked postings are keyed by virtual chunk ids, not document ids;
277        // a bitset over them would filter the wrong documents.
278        if reader.is_chunked_field(self.field) {
279            return None;
280        }
281        // Build bitset from posting list: O(M) where M = matching doc count.
282        // Much faster than O(N) fast-field scan for selective terms.
283        let pl = reader.get_postings_sync(self.field, &self.term).ok()??;
284        let mut bitset = super::DocBitset::new(reader.num_docs());
285        let mut iter = pl.iterator();
286        loop {
287            let doc = iter.doc();
288            if doc == crate::structures::TERMINATED {
289                break;
290            }
291            bitset.set(doc);
292            iter.advance();
293        }
294        Some(bitset)
295    }
296
297    fn text_terms(&self, out: &mut Vec<(Field, Vec<u8>)>) {
298        out.push((self.field, self.term.clone()));
299    }
300
301    fn decompose(&self) -> super::QueryDecomposition {
302        super::QueryDecomposition::TextTerm(TermQueryInfo {
303            weight: 1.0,
304            field: self.field,
305            term: self.term.clone(),
306        })
307    }
308}
309
310struct TermScorer {
311    iterator: crate::structures::BlockPostingIterator<'static>,
312    idf: f32,
313    /// Average field length for this field
314    avg_field_len: f32,
315    /// Field boost/weight for BM25F
316    field_boost: f32,
317    /// Field ID for position reporting
318    field_id: u32,
319    /// Positions of the term (if positions are enabled)
320    positions: Option<crate::structures::TermPositions>,
321    /// Persisted per-document field lengths; `None` keeps `tf` as the length.
322    lengths: Option<crate::segment::chunk_map::DocLengths>,
323    /// Per-field k1/b.
324    params: super::Bm25Params,
325}
326
327impl TermScorer {
328    pub fn new(
329        posting_list: BlockPostingList,
330        idf: f32,
331        avg_field_len: f32,
332        field_boost: f32,
333    ) -> Self {
334        Self {
335            iterator: posting_list.into_iterator(),
336            idf,
337            avg_field_len,
338            field_boost,
339            field_id: 0,
340            positions: None,
341            lengths: None,
342            params: super::Bm25Params::default(),
343        }
344    }
345
346    /// Score with the field's BM25 parameters.
347    pub fn with_params(mut self, params: super::Bm25Params) -> Self {
348        self.params = params;
349        self
350    }
351
352    /// Score with the field's persisted per-document lengths.
353    pub fn with_doc_lengths(mut self, lengths: crate::segment::chunk_map::DocLengths) -> Self {
354        self.lengths = Some(lengths);
355        self
356    }
357
358    pub fn with_positions(
359        mut self,
360        field_id: u32,
361        positions: crate::structures::TermPositions,
362    ) -> Self {
363        self.field_id = field_id;
364        self.positions = Some(positions);
365        self
366    }
367}
368
369impl super::docset::DocSet for TermScorer {
370    fn doc(&self) -> DocId {
371        self.iterator.doc()
372    }
373
374    fn advance(&mut self) -> DocId {
375        self.iterator.advance()
376    }
377
378    fn seek(&mut self, target: DocId) -> DocId {
379        self.iterator.seek(target)
380    }
381
382    fn size_hint(&self) -> u32 {
383        0
384    }
385}
386
387// ── Fast field text equality scorer ──────────────────────────────────────
388
389/// Scorer that scans a text fast field for exact string equality.
390/// Used as fallback when a TermQuery targets a fast-only text field (no inverted index).
391/// Returns score 1.0 for matching docs (filter-style, like RangeScorer).
392struct FastFieldTextScorer<'a> {
393    fast_field: &'a crate::structures::fast_field::FastFieldReader,
394    target_ordinal: u64,
395    current: u32,
396    num_docs: u32,
397}
398
399impl<'a> FastFieldTextScorer<'a> {
400    fn try_new(reader: &'a SegmentReader, field: Field, text: &str) -> Option<Self> {
401        let fast_field = reader.fast_field(field.0)?;
402        let target_ordinal = fast_field.text_ordinal(text)?;
403        let num_docs = reader.num_docs();
404        let mut scorer = Self {
405            fast_field,
406            target_ordinal,
407            current: 0,
408            num_docs,
409        };
410        // Position on first matching doc
411        if num_docs > 0 && fast_field.get_u64(0) != target_ordinal {
412            scorer.scan_forward();
413        }
414        Some(scorer)
415    }
416
417    fn scan_forward(&mut self) {
418        loop {
419            self.current += 1;
420            if self.current >= self.num_docs {
421                self.current = self.num_docs;
422                return;
423            }
424            if self.fast_field.get_u64(self.current) == self.target_ordinal {
425                return;
426            }
427        }
428    }
429}
430
431impl super::docset::DocSet for FastFieldTextScorer<'_> {
432    fn doc(&self) -> DocId {
433        if self.current >= self.num_docs {
434            TERMINATED
435        } else {
436            self.current
437        }
438    }
439
440    fn advance(&mut self) -> DocId {
441        self.scan_forward();
442        self.doc()
443    }
444
445    fn seek(&mut self, target: DocId) -> DocId {
446        if target > self.current {
447            self.current = target;
448            if self.current < self.num_docs
449                && self.fast_field.get_u64(self.current) != self.target_ordinal
450            {
451                self.scan_forward();
452            }
453        }
454        self.doc()
455    }
456
457    fn size_hint(&self) -> u32 {
458        0
459    }
460}
461
462impl Scorer for FastFieldTextScorer<'_> {
463    fn score(&self) -> Score {
464        1.0
465    }
466}
467
468impl Scorer for TermScorer {
469    fn score(&self) -> Score {
470        let tf = self.iterator.term_freq() as f32;
471        // Persisted field length when the segment has norms; otherwise `tf`
472        // stands in for the length (legacy segments).
473        let doc_len = self
474            .lengths
475            .as_ref()
476            .map(|lengths| lengths.length(self.iterator.doc()) as f32)
477            .filter(|len| *len > 0.0)
478            .unwrap_or(tf);
479        self.params
480            .score_boosted(tf, self.idf, doc_len, self.avg_field_len, self.field_boost)
481    }
482
483    fn matched_positions(&self) -> Option<super::MatchedPositions> {
484        let positions = self.positions.as_ref()?;
485        let doc_id = self.iterator.doc();
486        let pos = positions.positions(
487            doc_id,
488            self.iterator.position_cursor(),
489            self.iterator.term_freq(),
490        )?;
491        let score = self.score();
492        // Each position contributes equally to the term score
493        let per_position_score = if pos.is_empty() {
494            0.0
495        } else {
496            score / pos.len() as f32
497        };
498        let scored_positions: Vec<super::ScoredPosition> = pos
499            .iter()
500            .map(|&p| super::ScoredPosition::new(p, per_position_score))
501            .collect();
502        Some(vec![(self.field_id, scored_positions)])
503    }
504}