summa_core/segment/reader/
candidate_lookup.rs1use super::SegmentReader;
4use crate::dsl::{Field, FieldType};
5
6use crate::{DocId, Error, Result};
7
8#[derive(Clone, Copy, Debug)]
9pub(crate) struct CandidateLocation {
10 pub doc: DocId,
11 pub ordinal: u16,
12 pub physical: u32,
15}
16
17impl SegmentReader {
18 pub(crate) async fn candidate_locations(
22 &self,
23 field: Field,
24 documents: &[DocId],
25 max_locations: usize,
26 budget: &mut super::SparseProbeBudget,
27 ) -> Result<Vec<CandidateLocation>> {
28 if documents.iter().any(|&doc| doc >= self.num_docs())
29 || !documents.windows(2).all(|pair| pair[0] < pair[1])
30 {
31 return Err(Error::Query(
32 "candidate documents must be valid, unique and sorted".into(),
33 ));
34 }
35 if documents.is_empty() {
36 return Ok(Vec::new());
37 }
38 let entry = self
39 .schema
40 .get_field_entry(field)
41 .ok_or_else(|| Error::FieldNotFound(field.0.to_string()))?;
42 let mut locations = Vec::with_capacity(documents.len().min(max_locations));
43 let mut push = |doc, ordinal, physical| -> Result<()> {
44 if locations.len() == max_locations {
45 return Err(Error::Query(format!(
46 "candidate scoring expands beyond {max_locations} field values"
47 )));
48 }
49 locations.push(CandidateLocation {
50 doc,
51 ordinal,
52 physical,
53 });
54 Ok(())
55 };
56 match &entry.field_type {
57 FieldType::Text if entry.chunked => {
58 let Some(map) = self.chunk_map(field) else {
59 return Ok(locations);
60 };
61 if !map.has_logical_addressing() {
62 return Err(Error::Query("legacy reordered text needs explicit Reorder to upgrade its chunk map for L1".into()));
63 }
64 for &doc in documents {
65 for (ordinal, physical) in map.slots_for_document(doc) {
66 push(doc, ordinal, physical)?;
67 }
68 }
69 }
70 FieldType::SparseVector => {
71 if let Some(index) = self.seismic_index(field) {
72 for &doc in documents {
73 for physical in index.rows_for_document(doc) {
74 push(doc, index.key(physical).ordinal, physical)?;
75 }
76 }
77 return Ok(locations);
78 }
79
80 let Some(bmp) = self.bmp_indexes.get(&field.0) else {
81 if self.sparse_indexes.contains_key(&field.0) {
82 return self
83 .maxscore_candidate_locations(
84 field,
85 documents,
86 None,
87 max_locations,
88 budget,
89 )
90 .await;
91 }
92 return Ok(locations);
93 };
94 for &doc in documents {
95 if let Some(forward) = bmp.forward() {
96 for (ordinal, physical) in forward.for_document(doc) {
97 push(doc, ordinal, physical)?;
98 }
99 } else if bmp.logically_ordered() {
100 for (ordinal, physical) in bmp.ordered_slots_for_document(doc) {
101 push(doc, ordinal, physical)?;
102 }
103 } else {
104 return Err(Error::Query("reordered BMP backfill requires forward values; enable bmp_forward_index and explicitly reorder/rebuild, or disable L1 backfill".into()));
105 }
106 }
107 }
108 FieldType::DenseVector | FieldType::BinaryDenseVector => {
109 let Some(flat) = self.flat_vectors.get(&field.0) else {
110 if self.vector_indexes.contains_key(&field.0) {
111 return Err(Error::Query(format!(
112 "candidate scoring needs stored vectors for field {}",
113 entry.name
114 )));
115 }
116 return Ok(locations);
117 };
118 for &doc in documents {
119 let (start, count) = flat.flat_indexes_for_doc_range(doc);
120 for physical in start..start + count {
121 let (actual_doc, ordinal) = flat.get_doc_id(physical);
122 if actual_doc != doc {
123 return Err(Error::Corruption(
124 "flat vector lookup points to another document".into(),
125 ));
126 }
127 push(
128 doc,
129 ordinal,
130 u32::try_from(physical).map_err(|_| {
131 Error::Query("candidate flat vector address exceeds u32".into())
132 })?,
133 )?;
134 }
135 }
136 }
137 FieldType::Text => {
138 if self
139 .meta
140 .field_stats
141 .get(&field.0)
142 .is_none_or(|stats| stats.total_tokens == 0)
143 {
144 return Ok(locations);
145 }
146 let lengths = self.doc_lengths(field).ok_or_else(|| {
147 Error::Query(format!(
148 "candidate scoring requires field-length metadata for plain text field {}",
149 entry.name
150 ))
151 })?;
152 for &doc in documents {
153 if lengths.length(doc) == 0 {
154 continue;
155 }
156 push(doc, 0, doc)?;
157 }
158 }
159 other => {
160 return Err(Error::Query(format!(
161 "candidate scoring does not support field type {other:?}"
162 )));
163 }
164 }
165 Ok(locations)
166 }
167}
168
169impl SegmentReader {
170 pub fn unprepared_candidate_fields(&self) -> Vec<String> {
173 self.schema
174 .fields()
175 .filter_map(|(field, entry)| {
176 let prepared = if let Some(map) = self.chunk_map(field) {
177 map.has_logical_addressing()
178 } else if self.seismic_index(field).is_some() {
179 true
180 } else if let Some(bmp) = self.bmp_indexes.get(&field.0) {
181 bmp.forward().is_some() || bmp.logically_ordered()
182 } else {
183 !(self.vector_indexes.contains_key(&field.0)
184 && !self.flat_vectors.contains_key(&field.0))
185 && !(matches!(entry.field_type, FieldType::Text)
186 && !entry.chunked
187 && self
188 .meta
189 .field_stats
190 .get(&field.0)
191 .is_some_and(|stats| stats.total_tokens > 0)
192 && self.doc_lengths(field).is_none())
193 };
194 (!prepared).then(|| entry.name.clone())
195 })
196 .collect()
197 }
198}
199
200impl SegmentReader {
201 pub(crate) async fn candidate_passage_locations(
204 &self,
205 field: Field,
206 targets: &[crate::segment::logical_address::LogicalUnit],
207 budget: &mut super::SparseProbeBudget,
208 ) -> Result<Vec<CandidateLocation>> {
209 use crate::segment::logical_address::{LogicalUnit, ordered_slot_for_unit};
210 if self.sparse_indexes.contains_key(&field.0) {
211 let mut documents: Vec<_> = targets.iter().map(|t| t.doc).collect();
212 documents.dedup();
213 return self
214 .maxscore_candidate_locations(
215 field,
216 &documents,
217 Some(targets),
218 targets.len(),
219 budget,
220 )
221 .await;
222 }
223 let mut locations = Vec::with_capacity(targets.len());
224 for &target in targets {
225 let physical = if let Some(map) = self.chunk_map(field) {
226 if !map.has_logical_addressing() {
227 return Err(Error::Query("legacy reordered text needs explicit Reorder to upgrade its chunk map for L1".into()));
228 }
229 map.slot_for_unit(target)
230 } else if let Some(index) = self.seismic_index(field) {
231 index
232 .rows_for_document(target.doc)
233 .find(|&row| index.key(row).ordinal == target.ordinal)
234 } else if let Some(bmp) = self.bmp_indexes.get(&field.0) {
235 if let Some(forward) = bmp.forward() {
236 forward.find(target)
237 } else if bmp.logically_ordered() {
238 ordered_slot_for_unit(bmp.num_virtual_docs, target, |physical| {
239 let (doc, ordinal) = bmp.virtual_to_doc(physical);
240 (doc != u32::MAX).then_some(LogicalUnit { doc, ordinal })
241 })
242 } else {
243 return Err(Error::Query(
244 "reordered BMP backfill requires forward values; enable bmp_forward_index and explicitly reorder/rebuild, or disable L1 backfill"
245 .into(),
246 ));
247 }
248 } else if let Some(flat) = self.flat_vectors.get(&field.0) {
249 let (start, count) = flat.flat_indexes_for_doc_range(target.doc);
250 let mut low = start;
251 let mut high = start + count;
252 while low < high {
253 let mid = low + (high - low) / 2;
254 if flat.get_doc_id(mid).1 < target.ordinal {
255 low = mid + 1;
256 } else {
257 high = mid;
258 }
259 }
260 if low < start + count && flat.get_doc_id(low) == (target.doc, target.ordinal) {
261 Some(
262 u32::try_from(low)
263 .map_err(|_| Error::Query("flat vector address exceeds u32".into()))?,
264 )
265 } else {
266 None
267 }
268 } else if self.vector_indexes.contains_key(&field.0) {
269 return Err(Error::Query(
270 "candidate scoring needs stored flat vectors".into(),
271 ));
272 } else {
273 None
274 };
275 if let Some(physical) = physical {
276 let actual = if let Some(map) = self.chunk_map(field) {
277 map.resolve(physical)
278 } else if let Some(index) = self.seismic_index(field) {
279 let key = index.key(physical);
280 (key.doc, key.ordinal)
281 } else if let Some(bmp) = self.bmp_indexes.get(&field.0) {
282 if let Some(forward) = bmp.forward() {
283 let key = forward.key(physical);
284 (key.doc, key.ordinal)
285 } else {
286 bmp.virtual_to_doc(physical)
287 }
288 } else {
289 self.flat_vectors[&field.0].get_doc_id(physical as usize)
290 };
291 if actual != (target.doc, target.ordinal) {
292 return Err(Error::Corruption(
293 "candidate lookup points to another passage".into(),
294 ));
295 }
296 locations.push(CandidateLocation {
297 doc: target.doc,
298 ordinal: target.ordinal,
299 physical,
300 });
301 }
302 }
303 Ok(locations)
304 }
305}
306
307impl SegmentReader {
308 async fn maxscore_candidate_locations(
309 &self,
310 field: Field,
311 documents: &[DocId],
312 targets: Option<&[crate::segment::logical_address::LogicalUnit]>,
313 limit: usize,
314 budget: &mut super::SparseProbeBudget,
315 ) -> Result<Vec<CandidateLocation>> {
316 use crate::segment::logical_address::LogicalUnit;
317 let index = &self.sparse_indexes[&field.0];
318 let mut units = std::collections::BTreeSet::new();
319 index
320 .probe_candidates(documents, None, budget, |doc, ordinal, _| {
321 let key = LogicalUnit { doc, ordinal };
322 if targets.is_some_and(|targets| targets.binary_search(&key).is_err()) {
323 return Ok(());
324 }
325 if !units.contains(&key) {
326 if units.len() == limit {
327 return Err(Error::Query(
328 "L1 sparse field-value expansion budget exceeded".into(),
329 ));
330 }
331 units.insert(key);
332 }
333 Ok(())
334 })
335 .await?;
336 Ok(units
337 .into_iter()
338 .enumerate()
339 .map(|(i, key)| CandidateLocation {
340 doc: key.doc,
341 ordinal: key.ordinal,
342 physical: i as u32,
343 })
344 .collect())
345 }
346}
347
348impl SegmentReader {
349 pub(crate) fn reserve_candidate_bmp_reads(
352 &self,
353 field: Field,
354 targets: &[u32],
355 remaining: &mut u64,
356 ) -> Result<()> {
357 let bmp = self
358 .bmp_index(field)
359 .ok_or_else(|| Error::Corruption("L1 BMP locations lack a BMP index".into()))?;
360 let mut previous_block = None;
361 for &target in targets {
362 let bytes = if let Some(forward) = bmp.forward() {
363 forward.vector_byte_len(target)?
364 } else {
365 if target >= bmp.num_virtual_docs {
366 return Err(Error::Corruption("BMP candidate slot out of bounds".into()));
367 }
368 let block = target / bmp.bmp_block_size;
369 if previous_block == Some(block) {
370 continue;
371 }
372 previous_block = Some(block);
373 let (start, end) = bmp.block_data_range(block);
374 end - start
375 };
376 *remaining = remaining.checked_sub(bytes).ok_or_else(|| {
377 Error::Query("L1 text/BMP payload read budget exceeded (256 MiB)".into())
378 })?;
379 }
380 Ok(())
381 }
382
383 pub(crate) async fn reserve_candidate_text_reads(
386 &self,
387 field: Field,
388 term: &[u8],
389 positions: bool,
390 remaining: &mut u64,
391 ) -> Result<()> {
392 let lazy_postings = !self.postings.file().is_sync();
393 let lazy_positions =
394 positions && self.postings.positions_file().is_some_and(|h| !h.is_sync());
395 if !lazy_postings && !lazy_positions {
396 return Ok(());
397 }
398 let mut key = Vec::with_capacity(4 + term.len());
399 key.extend_from_slice(&field.0.to_le_bytes());
400 key.extend_from_slice(term);
401 let Some(info) = self.term_dict.get(&key).await? else {
402 return Ok(());
403 };
404 let posting_bytes = if lazy_postings {
405 info.external_info().map_or(0, |(_, bytes)| bytes)
406 } else {
407 0
408 };
409 let position_bytes = if lazy_positions {
410 info.position_info().map_or(0, |(_, bytes)| bytes)
411 } else {
412 0
413 };
414 *remaining = posting_bytes
415 .checked_add(position_bytes)
416 .and_then(|bytes| remaining.checked_sub(bytes))
417 .ok_or_else(|| Error::Query("L1 lazy text read budget exceeded (256 MiB)".into()))?;
418 Ok(())
419 }
420}
421
422#[cfg(all(test, feature = "native"))]
423mod tests {
424 use super::*;
425 #[tokio::test]
426 async fn lazy_text_metadata_counts_and_backfill_admission_avoid_payload_reads() {
427 use crate::directories::{FileHandle, RamDirectory};
428 use crate::{Document, Index, IndexConfig, IndexWriter, Schema};
429 let mut schema = Schema::builder();
430 let field = schema.add_text_field_with_tokenizer("body", true, false, "simple");
431 let schema = std::sync::Arc::new(schema.build());
432 let dir = RamDirectory::new();
433 let config = IndexConfig::default();
434 let mut writer = IndexWriter::create(dir.clone(), (*schema).clone(), config.clone())
435 .await
436 .unwrap();
437 for _ in 0..256 {
438 let mut doc = Document::new();
439 doc.add_text(field, "common term");
440 writer.add_document(doc).unwrap();
441 }
442 writer.commit().await.unwrap();
443 let index = Index::open(dir.clone(), config).await.unwrap();
444 let searcher = index.reader().await.unwrap().searcher().await.unwrap();
445 let id = crate::segment::SegmentId(searcher.segment_readers()[0].meta().id);
446 let mut reader = SegmentReader::open(&dir, id, schema, 4).await.unwrap();
447 reader.postings = crate::structures::postings::PostingListReader::new(
448 FileHandle::lazy(
449 reader.postings.file().len(),
450 std::sync::Arc::new(|_| Box::pin(async { panic!("payload I/O before admission") })),
451 ),
452 reader.postings.positions_file().cloned(),
453 );
454 let error = reader
455 .reserve_candidate_text_reads(field, b"common", false, &mut 0)
456 .await
457 .unwrap_err();
458 assert!(error.to_string().contains("text read budget"));
459 reader
460 .reserve_candidate_text_reads(field, b"absent", false, &mut 0)
461 .await
462 .unwrap();
463 assert_eq!(reader.text_doc_freq(field, b"common").await.unwrap(), 256);
466 assert_eq!(reader.text_doc_freq(field, b"absent").await.unwrap(), 0);
467 use crate::query::{CountCollector, Query, TermQuery, collect_segment};
468 let required = crate::query::BooleanQuery::new()
469 .must(TermQuery::text(field, "common"))
470 .should(TermQuery::text(field, "term"))
471 .should(TermQuery::text(field, "absent"));
472 let mut count = CountCollector::new();
473 collect_segment(&reader, &required, &mut count)
474 .await
475 .unwrap();
476 assert_eq!(count.count(), 256);
477 for (term, expected) in [("common", 256), ("absent", 0)] {
478 let query = TermQuery::text(field, term);
479 let mut count = CountCollector::new();
480 collect_segment(&reader, &query, &mut count).await.unwrap();
481 assert_eq!(count.count(), expected);
482 assert_eq!(
483 u64::from(query.count_estimate(&reader).await.unwrap()),
484 expected
485 );
486 }
487 }
488}