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, PositionPostingList, TERMINATED};
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    /// Optional slop (max distance between terms, 0 = exact phrase)
22    pub slop: u32,
23    /// Optional global statistics for cross-segment IDF
24    global_stats: Option<Arc<GlobalStats>>,
25}
26
27impl std::fmt::Display for PhraseQuery {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        let terms: Vec<_> = self
30            .terms
31            .iter()
32            .map(|t| String::from_utf8_lossy(t))
33            .collect();
34        write!(f, "Phrase({}:\"{}\"", self.field.0, terms.join(" "))?;
35        if self.slop > 0 {
36            write!(f, "~{}", self.slop)?;
37        }
38        write!(f, ")")
39    }
40}
41
42impl std::fmt::Debug for PhraseQuery {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        let terms: Vec<_> = self
45            .terms
46            .iter()
47            .map(|t| String::from_utf8_lossy(t).to_string())
48            .collect();
49        f.debug_struct("PhraseQuery")
50            .field("field", &self.field)
51            .field("terms", &terms)
52            .field("slop", &self.slop)
53            .finish()
54    }
55}
56
57impl PhraseQuery {
58    /// Create a new exact phrase query
59    pub fn new(field: Field, terms: Vec<Vec<u8>>) -> Self {
60        Self {
61            field,
62            terms,
63            slop: 0,
64            global_stats: None,
65        }
66    }
67
68    /// Create from text using the simple tokenizer (whitespace split,
69    /// punctuation stripped, lowercased). Fields with a stemming tokenizer
70    /// should tokenize the phrase themselves and call [`PhraseQuery::new`] so
71    /// the query terms match the indexed stems.
72    pub fn text(field: Field, phrase: &str) -> Self {
73        use crate::tokenizer::Tokenizer;
74        let terms: Vec<Vec<u8>> = crate::tokenizer::SimpleTokenizer
75            .tokenize(phrase)
76            .into_iter()
77            .map(|token| token.text.into_bytes())
78            .collect();
79        Self {
80            field,
81            terms,
82            slop: 0,
83            global_stats: None,
84        }
85    }
86
87    /// Set slop (max distance between terms)
88    pub fn with_slop(mut self, slop: u32) -> Self {
89        self.slop = slop;
90        self
91    }
92
93    /// Set global statistics for cross-segment IDF
94    pub fn with_global_stats(mut self, stats: Arc<GlobalStats>) -> Self {
95        self.global_stats = Some(stats);
96        self
97    }
98}
99
100/// Phrase over a chunked field: match and score every chunk (posting ids are
101/// virtual chunk ids, positions restart per chunk so a phrase never spans two
102/// chunks), then fold the chunk hits into documents with per-ordinal scores.
103///
104/// The phrase is a conjunction, so draining the positional scorer costs one
105/// pass over the matching chunks only.
106fn build_chunked_phrase_scorer<'a>(
107    term_data: Vec<(BlockPostingList, PositionPostingList)>,
108    slop: u32,
109    reader: &SegmentReader,
110    field: Field,
111    limit: usize,
112) -> crate::Result<Box<dyn Scorer + 'a>> {
113    let Some(chunk_map) = reader.chunk_map(field) else {
114        return Err(crate::Error::Corruption(format!(
115            "chunked text field '{}' has postings but segment {:016x} carries no chunk map",
116            reader.schema().get_field_name(field).unwrap_or("?"),
117            reader.meta().id,
118        )));
119    };
120    let num_chunks = chunk_map.num_chunks() as f32;
121    let idf: f32 = term_data
122        .iter()
123        .map(|(p, _)| super::bm25_idf(p.doc_count() as f32, num_chunks))
124        .sum();
125    let (postings, positions): (Vec<_>, Vec<_>) = term_data.into_iter().unzip();
126    let mut scorer = PhraseScorer::new(postings, positions, slop, idf, chunk_map.avg_len())
127        .with_chunk_lengths(chunk_map.clone());
128
129    use super::docset::DocSet as _;
130    let mut raw: Vec<(u32, u16, f32)> = Vec::new();
131    while scorer.doc() != TERMINATED {
132        let (doc_id, ordinal) = chunk_map.resolve(scorer.doc());
133        raw.push((doc_id, ordinal, scorer.score()));
134        scorer.advance();
135    }
136    let combined =
137        crate::segment::combine_ordinal_results(raw, super::MultiValueCombiner::Max, limit.max(1));
138    Ok(Box::new(super::planner::VectorTopKResultScorer::new(
139        combined, field.0,
140    )) as Box<dyn Scorer + 'a>)
141}
142
143/// Build a PhraseScorer from already-fetched term data.
144fn build_phrase_scorer<'a>(
145    term_data: Vec<(BlockPostingList, PositionPostingList)>,
146    slop: u32,
147    reader: &SegmentReader,
148    field: Field,
149) -> Box<dyn Scorer + 'a> {
150    let idf: f32 = term_data
151        .iter()
152        .map(|(p, _)| {
153            let num_docs = reader.num_docs() as f32;
154            let doc_freq = p.doc_count() as f32;
155            super::bm25_idf(doc_freq, num_docs)
156        })
157        .sum();
158    let avg_field_len = reader.avg_field_len(field);
159    let (postings, positions): (Vec<_>, Vec<_>) = term_data.into_iter().unzip();
160    Box::new(PhraseScorer::new(
161        postings,
162        positions,
163        slop,
164        idf,
165        avg_field_len,
166    ))
167}
168
169// ── Shared early-return checks for phrase scorer ─────────────────────────
170//
171// Handles: empty terms, single-term delegation, no-positions fallback.
172// Parameterised on the option-aware scorer function plus async/sync awaiting.
173macro_rules! phrase_early_returns {
174    ($field:expr, $terms:expr, $reader:expr, $limit:expr,
175     $scorer_fn:ident, $options:expr $(, $aw:tt)*) => {
176        if $terms.is_empty() {
177            return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
178        }
179        if $terms.len() == 1 {
180            let tq = super::TermQuery::new($field, $terms[0].clone());
181            return tq.$scorer_fn($reader, $limit, $options) $(. $aw)* ;
182        }
183        if !$reader.has_positions($field) {
184            let mut bq = super::BooleanQuery::new();
185            for t in $terms.iter() {
186                bq = bq.must(super::TermQuery::new($field, t.clone()));
187            }
188            return bq.$scorer_fn($reader, $limit, $options) $(. $aw)* ;
189        }
190    };
191}
192
193impl Query for PhraseQuery {
194    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
195        self.scorer_with_options(reader, limit, super::ScorerOptions::with_positions())
196    }
197
198    fn scorer_with_options<'a>(
199        &self,
200        reader: &'a SegmentReader,
201        limit: usize,
202        options: super::ScorerOptions,
203    ) -> ScorerFuture<'a> {
204        let field = self.field;
205        let terms = self.terms.clone();
206        let slop = self.slop;
207
208        Box::pin(async move {
209            phrase_early_returns!(
210                field,
211                terms,
212                reader,
213                limit,
214                scorer_with_options,
215                options,
216                await
217            );
218
219            // Fetch postings + positions in parallel per term via futures::join!
220            let mut term_data = Vec::with_capacity(terms.len());
221            for term in &terms {
222                let (postings, positions) = futures::join!(
223                    reader.get_postings(field, term),
224                    reader.get_positions(field, term)
225                );
226                match (postings?, positions?) {
227                    (Some(p), Some(pos)) => term_data.push((p, pos)),
228                    _ => return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + 'a>),
229                }
230            }
231
232            if reader.is_chunked_field(field) {
233                return build_chunked_phrase_scorer(term_data, slop, reader, field, limit);
234            }
235            Ok(build_phrase_scorer(term_data, slop, reader, field))
236        })
237    }
238
239    #[cfg(feature = "sync")]
240    fn scorer_sync<'a>(
241        &self,
242        reader: &'a SegmentReader,
243        limit: usize,
244    ) -> crate::Result<Box<dyn Scorer + 'a>> {
245        self.scorer_sync_with_options(reader, limit, super::ScorerOptions::with_positions())
246    }
247
248    #[cfg(feature = "sync")]
249    fn scorer_sync_with_options<'a>(
250        &self,
251        reader: &'a SegmentReader,
252        limit: usize,
253        options: super::ScorerOptions,
254    ) -> crate::Result<Box<dyn Scorer + 'a>> {
255        phrase_early_returns!(
256            self.field,
257            self.terms,
258            reader,
259            limit,
260            scorer_sync_with_options,
261            options
262        );
263
264        // Parallel fetch across all terms via rayon
265        use rayon::prelude::*;
266        let pairs: crate::Result<Vec<Option<(BlockPostingList, PositionPostingList)>>> = self
267            .terms
268            .par_iter()
269            .map(|term| {
270                let postings = reader.get_postings_sync(self.field, term)?;
271                let positions = reader.get_positions_sync(self.field, term)?;
272                Ok(match (postings, positions) {
273                    (Some(p), Some(pos)) => Some((p, pos)),
274                    _ => None,
275                })
276            })
277            .collect();
278        let mut term_data = Vec::with_capacity(self.terms.len());
279        for entry in pairs? {
280            match entry {
281                Some(pair) => term_data.push(pair),
282                None => return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + 'a>),
283            }
284        }
285
286        if reader.is_chunked_field(self.field) {
287            return build_chunked_phrase_scorer(term_data, self.slop, reader, self.field, limit);
288        }
289        Ok(build_phrase_scorer(
290            term_data, self.slop, reader, self.field,
291        ))
292    }
293
294    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
295        let field = self.field;
296        let terms = self.terms.clone();
297
298        Box::pin(async move {
299            if terms.is_empty() {
300                return Ok(0);
301            }
302
303            // Estimate based on minimum posting list size
304            let mut min_count = u32::MAX;
305            for term in &terms {
306                match reader.get_postings(field, term).await? {
307                    Some(list) => min_count = min_count.min(list.doc_count()),
308                    None => return Ok(0),
309                }
310            }
311
312            // Phrase matching will typically match fewer docs than the minimum
313            // Estimate ~10% of the smallest posting list
314            Ok((min_count / 10).max(1))
315        })
316    }
317}
318
319/// Scorer that checks phrase positions
320struct PhraseScorer {
321    /// Posting iterators for each term
322    posting_iters: Vec<BlockPostingIterator<'static>>,
323    /// Position iterators for each term
324    position_lists: Vec<PositionPostingList>,
325    /// Max slop between terms
326    slop: u32,
327    /// Current matching document
328    current_doc: DocId,
329    /// Combined IDF
330    idf: f32,
331    /// Average field length
332    avg_field_len: f32,
333    /// Real per-chunk lengths for chunked fields (posting ids are virtual
334    /// chunk ids). `None` keeps the historic `tf`-as-length approximation.
335    chunk_lengths: Option<crate::segment::chunk_map::ChunkMap>,
336    /// Reusable position buffers (one per term, avoids per-document allocation)
337    position_bufs: Vec<Vec<u32>>,
338}
339
340impl PhraseScorer {
341    fn new(
342        posting_lists: Vec<BlockPostingList>,
343        position_lists: Vec<PositionPostingList>,
344        slop: u32,
345        idf: f32,
346        avg_field_len: f32,
347    ) -> Self {
348        let posting_iters: Vec<_> = posting_lists
349            .into_iter()
350            .map(|p| p.into_iterator())
351            .collect();
352
353        let num_terms = position_lists.len();
354        let mut scorer = Self {
355            posting_iters,
356            position_lists,
357            slop,
358            current_doc: 0,
359            idf,
360            avg_field_len,
361            chunk_lengths: None,
362            position_bufs: (0..num_terms).map(|_| Vec::new()).collect(),
363        };
364
365        scorer.find_next_phrase_match();
366        scorer
367    }
368
369    /// Score with each chunk's real length (chunked fields).
370    fn with_chunk_lengths(mut self, lengths: crate::segment::chunk_map::ChunkMap) -> Self {
371        self.chunk_lengths = Some(lengths);
372        self
373    }
374
375    /// Find next document where all terms appear as a phrase
376    fn find_next_phrase_match(&mut self) {
377        loop {
378            // First, find a document where all terms appear (AND semantics)
379            let doc = self.find_next_and_match();
380            if doc == TERMINATED {
381                self.current_doc = TERMINATED;
382                return;
383            }
384
385            // Check if positions form a valid phrase
386            if self.check_phrase_positions(doc) {
387                self.current_doc = doc;
388                return;
389            }
390
391            // Advance and try again
392            self.posting_iters[0].advance();
393        }
394    }
395
396    /// Find next document where all terms appear
397    fn find_next_and_match(&mut self) -> DocId {
398        if self.posting_iters.is_empty() {
399            return TERMINATED;
400        }
401
402        loop {
403            let max_doc = self.posting_iters.iter().map(|it| it.doc()).max().unwrap();
404
405            if max_doc == TERMINATED {
406                return TERMINATED;
407            }
408
409            let mut all_match = true;
410            for it in &mut self.posting_iters {
411                let doc = it.seek(max_doc);
412                if doc != max_doc {
413                    all_match = false;
414                    if doc == TERMINATED {
415                        return TERMINATED;
416                    }
417                }
418            }
419
420            if all_match {
421                return max_doc;
422            }
423        }
424    }
425
426    /// Check if positions form a valid phrase for the given document
427    fn check_phrase_positions(&mut self, doc_id: DocId) -> bool {
428        // Get positions for each term into reusable buffers (zero allocation)
429        for (i, pos_list) in self.position_lists.iter().enumerate() {
430            if !pos_list.get_positions_into(doc_id, &mut self.position_bufs[i]) {
431                return false;
432            }
433        }
434
435        // Check for consecutive positions
436        // For exact phrase (slop=0), position[i+1] = position[i] + 1
437        self.find_phrase_match_from_bufs()
438    }
439
440    /// Find phrase match using the internal reusable buffers
441    fn find_phrase_match_from_bufs(&self) -> bool {
442        if self.position_bufs.is_empty() || self.position_bufs[0].is_empty() {
443            return false;
444        }
445
446        for &first_pos in &self.position_bufs[0] {
447            if self.check_phrase_from_position(first_pos, &self.position_bufs) {
448                return true;
449            }
450        }
451
452        false
453    }
454
455    /// Check if a phrase exists starting from the given position
456    fn check_phrase_from_position(&self, start_pos: u32, term_positions: &[Vec<u32>]) -> bool {
457        let mut expected_pos = start_pos;
458
459        for (i, positions) in term_positions.iter().enumerate() {
460            if i == 0 {
461                continue; // Skip first term, already matched
462            }
463
464            expected_pos += 1;
465
466            // Find a position within slop distance
467            let found = positions.iter().any(|&pos| {
468                if self.slop == 0 {
469                    pos == expected_pos
470                } else {
471                    let diff = pos.abs_diff(expected_pos);
472                    diff <= self.slop
473                }
474            });
475
476            if !found {
477                return false;
478            }
479        }
480
481        true
482    }
483}
484
485impl super::docset::DocSet for PhraseScorer {
486    fn doc(&self) -> DocId {
487        self.current_doc
488    }
489
490    fn advance(&mut self) -> DocId {
491        if self.current_doc == TERMINATED {
492            return TERMINATED;
493        }
494
495        self.posting_iters[0].advance();
496        self.find_next_phrase_match();
497        self.current_doc
498    }
499
500    fn seek(&mut self, target: DocId) -> DocId {
501        if target == TERMINATED {
502            self.current_doc = TERMINATED;
503            return TERMINATED;
504        }
505
506        self.posting_iters[0].seek(target);
507        self.find_next_phrase_match();
508        self.current_doc
509    }
510
511    fn size_hint(&self) -> u32 {
512        0
513    }
514}
515
516impl Scorer for PhraseScorer {
517    fn score(&self) -> Score {
518        if self.current_doc == TERMINATED {
519            return 0.0;
520        }
521
522        // Sum term frequencies for BM25 scoring
523        let tf: f32 = self
524            .posting_iters
525            .iter()
526            .map(|it| it.term_freq() as f32)
527            .sum();
528
529        // Chunked fields know the real chunk length; other fields keep the
530        // `tf`-as-length approximation.
531        let doc_len = match &self.chunk_lengths {
532            Some(lengths) => lengths.length(self.current_doc) as f32,
533            None => tf,
534        };
535
536        // Phrase matches get a boost since they're more precise
537        super::bm25_score(tf, self.idf, doc_len, self.avg_field_len) * 1.5
538    }
539}