1use 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#[derive(Clone)]
17pub struct PhraseQuery {
18 pub field: Field,
19 pub terms: Vec<Vec<u8>>,
21 pub offsets: Vec<u32>,
27 pub slop: u32,
29 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 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 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 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 fn is_adjacent(&self) -> bool {
119 self.offsets.windows(2).all(|pair| pair[1] == pair[0] + 1)
120 }
121
122 pub fn with_slop(mut self, slop: u32) -> Self {
124 self.slop = slop;
125 self
126 }
127
128 pub fn with_global_stats(mut self, stats: Arc<GlobalStats>) -> Self {
130 self.global_stats = Some(stats);
131 self
132 }
133}
134
135fn 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 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
183fn 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
209macro_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 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 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 #[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 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 #[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 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 Ok((min_count / 10).max(1))
423 })
424 }
425}
426
427enum 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
443struct PhraseScorer {
445 posting_iters: Vec<BlockPostingIterator<'static>>,
447 position_lists: Vec<TermPositions>,
449 position_scratch: Vec<u32>,
451 deltas: Vec<u32>,
454 slop: u32,
456 current_doc: DocId,
458 current_matches: u32,
461 idf: f32,
463 params: super::Bm25Params,
465 avg_field_len: f32,
467 lengths: Option<Lengths>,
470 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 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 fn with_lengths(mut self, lengths: Lengths) -> Self {
516 self.lengths = Some(lengths);
517 self
518 }
519
520 fn with_params(mut self, params: super::Bm25Params) -> Self {
522 self.params = params;
523 self
524 }
525
526 fn find_next_phrase_match(&mut self) {
528 loop {
529 let doc = self.find_next_and_match();
531 if doc == TERMINATED {
532 self.current_doc = TERMINATED;
533 return;
534 }
535
536 if self.check_phrase_positions(doc) {
538 self.current_doc = doc;
539 return;
540 }
541
542 self.posting_iters[0].advance();
544 }
545 }
546
547 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 fn check_phrase_positions(&mut self, doc_id: DocId) -> bool {
579 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 self.current_matches = self.count_phrase_matches_in_bufs();
599 self.current_matches > 0
600 }
601
602 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 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; }
619
620 let expected_pos = start_pos + self.deltas[i];
621
622 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 let tf = self.current_matches.max(1) as f32;
682
683 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}