1use std::sync::Arc;
4
5use crate::dsl::Field;
6use crate::segment::SegmentReader;
7use crate::structures::BlockPostingList;
8use crate::structures::TERMINATED;
9use crate::{DocId, Score};
10
11use super::docset::DocSet;
12use super::{CountFuture, EmptyScorer, GlobalStats, Query, Scorer, ScorerFuture, TermQueryInfo};
13
14#[derive(Clone)]
16pub struct TermQuery {
17 pub field: Field,
18 pub term: Vec<u8>,
19 global_stats: Option<Arc<GlobalStats>>,
21}
22
23impl std::fmt::Debug for TermQuery {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 f.debug_struct("TermQuery")
26 .field("field", &self.field)
27 .field("term", &String::from_utf8_lossy(&self.term))
28 .field("has_global_stats", &self.global_stats.is_some())
29 .finish()
30 }
31}
32
33impl std::fmt::Display for TermQuery {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 write!(
36 f,
37 "Term({}:\"{}\")",
38 self.field.0,
39 String::from_utf8_lossy(&self.term)
40 )
41 }
42}
43
44impl TermQuery {
45 pub fn new(field: Field, term: impl Into<Vec<u8>>) -> Self {
46 Self {
47 field,
48 term: term.into(),
49 global_stats: None,
50 }
51 }
52
53 pub fn text(field: Field, text: &str) -> Self {
54 Self {
55 field,
56 term: text.to_lowercase().into_bytes(),
57 global_stats: None,
58 }
59 }
60
61 pub fn with_global_stats(field: Field, text: &str, stats: Arc<GlobalStats>) -> Self {
63 Self {
64 field,
65 term: text.to_lowercase().into_bytes(),
66 global_stats: Some(stats),
67 }
68 }
69
70 pub fn set_global_stats(&mut self, stats: Arc<GlobalStats>) {
72 self.global_stats = Some(stats);
73 }
74
75 fn fast_field_bitset(
76 &self,
77 reader: &SegmentReader,
78 options: &super::ScorerOptions,
79 ) -> Option<super::DocBitset> {
80 let mut bits = super::DocBitset::new(reader.num_docs());
81 let Some(fast_field) = reader.fast_field(self.field.0) else {
82 return Some(bits);
83 };
84 let term = String::from_utf8_lossy(&self.term);
85 let Some(target_ordinal) = fast_field.text_ordinal(&term) else {
86 return (!options.stop_if_expired()).then_some(bits);
87 };
88 if !fast_field.multi {
89 let scanned = fast_field.try_scan_single_values(|doc, ordinal| {
91 if doc.is_multiple_of(1024) && options.stop_if_expired() {
92 return Err(());
93 }
94 if ordinal == target_ordinal {
95 bits.set(doc);
96 }
97 Ok(())
98 });
99 return (scanned.is_ok() && !options.stop_if_expired()).then_some(bits);
100 }
101 if let Some(mut scorer) = FastFieldTextScorer::try_new(
104 reader,
105 self.field,
106 &term,
107 options.shared_threshold.as_ref(),
108 ) {
109 let mut doc = scorer.doc();
110 while doc != TERMINATED {
111 bits.set(doc);
112 doc = scorer.advance();
113 }
114 }
115 (!options.stop_if_expired()).then_some(bits)
117 }
118}
119
120pub(super) fn compute_term_idf(
122 posting_list: &BlockPostingList,
123 field: Field,
124 reader: &SegmentReader,
125 global_stats: Option<&Arc<GlobalStats>>,
126 term: &[u8],
127) -> (f32, f32) {
128 if let Some(stats) = global_stats {
129 let term_str = String::from_utf8_lossy(term);
130 let global_idf = stats.text_idf(field, &term_str);
131 if global_idf > 0.0 {
132 return (global_idf, stats.avg_field_len(field));
133 }
134 }
135 let num_docs = reader.text_corpus_size(field);
136 let doc_freq = posting_list.doc_count() as f32;
137 (
138 super::bm25_idf(doc_freq, num_docs),
139 reader.avg_field_len(field),
140 )
141}
142
143fn can_rank_term(
146 postings: &BlockPostingList,
147 limit: usize,
148 complete: bool,
149 collect_positions: bool,
150) -> bool {
151 if complete || collect_positions {
152 return false;
153 }
154 let has_block_bounds = postings.min_len().is_some() && postings.num_blocks() > 1;
155 let needs_top_k = limit < postings.doc_count() as usize;
156 has_block_bounds && needs_top_k
157}
158
159macro_rules! term_plan {
166 ($field:expr, $term:expr, $global_stats:expr, $reader:expr, $limit:expr,
167 $load_positions:expr, $eligibility:expr, $budget:expr, $complete:expr, $skip_scoring_setup:expr, $initial_threshold:expr, $physical_field:expr, $get_postings_fn:ident, $get_positions_fn:ident
168 $(, $aw:tt)*) => {{
169 let field: Field = $field;
170 let term: &[u8] = $term;
171 let global_stats: Option<&Arc<GlobalStats>> = $global_stats;
172 let reader: &SegmentReader = $reader;
173 let limit: usize = $limit;
174 let budget: Option<&super::SharedThreshold> = $budget;
175 if budget.is_some_and(super::SharedThreshold::stop_if_expired) {
176 return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
177 }
178
179 let is_indexed = reader.schema().get_field_entry(field).is_none_or(|e| e.indexed);
181 if !is_indexed {
182 let term_str = String::from_utf8_lossy(term);
183 if let Some(scorer) = FastFieldTextScorer::try_new(reader, field, &term_str, budget) {
184 return Ok(Box::new(scorer) as Box<dyn Scorer + '_>);
185 }
186 return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
187 }
188
189 let postings = reader.$get_postings_fn(field, term) $(. $aw)* ?;
190
191 match postings {
192 Some(posting_list) if reader.chunk_map(field).is_some_and(|map| map.is_document_map())
193 && ($complete || $load_positions || $physical_field == Some(field)) => {
194 let map = reader.chunk_map(field).unwrap();
195 let (idf, avg_field_len) = compute_term_idf(&posting_list, field, reader, global_stats, term);
196 let mut scorer = TermScorer::new(posting_list, idf, avg_field_len, 1.0)
197 .with_params(super::Bm25Params::for_field(reader.schema(), field));
198 scorer.chunk_lengths = Some(map.clone());
199 scorer.budget = budget.cloned();
200 if $load_positions && let Some(positions) = reader.$get_positions_fn(field, term) $(. $aw)* ? {
201 scorer = scorer.with_positions(field.0, positions);
202 }
203 if $physical_field == Some(field) {
204 return Ok(Box::new(scorer) as Box<dyn Scorer + '_>);
205 }
206 let scorer = super::required_text::mapped_documents(
207 scorer, map.clone(), reader.num_docs(), budget.cloned(),
208 |scorer, slot| scorer.iterator.seek_physical(slot),
209 )?;
210 Ok(super::filtered::filtered(scorer, $eligibility.clone()))
211 }
212 Some(posting_list) if reader.has_text_mapping(field) => {
215 let (idf, avg_field_len) =
216 compute_term_idf(&posting_list, field, reader, global_stats, term);
217 if $complete {
218 return complete_text_scorer(vec![(posting_list, idf)], avg_field_len, reader, field, &super::ScorerOptions {
219 shared_threshold: budget.cloned(), eligibility: $eligibility.clone(),
220 skip_scoring_setup: $skip_scoring_setup, ..Default::default()
221 });
222 }
223 super::planner::finish_chunked_text_maxscore(
224 vec![(posting_list, idf)],
225 avg_field_len,
226 limit,
227 reader,
228 field,
229 $eligibility.as_ref().map(|filter| {
230 let filter = filter.clone();
231 Box::new(move |doc| filter.contains(doc)) as super::DocPredicate<'_>
232 }),
233 None,
234 1.0,
235 budget,
236 )
237 }
238 Some(posting_list) => {
239 let (idf, avg_field_len) =
240 compute_term_idf(&posting_list, field, reader, global_stats, term);
241
242 if can_rank_term(&posting_list, limit, $complete, $load_positions) {
245 return super::planner::finish_text_maxscore(
246 vec![(posting_list, idf)],
247 avg_field_len,
248 reader.doc_lengths(field),
249 limit,
250 &std::cell::Cell::new($initial_threshold),
251 reader,
252 field,
253 $eligibility.as_ref().map(|filter| {
254 let filter = filter.clone();
255 Box::new(move |doc| filter.contains(doc)) as super::DocPredicate<'_>
256 }),
257 super::Bm25Params::for_field(reader.schema(), field),
258 None,
259 1.0,
260 budget,
261 );
262 }
263
264 let positions = if $load_positions {
265 reader.$get_positions_fn(field, term) $(. $aw)* ?
266 } else {
267 None
268 };
269
270 let mut scorer = TermScorer::new(posting_list, idf, avg_field_len, 1.0)
271 .with_params(super::Bm25Params::for_field(reader.schema(), field));
272 scorer.budget = budget.filter(|b| b.deadline().is_some()).cloned();
273 if let Some(lengths) = reader.doc_lengths(field) {
274 scorer = scorer.with_doc_lengths(lengths.clone(), $skip_scoring_setup);
275 }
276 if let Some(pos) = positions {
277 scorer = scorer.with_positions(field.0, pos);
278 }
279 Ok(Box::new(scorer) as Box<dyn Scorer + '_>)
280 }
281 None => {
282 let term_str = String::from_utf8_lossy(term);
283 if let Some(scorer) = FastFieldTextScorer::try_new(reader, field, &term_str, budget) {
284 Ok(Box::new(scorer) as Box<dyn Scorer + '_>)
285 } else {
286 Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>)
287 }
288 }
289 }
290 }};
291}
292
293impl Query for TermQuery {
294 fn physical_text_field(&self, reader: &SegmentReader, complete: bool) -> Option<Field> {
295 let entry = reader.schema().get_field_entry(self.field)?;
296 (complete
297 && entry.indexed
298 && !entry.fast
299 && reader
300 .chunk_map(self.field)
301 .is_some_and(|map| map.is_document_map()))
302 .then_some(self.field)
303 }
304 fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
305 self.scorer_with_options(reader, limit, super::ScorerOptions::with_positions())
306 }
307
308 fn scorer_with_options<'a>(
309 &self,
310 reader: &'a SegmentReader,
311 limit: usize,
312 options: super::ScorerOptions,
313 ) -> ScorerFuture<'a> {
314 let field = self.field;
315 let term = self.term.clone();
316 let global_stats = self
317 .global_stats
318 .clone()
319 .or_else(|| options.global_stats.clone());
320 let load_positions = options.collect_positions;
321 Box::pin(async move {
322 term_plan!(
323 field,
324 &term,
325 global_stats.as_ref(),
326 reader,
327 limit,
328 load_positions,
329 options.eligibility,
330 options.shared_threshold.as_ref(),
331 options.complete_text_matches,
332 options.skip_scoring_setup,
333 options.initial_threshold,
334 options.physical_text_field,
335 get_postings,
336 get_positions,
337 await
338 )
339 })
340 }
341
342 fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
343 let field = self.field;
344 let term = self.term.clone();
345 Box::pin(async move { reader.text_doc_freq(field, &term).await })
346 }
347
348 #[cfg(feature = "sync")]
349 fn scorer_sync<'a>(
350 &self,
351 reader: &'a SegmentReader,
352 limit: usize,
353 ) -> crate::Result<Box<dyn Scorer + 'a>> {
354 self.scorer_sync_with_options(reader, limit, super::ScorerOptions::with_positions())
355 }
356
357 #[cfg(feature = "sync")]
358 fn scorer_sync_with_options<'a>(
359 &self,
360 reader: &'a SegmentReader,
361 limit: usize,
362 options: super::ScorerOptions,
363 ) -> crate::Result<Box<dyn Scorer + 'a>> {
364 let global_stats = self
365 .global_stats
366 .clone()
367 .or_else(|| options.global_stats.clone());
368 term_plan!(
369 self.field,
370 &self.term,
371 global_stats.as_ref(),
372 reader,
373 limit,
374 options.collect_positions,
375 options.eligibility,
376 options.shared_threshold.as_ref(),
377 options.complete_text_matches,
378 options.skip_scoring_setup,
379 options.initial_threshold,
380 options.physical_text_field,
381 get_postings_sync,
382 get_positions_sync
383 )
384 }
385
386 fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<super::DocPredicate<'a>> {
387 let entry = reader.schema().get_field_entry(self.field)?;
388 if entry.indexed
392 && (entry.multi || !matches!(entry.tokenizer.as_deref(), Some("raw" | "raw_ci")))
393 {
394 return None;
395 }
396 let fast_field = reader.fast_field(self.field.0)?;
397 let term_str = String::from_utf8_lossy(&self.term);
398 match fast_field.text_ordinal(&term_str) {
399 Some(target_ordinal) => Some(Box::new(move |doc_id: DocId| -> bool {
400 fast_field.get_u64(doc_id) == target_ordinal
401 })),
402 None => Some(Box::new(|_| false)),
404 }
405 }
406
407 #[cfg(feature = "sync")]
408 fn bitset_cardinality_estimate(&self, reader: &SegmentReader) -> Option<u64> {
409 if reader.has_text_mapping(self.field) {
411 return None;
412 }
413 let pl = reader.get_postings_sync(self.field, &self.term).ok()??;
415 Some(pl.doc_count() as u64)
416 }
417
418 fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<super::DocBitset> {
419 self.as_doc_bitset_with_options(reader, &super::ScorerOptions::default())
420 }
421
422 fn as_doc_bitset_with_options(
423 &self,
424 reader: &SegmentReader,
425 options: &super::ScorerOptions,
426 ) -> Option<super::DocBitset> {
427 if options.stop_if_expired() {
428 return None;
429 }
430 if reader
431 .schema()
432 .get_field_entry(self.field)
433 .is_some_and(|entry| !entry.indexed)
434 {
435 return self.fast_field_bitset(reader, options);
438 }
439 #[cfg(feature = "sync")]
440 {
441 if reader.has_text_mapping(self.field) {
443 return None;
444 }
445 let Some(pl) = reader.get_postings_sync(self.field, &self.term).ok()? else {
446 return self.fast_field_bitset(reader, options);
448 };
449 let mut bitset = super::DocBitset::new(reader.num_docs());
451 let mut iter = pl.iterator();
452 let mut visited = 0usize;
453 while iter.doc() != TERMINATED {
454 if visited.is_multiple_of(1024) && options.stop_if_expired() {
455 return None;
456 }
457 bitset.set(iter.doc());
458 iter.advance();
459 visited += 1;
460 }
461 (!options.stop_if_expired()).then_some(bitset)
462 }
463 #[cfg(not(feature = "sync"))]
464 {
465 None
466 }
467 }
468
469 fn text_terms(&self, out: &mut Vec<(Field, Vec<u8>)>) {
470 out.push((self.field, self.term.clone()));
471 }
472
473 fn decompose(&self) -> super::QueryDecomposition {
474 super::QueryDecomposition::TextTerm(TermQueryInfo {
475 weight: 1.0,
476 field: self.field,
477 term: self.term.clone(),
478 global_stats: self.global_stats.clone(),
479 })
480 }
481}
482
483struct TermScorer {
484 budget: Option<super::SharedThreshold>,
485 iterator: crate::structures::BlockPostingIterator<'static>,
486 doc_count: u32,
488 idf: f32,
489 avg_field_len: f32,
491 field_boost: f32,
493 field_id: u32,
495 positions: Option<crate::structures::TermPositions>,
497 lengths: Option<crate::segment::chunk_map::DocLengths>,
499 chunk_lengths: Option<crate::segment::chunk_map::ChunkMap>,
500 params: super::Bm25Params,
502 normalization: Option<Box<super::bm25::NormTable>>,
503}
504
505impl TermScorer {
506 pub fn new(
507 posting_list: BlockPostingList,
508 idf: f32,
509 avg_field_len: f32,
510 field_boost: f32,
511 ) -> Self {
512 Self {
513 budget: None,
514 doc_count: posting_list.doc_count(),
515 iterator: posting_list.into_iterator(),
516 idf,
517 avg_field_len,
518 field_boost,
519 field_id: 0,
520 positions: None,
521 lengths: None,
522 chunk_lengths: None,
523 params: super::Bm25Params::default(),
524 normalization: None,
525 }
526 }
527
528 pub fn with_params(mut self, params: super::Bm25Params) -> Self {
530 self.params = params;
531 if self
532 .lengths
533 .as_ref()
534 .is_some_and(|lengths| lengths.is_quantized())
535 {
536 self.normalization = Some(Box::new(super::bm25::NormTable::new(
537 params,
538 self.avg_field_len,
539 )));
540 }
541 self
542 }
543
544 fn with_doc_lengths(
546 mut self,
547 lengths: crate::segment::chunk_map::DocLengths,
548 skip_scoring_setup: bool,
549 ) -> Self {
550 if lengths.is_quantized() && !skip_scoring_setup {
551 self.normalization = Some(Box::new(super::bm25::NormTable::new(
552 self.params,
553 self.avg_field_len,
554 )));
555 } else {
556 self.normalization = None;
557 }
558 self.lengths = Some(lengths);
559 self
560 }
561
562 pub fn with_positions(
563 mut self,
564 field_id: u32,
565 positions: crate::structures::TermPositions,
566 ) -> Self {
567 self.field_id = field_id;
568 self.positions = Some(positions);
569 self
570 }
571
572 fn score_batch_values(&self, docs: &[DocId], tfs: &[u32], scores: &mut [Score]) {
573 let lengths = self
574 .chunk_lengths
575 .as_ref()
576 .map(super::scoring::LengthSource::Chunks)
577 .or_else(|| {
578 self.lengths
579 .as_ref()
580 .map(super::scoring::LengthSource::Docs)
581 });
582 super::scoring::score_text_run(
583 self.params,
584 self.idf,
585 self.avg_field_len,
586 lengths,
587 self.normalization.as_deref(),
588 docs,
589 tfs,
590 scores,
591 );
592 }
593
594 fn score_window<const ACCUMULATE: bool>(
595 &mut self,
596 base: DocId,
597 scores: &mut [Score; super::docset::DOC_WINDOW_SIZE as usize],
598 bits: &mut super::docset::DocWindow,
599 ) {
600 use super::docset::DocSet;
601 if self.seek(base) == TERMINATED {
602 return;
603 }
604 let end = base.saturating_add(super::docset::DOC_WINDOW_SIZE);
605 let mut lengths = [0u32; crate::structures::postings::POSTING_BLOCK_SIZE];
606 let mut values = [0.0; crate::structures::postings::POSTING_BLOCK_SIZE];
607 let params = self.params;
608 let idf = self.idf;
609 let avg_len = self.avg_field_len;
610 let boost = self.field_boost;
611 let length_source = self
612 .chunk_lengths
613 .as_ref()
614 .map(super::scoring::LengthSource::Chunks)
615 .or_else(|| {
616 self.lengths
617 .as_ref()
618 .map(super::scoring::LengthSource::Docs)
619 });
620 let budget = &self.budget;
621 let normalization = self.normalization.as_deref();
622 self.iterator.visit_postings_until(end, |docs, tfs| {
623 crate::observe::search_work!(score_batches += 1);
624 if budget
625 .as_ref()
626 .is_some_and(super::SharedThreshold::stop_if_expired)
627 {
628 return false;
629 }
630 if let (Some(super::scoring::LengthSource::Docs(norms)), Some(table)) =
631 (length_source, normalization)
632 {
633 crate::observe::search_work!(lookup_score_units += docs.len());
634 table.score_batch(
635 params,
636 idf,
637 avg_len,
638 boost,
639 docs.iter().map(|&doc| norms.norm_code(doc)),
640 tfs,
641 &mut values[..docs.len()],
642 );
643 } else {
644 crate::observe::search_work!(exact_score_units += docs.len());
645 if let Some(source) = length_source {
646 source.gather_lengths(docs, &mut lengths[..docs.len()]);
647 } else {
648 lengths[..docs.len()].copy_from_slice(tfs);
649 }
650 for i in 0..docs.len() {
653 let tf = tfs[i] as f32;
654 let len = if lengths[i] == 0 {
655 tf
656 } else {
657 lengths[i] as f32
658 };
659 values[i] = params.score_boosted(tf, idf, len, avg_len, boost);
660 }
661 }
662 for (i, &doc) in docs.iter().enumerate() {
663 let offset = (doc - base) as usize;
664 if ACCUMULATE {
665 scores[offset] += values[i];
666 } else {
667 scores[offset] = values[i];
668 }
669 bits[offset / 64] |= 1u64 << (offset % 64);
670 }
671 true
672 });
673 }
674}
675
676impl super::docset::DocSet for TermScorer {
677 fn supports_doc_batches(&self) -> bool {
678 self.budget.is_none()
679 }
680
681 fn fill_doc_batch(&mut self, docs: &mut super::docset::DocBatch) -> usize {
682 if self.budget.is_some() {
683 return super::docset::fill_batch(self, docs);
684 }
685 self.iterator.fill_doc_batch(docs)
686 }
687
688 fn retain_doc_batch(&mut self, docs: &mut super::docset::DocBatch, len: usize) -> usize {
689 assert!(len <= docs.len());
690 if self.budget.is_some() {
691 return super::docset::retain_batch(self, docs, len);
692 }
693 self.iterator.retain_doc_batch(&mut docs[..len])
694 }
695
696 fn supports_doc_windows(&self) -> bool {
697 true
698 }
699
700 fn fill_doc_window(&mut self, base: DocId, bits: &mut super::docset::DocWindow) {
701 if self.doc() == TERMINATED {
702 bits.fill(0);
703 return;
704 }
705 self.iterator.fill_doc_window(base, bits);
706 }
707
708 fn doc(&self) -> DocId {
709 if self
710 .budget
711 .as_ref()
712 .is_some_and(super::SharedThreshold::stop_if_expired)
713 {
714 return TERMINATED;
715 }
716 self.iterator.doc()
717 }
718
719 fn advance(&mut self) -> DocId {
720 if self.doc() == TERMINATED {
721 return TERMINATED;
722 }
723 let doc = self.iterator.advance();
724 if self.positions.is_some() {
725 self.iterator.position_cursor_mut();
728 }
729 doc
730 }
731
732 fn seek(&mut self, target: DocId) -> DocId {
733 if self.doc() == TERMINATED {
734 return TERMINATED;
735 }
736 let doc = self.iterator.seek(target);
737 if self.positions.is_some() {
738 self.iterator.position_cursor_mut();
740 }
741 doc
742 }
743
744 fn size_hint(&self) -> u32 {
745 self.doc_count
746 }
747}
748
749struct FastFieldTextScorer<'a> {
755 fast_field: &'a crate::structures::fast_field::FastFieldReader,
756 target_ordinal: u64,
757 current: u32,
758 num_docs: u32,
759 budget: Option<super::SharedThreshold>,
760}
761
762impl<'a> FastFieldTextScorer<'a> {
763 fn try_new(
764 reader: &'a SegmentReader,
765 field: Field,
766 text: &str,
767 budget: Option<&super::SharedThreshold>,
768 ) -> Option<Self> {
769 let fast_field = reader.fast_field(field.0)?;
770 let target_ordinal = fast_field.text_ordinal(text)?;
771 let num_docs = reader.num_docs();
772 let mut scorer = Self {
773 fast_field,
774 target_ordinal,
775 current: 0,
776 num_docs,
777 budget: budget.filter(|budget| budget.deadline().is_some()).cloned(),
778 };
779 if scorer.doc() != TERMINATED && fast_field.get_u64(0) != target_ordinal {
781 scorer.scan_forward();
782 }
783 Some(scorer)
784 }
785
786 fn scan_forward(&mut self) {
787 loop {
788 self.current += 1;
789 if self.current >= self.num_docs
790 || (self.current.is_multiple_of(1024)
791 && self
792 .budget
793 .as_ref()
794 .is_some_and(super::SharedThreshold::stop_if_expired))
795 {
796 self.current = self.num_docs;
797 return;
798 }
799 if self.fast_field.get_u64(self.current) == self.target_ordinal {
800 return;
801 }
802 }
803 }
804}
805
806impl super::docset::DocSet for FastFieldTextScorer<'_> {
807 fn doc(&self) -> DocId {
808 if self.current >= self.num_docs
809 || self
810 .budget
811 .as_ref()
812 .is_some_and(super::SharedThreshold::stop_if_expired)
813 {
814 TERMINATED
815 } else {
816 self.current
817 }
818 }
819
820 fn advance(&mut self) -> DocId {
821 if self.doc() == TERMINATED {
822 return TERMINATED;
823 }
824 self.scan_forward();
825 self.doc()
826 }
827
828 fn seek(&mut self, target: DocId) -> DocId {
829 if self.doc() == TERMINATED {
830 return TERMINATED;
831 }
832 if target > self.current {
833 self.current = target;
834 if self.current < self.num_docs
835 && self.fast_field.get_u64(self.current) != self.target_ordinal
836 {
837 self.scan_forward();
838 }
839 }
840 self.doc()
841 }
842
843 fn size_hint(&self) -> u32 {
844 0
845 }
846}
847
848impl Scorer for FastFieldTextScorer<'_> {
849 fn score(&self) -> Score {
850 1.0
851 }
852}
853
854impl Scorer for TermScorer {
855 fn supports_score_batches(&self) -> bool {
856 self.budget.is_none() && self.field_boost == 1.0
857 }
858
859 fn fill_score_batch(
860 &mut self,
861 docs: &mut super::docset::DocBatch,
862 scores: &mut super::ScoreBatch,
863 ) -> usize {
864 if !self.supports_score_batches() {
865 return super::traits::fill_score_batch_scalar(self, docs, scores);
866 }
867 let mut tfs = [0; super::docset::DOC_BATCH_SIZE];
868 let count = self.iterator.fill_scored_doc_batch(docs, &mut tfs);
869 self.score_batch_values(&docs[..count], &tfs[..count], &mut scores[..count]);
870 count
871 }
872
873 fn score_batch_matches(
874 &mut self,
875 docs: &super::docset::DocBatch,
876 len: usize,
877 scores: &mut super::ScoreBatch,
878 matches: &mut super::ScoreBatchMask,
879 ) {
880 assert!(len <= docs.len());
881 if !self.supports_score_batches() {
882 return super::traits::score_batch_matches_scalar(self, docs, len, scores, matches);
883 }
884 matches.fill(0);
885 let mut retained = *docs;
886 let mut tfs = [0; super::docset::DOC_BATCH_SIZE];
887 let count = self
888 .iterator
889 .retain_scored_doc_batch(&mut retained[..len], &mut tfs[..len]);
890 let mut values = [0.0; super::docset::DOC_BATCH_SIZE];
891 self.score_batch_values(&retained[..count], &tfs[..count], &mut values[..count]);
892 let mut input = 0;
893 for i in 0..count {
894 while docs[input] < retained[i] {
895 input += 1;
896 }
897 scores[input] = values[i];
898 matches[input / 64] |= 1 << (input % 64);
899 }
900 }
901
902 fn supports_score_windows(&self) -> bool {
903 self.doc_count > crate::structures::postings::POSTING_BLOCK_SIZE as u32
904 }
905
906 fn fill_score_window(
907 &mut self,
908 base: DocId,
909 scores: &mut [Score; super::docset::DOC_WINDOW_SIZE as usize],
910 bits: &mut super::docset::DocWindow,
911 ) {
912 bits.fill(0);
913 self.score_window::<false>(base, scores, bits);
914 }
915
916 fn accumulate_score_window(
917 &mut self,
918 base: DocId,
919 scores: &mut [Score; super::docset::DOC_WINDOW_SIZE as usize],
920 bits: &mut super::docset::DocWindow,
921 ) {
922 self.score_window::<true>(base, scores, bits);
923 }
924
925 fn score(&self) -> Score {
926 let tf = self.iterator.term_freq() as f32;
927 if let (Some(lengths), Some(table)) = (&self.lengths, &self.normalization)
928 && self.chunk_lengths.is_none()
929 {
930 crate::observe::search_work!(lookup_score_units += 1);
931 return table.score_boosted(
932 self.params,
933 tf,
934 self.idf,
935 lengths.norm_code(self.iterator.doc()),
936 self.avg_field_len,
937 self.field_boost,
938 );
939 }
940 crate::observe::search_work!(exact_score_units += 1);
942 let doc_len = self
944 .chunk_lengths
945 .as_ref()
946 .map(|map| map.length(self.iterator.doc()).max(map.length_floor()) as f32)
947 .or_else(|| {
948 self.lengths
949 .as_ref()
950 .map(|lengths| lengths.length(self.iterator.doc()) as f32)
951 })
952 .filter(|len| *len > 0.0)
953 .unwrap_or(tf);
954 self.params
955 .score_boosted(tf, self.idf, doc_len, self.avg_field_len, self.field_boost)
956 }
957
958 fn matched_positions(&self) -> Option<super::MatchedPositions> {
959 let positions = self.positions.as_ref()?;
960 let pos =
961 positions.positions(self.iterator.position_cursor(), self.iterator.term_freq())?;
962 let score = self.score();
963 let per_position_score = if pos.is_empty() {
965 0.0
966 } else {
967 score / pos.len() as f32
968 };
969 let scored_positions: Vec<super::ScoredPosition> = pos
970 .iter()
971 .map(|&p| super::ScoredPosition::new(p, per_position_score))
972 .collect();
973 Some(vec![(self.field_id, scored_positions)])
974 }
975}
976
977pub(super) fn complete_text_scorer<'a>(
978 postings: Vec<(BlockPostingList, f32)>,
979 avg_field_len: f32,
980 reader: &'a SegmentReader,
981 field: Field,
982 options: &super::ScorerOptions,
983) -> crate::Result<Box<dyn Scorer + 'a>> {
984 let budget = options.shared_threshold.clone();
985 let eligibility = options.eligibility.clone();
986 let skip_scoring_setup = options.skip_scoring_setup;
987 let physical = options.physical_text_field == Some(field);
988 if postings.is_empty()
989 || budget
990 .as_ref()
991 .is_some_and(super::SharedThreshold::stop_if_expired)
992 {
993 return Ok(Box::new(EmptyScorer));
994 }
995 let map = reader.chunk_map(field);
996 if reader.has_text_mapping(field) && map.is_none() {
997 return Err(crate::Error::Corruption(
998 "chunked text has postings without a chunk map".into(),
999 ));
1000 }
1001 if !physical && map.is_some_and(|map| !map.is_doc_ordered()) {
1002 return super::required_text::scorer(
1003 postings,
1004 avg_field_len,
1005 reader,
1006 field,
1007 budget,
1008 eligibility,
1009 );
1010 }
1011 let mut terms: Vec<Box<dyn Scorer>> = Vec::with_capacity(postings.len());
1012 for (posting, idf) in postings {
1013 let mut scorer = TermScorer::new(posting, idf, avg_field_len, 1.0)
1014 .with_params(super::Bm25Params::for_field(reader.schema(), field));
1015 scorer.chunk_lengths = map.cloned();
1016 if let Some(lengths) = reader.doc_lengths(field) {
1017 scorer = scorer.with_doc_lengths(lengths.clone(), skip_scoring_setup);
1018 }
1019 scorer.budget = budget.clone();
1020 terms.push(Box::new(scorer));
1021 }
1022 let scorer = super::boolean::BooleanScorer::disjunction(terms);
1023 let scorer: Box<dyn Scorer + 'a> = match map {
1024 Some(map) if !map.is_document_map() => {
1025 super::phrase::fold_chunked_phrase_scorer(scorer, map.clone(), field.0, budget)
1026 }
1027 Some(_) => Box::new(scorer),
1028 None => Box::new(scorer),
1029 };
1030 Ok(super::filtered::filtered(scorer, eligibility))
1031}
1032
1033pub(super) async fn score_term_candidates(
1035 reader: &SegmentReader,
1036 field: Field,
1037 terms: &[(Vec<u8>, f32)],
1038 targets: &[u32],
1039 stats: Option<&Arc<GlobalStats>>,
1040 scratch: &mut crate::structures::postings::PostingDecodeScratch,
1041) -> crate::Result<Vec<f32>> {
1042 reader.check_posting_integrity()?;
1043 let mut scores = vec![0.0; targets.len()];
1044 let Some(&first_target) = targets.first() else {
1045 return Ok(scores);
1046 };
1047 let params = super::Bm25Params::for_field(reader.schema(), field);
1048 for (term, weight) in terms {
1049 let Some(postings) = reader.get_postings(field, term).await? else {
1050 continue;
1051 };
1052 let (idf, avg_len) = compute_term_idf(&postings, field, reader, stats, term);
1053 let mut cursor = postings.into_candidate_iterator(first_target, scratch);
1054 for (index, &target) in targets.iter().enumerate() {
1055 if cursor.seek(target) != target {
1056 continue;
1057 }
1058 let tf = cursor.term_freq() as f32;
1059 let length = if let Some(map) = reader.chunk_map(field) {
1060 map.bm25_length(target) as f32
1061 } else if let Some(lengths) = reader.doc_lengths(field) {
1062 lengths.length(target) as f32
1063 } else {
1064 tf
1065 };
1066 scores[index] += params.score(tf, idf * weight, length, avg_len);
1067 }
1068 cursor.recycle(scratch);
1069 }
1070 reader.check_posting_integrity()?;
1071 Ok(scores)
1072}
1073
1074#[cfg(test)]
1075mod score_window_tests {
1076 use super::*;
1077 use crate::query::DocSet;
1078 use crate::segment::chunk_map::DocLengths;
1079 use crate::structures::postings::{PostingCodec, PostingList};
1080
1081 #[test]
1082 fn compact_term_scores_preserve_scalar_bits_membership_and_resume() {
1083 let mut list = PostingList::new();
1084 for i in 0..701u32 {
1085 list.push(i * 11 + 1, [0, 1, 3, 65536, u32::MAX][i as usize % 5]);
1086 }
1087 let lengths = DocLengths::from_lengths(
1088 &(0..7800)
1089 .map(|i| [0, 1, 97, 65535][i % 4])
1090 .collect::<Vec<_>>(),
1091 );
1092 for codec in [
1093 PostingCodec::Rounded,
1094 PostingCodec::Packed,
1095 PostingCodec::Pfor,
1096 PostingCodec::Simd4x,
1097 ] {
1098 let postings = BlockPostingList::from_posting_list_with_codec(&list, codec).unwrap();
1099 for with_lengths in [false, true] {
1100 for boost in [0.0, 0.5, 1.0, 2.5] {
1101 for params in [
1102 super::super::Bm25Params { k1: 0.0, b: 0.0 },
1103 super::super::Bm25Params::default(),
1104 super::super::Bm25Params { k1: 14.3, b: 1.0 },
1105 ] {
1106 let build = || {
1107 let mut scorer =
1108 TermScorer::new(postings.clone(), 2.713, 503.17, boost)
1109 .with_params(params);
1110 if with_lengths {
1111 scorer.lengths = Some(lengths.clone());
1112 }
1113 scorer
1114 };
1115 let mut scalar = build();
1116 let mut batch = build();
1117 assert_eq!(batch.supports_score_batches(), boost == 1.0);
1118 let mut docs = [0; super::super::docset::DOC_BATCH_SIZE];
1119 let mut scores = [f32::NAN; super::super::docset::DOC_BATCH_SIZE];
1120 scalar.seek(121);
1121 batch.seek(121);
1122 while batch.doc() != TERMINATED {
1123 let len = batch.fill_score_batch(&mut docs, &mut scores);
1124 assert!(len > 0);
1125 for i in 0..len {
1126 assert_eq!(docs[i], scalar.doc());
1127 assert_eq!(
1128 scores[i].to_bits(),
1129 scalar.score().to_bits(),
1130 "{codec:?} boost={boost} doc={}",
1131 docs[i]
1132 );
1133 scalar.advance();
1134 }
1135 assert_eq!(batch.doc(), scalar.doc());
1136 }
1137 let mut scalar = build();
1138 let mut batch = build();
1139 for start in (0..8000u32).step_by(128) {
1140 let docs = std::array::from_fn(|i| start + i as u32);
1141 let mut bits = [u64::MAX; 2];
1142 batch.score_batch_matches(&docs, docs.len(), &mut scores, &mut bits);
1143 for (i, &doc) in docs.iter().enumerate() {
1144 let found = scalar.seek(doc) == doc;
1145 assert_eq!(bits[i / 64] & (1 << (i % 64)) != 0, found);
1146 if found {
1147 assert_eq!(scores[i].to_bits(), scalar.score().to_bits());
1148 }
1149 }
1150 assert_eq!(batch.doc(), scalar.doc());
1151 }
1152 }
1153 }
1154 }
1155 }
1156 let mut timed = TermScorer::new(
1157 BlockPostingList::from_posting_list(&list).unwrap(),
1158 1.0,
1159 1.0,
1160 1.0,
1161 );
1162 timed.budget = Some(super::super::SharedThreshold::new());
1163 assert!(!timed.supports_score_batches());
1164 }
1165
1166 #[test]
1167 fn replacement_term_windows_clear_stale_hits_and_stop_at_deadlines() {
1168 let mut list = PostingList::new();
1169 for doc in [0, 4095, 4096, 8192, TERMINATED - 1] {
1170 list.push(doc, 1);
1171 }
1172 let postings = BlockPostingList::from_posting_list(&list).unwrap();
1173 let mut scalar = TermScorer::new(postings.clone(), -0.0, 10.0, 1.0);
1174 let mut batched = TermScorer::new(postings.clone(), -0.0, 10.0, 1.0);
1175 let mut scores = Box::new([f32::NAN; super::super::docset::DOC_WINDOW_SIZE as usize]);
1176 let mut bits = [u64::MAX; super::super::docset::DOC_WINDOW_WORDS];
1177 for base in [
1178 0,
1179 4096,
1180 8192,
1181 TERMINATED - super::super::docset::DOC_WINDOW_SIZE,
1182 ] {
1183 bits.fill(u64::MAX);
1184 scores.fill(f32::NAN);
1185 batched.fill_score_window(base, &mut scores, &mut bits);
1186 scalar.seek(base);
1187 let end = base.saturating_add(super::super::docset::DOC_WINDOW_SIZE);
1188 while scalar.doc() < end {
1189 let offset = (scalar.doc() - base) as usize;
1190 assert_ne!(bits[offset / 64] & (1u64 << (offset % 64)), 0);
1191 assert_eq!(scores[offset].to_bits(), scalar.score().to_bits());
1192 bits[offset / 64] &= !(1u64 << (offset % 64));
1193 scalar.advance();
1194 }
1195 assert!(bits.iter().all(|&word| word == 0));
1196 assert_eq!(batched.doc(), scalar.doc());
1197 }
1198 assert_eq!(batched.doc(), TERMINATED);
1199 let mut expired = TermScorer::new(postings, 1.0, 10.0, 1.0);
1200 let budget = super::super::SharedThreshold::for_limit(1)
1201 .with_deadline(Some(std::time::Instant::now()));
1202 expired.budget = Some(budget.clone());
1203 bits.fill(u64::MAX);
1204 expired.fill_score_window(0, &mut scores, &mut bits);
1205 assert!(bits.iter().all(|&word| word == 0));
1206 assert_eq!(expired.doc(), TERMINATED);
1207 assert!(budget.truncated());
1208 }
1209
1210 #[test]
1211 fn term_score_runs_preserve_scalar_bits_with_boosts_lengths_and_legacy_fallback() {
1212 let mut list = PostingList::new();
1213 for i in 0..327u32 {
1214 list.push(i * 11, [1, 3, 127, 65535, 65536, u32::MAX][i as usize % 6]);
1215 }
1216 let lengths = DocLengths::from_lengths(
1217 &(0..3600)
1218 .map(|i| [0, 1, 97, 65535][i % 4])
1219 .collect::<Vec<_>>(),
1220 );
1221 for codec in [
1222 PostingCodec::Rounded,
1223 PostingCodec::Packed,
1224 PostingCodec::Pfor,
1225 PostingCodec::Simd4x,
1226 ] {
1227 let postings = BlockPostingList::from_posting_list_with_codec(&list, codec).unwrap();
1228 for with_lengths in [false, true] {
1229 for params in [
1230 super::super::Bm25Params { k1: 0.0, b: 0.0 },
1231 super::super::Bm25Params::default(),
1232 super::super::Bm25Params { k1: 14.3, b: 1.0 },
1233 ] {
1234 for boost in [0.0, 0.5, 1.0, 2.5] {
1235 for avg in [0.0, 1.0, 503.17] {
1236 for idf in [-0.0, 0.001, 10.0] {
1237 let build = || {
1238 let mut scorer =
1239 TermScorer::new(postings.clone(), idf, avg, boost)
1240 .with_params(params);
1241 if with_lengths {
1242 scorer.lengths = Some(lengths.clone());
1243 }
1244 scorer
1245 };
1246 for replace in [false, true] {
1247 let mut scalar = build();
1248 let mut batched = build();
1249 let base = 121;
1250 scalar.seek(base);
1251 batched.seek(base);
1252 let mut scores = Box::new(
1253 [0.0; super::super::docset::DOC_WINDOW_SIZE as usize],
1254 );
1255 let mut bits = [if replace { u64::MAX } else { 0 };
1256 super::super::docset::DOC_WINDOW_WORDS];
1257 if replace {
1258 scores.fill(f32::NAN);
1259 batched.fill_score_window(base, &mut scores, &mut bits);
1260 } else {
1261 batched.accumulate_score_window(
1262 base,
1263 &mut scores,
1264 &mut bits,
1265 );
1266 }
1267 while scalar.doc() != TERMINATED {
1268 let offset = (scalar.doc() - base) as usize;
1269 assert_ne!(bits[offset / 64] & (1u64 << (offset % 64)), 0);
1270 let expected = if replace {
1271 scalar.score()
1272 } else {
1273 0.0 + scalar.score()
1274 };
1275 assert_eq!(
1276 scores[offset].to_bits(),
1277 expected.to_bits(),
1278 "codec={codec:?} lengths={with_lengths} params={params:?} boost={boost} avg={avg} idf={idf} doc={}",
1279 scalar.doc()
1280 );
1281 bits[offset / 64] &= !(1u64 << (offset % 64));
1282 scalar.advance();
1283 }
1284 assert!(bits.iter().all(|word| *word == 0));
1285 assert_eq!(batched.doc(), TERMINATED);
1286 }
1287 }
1288 }
1289 }
1290 }
1291 }
1292 }
1293 }
1294}