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/// Build a PhraseScorer from already-fetched term data.
101fn build_phrase_scorer<'a>(
102    term_data: Vec<(BlockPostingList, PositionPostingList)>,
103    slop: u32,
104    reader: &SegmentReader,
105    field: Field,
106) -> Box<dyn Scorer + 'a> {
107    let idf: f32 = term_data
108        .iter()
109        .map(|(p, _)| {
110            let num_docs = reader.num_docs() as f32;
111            let doc_freq = p.doc_count() as f32;
112            super::bm25_idf(doc_freq, num_docs)
113        })
114        .sum();
115    let avg_field_len = reader.avg_field_len(field);
116    let (postings, positions): (Vec<_>, Vec<_>) = term_data.into_iter().unzip();
117    Box::new(PhraseScorer::new(
118        postings,
119        positions,
120        slop,
121        idf,
122        avg_field_len,
123    ))
124}
125
126// ── Shared early-return checks for phrase scorer ─────────────────────────
127//
128// Handles: empty terms, single-term delegation, no-positions fallback.
129// Parameterised on the option-aware scorer function plus async/sync awaiting.
130macro_rules! phrase_early_returns {
131    ($field:expr, $terms:expr, $reader:expr, $limit:expr,
132     $scorer_fn:ident, $options:expr $(, $aw:tt)*) => {
133        if $terms.is_empty() {
134            return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
135        }
136        if $terms.len() == 1 {
137            let tq = super::TermQuery::new($field, $terms[0].clone());
138            return tq.$scorer_fn($reader, $limit, $options) $(. $aw)* ;
139        }
140        if !$reader.has_positions($field) {
141            let mut bq = super::BooleanQuery::new();
142            for t in $terms.iter() {
143                bq = bq.must(super::TermQuery::new($field, t.clone()));
144            }
145            return bq.$scorer_fn($reader, $limit, $options) $(. $aw)* ;
146        }
147    };
148}
149
150impl Query for PhraseQuery {
151    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
152        self.scorer_with_options(reader, limit, super::ScorerOptions::with_positions())
153    }
154
155    fn scorer_with_options<'a>(
156        &self,
157        reader: &'a SegmentReader,
158        limit: usize,
159        options: super::ScorerOptions,
160    ) -> ScorerFuture<'a> {
161        let field = self.field;
162        let terms = self.terms.clone();
163        let slop = self.slop;
164
165        Box::pin(async move {
166            phrase_early_returns!(
167                field,
168                terms,
169                reader,
170                limit,
171                scorer_with_options,
172                options,
173                await
174            );
175
176            // Fetch postings + positions in parallel per term via futures::join!
177            let mut term_data = Vec::with_capacity(terms.len());
178            for term in &terms {
179                let (postings, positions) = futures::join!(
180                    reader.get_postings(field, term),
181                    reader.get_positions(field, term)
182                );
183                match (postings?, positions?) {
184                    (Some(p), Some(pos)) => term_data.push((p, pos)),
185                    _ => return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + 'a>),
186                }
187            }
188
189            Ok(build_phrase_scorer(term_data, slop, reader, field))
190        })
191    }
192
193    #[cfg(feature = "sync")]
194    fn scorer_sync<'a>(
195        &self,
196        reader: &'a SegmentReader,
197        limit: usize,
198    ) -> crate::Result<Box<dyn Scorer + 'a>> {
199        self.scorer_sync_with_options(reader, limit, super::ScorerOptions::with_positions())
200    }
201
202    #[cfg(feature = "sync")]
203    fn scorer_sync_with_options<'a>(
204        &self,
205        reader: &'a SegmentReader,
206        limit: usize,
207        options: super::ScorerOptions,
208    ) -> crate::Result<Box<dyn Scorer + 'a>> {
209        phrase_early_returns!(
210            self.field,
211            self.terms,
212            reader,
213            limit,
214            scorer_sync_with_options,
215            options
216        );
217
218        // Parallel fetch across all terms via rayon
219        use rayon::prelude::*;
220        let pairs: crate::Result<Vec<Option<(BlockPostingList, PositionPostingList)>>> = self
221            .terms
222            .par_iter()
223            .map(|term| {
224                let postings = reader.get_postings_sync(self.field, term)?;
225                let positions = reader.get_positions_sync(self.field, term)?;
226                Ok(match (postings, positions) {
227                    (Some(p), Some(pos)) => Some((p, pos)),
228                    _ => None,
229                })
230            })
231            .collect();
232        let mut term_data = Vec::with_capacity(self.terms.len());
233        for entry in pairs? {
234            match entry {
235                Some(pair) => term_data.push(pair),
236                None => return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + 'a>),
237            }
238        }
239
240        Ok(build_phrase_scorer(
241            term_data, self.slop, reader, self.field,
242        ))
243    }
244
245    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
246        let field = self.field;
247        let terms = self.terms.clone();
248
249        Box::pin(async move {
250            if terms.is_empty() {
251                return Ok(0);
252            }
253
254            // Estimate based on minimum posting list size
255            let mut min_count = u32::MAX;
256            for term in &terms {
257                match reader.get_postings(field, term).await? {
258                    Some(list) => min_count = min_count.min(list.doc_count()),
259                    None => return Ok(0),
260                }
261            }
262
263            // Phrase matching will typically match fewer docs than the minimum
264            // Estimate ~10% of the smallest posting list
265            Ok((min_count / 10).max(1))
266        })
267    }
268}
269
270/// Scorer that checks phrase positions
271struct PhraseScorer {
272    /// Posting iterators for each term
273    posting_iters: Vec<BlockPostingIterator<'static>>,
274    /// Position iterators for each term
275    position_lists: Vec<PositionPostingList>,
276    /// Max slop between terms
277    slop: u32,
278    /// Current matching document
279    current_doc: DocId,
280    /// Combined IDF
281    idf: f32,
282    /// Average field length
283    avg_field_len: f32,
284    /// Reusable position buffers (one per term, avoids per-document allocation)
285    position_bufs: Vec<Vec<u32>>,
286}
287
288impl PhraseScorer {
289    fn new(
290        posting_lists: Vec<BlockPostingList>,
291        position_lists: Vec<PositionPostingList>,
292        slop: u32,
293        idf: f32,
294        avg_field_len: f32,
295    ) -> Self {
296        let posting_iters: Vec<_> = posting_lists
297            .into_iter()
298            .map(|p| p.into_iterator())
299            .collect();
300
301        let num_terms = position_lists.len();
302        let mut scorer = Self {
303            posting_iters,
304            position_lists,
305            slop,
306            current_doc: 0,
307            idf,
308            avg_field_len,
309            position_bufs: (0..num_terms).map(|_| Vec::new()).collect(),
310        };
311
312        scorer.find_next_phrase_match();
313        scorer
314    }
315
316    /// Find next document where all terms appear as a phrase
317    fn find_next_phrase_match(&mut self) {
318        loop {
319            // First, find a document where all terms appear (AND semantics)
320            let doc = self.find_next_and_match();
321            if doc == TERMINATED {
322                self.current_doc = TERMINATED;
323                return;
324            }
325
326            // Check if positions form a valid phrase
327            if self.check_phrase_positions(doc) {
328                self.current_doc = doc;
329                return;
330            }
331
332            // Advance and try again
333            self.posting_iters[0].advance();
334        }
335    }
336
337    /// Find next document where all terms appear
338    fn find_next_and_match(&mut self) -> DocId {
339        if self.posting_iters.is_empty() {
340            return TERMINATED;
341        }
342
343        loop {
344            let max_doc = self.posting_iters.iter().map(|it| it.doc()).max().unwrap();
345
346            if max_doc == TERMINATED {
347                return TERMINATED;
348            }
349
350            let mut all_match = true;
351            for it in &mut self.posting_iters {
352                let doc = it.seek(max_doc);
353                if doc != max_doc {
354                    all_match = false;
355                    if doc == TERMINATED {
356                        return TERMINATED;
357                    }
358                }
359            }
360
361            if all_match {
362                return max_doc;
363            }
364        }
365    }
366
367    /// Check if positions form a valid phrase for the given document
368    fn check_phrase_positions(&mut self, doc_id: DocId) -> bool {
369        // Get positions for each term into reusable buffers (zero allocation)
370        for (i, pos_list) in self.position_lists.iter().enumerate() {
371            if !pos_list.get_positions_into(doc_id, &mut self.position_bufs[i]) {
372                return false;
373            }
374        }
375
376        // Check for consecutive positions
377        // For exact phrase (slop=0), position[i+1] = position[i] + 1
378        self.find_phrase_match_from_bufs()
379    }
380
381    /// Find phrase match using the internal reusable buffers
382    fn find_phrase_match_from_bufs(&self) -> bool {
383        if self.position_bufs.is_empty() || self.position_bufs[0].is_empty() {
384            return false;
385        }
386
387        for &first_pos in &self.position_bufs[0] {
388            if self.check_phrase_from_position(first_pos, &self.position_bufs) {
389                return true;
390            }
391        }
392
393        false
394    }
395
396    /// Check if a phrase exists starting from the given position
397    fn check_phrase_from_position(&self, start_pos: u32, term_positions: &[Vec<u32>]) -> bool {
398        let mut expected_pos = start_pos;
399
400        for (i, positions) in term_positions.iter().enumerate() {
401            if i == 0 {
402                continue; // Skip first term, already matched
403            }
404
405            expected_pos += 1;
406
407            // Find a position within slop distance
408            let found = positions.iter().any(|&pos| {
409                if self.slop == 0 {
410                    pos == expected_pos
411                } else {
412                    let diff = pos.abs_diff(expected_pos);
413                    diff <= self.slop
414                }
415            });
416
417            if !found {
418                return false;
419            }
420        }
421
422        true
423    }
424}
425
426impl super::docset::DocSet for PhraseScorer {
427    fn doc(&self) -> DocId {
428        self.current_doc
429    }
430
431    fn advance(&mut self) -> DocId {
432        if self.current_doc == TERMINATED {
433            return TERMINATED;
434        }
435
436        self.posting_iters[0].advance();
437        self.find_next_phrase_match();
438        self.current_doc
439    }
440
441    fn seek(&mut self, target: DocId) -> DocId {
442        if target == TERMINATED {
443            self.current_doc = TERMINATED;
444            return TERMINATED;
445        }
446
447        self.posting_iters[0].seek(target);
448        self.find_next_phrase_match();
449        self.current_doc
450    }
451
452    fn size_hint(&self) -> u32 {
453        0
454    }
455}
456
457impl Scorer for PhraseScorer {
458    fn score(&self) -> Score {
459        if self.current_doc == TERMINATED {
460            return 0.0;
461        }
462
463        // Sum term frequencies for BM25 scoring
464        let tf: f32 = self
465            .posting_iters
466            .iter()
467            .map(|it| it.term_freq() as f32)
468            .sum();
469
470        // Phrase matches get a boost since they're more precise
471        super::bm25_score(tf, self.idf, tf, self.avg_field_len) * 1.5
472    }
473}