1use 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#[derive(Clone)]
17pub struct PhraseQuery {
18 pub field: Field,
19 pub terms: Vec<Vec<u8>>,
21 pub slop: u32,
23 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 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 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 pub fn with_slop(mut self, slop: u32) -> Self {
89 self.slop = slop;
90 self
91 }
92
93 pub fn with_global_stats(mut self, stats: Arc<GlobalStats>) -> Self {
95 self.global_stats = Some(stats);
96 self
97 }
98}
99
100fn 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
143fn 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
169macro_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 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 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 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 Ok((min_count / 10).max(1))
315 })
316 }
317}
318
319struct PhraseScorer {
321 posting_iters: Vec<BlockPostingIterator<'static>>,
323 position_lists: Vec<PositionPostingList>,
325 slop: u32,
327 current_doc: DocId,
329 idf: f32,
331 avg_field_len: f32,
333 chunk_lengths: Option<crate::segment::chunk_map::ChunkMap>,
336 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 fn with_chunk_lengths(mut self, lengths: crate::segment::chunk_map::ChunkMap) -> Self {
371 self.chunk_lengths = Some(lengths);
372 self
373 }
374
375 fn find_next_phrase_match(&mut self) {
377 loop {
378 let doc = self.find_next_and_match();
380 if doc == TERMINATED {
381 self.current_doc = TERMINATED;
382 return;
383 }
384
385 if self.check_phrase_positions(doc) {
387 self.current_doc = doc;
388 return;
389 }
390
391 self.posting_iters[0].advance();
393 }
394 }
395
396 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 fn check_phrase_positions(&mut self, doc_id: DocId) -> bool {
428 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 self.find_phrase_match_from_bufs()
438 }
439
440 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 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; }
463
464 expected_pos += 1;
465
466 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 let tf: f32 = self
524 .posting_iters
525 .iter()
526 .map(|it| it.term_freq() as f32)
527 .sum();
528
529 let doc_len = match &self.chunk_lengths {
532 Some(lengths) => lengths.length(self.current_doc) as f32,
533 None => tf,
534 };
535
536 super::bm25_score(tf, self.idf, doc_len, self.avg_field_len) * 1.5
538 }
539}