Skip to main content

hermes_core/query/
phrase.rs

1//! Phrase query - matches documents containing terms in consecutive positions
2
3use std::sync::Arc;
4
5use crate::dsl::Field;
6use crate::segment::SegmentReader;
7use crate::structures::{BlockPostingIterator, BlockPostingList, TERMINATED, TermPositions};
8use crate::{DocId, Score};
9
10use super::{CountFuture, EmptyScorer, GlobalStats, Query, Scorer, ScorerFuture};
11
12/// Phrase query - matches documents containing terms in consecutive positions
13///
14/// Example: "quick brown fox" matches only if all three terms appear
15/// consecutively in the document.
16#[derive(Clone)]
17pub struct PhraseQuery {
18    pub field: Field,
19    /// Terms in the phrase, in order
20    pub terms: Vec<Vec<u8>>,
21    /// Token offset of each term inside the phrase, ascending, one per term.
22    /// `offsets[i + 1] - offsets[i]` is the required distance between two
23    /// consecutive terms: 1 for adjacent words, more when index-time stop
24    /// words were dropped between them (`quantum@0 art@3`). [`PhraseQuery::new`]
25    /// makes every term adjacent.
26    pub offsets: Vec<u32>,
27    /// Optional slop (max distance between terms, 0 = exact phrase)
28    pub slop: u32,
29    /// Optional global statistics for cross-segment IDF
30    global_stats: Option<Arc<GlobalStats>>,
31}
32
33impl std::fmt::Display for PhraseQuery {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        let terms: Vec<String> = self
36            .terms
37            .iter()
38            .zip(&self.offsets)
39            .map(|(term, offset)| {
40                if self.is_adjacent() {
41                    String::from_utf8_lossy(term).into_owned()
42                } else {
43                    format!("{}@{offset}", String::from_utf8_lossy(term))
44                }
45            })
46            .collect();
47        write!(f, "Phrase({}:\"{}\"", self.field.0, terms.join(" "))?;
48        if self.slop > 0 {
49            write!(f, "~{}", self.slop)?;
50        }
51        write!(f, ")")
52    }
53}
54
55impl std::fmt::Debug for PhraseQuery {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        let terms: Vec<_> = self
58            .terms
59            .iter()
60            .map(|t| String::from_utf8_lossy(t).to_string())
61            .collect();
62        f.debug_struct("PhraseQuery")
63            .field("field", &self.field)
64            .field("terms", &terms)
65            .field("offsets", &self.offsets)
66            .field("slop", &self.slop)
67            .finish()
68    }
69}
70
71impl PhraseQuery {
72    /// Create a new exact phrase query of adjacent terms.
73    pub fn new(field: Field, terms: Vec<Vec<u8>>) -> Self {
74        let offsets = (0..terms.len() as u32).collect();
75        Self {
76            field,
77            terms,
78            offsets,
79            slop: 0,
80            global_stats: None,
81        }
82    }
83
84    /// Create a phrase whose terms carry their token offsets, as produced by
85    /// a tokenizer that drops stop words without renumbering (`(0, quantum)`,
86    /// `(3, art)` for "quantum of the art"). Offsets must be ascending.
87    pub fn with_offsets(field: Field, terms: Vec<(u32, Vec<u8>)>) -> Self {
88        debug_assert!(
89            terms.windows(2).all(|pair| pair[0].0 < pair[1].0),
90            "phrase offsets must be strictly ascending"
91        );
92        let (offsets, terms): (Vec<u32>, Vec<Vec<u8>>) = terms.into_iter().unzip();
93        Self {
94            field,
95            terms,
96            offsets,
97            slop: 0,
98            global_stats: None,
99        }
100    }
101
102    /// Create from text using the simple tokenizer (whitespace split,
103    /// punctuation stripped, lowercased). Fields with a stemming tokenizer
104    /// should tokenize the phrase themselves and call
105    /// [`PhraseQuery::with_offsets`] so the query terms match the indexed
106    /// stems and keep the gaps of dropped stop words.
107    pub fn text(field: Field, phrase: &str) -> Self {
108        use crate::tokenizer::Tokenizer;
109        let terms: Vec<(u32, Vec<u8>)> = crate::tokenizer::SimpleTokenizer
110            .tokenize(phrase)
111            .into_iter()
112            .map(|token| (token.position, token.text.into_bytes()))
113            .collect();
114        Self::with_offsets(field, terms)
115    }
116
117    /// Whether every term must directly follow the previous one.
118    fn is_adjacent(&self) -> bool {
119        self.offsets.windows(2).all(|pair| pair[1] == pair[0] + 1)
120    }
121
122    /// Set slop (max distance between terms)
123    pub fn with_slop(mut self, slop: u32) -> Self {
124        self.slop = slop;
125        self
126    }
127
128    /// Set global statistics for cross-segment IDF
129    pub fn with_global_stats(mut self, stats: Arc<GlobalStats>) -> Self {
130        self.global_stats = Some(stats);
131        self
132    }
133}
134
135/// Phrase over a chunked field: match and score every chunk (posting ids are
136/// virtual chunk ids, positions restart per chunk so a phrase never spans two
137/// chunks), then fold the chunk hits into documents with per-ordinal scores.
138///
139/// The phrase is a conjunction, so draining the positional scorer costs one
140/// pass over the matching chunks only.
141fn build_chunked_phrase_scorer<'a>(
142    term_data: Vec<(BlockPostingList, TermPositions)>,
143    offsets: &[u32],
144    slop: u32,
145    reader: &SegmentReader,
146    field: Field,
147    limit: usize,
148) -> crate::Result<Box<dyn Scorer + 'a>> {
149    let Some(chunk_map) = reader.chunk_map(field) else {
150        return Err(crate::Error::Corruption(format!(
151            "chunked text field '{}' has postings but segment {:016x} carries no chunk map",
152            reader.schema().get_field_name(field).unwrap_or("?"),
153            reader.meta().id,
154        )));
155    };
156    let num_chunks = chunk_map.num_chunks() as f32;
157    let idf: f32 = term_data
158        .iter()
159        .map(|(p, _)| super::bm25_idf(p.doc_count() as f32, num_chunks))
160        .sum();
161    let (postings, positions): (Vec<_>, Vec<_>) = term_data.into_iter().unzip();
162    let mut scorer =
163        PhraseScorer::new(postings, positions, offsets, slop, idf, chunk_map.avg_len())
164            .with_lengths(Lengths::Chunks(chunk_map.clone()))
165            .with_params(super::Bm25Params::for_field(reader.schema(), field));
166
167    use super::docset::DocSet as _;
168    let mut raw: Vec<(u32, u16, f32)> = Vec::new();
169    while scorer.doc() != TERMINATED {
170        let (doc_id, ordinal) = chunk_map.resolve(scorer.doc());
171        raw.push((doc_id, ordinal, scorer.score()));
172        scorer.advance();
173    }
174    // Every matching document is kept: a phrase is also used as a MUST
175    // constraint (verifier or bitset), where truncating to `limit` would
176    // silently reject documents that do contain the phrase.
177    let _ = limit;
178    let combined =
179        crate::segment::combine_ordinal_results(raw, super::MultiValueCombiner::Max, usize::MAX);
180    Ok(Box::new(super::vector::VectorResultScorer::new(combined, field.0)) as Box<dyn Scorer + 'a>)
181}
182
183/// Build a PhraseScorer from already-fetched term data.
184fn build_phrase_scorer<'a>(
185    term_data: Vec<(BlockPostingList, TermPositions)>,
186    offsets: &[u32],
187    slop: u32,
188    reader: &SegmentReader,
189    field: Field,
190) -> Box<dyn Scorer + 'a> {
191    let idf: f32 = term_data
192        .iter()
193        .map(|(p, _)| {
194            let num_docs = reader.num_docs() as f32;
195            let doc_freq = p.doc_count() as f32;
196            super::bm25_idf(doc_freq, num_docs)
197        })
198        .sum();
199    let avg_field_len = reader.avg_field_len(field);
200    let (postings, positions): (Vec<_>, Vec<_>) = term_data.into_iter().unzip();
201    let mut scorer = PhraseScorer::new(postings, positions, offsets, slop, idf, avg_field_len)
202        .with_params(super::Bm25Params::for_field(reader.schema(), field));
203    if let Some(lengths) = reader.doc_lengths(field) {
204        scorer = scorer.with_lengths(Lengths::Docs(lengths.clone()));
205    }
206    Box::new(scorer)
207}
208
209// ── Shared early-return checks for phrase scorer ─────────────────────────
210//
211// Handles: empty terms, single-term delegation, no-positions fallback.
212// Parameterised on the option-aware scorer function plus async/sync awaiting.
213macro_rules! phrase_early_returns {
214    ($field:expr, $terms:expr, $reader:expr, $limit:expr,
215     $scorer_fn:ident, $options:expr $(, $aw:tt)*) => {
216        if $terms.is_empty() {
217            return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
218        }
219        if $terms.len() == 1 {
220            let tq = super::TermQuery::new($field, $terms[0].clone());
221            return tq.$scorer_fn($reader, $limit, $options) $(. $aw)* ;
222        }
223        if !$reader.has_positions($field) {
224            let mut bq = super::BooleanQuery::new();
225            for t in $terms.iter() {
226                bq = bq.must(super::TermQuery::new($field, t.clone()));
227            }
228            return bq.$scorer_fn($reader, $limit, $options) $(. $aw)* ;
229        }
230    };
231}
232
233impl Query for PhraseQuery {
234    fn text_terms(&self, out: &mut Vec<(Field, Vec<u8>)>) {
235        for term in &self.terms {
236            out.push((self.field, term.clone()));
237        }
238    }
239
240    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
241        self.scorer_with_options(reader, limit, super::ScorerOptions::with_positions())
242    }
243
244    fn scorer_with_options<'a>(
245        &self,
246        reader: &'a SegmentReader,
247        limit: usize,
248        options: super::ScorerOptions,
249    ) -> ScorerFuture<'a> {
250        let field = self.field;
251        let terms = self.terms.clone();
252        let offsets = self.offsets.clone();
253        let slop = self.slop;
254
255        Box::pin(async move {
256            phrase_early_returns!(
257                field,
258                terms,
259                reader,
260                limit,
261                scorer_with_options,
262                options,
263                await
264            );
265
266            // Fetch postings + positions in parallel per term via futures::join!
267            let mut term_data = Vec::with_capacity(terms.len());
268            for term in &terms {
269                let (postings, positions) = futures::join!(
270                    reader.get_postings(field, term),
271                    reader.get_positions(field, term)
272                );
273                match (postings?, positions?) {
274                    (Some(p), Some(pos)) => term_data.push((p, pos)),
275                    _ => return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + 'a>),
276                }
277            }
278
279            if reader.is_chunked_field(field) {
280                return build_chunked_phrase_scorer(
281                    term_data, &offsets, slop, reader, field, limit,
282                );
283            }
284            Ok(build_phrase_scorer(
285                term_data, &offsets, slop, reader, field,
286            ))
287        })
288    }
289
290    #[cfg(feature = "sync")]
291    fn scorer_sync<'a>(
292        &self,
293        reader: &'a SegmentReader,
294        limit: usize,
295    ) -> crate::Result<Box<dyn Scorer + 'a>> {
296        self.scorer_sync_with_options(reader, limit, super::ScorerOptions::with_positions())
297    }
298
299    #[cfg(feature = "sync")]
300    fn scorer_sync_with_options<'a>(
301        &self,
302        reader: &'a SegmentReader,
303        limit: usize,
304        options: super::ScorerOptions,
305    ) -> crate::Result<Box<dyn Scorer + 'a>> {
306        phrase_early_returns!(
307            self.field,
308            self.terms,
309            reader,
310            limit,
311            scorer_sync_with_options,
312            options
313        );
314
315        // Parallel fetch across all terms via rayon
316        use rayon::prelude::*;
317        let pairs: crate::Result<Vec<Option<(BlockPostingList, TermPositions)>>> = self
318            .terms
319            .par_iter()
320            .map(|term| {
321                let postings = reader.get_postings_sync(self.field, term)?;
322                let positions = reader.get_positions_sync(self.field, term)?;
323                Ok(match (postings, positions) {
324                    (Some(p), Some(pos)) => Some((p, pos)),
325                    _ => None,
326                })
327            })
328            .collect();
329        let mut term_data = Vec::with_capacity(self.terms.len());
330        for entry in pairs? {
331            match entry {
332                Some(pair) => term_data.push(pair),
333                None => return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + 'a>),
334            }
335        }
336
337        if reader.is_chunked_field(self.field) {
338            return build_chunked_phrase_scorer(
339                term_data,
340                &self.offsets,
341                self.slop,
342                reader,
343                self.field,
344                limit,
345            );
346        }
347        Ok(build_phrase_scorer(
348            term_data,
349            &self.offsets,
350            self.slop,
351            reader,
352            self.field,
353        ))
354    }
355
356    /// Every document containing the phrase, as a bitset (documents, also
357    /// for chunked fields). Lets the planner push a quoted span into the
358    /// MaxScore executors as an O(1) predicate instead of a verifier.
359    #[cfg(feature = "sync")]
360    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<super::DocBitset> {
361        if self.terms.is_empty() {
362            return None;
363        }
364        let mut bitset = super::DocBitset::new(reader.num_docs());
365        if self.terms.len() == 1 {
366            // A one-term phrase is the term itself; walk its postings and
367            // resolve chunk ids to documents where needed.
368            let list = reader
369                .get_postings_sync(self.field, &self.terms[0])
370                .ok()??;
371            let chunk_map = reader.chunk_map(self.field);
372            let mut it = list.iterator();
373            while it.doc() != TERMINATED {
374                let doc = chunk_map.map_or(it.doc(), |map| map.doc_id(it.doc()));
375                bitset.set(doc);
376                it.advance();
377            }
378            return Some(bitset);
379        }
380        let mut scorer = self
381            .scorer_sync_with_options(reader, usize::MAX, super::ScorerOptions::with_positions())
382            .ok()?;
383        while scorer.doc() != TERMINATED {
384            bitset.set(scorer.doc());
385            scorer.advance();
386        }
387        Some(bitset)
388    }
389
390    /// Matches are at most the rarest term's postings; the planner only
391    /// needs the order of magnitude to pick which clause to materialize.
392    #[cfg(feature = "sync")]
393    fn bitset_cardinality_estimate(&self, reader: &SegmentReader) -> Option<u64> {
394        let mut min = u64::MAX;
395        for term in &self.terms {
396            let list = reader.get_postings_sync(self.field, term).ok()??;
397            min = min.min(u64::from(list.doc_count()));
398        }
399        Some((min / 10).max(1))
400    }
401
402    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
403        let field = self.field;
404        let terms = self.terms.clone();
405
406        Box::pin(async move {
407            if terms.is_empty() {
408                return Ok(0);
409            }
410
411            // Estimate based on minimum posting list size
412            let mut min_count = u32::MAX;
413            for term in &terms {
414                match reader.get_postings(field, term).await? {
415                    Some(list) => min_count = min_count.min(list.doc_count()),
416                    None => return Ok(0),
417                }
418            }
419
420            // Phrase matching will typically match fewer docs than the minimum
421            // Estimate ~10% of the smallest posting list
422            Ok((min_count / 10).max(1))
423        })
424    }
425}
426
427/// Real lengths of the scoring units of a phrase: chunk lengths of a chunked
428/// field or persisted document lengths of a plain field.
429enum Lengths {
430    Chunks(crate::segment::chunk_map::ChunkMap),
431    Docs(crate::segment::chunk_map::DocLengths),
432}
433
434impl Lengths {
435    fn length(&self, id: u32) -> u32 {
436        match self {
437            Lengths::Chunks(map) => map.length(id),
438            Lengths::Docs(lengths) => lengths.length(id),
439        }
440    }
441}
442
443/// Scorer that checks phrase positions
444struct PhraseScorer {
445    /// Posting iterators for each term
446    posting_iters: Vec<BlockPostingIterator<'static>>,
447    /// Positions of each term (legacy list or cursor-addressed stream)
448    position_lists: Vec<TermPositions>,
449    /// One decoded position block, reused across documents and terms
450    position_scratch: Vec<u32>,
451    /// Required distance of each term from the first one (`offsets[i] -
452    /// offsets[0]`); `deltas[0]` is 0.
453    deltas: Vec<u32>,
454    /// Max slop between terms
455    slop: u32,
456    /// Current matching document
457    current_doc: DocId,
458    /// Number of phrase occurrences in the current document (phrase
459    /// frequency), the `tf` of the phrase for BM25.
460    current_matches: u32,
461    /// Combined IDF
462    idf: f32,
463    /// Per-field k1/b.
464    params: super::Bm25Params,
465    /// Average field length
466    avg_field_len: f32,
467    /// Real lengths of the scoring units. `None` keeps the historic
468    /// `tf`-as-length approximation.
469    lengths: Option<Lengths>,
470    /// Reusable position buffers (one per term, avoids per-document allocation)
471    position_bufs: Vec<Vec<u32>>,
472}
473
474impl PhraseScorer {
475    fn new(
476        posting_lists: Vec<BlockPostingList>,
477        position_lists: Vec<TermPositions>,
478        offsets: &[u32],
479        slop: u32,
480        idf: f32,
481        avg_field_len: f32,
482    ) -> Self {
483        let posting_iters: Vec<_> = posting_lists
484            .into_iter()
485            .map(|p| p.into_iterator())
486            .collect();
487
488        let num_terms = position_lists.len();
489        // Offsets are optional for callers that built the query term by
490        // term; missing entries mean adjacency.
491        let first = offsets.first().copied().unwrap_or(0);
492        let deltas: Vec<u32> = (0..num_terms)
493            .map(|i| offsets.get(i).map_or(i as u32, |o| o - first))
494            .collect();
495        let mut scorer = Self {
496            posting_iters,
497            position_lists,
498            position_scratch: Vec::new(),
499            deltas,
500            slop,
501            current_doc: 0,
502            current_matches: 0,
503            params: super::Bm25Params::default(),
504            idf,
505            avg_field_len,
506            lengths: None,
507            position_bufs: (0..num_terms).map(|_| Vec::new()).collect(),
508        };
509
510        scorer.find_next_phrase_match();
511        scorer
512    }
513
514    /// Score with the real length of each scoring unit.
515    fn with_lengths(mut self, lengths: Lengths) -> Self {
516        self.lengths = Some(lengths);
517        self
518    }
519
520    /// Score with the field's BM25 parameters.
521    fn with_params(mut self, params: super::Bm25Params) -> Self {
522        self.params = params;
523        self
524    }
525
526    /// Find next document where all terms appear as a phrase
527    fn find_next_phrase_match(&mut self) {
528        loop {
529            // First, find a document where all terms appear (AND semantics)
530            let doc = self.find_next_and_match();
531            if doc == TERMINATED {
532                self.current_doc = TERMINATED;
533                return;
534            }
535
536            // Check if positions form a valid phrase
537            if self.check_phrase_positions(doc) {
538                self.current_doc = doc;
539                return;
540            }
541
542            // Advance and try again
543            self.posting_iters[0].advance();
544        }
545    }
546
547    /// Find next document where all terms appear
548    fn find_next_and_match(&mut self) -> DocId {
549        if self.posting_iters.is_empty() {
550            return TERMINATED;
551        }
552
553        loop {
554            let max_doc = self.posting_iters.iter().map(|it| it.doc()).max().unwrap();
555
556            if max_doc == TERMINATED {
557                return TERMINATED;
558            }
559
560            let mut all_match = true;
561            for it in &mut self.posting_iters {
562                let doc = it.seek(max_doc);
563                if doc != max_doc {
564                    all_match = false;
565                    if doc == TERMINATED {
566                        return TERMINATED;
567                    }
568                }
569            }
570
571            if all_match {
572                return max_doc;
573            }
574        }
575    }
576
577    /// Check if positions form a valid phrase for the given document
578    fn check_phrase_positions(&mut self, doc_id: DocId) -> bool {
579        // Get positions for each term into reusable buffers (zero allocation).
580        // The doc-posting iterator of every term is parked on `doc_id`, so
581        // its cursor and term frequency address the term's position stream.
582        for i in 0..self.position_lists.len() {
583            let cursor = self.posting_iters[i].position_cursor();
584            let tf = self.posting_iters[i].term_freq();
585            if !self.position_lists[i].positions_into(
586                doc_id,
587                cursor,
588                tf,
589                &mut self.position_scratch,
590                &mut self.position_bufs[i],
591            ) {
592                return false;
593            }
594        }
595
596        // Count the occurrences: every position of the first term that
597        // starts a full match. The count is the phrase frequency BM25 scores.
598        self.current_matches = self.count_phrase_matches_in_bufs();
599        self.current_matches > 0
600    }
601
602    /// Number of phrase occurrences in the internal reusable buffers.
603    fn count_phrase_matches_in_bufs(&self) -> u32 {
604        if self.position_bufs.is_empty() || self.position_bufs[0].is_empty() {
605            return 0;
606        }
607        self.position_bufs[0]
608            .iter()
609            .filter(|&&first_pos| self.check_phrase_from_position(first_pos, &self.position_bufs))
610            .count() as u32
611    }
612
613    /// Check if a phrase exists starting from the given position
614    fn check_phrase_from_position(&self, start_pos: u32, term_positions: &[Vec<u32>]) -> bool {
615        for (i, positions) in term_positions.iter().enumerate() {
616            if i == 0 {
617                continue; // Skip first term, already matched
618            }
619
620            let expected_pos = start_pos + self.deltas[i];
621
622            // Find a position within slop distance
623            let found = positions.iter().any(|&pos| {
624                if self.slop == 0 {
625                    pos == expected_pos
626                } else {
627                    let diff = pos.abs_diff(expected_pos);
628                    diff <= self.slop
629                }
630            });
631
632            if !found {
633                return false;
634            }
635        }
636
637        true
638    }
639}
640
641impl super::docset::DocSet for PhraseScorer {
642    fn doc(&self) -> DocId {
643        self.current_doc
644    }
645
646    fn advance(&mut self) -> DocId {
647        if self.current_doc == TERMINATED {
648            return TERMINATED;
649        }
650
651        self.posting_iters[0].advance();
652        self.find_next_phrase_match();
653        self.current_doc
654    }
655
656    fn seek(&mut self, target: DocId) -> DocId {
657        if target == TERMINATED {
658            self.current_doc = TERMINATED;
659            return TERMINATED;
660        }
661
662        self.posting_iters[0].seek(target);
663        self.find_next_phrase_match();
664        self.current_doc
665    }
666
667    fn size_hint(&self) -> u32 {
668        0
669    }
670}
671
672impl Scorer for PhraseScorer {
673    fn score(&self) -> Score {
674        if self.current_doc == TERMINATED {
675            return 0.0;
676        }
677
678        // BM25 over the phrase frequency with the summed idf of the terms
679        // (Lucene semantics): a document with two occurrences of the phrase
680        // outranks one with a single occurrence at equal length.
681        let tf = self.current_matches.max(1) as f32;
682
683        // Real unit length when the segment has it; otherwise the summed
684        // term frequency stands in for the length (legacy segments).
685        let doc_len = match &self.lengths {
686            Some(lengths) => (lengths.length(self.current_doc) as f32).max(1.0),
687            None => self
688                .posting_iters
689                .iter()
690                .map(|it| it.term_freq() as f32)
691                .sum::<f32>()
692                .max(tf),
693        };
694
695        self.params.score(tf, self.idf, doc_len, self.avg_field_len)
696    }
697}