1use super::*;
2use crate::directories::Directory;
3use crate::index::Searcher;
4use crate::query::{GlobalStats, GlobalStatsBuilder, ScoredPosition, SearchResult};
5use crate::segment::SegmentReader;
6use std::collections::{BTreeMap, BTreeSet};
7use std::sync::Arc;
8
9const MAX_FEATURES: usize = crate::query::MAX_FUSION_SUB_QUERIES;
10const MAX_FEATURE_VALUES: usize = 2_000_000;
11const MAX_VECTOR_BYTES: usize = 1024 * 1024 * 1024;
12
13struct CandidateProbeState {
14 sparse: crate::segment::reader::SparseProbeBudget,
15 payload_remaining: u64,
16 text_scratch: crate::structures::postings::PostingDecodeScratch,
17}
18
19impl Default for CandidateProbeState {
20 fn default() -> Self {
21 Self {
22 sparse: Default::default(),
23 payload_remaining: 256 * 1024 * 1024,
24 text_scratch: Default::default(),
25 }
26 }
27}
28
29#[derive(Default)]
30struct ComponentPreparation {
31 sparse: crate::query::bmp::CandidateBmpPreparation,
32 vector: Option<crate::query::reranker::CandidateVectorPreparation>,
33}
34
35impl CandidateScoringPlan {
36 pub fn validate(&self, schema: &crate::Schema) -> Result<()> {
37 self.document_combiner.validate().map_err(Error::Query)?;
38 if self.seed_document_passages
39 && (!self.backfill || !self.features.iter().any(|f| f.scope == ScoreScope::Chunk))
40 {
41 return Err(Error::Query(
42 "seed_document_passages requires backfill and a chunk-scoped feature".into(),
43 ));
44 }
45 if self.all_passages && !self.backfill {
46 return Err(Error::Query(
47 "all_passages diagnostics require backfill".into(),
48 ));
49 }
50 if self.features.is_empty()
51 || self.features.len() > MAX_FEATURES
52 || self.export_passages == 0
53 || self.export_passages > u16::MAX as usize + 1
54 {
55 return Err(Error::Query("candidate scoring needs 1..16 branches and 1..65536 exported passages per document".into()));
56 }
57 let mut names = BTreeSet::new();
58 for feature in &self.features {
59 feature.query.document.validate()?;
60 if feature.name.is_empty()
61 || feature.name.len() > 128
62 || !feature
63 .name
64 .bytes()
65 .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))
66 || !names.insert(feature.name.as_str())
67 {
68 return Err(Error::Query("candidate scoring requires unique query names (1..128 ASCII letters, digits, '.', '_' or '-')".into()));
69 }
70 let entry = schema
71 .get_field_entry(feature.query.field)
72 .ok_or_else(|| Error::FieldNotFound(feature.query.field.0.to_string()))?;
73 if !entry.indexed {
74 return Err(Error::Query(format!(
75 "L1 branch '{}' needs an indexed field",
76 feature.name
77 )));
78 }
79 if feature.scope == ScoreScope::Chunk
80 && entry.field_type == crate::FieldType::Text
81 && !entry.chunked
82 {
83 return Err(Error::Query(format!(
84 "L1 chunk branch '{}' needs chunked text; plain text is document scope",
85 feature.name
86 )));
87 }
88 if feature.query.components.is_empty()
89 || feature.query.components.len() > crate::query::MAX_QUERY_TERMS
90 {
91 return Err(Error::Query(
92 "L1 branch has an invalid component count".into(),
93 ));
94 }
95 for (component, boost) in &feature.query.components {
96 if !boost.is_finite() {
97 return Err(Error::Query("non-finite L1 query boost".into()));
98 }
99 match component {
100 ScoreComponent::Text(terms) => {
101 if entry.field_type != crate::FieldType::Text
102 || terms.len() > crate::query::MAX_QUERY_TERMS
103 || terms.iter().any(|(_, w)| !w.is_finite())
104 {
105 return Err(Error::Query("invalid L1 text feature".into()));
106 }
107 }
108 ScoreComponent::Phrase(query) => {
109 let max_terms = schema.max_l1_phrase_terms();
110 if query.terms.len() > max_terms {
111 return Err(Error::Query(format!(
112 "L1 phrase feature '{}' has {} terms; maximum is {max_terms}",
113 feature.name,
114 query.terms.len(),
115 )));
116 }
117 if entry.field_type != crate::FieldType::Text
118 || query.field != feature.query.field
119 || query.offsets.len() != query.terms.len()
120 || !query.offsets.windows(2).all(|p| p[0] < p[1])
121 || (query.terms.len() > 1 && entry.positions.is_none())
122 {
123 return Err(Error::Query(
124 "invalid L1 phrase feature or missing positions".into(),
125 ));
126 }
127 }
128 ScoreComponent::Sparse(terms) => {
129 if entry.field_type != crate::FieldType::SparseVector
130 || terms.len() > crate::query::MAX_QUERY_TERMS
131 || terms.iter().any(|(_, w)| !w.is_finite())
132 {
133 return Err(Error::Query("invalid L1 sparse feature".into()));
134 }
135 }
136 ScoreComponent::Dense(vector) => {
137 let Some(config) = &entry.dense_vector_config else {
138 return Err(Error::Query(
139 "L1 dense feature requires a dense field".into(),
140 ));
141 };
142 if vector.len() != config.dim
143 || vector.is_empty()
144 || vector.iter().any(|v| !v.is_finite())
145 {
146 return Err(Error::Query(
147 "invalid L1 dense dimensions or values".into(),
148 ));
149 }
150 }
151 ScoreComponent::Binary(vector) => {
152 let Some(config) = &entry.binary_dense_vector_config else {
153 return Err(Error::Query(
154 "L1 binary feature requires a binary field".into(),
155 ));
156 };
157 if vector.is_empty() || vector.len() != config.byte_len() {
158 return Err(Error::Query("invalid L1 binary dimension".into()));
159 }
160 }
161 }
162 }
163 }
164 if let Some(model) = &self.model {
165 model.validate(
166 &self
167 .features
168 .iter()
169 .map(|f| f.name.as_str())
170 .collect::<Vec<_>>(),
171 )?;
172 }
173 Ok(())
174 }
175}
176
177async fn score_field<D: Directory + 'static>(
178 searcher: &Searcher<D>,
179 reader: &SegmentReader,
180 feature: &CandidateFeature,
181 locations: &[crate::segment::reader::candidate_lookup::CandidateLocation],
182 stats: &Arc<GlobalStats>,
183 budget: &mut CandidateProbeState,
184 preparation: &mut [ComponentPreparation],
185) -> Result<(Vec<f32>, Vec<Vec<f32>>)> {
186 let query = &feature.query;
187 let document_scope = feature.scope == ScoreScope::Document;
188 let targets: Vec<u32> = locations.iter().map(|location| location.physical).collect();
189 let targets = targets.as_slice();
190 let mut result = vec![0.0; targets.len()];
191 let mut components = Vec::new();
192 for ((component, boost), preparation) in query.components.iter().zip(preparation) {
193 let values = match component {
194 ScoreComponent::Text(terms) => {
195 for (term, _) in terms {
196 reader
197 .reserve_candidate_text_reads(
198 query.field,
199 term,
200 false,
201 &mut budget.payload_remaining,
202 )
203 .await?;
204 }
205 crate::query::term::score_term_candidates(
206 reader,
207 query.field,
208 terms,
209 targets,
210 Some(stats),
211 &mut budget.text_scratch,
212 )
213 .await?
214 }
215 ScoreComponent::Phrase(phrase) => {
216 for term in &phrase.terms {
217 reader
218 .reserve_candidate_text_reads(
219 query.field,
220 term,
221 phrase.terms.len() > 1,
222 &mut budget.payload_remaining,
223 )
224 .await?;
225 }
226 crate::query::phrase::score_phrase_candidates(reader, phrase, targets, Some(stats))
227 .await?
228 }
229 ScoreComponent::Sparse(terms) => {
230 if let Some(index) = reader.seismic_index(query.field) {
231 for &row in targets {
233 let bytes = index.vector_byte_len(row)? as u64;
234 budget.payload_remaining =
235 budget.payload_remaining.checked_sub(bytes).ok_or_else(|| {
236 Error::Query("candidate sparse payload read budget exceeded".into())
237 })?;
238 }
239 searcher.install_search_cpu(|| {
240 crate::query::seismic::score_candidates(index, terms, targets)
241 })?
242 } else if let Some(index) = reader.bmp_index(query.field) {
243 reader.reserve_candidate_bmp_reads(
244 query.field,
245 targets,
246 &mut budget.payload_remaining,
247 )?;
248 searcher
249 .install_search_cpu(|| preparation.sparse.score(index, terms, targets))?
250 } else {
251 let index = reader.sparse_index(query.field).ok_or_else(|| {
252 Error::Corruption("L1 sparse locations lack a sparse index".into())
253 })?;
254 let mut documents: Vec<_> = locations.iter().map(|l| l.doc).collect();
255 documents.dedup();
256 let mut scores = vec![0.0f32; locations.len()];
257 for &(dimension, weight) in terms {
258 index
259 .probe_candidates(
260 &documents,
261 Some((dimension, weight)),
262 &mut budget.sparse,
263 |doc, ordinal, value| {
264 if let Ok(i) = locations
265 .binary_search_by_key(&(doc, ordinal), |l| {
266 (l.doc, l.ordinal)
267 })
268 {
269 scores[i] += value;
270 if !scores[i].is_finite() {
271 return Err(Error::Query(
272 "L1 sparse score overflow".into(),
273 ));
274 }
275 }
276 Ok(())
277 },
278 )
279 .await?;
280 }
281 scores
282 }
283 }
284 ScoreComponent::Dense(vector) => {
285 let flat = reader.flat_vectors().get(&query.field.0).ok_or_else(|| {
286 Error::Corruption("L1 dense locations lack stored vectors".into())
287 })?;
288 let unit_norm = reader
289 .schema()
290 .get_field_entry(query.field)
291 .and_then(|e| e.dense_vector_config.as_ref())
292 .is_some_and(|c| c.unit_norm);
293 crate::query::reranker::score_vector_candidates(
294 searcher,
295 flat,
296 vector,
297 &[],
298 unit_norm,
299 targets,
300 &mut preparation.vector,
301 )
302 .await?
303 }
304 ScoreComponent::Binary(vector) => {
305 let flat = reader.flat_vectors().get(&query.field.0).ok_or_else(|| {
306 Error::Corruption("L1 binary locations lack stored vectors".into())
307 })?;
308 crate::query::reranker::score_vector_candidates(
309 searcher,
310 flat,
311 &[],
312 vector,
313 false,
314 targets,
315 &mut preparation.vector,
316 )
317 .await?
318 }
319 };
320 for (total, value) in result.iter_mut().zip(&values) {
321 *total += value * boost;
322 if !total.is_finite() {
323 return Err(Error::Query("L1 feature score overflow".into()));
324 }
325 }
326 if document_scope {
327 components.push(values);
328 }
329 }
330 Ok((result, components))
331}
332
333impl<D: Directory + 'static> Searcher<D> {
334 pub async fn candidate_text_stats(
337 &self,
338 plan: &CandidateScoringPlan,
339 ) -> Result<Arc<GlobalStats>> {
340 let mut terms = Vec::new();
341 for feature in &plan.features {
342 feature.query.text_terms(&mut terms);
343 }
344 terms.sort_unstable_by(|a, b| (a.0.0, &a.1).cmp(&(b.0.0, &b.1)));
345 terms.dedup();
346 let mut builder = GlobalStatsBuilder::new();
347 let mut fields = BTreeSet::new();
348 for reader in self.segment_readers() {
349 builder.add_segment(reader);
350 }
351 for (field, term) in terms {
352 if fields.insert(field.0) {
353 let mut corpus_size = 0u64;
354 let mut total_length = 0.0f64;
355 for reader in self.segment_readers() {
356 let size = reader.text_corpus_size(field) as u64;
357 corpus_size += size;
358 total_length += f64::from(reader.avg_field_len(field)) * size as f64;
359 }
360 builder.set_text_corpus_size(field, corpus_size);
361 builder.set_avg_field_len(field, (total_length / corpus_size.max(1) as f64) as f32);
362 }
363 for reader in self.segment_readers() {
364 let count = reader.text_doc_freq(field, &term).await?;
365 builder.add_text_df(
366 field,
367 String::from_utf8_lossy(&term).into_owned(),
368 u64::from(count),
369 );
370 }
371 }
372 Ok(Arc::new(builder.build(0)))
373 }
374
375 pub async fn score_candidates(
379 &self,
380 candidates: &[SearchResult],
381 plan: &CandidateScoringPlan,
382 stats: Option<Arc<GlobalStats>>,
383 ) -> Result<Vec<ScoredCandidate>> {
384 self.score_candidates_with_retrieved(candidates, plan, stats, &[])
385 .await
386 }
387
388 pub async fn score_candidates_with_retrieved(
391 &self,
392 candidates: &[SearchResult],
393 plan: &CandidateScoringPlan,
394 stats: Option<Arc<GlobalStats>>,
395 retrieved: &[(usize, &[SearchResult])],
396 ) -> Result<Vec<ScoredCandidate>> {
397 self.score_candidates_with_retrieved_and_rrf(candidates, plan, stats, retrieved, None)
398 .await
399 }
400
401 pub async fn score_candidates_with_retrieved_and_rrf(
403 &self,
404 candidates: &[SearchResult],
405 plan: &CandidateScoringPlan,
406 stats: Option<Arc<GlobalStats>>,
407 retrieved: &[(usize, &[SearchResult])],
408 rrf: Option<&[crate::query::RrfScore]>,
409 ) -> Result<Vec<ScoredCandidate>> {
410 plan.validate(self.schema())?;
411 if rrf.is_some_and(|scores| scores.len() != candidates.len() || plan.model.is_none())
412 || (plan.model.as_ref().is_some_and(RankingModel::needs_rrf) && rrf.is_none())
413 {
414 return Err(Error::Query(
415 "invalid or missing RRF L1 candidate features".into(),
416 ));
417 }
418 if candidates.len() > crate::query::MAX_FUSION_CANDIDATE_SLOTS {
419 return Err(Error::Query(
420 "candidate scoring document budget exceeded".into(),
421 ));
422 }
423 self.run_search_cpu(self.score_candidate_features(candidates, plan, stats, retrieved, rrf))
424 .await
425 }
426
427 async fn score_candidate_features(
428 &self,
429 candidates: &[SearchResult],
430 plan: &CandidateScoringPlan,
431 stats: Option<Arc<GlobalStats>>,
432 retrieved: &[(usize, &[SearchResult])],
433 rrf: Option<&[crate::query::RrfScore]>,
434 ) -> Result<Vec<ScoredCandidate>> {
435 let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
436 let mut addresses = BTreeSet::new();
437 for (i, candidate) in candidates.iter().enumerate() {
438 let &segment = self
439 .segment_map()
440 .get(&candidate.segment_id)
441 .ok_or_else(|| {
442 Error::Query("candidate address is stale or belongs to another snapshot".into())
443 })?;
444 if !self.segment_readers()[segment].is_alive(candidate.doc_id)
445 || !addresses.insert((candidate.segment_id, candidate.doc_id))
446 {
447 return Err(Error::Query(
448 "candidate addresses must be valid and unique".into(),
449 ));
450 }
451 groups.entry(segment).or_default().push(i);
452 }
453 let organic = super::retrieved::RetrievedScores::new(retrieved, plan, &addresses)?;
454 let stats = if plan.backfill {
455 Some(match stats {
456 Some(stats) => stats,
457 None => self.candidate_text_stats(plan).await?,
458 })
459 } else {
460 None
461 };
462 let names: Vec<&str> = plan.features.iter().map(|f| f.name.as_str()).collect();
463 let count = names.len();
464 let chunk_fields: BTreeSet<u32> = plan
465 .features
466 .iter()
467 .filter(|feature| feature.scope == ScoreScope::Chunk)
468 .map(|feature| feature.query.field.0)
469 .collect();
470 let mut output = Vec::with_capacity(candidates.len());
471 let mut scored_values = 0usize;
472 let mut vector_bytes = 0usize;
473 let mut matrix_values = 0usize;
474 let mut probe_budget = CandidateProbeState::default();
475 let mut preparations: Vec<Vec<ComponentPreparation>> = plan
476 .features
477 .iter()
478 .map(|feature| {
479 std::iter::repeat_with(ComponentPreparation::default)
480 .take(feature.query.components.len())
481 .collect()
482 })
483 .collect();
484 let mut reduction_locations = Vec::new();
485 let mut seed_locations = 0usize;
486 for (segment, mut candidate_indices) in groups {
487 let reader = &self.segment_readers()[segment];
488 candidate_indices.sort_unstable_by_key(|&i| candidates[i].doc_id);
489 let documents: Vec<u32> = candidate_indices
490 .iter()
491 .map(|&i| candidates[i].doc_id)
492 .collect();
493 matrix_values = matrix_values
494 .checked_add(documents.len().saturating_mul(count))
495 .ok_or_else(|| Error::Query("L1 feature matrix size overflow".into()))?;
496 if matrix_values > MAX_FEATURE_VALUES {
497 return Err(Error::Query("L1 feature matrix budget exceeded".into()));
498 }
499 let mut doc_values = vec![vec![None; count]; documents.len()];
500 let mut passages: BTreeMap<(u32, u16), Vec<Option<f32>>> = BTreeMap::new();
501 let mut nominated = Vec::new();
502 if !plan.all_passages && retrieved.is_empty() {
503 for &i in &candidate_indices {
504 for (field, positions) in &candidates[i].positions {
505 if !chunk_fields.contains(field) {
506 continue;
507 }
508 for position in positions {
509 nominated.push(crate::segment::logical_address::LogicalUnit {
510 doc: candidates[i].doc_id,
511 ordinal: u16::try_from(position.position).map_err(|_| {
512 Error::Query("invalid nominated passage ordinal".into())
513 })?,
514 });
515 if nominated.len() > crate::query::MAX_FUSION_CHUNK_SLOTS {
516 return Err(Error::Query("too many nominated passages".into()));
517 }
518 }
519 }
520 }
521 nominated.sort_unstable();
522 nominated.dedup();
523 }
524 for (feature_index, feature) in plan.features.iter().enumerate() {
527 for (doc_index, &candidate_index) in candidate_indices.iter().enumerate() {
528 let candidate = &candidates[candidate_index];
529 let Some(hit) =
530 organic.get(feature_index, candidate.segment_id, candidate.doc_id)
531 else {
532 continue;
533 };
534 if feature.scope == ScoreScope::Document {
535 doc_values[doc_index][feature_index] = Some(hit.score);
536 } else {
537 for (field, positions) in &hit.positions {
538 if *field != feature.query.field.0 {
539 continue;
540 }
541 for position in positions {
542 let key = (hit.doc_id, position.position as u16);
543 if let std::collections::btree_map::Entry::Vacant(entry) =
544 passages.entry(key)
545 {
546 matrix_values = matrix_values.saturating_add(count);
547 if matrix_values > MAX_FEATURE_VALUES {
548 return Err(Error::Query(
549 "L1 feature matrix budget exceeded".into(),
550 ));
551 }
552 entry.insert(vec![None; count]);
553 }
554 let value = &mut passages.get_mut(&key).expect("inserted passage")
555 [feature_index];
556 if value.is_some() {
557 return Err(Error::Query(
558 "duplicate organic L1 passage score".into(),
559 ));
560 }
561 *value = Some(position.score);
562 }
563 }
564 }
565 }
566 }
567 nominated.extend(passages.keys().map(|&(doc, ordinal)| {
568 crate::segment::logical_address::LogicalUnit { doc, ordinal }
569 }));
570 nominated.sort_unstable();
571 nominated.dedup();
572 for key in &nominated {
573 if let std::collections::btree_map::Entry::Vacant(entry) =
574 passages.entry((key.doc, key.ordinal))
575 {
576 matrix_values = matrix_values.saturating_add(count);
577 if matrix_values > MAX_FEATURE_VALUES {
578 return Err(Error::Query("L1 feature matrix budget exceeded".into()));
579 }
580 entry.insert(vec![None; count]);
581 }
582 }
583 if plan.seed_document_passages && !plan.all_passages {
584 let missing_documents: Vec<_> = documents
585 .iter()
586 .copied()
587 .filter(|doc| {
588 let index = nominated.partition_point(|key| key.doc < *doc);
589 nominated.get(index).is_none_or(|key| key.doc != *doc)
590 })
591 .collect();
592 for &field in &chunk_fields {
593 let locations = reader
594 .candidate_locations(
595 crate::dsl::Field(field),
596 &missing_documents,
597 MAX_FEATURE_VALUES.saturating_sub(seed_locations),
598 &mut probe_budget.sparse,
599 )
600 .await?;
601 seed_locations += locations.len();
602 for location in locations {
603 if let std::collections::btree_map::Entry::Vacant(entry) =
604 passages.entry((location.doc, location.ordinal))
605 {
606 matrix_values = matrix_values.saturating_add(count);
607 if matrix_values > MAX_FEATURE_VALUES {
608 return Err(Error::Query(
609 "L1 feature matrix budget exceeded".into(),
610 ));
611 }
612 entry.insert(vec![None; count]);
613 }
614 }
615 }
616 nominated = passages
617 .keys()
618 .map(
619 |&(doc, ordinal)| crate::segment::logical_address::LogicalUnit {
620 doc,
621 ordinal,
622 },
623 )
624 .collect();
625 }
626 for (feature_index, feature) in plan.features.iter().enumerate() {
627 if !plan.backfill {
628 continue;
629 }
630 let missing_documents: Vec<_> = documents
631 .iter()
632 .enumerate()
633 .filter_map(|(i, &doc)| {
634 (feature.scope == ScoreScope::Chunk
635 || doc_values[i][feature_index].is_none())
636 .then_some(doc)
637 })
638 .collect();
639 let missing_passages: Vec<_> = nominated
640 .iter()
641 .copied()
642 .filter(|key| passages[&(key.doc, key.ordinal)][feature_index].is_none())
643 .collect();
644 let mut locations = if feature.scope == ScoreScope::Chunk && !plan.all_passages {
645 reader
646 .candidate_passage_locations(
647 feature.query.field,
648 &missing_passages,
649 &mut probe_budget.sparse,
650 )
651 .await?
652 } else {
653 reader
654 .candidate_locations(
655 feature.query.field,
656 &missing_documents,
657 MAX_FEATURE_VALUES.saturating_sub(scored_values),
658 &mut probe_budget.sparse,
659 )
660 .await?
661 };
662 if feature.scope == ScoreScope::Chunk {
663 locations.retain(|location| {
664 passages
665 .get(&(location.doc, location.ordinal))
666 .is_none_or(|row| row[feature_index].is_none())
667 });
668 }
669 if locations.len() > MAX_FEATURE_VALUES.saturating_sub(scored_values) {
670 return Err(Error::Query("L1 scored-value budget exceeded".into()));
671 }
672 let work = locations
673 .len()
674 .checked_mul(feature.query.components.len())
675 .ok_or_else(|| Error::Query("L1 feature work overflow".into()))?;
676 scored_values = scored_values
677 .checked_add(work)
678 .filter(|&n| n <= MAX_FEATURE_VALUES)
679 .ok_or_else(|| Error::Query("L1 scored-component budget exceeded".into()))?;
680 if locations.is_empty() {
681 continue;
682 }
683 if let Some(flat) = reader.flat_vectors().get(&feature.query.field.0) {
684 vector_bytes = vector_bytes
685 .checked_add(
686 locations
687 .len()
688 .checked_mul(feature.query.components.len())
689 .and_then(|n| n.checked_mul(flat.vector_byte_size()))
690 .ok_or_else(|| {
691 Error::Query("L1 vector byte count overflow".into())
692 })?,
693 )
694 .ok_or_else(|| Error::Query("L1 vector byte count overflow".into()))?;
695 if vector_bytes > MAX_VECTOR_BYTES {
696 return Err(Error::Query("L1 exceeds stored vector read budget".into()));
697 }
698 }
699 locations.sort_unstable_by_key(|location| location.physical);
700 let document_scope = feature.scope == ScoreScope::Document;
701 let (scores, components) = score_field(
702 self,
703 reader,
704 feature,
705 &locations,
706 stats.as_ref().expect("backfill statistics"),
707 &mut probe_budget,
708 &mut preparations[feature_index],
709 )
710 .await?;
711 if document_scope {
712 reduction_locations.clear();
713 reduction_locations.extend(
714 locations
715 .iter()
716 .enumerate()
717 .map(|(i, location)| (u32::from(location.ordinal), i)),
718 );
719 reduction_locations
721 .sort_unstable_by_key(|&(ordinal, i)| (locations[i].doc, ordinal));
722 for selected in reduction_locations
723 .chunk_by(|a, b| locations[a.1].doc == locations[b.1].doc)
724 {
725 let doc = locations[selected[0].1].doc;
726 let doc_index = documents
727 .binary_search(&doc)
728 .expect("resolved selected doc");
729 doc_values[doc_index][feature_index] =
730 Some(feature.query.document.score(&components, selected)?);
731 }
732 continue;
733 }
734 for (location, score) in locations.into_iter().zip(scores) {
735 {
736 let values = match passages.entry((location.doc, location.ordinal)) {
737 std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(),
738 std::collections::btree_map::Entry::Vacant(entry) => {
739 matrix_values += count;
740 if matrix_values > MAX_FEATURE_VALUES {
741 return Err(Error::Query(
742 "L1 feature matrix budget exceeded".into(),
743 ));
744 }
745 entry.insert(vec![None; count])
746 }
747 };
748 values[feature_index] = Some(score);
749 }
750 }
751 }
752 let mut passages = passages.into_iter().peekable();
753 for (doc_index, &candidate_index) in candidate_indices.iter().enumerate() {
754 let candidate = &candidates[candidate_index];
755 let document = std::mem::take(&mut doc_values[doc_index]);
756 let mut rows = Vec::new();
757 while passages
758 .peek()
759 .is_some_and(|((doc, _), _)| *doc == candidate.doc_id)
760 {
761 let ((_, ordinal), values) = passages.next().expect("peeked row");
762 rows.push(PassageFeatures {
763 ordinal,
764 score: candidate.score,
765 values,
766 });
767 }
768 let scored_passages = rows.len();
769 let mut result = SearchResult {
770 doc_id: candidate.doc_id,
771 segment_id: candidate.segment_id,
772 score: candidate.score,
773 positions: if plan.model.is_some() {
774 Vec::new()
775 } else {
776 candidate.positions.clone()
777 },
778 };
779 let mut features = CandidateScores {
780 document,
781 passages: rows,
782 scored_passages,
783 };
784 if let Some(model) = &plan.model {
785 result.score = model.score_candidate(
786 &names,
787 &mut features,
788 plan.document_combiner,
789 rrf.map(|scores| &scores[candidate_index]),
790 )?;
791 }
792 let CandidateScores {
793 document,
794 passages: mut rows,
795 ..
796 } = features;
797 if plan.model.is_none() && rows.len() > plan.export_passages {
798 return Err(Error::Query(format!(
799 "feature export would omit {} passages of document {}; increase export_passages or supply l1",
800 rows.len() - plan.export_passages,
801 candidate.doc_id
802 )));
803 }
804 rows.sort_unstable_by(|a, b| {
805 b.score
806 .total_cmp(&a.score)
807 .then_with(|| a.ordinal.cmp(&b.ordinal))
808 });
809 rows.truncate(plan.export_passages);
810 if plan.model.is_some() {
812 result.positions = chunk_fields
813 .iter()
814 .map(|&field| {
815 (
816 field,
817 rows.iter()
818 .map(|row| {
819 ScoredPosition::new(u32::from(row.ordinal), row.score)
820 })
821 .collect(),
822 )
823 })
824 .collect();
825 }
826 output.push(ScoredCandidate {
827 result,
828 features: CandidateScores {
829 document,
830 passages: rows,
831 scored_passages,
832 },
833 });
834 }
835 }
836 output.sort_unstable_by(|a, b| {
837 crate::query::compare_search_results_desc(&a.result, &b.result)
838 });
839 Ok(output)
840 }
841}
842
843#[cfg(all(test, feature = "native"))]
844mod tests {
845 use super::*;
846 use crate::query::{Query, SparseVectorQuery};
847 use crate::structures::{SparseFormat, SparseVectorConfig};
848 use crate::{Document, Index, IndexConfig, IndexWriter, RamDirectory, Schema};
849
850 #[tokio::test]
851 async fn sparse_backfill_admits_payload_bytes_before_scoring_for_each_precision() {
852 let mut schema = Schema::builder();
853 let fields: Vec<_> = [
854 crate::structures::WeightQuantization::Float32,
855 crate::structures::WeightQuantization::UInt8,
856 ]
857 .into_iter()
858 .map(|quantization| {
859 schema.add_sparse_vector_field_with_config(
860 &format!("sparse_{quantization:?}"),
861 true,
862 false,
863 SparseVectorConfig {
864 format: SparseFormat::Seismic,
865 dims: Some(16),
866 weight_quantization: quantization,
867 ..Default::default()
868 },
869 )
870 })
871 .collect();
872 let directory = RamDirectory::new();
873 let config = IndexConfig::default();
874 let mut writer = IndexWriter::create(directory.clone(), schema.build(), config.clone())
875 .await
876 .unwrap();
877 let mut document = Document::new();
878 for &field in &fields {
879 document.add_sparse_vector(field, vec![(0, 1.0)]);
880 }
881 writer.add_document(document).unwrap();
882 writer.commit().await.unwrap();
883 let index = Index::open(directory, config).await.unwrap();
884 let searcher = index.reader().await.unwrap().searcher().await.unwrap();
885 let reader = &searcher.segment_readers()[0];
886 let stats = Arc::new(GlobalStatsBuilder::new().build(0));
887 for field in fields {
888 let query = SparseVectorQuery::new(field, vec![(0, 1.0)])
889 .candidate_query()
890 .unwrap();
891 let locations = reader
892 .candidate_locations(field, &[0], 1, &mut Default::default())
893 .await
894 .unwrap();
895 let mut budget = CandidateProbeState {
896 payload_remaining: 0,
897 ..Default::default()
898 };
899 let error = score_field(
900 &searcher,
901 reader,
902 &CandidateFeature {
903 name: "sparse".into(),
904 scope: ScoreScope::Chunk,
905 query,
906 },
907 &locations,
908 &stats,
909 &mut budget,
910 &mut [ComponentPreparation::default()],
911 )
912 .await
913 .unwrap_err();
914 assert!(matches!(error, Error::Query(_)), "{error}");
915 assert!(error.to_string().contains("payload read budget"), "{error}");
916 }
917 }
918}