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