1use lance_core::utils::row_addr_remap::RowAddrRemap;
5use std::fmt::{Debug, Display};
6use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
7use std::sync::{Arc, OnceLock};
8use std::{
9 cmp::{Reverse, min},
10 collections::BinaryHeap,
11};
12use std::{
13 collections::{BTreeMap, BTreeSet, HashMap, HashSet},
14 ops::Range,
15 time::Instant,
16};
17
18use crate::metrics::NoOpMetricsCollector;
19use crate::prefilter::NoFilter;
20use crate::scalar::registry::{TrainingCriteria, TrainingOrdering};
21use crate::vector::graph::OrderedFloat;
22use arrow::array::{FixedSizeListBuilder, Float32Builder, Int32Builder};
23use arrow::datatypes::{self, Float32Type, Int32Type, UInt64Type};
24use arrow::{
25 array::{
26 AsArray, LargeBinaryBuilder, ListBuilder, StringBuilder, UInt32Builder, UInt64Builder,
27 },
28 buffer::{Buffer, OffsetBuffer},
29};
30use arrow::{buffer::ScalarBuffer, datatypes::UInt32Type};
31use arrow_array::{
32 Array, ArrayRef, Float32Array, LargeBinaryArray, ListArray, OffsetSizeTrait, RecordBatch,
33 UInt32Array, UInt64Array,
34};
35use arrow_schema::{DataType, Field, Schema, SchemaRef};
36use async_trait::async_trait;
37use datafusion::execution::SendableRecordBatchStream;
38use datafusion::physical_plan::metrics::Time;
39use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
40use fst::{Automaton, IntoStreamer, Streamer};
41use futures::{FutureExt, Stream, StreamExt, TryStreamExt, stream};
42use itertools::{Either, Itertools};
43use lance_arrow::{RecordBatchExt, iter_str_array};
44use lance_core::cache::{
45 CacheCodec, CacheKey, CacheKeySchema, KeyBuilder, LanceCache, WeakLanceCache,
46};
47use lance_core::deepsize::DeepSizeOf;
48use lance_core::error::{DataFusionResult, LanceOptionExt};
49use lance_core::utils::address::RowAddress;
50use lance_core::utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu};
51use lance_core::utils::tracing::{IO_TYPE_LOAD_SCALAR_PART, TRACE_IO_EVENTS};
52use lance_core::{Error, ROW_ID, ROW_ID_FIELD, Result};
53use lance_select::{RowAddrMask, RowAddrTreeMap};
54use roaring::RoaringBitmap;
55use std::sync::LazyLock;
56use tokio::{
57 sync::{Mutex, OnceCell},
58 task::spawn_blocking,
59};
60use tracing::{info, instrument, warn};
61
62use super::documents::{
63 DocId, DocLengths, DocVisibility, PartitionDocumentStore, PartitionDocuments,
64};
65use super::encoding::{MAX_POSTING_BLOCK_SIZE, PositionBlockBuilder};
66use super::impact::{IMPACT_LEVEL1_BLOCKS, ImpactSkipData, ImpactSkipDataBuilder};
67use super::iter::PostingListIterator;
68use super::tokenizer::{LEGACY_BLOCK_SIZE, validate_block_size};
69use super::{InvertedIndexBuilder, InvertedIndexParams, wand::*};
70use super::{
71 builder::{
72 BLOCK_SIZE, ScoredDoc, doc_file_path,
73 inverted_list_schema_for_version_with_block_size_and_impacts, posting_file_path,
74 token_file_path,
75 },
76 iter::PlainPostingListIterator,
77 query::*,
78 scorer::{B, IndexBM25Scorer, K1, Scorer, idf},
79};
80use super::{
81 builder::{InnerBuilder, PositionRecorder},
82 iter::CompressedPostingListIterator,
83};
84use crate::pbold;
85use crate::progress::IndexBuildProgress;
86use crate::scalar::inverted::scorer::MemBM25Scorer;
87use crate::scalar::inverted::tokenizer::document_tokenizer::LanceTokenizer;
88use crate::scalar::{
89 AnyQuery, BuiltinIndexType, CreatedIndex, IndexReader, IndexStore, MetricsCollector,
90 OldIndexDataFilter, RowIdRemapper, ScalarIndex, ScalarIndexParams, SearchResult, TokenQuery,
91 UpdateCriteria,
92};
93use crate::{FtsPrewarmOptions, Index};
94use crate::{prefilter::PreFilter, scalar::inverted::iter::take_fst_keys};
95use std::str::FromStr;
96
97pub const INVERTED_INDEX_VERSION_V1: u32 = 1;
102pub const INVERTED_INDEX_VERSION_V2: u32 = 2;
103pub const INVERTED_INDEX_VERSION_V3: u32 = 3;
104pub const TOKENS_FILE: &str = "tokens.lance";
105pub const INVERT_LIST_FILE: &str = "invert.lance";
106pub const DOCS_FILE: &str = "docs.lance";
107pub const METADATA_FILE: &str = "metadata.lance";
108
109fn fts_search_chunk() -> usize {
114 static CHUNK: LazyLock<usize> = LazyLock::new(|| {
115 std::env::var("LANCE_FTS_SEARCH_CHUNK")
116 .ok()
117 .and_then(|value| value.parse().ok())
118 .filter(|&value| value >= 1)
119 .unwrap_or(16)
120 });
121 *CHUNK
122}
123
124pub const TOKEN_COL: &str = "_token";
125pub const TOKEN_ID_COL: &str = "_token_id";
126pub const TOKEN_FST_BYTES_COL: &str = "_token_fst_bytes";
127pub const TOKEN_NEXT_ID_COL: &str = "_token_next_id";
128pub const TOKEN_TOTAL_LENGTH_COL: &str = "_token_total_length";
129pub const FREQUENCY_COL: &str = "_frequency";
130pub const POSITION_COL: &str = "_position";
131pub const COMPRESSED_POSITION_COL: &str = "_compressed_position";
132pub const POSITION_BLOCK_OFFSET_COL: &str = "_position_block_offset";
133pub const POSTING_COL: &str = "_posting";
134pub const IMPACT_COL: &str = "_impacts";
135pub const MAX_SCORE_COL: &str = "_max_score";
136pub const LENGTH_COL: &str = "_length";
137pub const BLOCK_MAX_SCORE_COL: &str = "_block_max_score";
138pub const NUM_TOKEN_COL: &str = "_num_tokens";
139pub const SCORE_COL: &str = "_score";
140pub const TOKEN_SET_FORMAT_KEY: &str = "token_set_format";
141pub const POSTING_TAIL_CODEC_KEY: &str = "posting_tail_codec";
142pub const FTS_FORMAT_VERSION_KEY: &str = "format_version";
143pub const POSITIONS_LAYOUT_KEY: &str = "positions_layout";
144pub const POSITIONS_CODEC_KEY: &str = "positions_codec";
145pub const POSTING_BLOCK_SIZE_KEY: &str = "posting_block_size";
146pub const POSTING_TAIL_CODEC_FIXED32_V1: &str = "fixed32_v1";
147pub const POSTING_TAIL_CODEC_VARINT_DELTA_V1: &str = "varint_delta_v1";
148pub const POSITIONS_LAYOUT_SHARED_STREAM_V2: &str = "shared_stream_v2";
149pub const POSITIONS_CODEC_VARINT_DOC_DELTA_V2: &str = "varint_doc_delta_v2";
150pub const POSITIONS_CODEC_PACKED_DELTA_V1: &str = "packed_delta_v1";
151pub const DELETED_FRAGMENTS_COL: &str = "deleted_fragments";
152
153pub const ESTIMATED_MAX_TOKENS_PER_ROW: usize = 4 * 1024;
155
156pub static SCORE_FIELD: LazyLock<Field> =
157 LazyLock::new(|| Field::new(SCORE_COL, DataType::Float32, true));
158pub static FTS_SCHEMA: LazyLock<SchemaRef> =
159 LazyLock::new(|| Arc::new(Schema::new(vec![ROW_ID_FIELD.clone(), SCORE_FIELD.clone()])));
160static ROW_ID_SCHEMA: LazyLock<SchemaRef> =
161 LazyLock::new(|| Arc::new(Schema::new(vec![ROW_ID_FIELD.clone()])));
162
163pub fn resolve_fts_format_version(
164 value: Option<&str>,
165) -> std::result::Result<InvertedListFormatVersion, Error> {
166 match value {
167 Some(value) => value.parse(),
168 None => Ok(default_fts_format_version()),
169 }
170}
171
172pub fn default_fts_format_version() -> InvertedListFormatVersion {
173 InvertedListFormatVersion::V2
174}
175
176pub fn current_fts_format_version() -> InvertedListFormatVersion {
177 default_fts_format_version()
178}
179
180pub fn max_supported_fts_format_version() -> InvertedListFormatVersion {
181 InvertedListFormatVersion::V3
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
185pub enum InvertedListFormatVersion {
186 V1,
187 #[default]
188 V2,
189 V3,
190}
191
192impl InvertedListFormatVersion {
193 pub fn from_posting_tail_codec(codec: PostingTailCodec) -> Self {
194 match codec {
195 PostingTailCodec::Fixed32 => Self::V1,
196 PostingTailCodec::VarintDelta => Self::V2,
197 }
198 }
199
200 pub fn from_posting_tail_codec_and_block_size(
201 codec: PostingTailCodec,
202 block_size: usize,
203 ) -> Result<Self> {
204 validate_block_size(block_size)?;
205 let format_version = match (codec, block_size) {
206 (PostingTailCodec::Fixed32, LEGACY_BLOCK_SIZE) => Self::V1,
207 (PostingTailCodec::VarintDelta, LEGACY_BLOCK_SIZE) => Self::V2,
208 (PostingTailCodec::VarintDelta, 256) => Self::V3,
209 (PostingTailCodec::Fixed32, 256) => {
210 return Err(Error::invalid_input(
211 "FTS format_version=3 requires the varint-delta posting tail codec".to_string(),
212 ));
213 }
214 _ => unreachable!("validate_block_size limits supported block sizes"),
215 };
216 validate_format_version_block_size(format_version, block_size)?;
217 Ok(format_version)
218 }
219
220 pub fn index_version(self) -> u32 {
221 match self {
222 Self::V1 => INVERTED_INDEX_VERSION_V1,
223 Self::V2 => INVERTED_INDEX_VERSION_V2,
224 Self::V3 => INVERTED_INDEX_VERSION_V3,
225 }
226 }
227
228 pub fn posting_tail_codec(self) -> PostingTailCodec {
229 match self {
230 Self::V1 => PostingTailCodec::Fixed32,
231 Self::V2 | Self::V3 => PostingTailCodec::VarintDelta,
232 }
233 }
234
235 pub fn position_codec(self) -> Option<PositionStreamCodec> {
236 match self {
237 Self::V1 => None,
238 Self::V2 | Self::V3 => Some(PositionStreamCodec::PackedDelta),
239 }
240 }
241
242 pub fn uses_shared_position_stream(self) -> bool {
243 matches!(self, Self::V2 | Self::V3)
244 }
245}
246
247impl FromStr for InvertedListFormatVersion {
248 type Err = Error;
249
250 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
251 match s.trim() {
252 "1" | "v1" | "V1" => Ok(Self::V1),
253 "2" | "v2" | "V2" => Ok(Self::V2),
254 "3" | "v3" | "V3" => Ok(Self::V3),
255 other => Err(Error::index(format!(
256 "unsupported FTS format version {}, expected 1, 2, or 3",
257 other
258 ))),
259 }
260 }
261}
262
263pub fn default_fts_format_version_for_block_size(
264 block_size: usize,
265) -> Result<InvertedListFormatVersion> {
266 validate_block_size(block_size)?;
267 match block_size {
268 LEGACY_BLOCK_SIZE => Ok(InvertedListFormatVersion::V2),
269 256 => Ok(InvertedListFormatVersion::V3),
270 _ => unreachable!("validate_block_size limits supported block sizes"),
271 }
272}
273
274pub fn validate_format_version_block_size(
275 format_version: InvertedListFormatVersion,
276 block_size: usize,
277) -> Result<()> {
278 validate_block_size(block_size)?;
279 match (format_version, block_size) {
280 (InvertedListFormatVersion::V1 | InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)
281 | (InvertedListFormatVersion::V3, _) => Ok(()),
282 (InvertedListFormatVersion::V1 | InvertedListFormatVersion::V2, 256) => {
283 Err(Error::invalid_input(format!(
284 "FTS format_version={} is incompatible with block_size=256; use format_version=3",
285 format_version.index_version()
286 )))
287 }
288 _ => unreachable!("validate_block_size limits supported block sizes"),
289 }
290}
291
292#[derive(Debug)]
293struct PartitionCandidates<C> {
294 tokens_by_position: Vec<String>,
295 grouped_expansions: Vec<GroupedExpansionTerms>,
296 candidates: Vec<DocCandidate<C>>,
297}
298
299struct ModernSearchRequest<'a> {
300 tokens: Arc<Tokens>,
301 params: Arc<FtsSearchParams>,
302 operator: Operator,
303 mask: Arc<RowAddrMask>,
304 metrics: Arc<dyn MetricsCollector>,
305 scorer: &'a MemBM25Scorer,
306 impact_scorer: Arc<MemBM25Scorer>,
307 limit: usize,
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312struct PartitionDocId {
313 partition_ordinal: u32,
314 doc_id: DocId,
315}
316
317impl PartitionDocId {
318 fn try_new(partition_ordinal: usize, doc_id: DocId) -> Result<Self> {
319 Ok(Self {
320 partition_ordinal: u32::try_from(partition_ordinal).map_err(|_| {
321 Error::index(format!(
322 "FTS partition ordinal {partition_ordinal} exceeds candidate identity capacity"
323 ))
324 })?,
325 doc_id,
326 })
327 }
328
329 fn partition_ordinal(self) -> usize {
330 self.partition_ordinal as usize
331 }
332}
333
334#[derive(Debug, Clone)]
335struct ScoredPartitionDoc {
336 document: PartitionDocId,
337 score: OrderedFloat,
338}
339
340impl ScoredPartitionDoc {
341 fn new(document: PartitionDocId, score: f32) -> Self {
342 Self {
343 document,
344 score: OrderedFloat(score),
345 }
346 }
347}
348
349impl PartialEq for ScoredPartitionDoc {
350 fn eq(&self, other: &Self) -> bool {
351 self.score == other.score
352 }
353}
354
355impl Eq for ScoredPartitionDoc {}
356
357impl PartialOrd for ScoredPartitionDoc {
358 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
359 Some(self.cmp(other))
360 }
361}
362
363impl Ord for ScoredPartitionDoc {
364 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
365 self.score.cmp(&other.score)
366 }
367}
368
369const MAX_CONCURRENT_ADDRESS_READ_BYTES: usize = 64 * 1024 * 1024;
370
371fn address_read_concurrency(io_parallelism: usize, largest_read_bytes: usize) -> usize {
372 let io_parallelism = io_parallelism.max(1);
373 if largest_read_bytes == 0 {
374 return io_parallelism;
375 }
376 io_parallelism.min(
377 MAX_CONCURRENT_ADDRESS_READ_BYTES
378 .checked_div(largest_read_bytes)
379 .unwrap_or(0)
380 .max(1),
381 )
382}
383
384fn push_scored_key(
385 candidates: &mut BinaryHeap<Reverse<ScoredDoc>>,
386 limit: usize,
387 key: u64,
388 score: f32,
389) {
390 if candidates.len() < limit {
391 candidates.push(Reverse(ScoredDoc::new(key, score)));
392 } else if candidates
393 .peek()
394 .is_some_and(|candidate| candidate.0.score.0 < score)
395 {
396 candidates.pop();
397 candidates.push(Reverse(ScoredDoc::new(key, score)));
398 }
399}
400
401fn push_scored_partition_doc(
402 candidates: &mut BinaryHeap<Reverse<ScoredPartitionDoc>>,
403 limit: usize,
404 document: PartitionDocId,
405 score: f32,
406) {
407 if candidates.len() < limit {
408 candidates.push(Reverse(ScoredPartitionDoc::new(document, score)));
409 } else if candidates
410 .peek()
411 .is_some_and(|candidate| candidate.0.score.0 < score)
412 {
413 candidates.pop();
414 candidates.push(Reverse(ScoredPartitionDoc::new(document, score)));
415 }
416}
417
418fn rescore_partition_candidates<C>(
419 partition: PartitionCandidates<C>,
420 scorer: &MemBM25Scorer,
421 idf_cache: &mut HashMap<String, f32>,
422) -> Vec<(C, f32)> {
423 let PartitionCandidates {
424 tokens_by_position,
425 grouped_expansions,
426 candidates,
427 } = partition;
428 let idf_by_position = tokens_by_position
429 .iter()
430 .map(|token| {
431 *idf_cache
432 .entry(token.clone())
433 .or_insert_with(|| scorer.query_weight(token))
434 })
435 .collect::<Vec<_>>();
436 let grouped_positions = grouped_expansions
437 .iter()
438 .map(|group| group.position)
439 .collect::<HashSet<_>>();
440
441 candidates
442 .into_iter()
443 .map(
444 |DocCandidate {
445 document,
446 posting_doc_id,
447 freqs,
448 doc_length,
449 }| {
450 let mut score = 0.0;
451 for (term_index, freq) in freqs {
452 if grouped_positions.contains(&term_index) {
453 continue;
454 }
455 debug_assert!((term_index as usize) < idf_by_position.len());
456 score +=
457 idf_by_position[term_index as usize] * scorer.doc_weight(freq, doc_length);
458 }
459 for group in &grouped_expansions {
460 for term in group.terms.iter() {
461 let Some(freq) = term.frequency(posting_doc_id) else {
462 continue;
463 };
464 score += term.query_weight() * scorer.doc_weight(freq, doc_length);
465 }
466 }
467 (document, score)
468 },
469 )
470 .collect()
471}
472
473#[derive(Debug)]
474struct LoadedPostings {
475 postings: Vec<PostingIterator>,
476 grouped_expansions: Vec<GroupedExpansionTerms>,
477 impact_safe: bool,
478 exact_scoring_required: bool,
479}
480
481enum LoadedDocLengths {
482 Legacy(Arc<DocSet>),
483 Modern(Arc<DocLengths>),
484}
485
486impl LoadedDocLengths {
487 fn scoring_num_tokens(&self, doc_id: u32) -> u32 {
488 match self {
489 Self::Legacy(docs) => docs.scoring_num_tokens(doc_id),
490 Self::Modern(lengths) => lengths.scoring(DocId::new(doc_id)),
491 }
492 }
493
494 fn num_tokens_by_row_id(&self, row_id: u64) -> u32 {
495 match self {
496 Self::Legacy(docs) => docs.num_tokens_by_row_id(row_id),
497 Self::Modern(_) => unreachable!("modern posting lists use dense DocIds"),
498 }
499 }
500}
501
502impl LoadedPostings {
503 fn empty() -> Self {
504 Self {
505 postings: Vec::new(),
506 grouped_expansions: Vec::new(),
507 impact_safe: false,
508 exact_scoring_required: false,
509 }
510 }
511}
512
513#[derive(Debug)]
514struct GroupedExpansionTerms {
515 position: u32,
516 terms: Arc<[GroupedTermScorer]>,
517}
518
519#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)]
520pub enum TokenSetFormat {
521 Arrow,
522 #[default]
523 Fst,
524}
525
526impl Display for TokenSetFormat {
527 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528 match self {
529 Self::Arrow => f.write_str("arrow"),
530 Self::Fst => f.write_str("fst"),
531 }
532 }
533}
534
535impl FromStr for TokenSetFormat {
536 type Err = Error;
537
538 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
539 match s.trim() {
540 "" => Ok(Self::Arrow),
541 "arrow" => Ok(Self::Arrow),
542 "fst" => Ok(Self::Fst),
543 other => Err(Error::index(format!(
544 "unsupported token set format {}",
545 other
546 ))),
547 }
548 }
549}
550
551impl DeepSizeOf for TokenSetFormat {
552 fn deep_size_of_children(&self, _: &mut lance_core::deepsize::Context) -> usize {
553 0
554 }
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
558pub enum PositionStreamCodec {
559 VarintDocDelta,
560 #[default]
561 PackedDelta,
562}
563
564#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
565pub enum PostingTailCodec {
566 Fixed32,
567 #[default]
568 VarintDelta,
569}
570
571impl PostingTailCodec {
572 pub fn as_str(self) -> &'static str {
573 match self {
574 Self::Fixed32 => POSTING_TAIL_CODEC_FIXED32_V1,
575 Self::VarintDelta => POSTING_TAIL_CODEC_VARINT_DELTA_V1,
576 }
577 }
578
579 fn from_metadata_value(value: &str) -> Result<Self> {
580 match value.trim() {
581 POSTING_TAIL_CODEC_FIXED32_V1 => Ok(Self::Fixed32),
582 POSTING_TAIL_CODEC_VARINT_DELTA_V1 => Ok(Self::VarintDelta),
583 other => Err(Error::index(format!(
584 "unsupported posting tail codec {}",
585 other
586 ))),
587 }
588 }
589}
590
591pub(super) fn parse_posting_tail_codec(
592 metadata: &HashMap<String, String>,
593) -> Result<PostingTailCodec> {
594 Ok(metadata
595 .get(POSTING_TAIL_CODEC_KEY)
596 .map(|codec| PostingTailCodec::from_metadata_value(codec))
597 .transpose()?
598 .unwrap_or(PostingTailCodec::Fixed32))
599}
600
601pub(super) fn parse_posting_block_size(metadata: &HashMap<String, String>) -> Result<usize> {
602 metadata
603 .get(POSTING_BLOCK_SIZE_KEY)
604 .map(|value| {
605 let block_size = value.parse::<usize>().map_err(|err| {
606 Error::index(format!(
607 "invalid {POSTING_BLOCK_SIZE_KEY} metadata value {value:?}: {err}"
608 ))
609 })?;
610 validate_block_size(block_size)
611 })
612 .transpose()
613 .map(|block_size| block_size.unwrap_or(LEGACY_BLOCK_SIZE))
614}
615
616impl PositionStreamCodec {
617 pub fn as_str(self) -> &'static str {
618 match self {
619 Self::VarintDocDelta => POSITIONS_CODEC_VARINT_DOC_DELTA_V2,
620 Self::PackedDelta => POSITIONS_CODEC_PACKED_DELTA_V1,
621 }
622 }
623
624 fn from_metadata_value(value: &str) -> Result<Self> {
625 match value.trim() {
626 POSITIONS_CODEC_VARINT_DOC_DELTA_V2 => Ok(Self::VarintDocDelta),
627 POSITIONS_CODEC_PACKED_DELTA_V1 => Ok(Self::PackedDelta),
628 other => Err(Error::index(format!(
629 "unsupported positions codec {}",
630 other
631 ))),
632 }
633 }
634}
635
636fn parse_shared_position_codec(metadata: &HashMap<String, String>) -> Result<PositionStreamCodec> {
637 if let Some(codec) = metadata.get(POSITIONS_CODEC_KEY) {
638 return PositionStreamCodec::from_metadata_value(codec);
639 }
640
641 match metadata
642 .get(POSITIONS_LAYOUT_KEY)
643 .map(|layout| layout.as_str())
644 {
645 Some(POSITIONS_LAYOUT_SHARED_STREAM_V2) => Ok(PositionStreamCodec::VarintDocDelta),
646 _ => Ok(PositionStreamCodec::VarintDocDelta),
647 }
648}
649
650pub(super) fn parse_format_version_from_metadata(
651 metadata: &HashMap<String, String>,
652) -> Result<InvertedListFormatVersion> {
653 if let Some(value) = metadata.get(FTS_FORMAT_VERSION_KEY) {
654 let format_version = InvertedListFormatVersion::from_str(value)?;
655 let block_size = parse_posting_block_size(metadata)?;
656 validate_format_version_block_size(format_version, block_size)?;
657 return Ok(format_version);
658 }
659 let block_size = parse_posting_block_size(metadata)?;
660 if block_size == 256 {
661 if metadata
662 .get(POSTING_TAIL_CODEC_KEY)
663 .map(|_| parse_posting_tail_codec(metadata))
664 .transpose()?
665 .is_some_and(|posting_tail_codec| posting_tail_codec != PostingTailCodec::VarintDelta)
666 {
667 return Err(Error::index(
668 "FTS block_size=256 requires the varint-delta posting tail codec".to_string(),
669 ));
670 }
671 return Ok(InvertedListFormatVersion::V3);
672 }
673 if metadata.contains_key(POSITIONS_CODEC_KEY) || metadata.contains_key(POSITIONS_LAYOUT_KEY) {
674 return Ok(InvertedListFormatVersion::V2);
675 }
676 if parse_posting_tail_codec(metadata)? == PostingTailCodec::VarintDelta {
677 Ok(InvertedListFormatVersion::V2)
678 } else {
679 Ok(InvertedListFormatVersion::V1)
680 }
681}
682
683#[derive(Debug, Default)]
684struct InvertedPrewarmState {
685 query_ready: bool,
686 positions_ready: bool,
687}
688
689impl InvertedPrewarmState {
690 fn satisfies(&self, with_position: bool) -> bool {
691 self.query_ready && (!with_position || self.positions_ready)
692 }
693}
694
695#[derive(Clone)]
696pub struct InvertedIndex {
697 params: InvertedIndexParams,
698 store: Arc<dyn IndexStore>,
699 tokenizer: Box<dyn LanceTokenizer>,
700 token_set_format: TokenSetFormat,
701 format_version: InvertedListFormatVersion,
702 pub(crate) partitions: Vec<Arc<InvertedPartition>>,
703 corpus_stats: Arc<OnceCell<(u64, usize)>>,
704 prewarm_state: Arc<Mutex<InvertedPrewarmState>>,
705 document_projections_resident: Arc<AtomicBool>,
708 deleted_fragments: RoaringBitmap,
711}
712
713impl Debug for InvertedIndex {
714 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
715 f.debug_struct("InvertedIndex")
716 .field("params", &self.params)
717 .field("token_set_format", &self.token_set_format)
718 .field("format_version", &self.format_version)
719 .field("partitions", &self.partitions)
720 .field("deleted_fragments", &self.deleted_fragments)
721 .finish()
722 }
723}
724
725impl DeepSizeOf for InvertedIndex {
726 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
727 self.partitions.deep_size_of_children(context)
728 }
729}
730
731impl InvertedIndex {
732 fn format_version(&self) -> InvertedListFormatVersion {
733 self.format_version
734 }
735
736 fn index_version(&self) -> u32 {
737 match (self.token_set_format, self.format_version()) {
738 (
739 TokenSetFormat::Arrow,
740 InvertedListFormatVersion::V1 | InvertedListFormatVersion::V2,
741 ) => 0,
742 (_, format_version) => format_version.index_version(),
743 }
744 }
745
746 fn posting_tail_codec(&self) -> PostingTailCodec {
747 self.partitions
748 .first()
749 .map(|partition| partition.inverted_list.posting_tail_codec())
750 .unwrap_or_default()
751 }
752
753 fn to_builder(&self) -> InvertedIndexBuilder {
754 self.to_builder_with_offset(None)
755 }
756
757 fn to_builder_with_offset(&self, fragment_mask: Option<u64>) -> InvertedIndexBuilder {
758 if self.is_legacy() {
759 InvertedIndexBuilder::from_existing_index(
761 self.params.clone(),
762 None,
763 Vec::new(),
764 self.token_set_format,
765 fragment_mask,
766 self.deleted_fragments.clone(),
767 )
768 .with_posting_tail_codec(self.posting_tail_codec())
769 } else {
770 let partitions = match fragment_mask {
771 Some(fragment_mask) => self
772 .partitions
773 .iter()
774 .filter(|part| part.belongs_to_fragment(fragment_mask))
778 .map(|part| part.id())
779 .collect(),
780 None => self.partitions.iter().map(|part| part.id()).collect(),
781 };
782
783 InvertedIndexBuilder::from_existing_index(
784 self.params.clone(),
785 Some(self.store.clone()),
786 partitions,
787 self.token_set_format,
788 fragment_mask,
789 self.deleted_fragments.clone(),
790 )
791 .with_format_version(self.format_version())
792 }
793 }
794
795 pub fn tokenizer(&self) -> Box<dyn LanceTokenizer> {
796 self.tokenizer.clone()
797 }
798
799 pub fn params(&self) -> &InvertedIndexParams {
800 &self.params
801 }
802
803 pub fn partition_count(&self) -> usize {
805 self.partitions.len()
806 }
807 pub fn deleted_fragments(&self) -> &RoaringBitmap {
813 &self.deleted_fragments
814 }
815
816 pub async fn merge_segments(
817 segments: &[Arc<Self>],
818 new_data: SendableRecordBatchStream,
819 dest_store: &dyn IndexStore,
820 old_data_filter: Option<OldIndexDataFilter>,
821 progress: Arc<dyn IndexBuildProgress>,
822 ) -> Result<CreatedIndex> {
823 let Some(first) = segments.first() else {
824 return Err(Error::invalid_input(
825 "cannot merge inverted index without at least one source segment".to_string(),
826 ));
827 };
828
829 for segment in segments.iter().skip(1) {
830 if segment.params != first.params {
831 return Err(Error::index(
832 "cannot merge inverted index segments with different parameters".to_string(),
833 ));
834 }
835 if segment.token_set_format != first.token_set_format {
836 return Err(Error::index(
837 "cannot merge inverted index segments with different token set formats"
838 .to_string(),
839 ));
840 }
841 if segment.format_version() != first.format_version() {
842 return Err(Error::index(
843 "cannot merge inverted index segments with different format versions"
844 .to_string(),
845 ));
846 }
847 if segment.posting_tail_codec() != first.posting_tail_codec() {
848 return Err(Error::index(
849 "cannot merge inverted index segments with different posting tail codecs"
850 .to_string(),
851 ));
852 }
853 }
854
855 let mut builder = InvertedIndexBuilder::new(first.params.clone()).with_progress(progress);
856 builder = builder
857 .with_token_set_format(first.token_set_format)
858 .with_format_version(first.format_version());
859 let files = builder
860 .update_from_segments(new_data, dest_store, segments, old_data_filter)
861 .await?;
862
863 let details = pbold::InvertedIndexDetails::try_from(&first.params)?;
864
865 Ok(CreatedIndex {
866 index_details: prost_types::Any::from_msg(&details).unwrap(),
867 index_version: first.index_version(),
868 files,
869 })
870 }
871
872 pub async fn bm25_base_scorer(
879 &self,
880 query_tokens: &Tokens,
881 params: &FtsSearchParams,
882 metrics: Option<&dyn MetricsCollector>,
883 ) -> Result<MemBM25Scorer> {
884 if matches!(params.fuzziness, Some(n) if n != 0) {
885 let expanded = self.expand_fuzzy_tokens(query_tokens, params)?;
886 self.bm25_scorer_for_final_tokens(&expanded, metrics).await
887 } else {
888 self.bm25_scorer_for_final_tokens(query_tokens, metrics)
889 .await
890 }
891 }
892
893 async fn bm25_scorer_for_final_tokens(
898 &self,
899 tokens: &Tokens,
900 metrics: Option<&dyn MetricsCollector>,
901 ) -> Result<MemBM25Scorer> {
902 let (total_tokens, num_docs) = self.aggregate_corpus_stats().await?;
903 let mut terms: Vec<String> = Vec::new();
904 let mut seen = HashSet::new();
905 for token in tokens {
906 if seen.insert(token.to_string()) {
907 terms.push(token.to_string());
908 }
909 }
910 let mut token_docs = HashMap::with_capacity(terms.len());
911 for term in &terms {
912 let df = self.df_for_term(term, metrics).await?;
913 token_docs.insert(term.clone(), df);
914 }
915 Ok(MemBM25Scorer::new(total_tokens, num_docs, token_docs))
916 }
917
918 pub async fn bm25_stats_for_terms(
919 &self,
920 terms: &[String],
921 metrics: Option<&dyn MetricsCollector>,
922 ) -> Result<(u64, usize, Vec<usize>)> {
923 let (total_tokens, num_docs) = self.aggregate_corpus_stats().await?;
924 let token_docs =
925 futures::future::try_join_all(terms.iter().map(|term| self.df_for_term(term, metrics)))
926 .await?;
927 Ok((total_tokens, num_docs, token_docs))
928 }
929
930 async fn aggregate_corpus_stats(&self) -> Result<(u64, usize)> {
934 self.corpus_stats
935 .get_or_try_init(|| async {
936 let io_parallelism = self.store.io_parallelism();
937 let futures = self
938 .partitions
939 .iter()
940 .map(|p| {
941 let part = p.clone();
942 async move { part.docs.stats().await }
943 })
944 .collect::<Vec<_>>();
945 let stats = stream::iter(futures)
946 .buffer_unordered(io_parallelism)
947 .try_collect::<Vec<_>>()
948 .await?;
949 let mut total_tokens = 0_u64;
950 let mut num_docs = 0_usize;
951 for stat in stats {
952 total_tokens = total_tokens
953 .checked_add(stat.total_tokens)
954 .ok_or_else(|| Error::index("FTS corpus token count overflows u64"))?;
955 num_docs = num_docs
956 .checked_add(stat.num_docs)
957 .ok_or_else(|| Error::index("FTS corpus document count overflows usize"))?;
958 }
959 Ok((total_tokens, num_docs))
960 })
961 .await
962 .copied()
963 }
964
965 async fn df_for_term(
969 &self,
970 term: &str,
971 metrics: Option<&dyn MetricsCollector>,
972 ) -> Result<usize> {
973 let io_parallelism = self.store.io_parallelism();
974 let futures = self
975 .partitions
976 .iter()
977 .map(|part| {
978 let part = part.clone();
979 async move {
980 match part.tokens.get(term) {
981 Some(token_id) => {
982 part.inverted_list
983 .posting_len_for_token(token_id, metrics)
984 .await
985 }
986 None => Ok(0),
987 }
988 }
989 })
990 .collect::<Vec<_>>();
991 let dfs: Vec<usize> = stream::iter(futures)
992 .buffer_unordered(io_parallelism)
993 .try_collect()
994 .await?;
995 Ok(dfs.into_iter().sum())
996 }
997
998 pub fn expand_fuzzy_tokens(&self, tokens: &Tokens, params: &FtsSearchParams) -> Result<Tokens> {
1008 let mut expanded_tokens = Vec::new();
1009 let mut expanded_positions = Vec::new();
1010 let mut seen = HashSet::new();
1011 for token_idx in 0..tokens.len() {
1012 let remaining = params.max_expansions.saturating_sub(expanded_tokens.len());
1013 if remaining == 0 {
1014 break;
1015 }
1016 let token = tokens.get_token(token_idx);
1017 let position = tokens.position(token_idx);
1018 let mut candidates = BTreeSet::new();
1023 let base_prefix_len = tokens.token_type().prefix_len(token) as u32;
1024 for partition in &self.partitions {
1025 partition.collect_fuzzy_candidates(
1026 token,
1027 base_prefix_len,
1028 params,
1029 remaining,
1030 &mut candidates,
1031 )?;
1032 }
1033 for candidate in candidates {
1034 if expanded_tokens.len() >= params.max_expansions {
1035 break;
1036 }
1037 if seen.insert((candidate.clone(), position)) {
1038 expanded_tokens.push(candidate);
1039 expanded_positions.push(position);
1040 }
1041 }
1042 }
1043 Ok(Tokens::with_positions(
1044 expanded_tokens,
1045 expanded_positions,
1046 tokens.token_type().clone(),
1047 ))
1048 }
1049
1050 #[instrument(level = "debug", skip_all)]
1055 pub async fn bm25_search(
1056 &self,
1057 tokens: Arc<Tokens>,
1058 params: Arc<FtsSearchParams>,
1059 operator: Operator,
1060 prefilter: Arc<dyn PreFilter>,
1061 metrics: Arc<dyn MetricsCollector>,
1062 base_scorer: Option<&MemBM25Scorer>,
1063 ) -> Result<(Vec<u64>, Vec<f32>)> {
1064 let tokens = if matches!(params.fuzziness, Some(n) if n != 0) {
1069 let expanded = Arc::new(self.expand_fuzzy_tokens(tokens.as_ref(), params.as_ref())?);
1070 if operator == Operator::And || params.phrase_slop.is_some() {
1071 let surviving = (0..expanded.len())
1075 .map(|idx| expanded.position(idx))
1076 .collect::<HashSet<_>>();
1077 if (0..tokens.len()).any(|idx| !surviving.contains(&tokens.position(idx))) {
1078 return Ok((Vec::new(), Vec::new()));
1079 }
1080 }
1081 expanded
1082 } else {
1083 tokens
1084 };
1085
1086 let local_scorer;
1091 let scorer: &MemBM25Scorer = if let Some(base_scorer) = base_scorer {
1092 base_scorer
1093 } else {
1094 local_scorer = self
1095 .bm25_scorer_for_final_tokens(tokens.as_ref(), Some(metrics.as_ref()))
1096 .await?;
1097 &local_scorer
1098 };
1099 let impact_scorer = Arc::new(scorer.clone());
1100
1101 let limit = params.limit.unwrap_or(usize::MAX);
1102 if limit == 0 {
1103 return Ok((Vec::new(), Vec::new()));
1104 }
1105 let mask = prefilter.mask();
1106 if self.is_legacy() {
1107 self.bm25_search_legacy(
1108 tokens,
1109 params,
1110 operator,
1111 mask,
1112 metrics,
1113 scorer,
1114 impact_scorer,
1115 limit,
1116 )
1117 .await
1118 } else {
1119 self.bm25_search_modern(ModernSearchRequest {
1120 tokens,
1121 params,
1122 operator,
1123 mask,
1124 metrics,
1125 scorer,
1126 impact_scorer,
1127 limit,
1128 })
1129 .await
1130 }
1131 }
1132
1133 #[allow(clippy::too_many_arguments)]
1134 async fn bm25_search_legacy(
1135 &self,
1136 tokens: Arc<Tokens>,
1137 params: Arc<FtsSearchParams>,
1138 operator: Operator,
1139 mask: Arc<RowAddrMask>,
1140 metrics: Arc<dyn MetricsCollector>,
1141 scorer: &MemBM25Scorer,
1142 impact_scorer: Arc<MemBM25Scorer>,
1143 limit: usize,
1144 ) -> Result<(Vec<u64>, Vec<f32>)> {
1145 let impact_shared_threshold = Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()));
1146 let io_parallelism = self.store.io_parallelism();
1147 let parts = self
1148 .partitions
1149 .chunks(fts_search_chunk())
1150 .map(|chunk| {
1151 let chunk = chunk.to_vec();
1152 let tokens = tokens.clone();
1153 let params = params.clone();
1154 let mask = mask.clone();
1155 let metrics = metrics.clone();
1156 let impact_scorer = impact_scorer.clone();
1157 let impact_shared_threshold = impact_shared_threshold.clone();
1158 async move {
1159 let loads = chunk.into_iter().map(|part| {
1160 let tokens = tokens.clone();
1161 let params = params.clone();
1162 let metrics = metrics.clone();
1163 let impact_scorer = impact_scorer.clone();
1164 let impact_shared_threshold = impact_shared_threshold.clone();
1165 async move {
1166 let LoadedPostings {
1167 postings,
1168 grouped_expansions,
1169 impact_safe,
1170 exact_scoring_required,
1171 } = part
1172 .load_posting_lists(
1173 tokens.as_ref(),
1174 params.as_ref(),
1175 operator,
1176 impact_scorer.as_ref(),
1177 metrics.as_ref(),
1178 )
1179 .await?;
1180 if postings.is_empty() {
1181 return Result::Ok(None);
1182 }
1183 let max_position = postings
1184 .iter()
1185 .map(|posting| posting.term_index() as usize)
1186 .max()
1187 .unwrap_or_default();
1188 let mut tokens_by_position = vec![String::new(); max_position + 1];
1189 for posting in &postings {
1190 tokens_by_position[posting.term_index() as usize] =
1191 posting.token().to_owned();
1192 }
1193 let docs = part.docs.legacy().cloned().ok_or_else(|| {
1194 Error::internal("legacy index contains modern partition documents")
1195 })?;
1196 let use_global_scorer = impact_safe || exact_scoring_required;
1197 let threshold = if use_global_scorer {
1198 impact_shared_threshold
1199 } else {
1200 Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()))
1201 };
1202 let wand_scorer = use_global_scorer.then(|| impact_scorer.clone());
1203 Result::Ok(Some((
1204 part,
1205 docs,
1206 postings,
1207 wand_scorer,
1208 threshold,
1209 tokens_by_position,
1210 grouped_expansions,
1211 )))
1212 }
1213 });
1214 let loaded = stream::iter(loads)
1215 .buffer_unordered(io_parallelism)
1216 .try_collect::<Vec<_>>()
1217 .await?
1218 .into_iter()
1219 .flatten()
1220 .collect::<Vec<_>>();
1221 if loaded.is_empty() {
1222 return Result::Ok(Vec::new());
1223 }
1224
1225 let results = spawn_cpu(move || {
1226 let mut results = Vec::with_capacity(loaded.len());
1227 for (
1228 part,
1229 docs,
1230 postings,
1231 wand_scorer,
1232 threshold,
1233 tokens_by_position,
1234 grouped_expansions,
1235 ) in loaded
1236 {
1237 let candidates = part.bm25_search_legacy(
1238 docs.as_ref(),
1239 params.as_ref(),
1240 operator,
1241 mask.as_ref(),
1242 postings,
1243 wand_scorer,
1244 metrics.as_ref(),
1245 threshold,
1246 )?;
1247 results.push(PartitionCandidates {
1248 tokens_by_position,
1249 grouped_expansions,
1250 candidates,
1251 });
1252 }
1253 Result::Ok(results)
1254 })
1255 .await?;
1256 Result::Ok(results)
1257 }
1258 })
1259 .collect::<Vec<_>>();
1260
1261 let mut ranked = BinaryHeap::new();
1262 let mut idf_cache = HashMap::new();
1263 let mut parts = stream::iter(parts)
1264 .buffer_unordered(get_num_compute_intensive_cpus().min(32))
1265 .map_ok(|results| stream::iter(results.into_iter().map(Result::Ok)))
1266 .try_flatten();
1267 while let Some(partition) = parts.try_next().await? {
1268 for (row_id, score) in rescore_partition_candidates(partition, scorer, &mut idf_cache) {
1269 push_scored_key(&mut ranked, limit, row_id, score);
1270 }
1271 }
1272 Ok(ranked
1273 .into_sorted_vec()
1274 .into_iter()
1275 .map(|Reverse(doc)| (doc.row_id, doc.score.0))
1276 .unzip())
1277 }
1278
1279 async fn bm25_search_modern(
1280 &self,
1281 request: ModernSearchRequest<'_>,
1282 ) -> Result<(Vec<u64>, Vec<f32>)> {
1283 if self.has_resident_document_projections() {
1287 self.bm25_search_modern_resident(request).await
1288 } else {
1289 self.bm25_search_modern_deferred(request).await
1290 }
1291 }
1292
1293 fn has_resident_document_projections(&self) -> bool {
1294 if self.document_projections_resident.load(Ordering::Acquire) {
1295 return true;
1296 }
1297 let resident = self.document_projections_resident_now();
1298 if resident {
1299 self.document_projections_resident
1300 .store(true, Ordering::Release);
1301 }
1302 resident
1303 }
1304
1305 fn document_projections_resident_now(&self) -> bool {
1306 self.partitions.iter().all(|partition| {
1307 partition
1308 .docs
1309 .modern()
1310 .is_some_and(|documents| documents.projection_resident())
1311 })
1312 }
1313
1314 async fn bm25_search_modern_resident(
1315 &self,
1316 request: ModernSearchRequest<'_>,
1317 ) -> Result<(Vec<u64>, Vec<f32>)> {
1318 let ranked = self.bm25_search_modern_candidates(request).await?;
1319 if let Some(result) = self.resolve_resident_modern_candidates(&ranked)? {
1320 return Ok(result);
1321 }
1322 self.document_projections_resident
1323 .store(false, Ordering::Release);
1324 self.resolve_deferred_modern_candidates(ranked).await
1325 }
1326
1327 async fn bm25_search_modern_deferred(
1328 &self,
1329 request: ModernSearchRequest<'_>,
1330 ) -> Result<(Vec<u64>, Vec<f32>)> {
1331 if self.corpus_stats.get().is_none() {
1337 self.aggregate_corpus_stats().await?;
1338 }
1339 let io_parallelism = self.store.io_parallelism();
1346 let uncached_lengths = self
1347 .partitions
1348 .iter()
1349 .filter_map(|part| {
1350 let docs = part.docs.modern()?.clone();
1351 if docs.cached_lengths().is_some() {
1352 return None;
1353 }
1354 let has_match = (0..request.tokens.len())
1355 .any(|i| part.tokens.get(request.tokens.get_token(i)).is_some());
1356 has_match.then_some(async move { docs.lengths().await.map(|_| ()) })
1357 })
1358 .collect::<Vec<_>>();
1359 if !uncached_lengths.is_empty() {
1360 stream::iter(uncached_lengths)
1361 .buffer_unordered(io_parallelism)
1362 .try_collect::<Vec<_>>()
1363 .await?;
1364 }
1365 let ranked = self.bm25_search_modern_candidates(request).await?;
1366 self.resolve_deferred_modern_candidates(ranked).await
1367 }
1368
1369 async fn bm25_search_modern_candidates(
1370 &self,
1371 request: ModernSearchRequest<'_>,
1372 ) -> Result<Vec<Reverse<ScoredPartitionDoc>>> {
1373 let ModernSearchRequest {
1374 tokens,
1375 params,
1376 operator,
1377 mask,
1378 metrics,
1379 scorer,
1380 impact_scorer,
1381 limit,
1382 } = request;
1383 if self.partitions.len() > u32::MAX as usize {
1384 return Err(Error::index(format!(
1385 "FTS partition count {} exceeds candidate identity capacity",
1386 self.partitions.len()
1387 )));
1388 }
1389 let impact_shared_threshold = Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()));
1390 let io_parallelism = self.store.io_parallelism();
1391 let parts = self
1392 .partitions
1393 .chunks(fts_search_chunk())
1394 .enumerate()
1395 .map(|(chunk_ordinal, chunk)| {
1396 let first_partition_ordinal = chunk_ordinal * fts_search_chunk();
1397 let chunk = chunk
1398 .iter()
1399 .cloned()
1400 .enumerate()
1401 .map(|(offset, part)| (first_partition_ordinal + offset, part))
1402 .collect::<Vec<_>>();
1403 let tokens = tokens.clone();
1404 let params = params.clone();
1405 let mask = mask.clone();
1406 let metrics = metrics.clone();
1407 let impact_scorer = impact_scorer.clone();
1408 let impact_shared_threshold = impact_shared_threshold.clone();
1409 async move {
1410 let loads = chunk.into_iter().map(|(partition_ordinal, part)| {
1411 let tokens = tokens.clone();
1412 let params = params.clone();
1413 let mask = mask.clone();
1414 let metrics = metrics.clone();
1415 let impact_scorer = impact_scorer.clone();
1416 let impact_shared_threshold = impact_shared_threshold.clone();
1417 async move {
1418 let LoadedPostings {
1419 postings,
1420 grouped_expansions,
1421 impact_safe,
1422 exact_scoring_required,
1423 } = part
1424 .load_posting_lists(
1425 tokens.as_ref(),
1426 params.as_ref(),
1427 operator,
1428 impact_scorer.as_ref(),
1429 metrics.as_ref(),
1430 )
1431 .await?;
1432 if postings.is_empty() {
1433 return Result::Ok(None);
1434 }
1435 let documents = part.docs.modern().cloned().ok_or_else(|| {
1436 Error::internal("modern index contains legacy partition documents")
1437 })?;
1438 let materialize_selected = operator == Operator::Or
1439 && mask.max_len().is_some_and(|selected| {
1440 u128::from(selected).saturating_mul(100)
1441 <= u128::from(*FLAT_SEARCH_PERCENT_THRESHOLD)
1442 .saturating_mul(documents.len() as u128)
1443 });
1444 let visibility = match documents
1445 .immediate_visibility(mask.clone(), materialize_selected)
1446 {
1447 Some(visibility) => visibility,
1448 None => {
1449 documents
1450 .visibility(mask.clone(), materialize_selected)
1451 .await?
1452 }
1453 };
1454 if visibility.is_empty() {
1455 return Result::Ok(None);
1456 }
1457 let lengths = match documents.cached_lengths() {
1458 Some(lengths) => lengths,
1459 None => documents.lengths().await?,
1460 };
1461 let max_position = postings
1462 .iter()
1463 .map(|posting| posting.term_index() as usize)
1464 .max()
1465 .unwrap_or_default();
1466 let mut tokens_by_position = vec![String::new(); max_position + 1];
1467 for posting in &postings {
1468 tokens_by_position[posting.term_index() as usize] =
1469 posting.token().to_owned();
1470 }
1471 let use_global_scorer = impact_safe || exact_scoring_required;
1472 let threshold = if use_global_scorer {
1473 impact_shared_threshold
1474 } else {
1475 Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()))
1476 };
1477 let wand_scorer = use_global_scorer.then(|| impact_scorer.clone());
1478 Result::Ok(Some((
1479 partition_ordinal,
1480 part,
1481 lengths,
1482 visibility,
1483 postings,
1484 wand_scorer,
1485 threshold,
1486 tokens_by_position,
1487 grouped_expansions,
1488 )))
1489 }
1490 });
1491 let loaded = stream::iter(loads)
1492 .buffer_unordered(io_parallelism)
1493 .try_collect::<Vec<_>>()
1494 .await?
1495 .into_iter()
1496 .flatten()
1497 .collect::<Vec<_>>();
1498 if loaded.is_empty() {
1499 return Result::Ok(Vec::new());
1500 }
1501
1502 let results = spawn_cpu(move || {
1503 let mut results = Vec::with_capacity(loaded.len());
1504 for (
1505 partition_ordinal,
1506 part,
1507 lengths,
1508 visibility,
1509 postings,
1510 wand_scorer,
1511 threshold,
1512 tokens_by_position,
1513 grouped_expansions,
1514 ) in loaded
1515 {
1516 let candidates = part.bm25_search_modern(
1517 lengths.as_ref(),
1518 &visibility,
1519 params.as_ref(),
1520 operator,
1521 postings,
1522 wand_scorer,
1523 metrics.as_ref(),
1524 threshold,
1525 )?;
1526 results.push((
1527 partition_ordinal,
1528 PartitionCandidates {
1529 tokens_by_position,
1530 grouped_expansions,
1531 candidates,
1532 },
1533 ));
1534 }
1535 Result::Ok(results)
1536 })
1537 .await?;
1538 Result::Ok(results)
1539 }
1540 })
1541 .collect::<Vec<_>>();
1542
1543 let mut ranked = BinaryHeap::new();
1544 let mut idf_cache = HashMap::new();
1545 let mut parts = stream::iter(parts)
1546 .buffer_unordered(get_num_compute_intensive_cpus().min(32))
1547 .map_ok(|results| stream::iter(results.into_iter().map(Result::Ok)))
1548 .try_flatten();
1549 while let Some((partition_ordinal, partition)) = parts.try_next().await? {
1550 for (doc_id, score) in rescore_partition_candidates(partition, scorer, &mut idf_cache) {
1551 push_scored_partition_doc(
1552 &mut ranked,
1553 limit,
1554 PartitionDocId::try_new(partition_ordinal, doc_id)?,
1555 score,
1556 );
1557 }
1558 }
1559
1560 Ok(ranked.into_sorted_vec())
1561 }
1562
1563 fn resolve_resident_modern_candidates(
1564 &self,
1565 ranked: &[Reverse<ScoredPartitionDoc>],
1566 ) -> Result<Option<(Vec<u64>, Vec<f32>)>> {
1567 let mut addresses = vec![0; ranked.len()];
1568 let mut by_partition = BTreeMap::<usize, Vec<(usize, DocId)>>::new();
1569 for (rank, Reverse(candidate)) in ranked.iter().enumerate() {
1570 let partition_ordinal = candidate.document.partition_ordinal();
1571 let doc_id = candidate.document.doc_id;
1572 by_partition
1573 .entry(partition_ordinal)
1574 .or_default()
1575 .push((rank, doc_id));
1576 }
1577 for (partition_ordinal, entries) in by_partition {
1578 let documents = self
1579 .partitions
1580 .get(partition_ordinal)
1581 .and_then(|partition| partition.docs.modern())
1582 .ok_or_else(|| {
1583 Error::internal(format!(
1584 "resident FTS candidates reference missing modern partition ordinal {partition_ordinal}"
1585 ))
1586 })?;
1587 let doc_ids = entries
1588 .iter()
1589 .map(|(_, doc_id)| *doc_id)
1590 .collect::<Vec<_>>();
1591 let Some(resolved) = documents.cached_row_addresses(&doc_ids)? else {
1592 return Ok(None);
1593 };
1594 for ((rank, _), address) in entries.into_iter().zip(resolved) {
1595 addresses[rank] = address;
1596 }
1597 }
1598 let scores = ranked
1599 .iter()
1600 .map(|Reverse(candidate)| candidate.score.0)
1601 .collect();
1602 Ok(Some((addresses, scores)))
1603 }
1604
1605 async fn resolve_deferred_modern_candidates(
1606 &self,
1607 ranked: Vec<Reverse<ScoredPartitionDoc>>,
1608 ) -> Result<(Vec<u64>, Vec<f32>)> {
1609 let mut addresses = vec![0_u64; ranked.len()];
1610 let mut by_partition = BTreeMap::<usize, Vec<(usize, DocId)>>::new();
1611 for (rank, Reverse(candidate)) in ranked.iter().enumerate() {
1612 let partition_ordinal = candidate.document.partition_ordinal();
1613 let doc_id = candidate.document.doc_id;
1614 by_partition
1615 .entry(partition_ordinal)
1616 .or_default()
1617 .push((rank, doc_id));
1618 }
1619 let mut address_reads = Vec::with_capacity(by_partition.len());
1620 let mut largest_read_bytes = 0;
1621 for (partition_ordinal, entries) in by_partition {
1622 let documents = self
1623 .partitions
1624 .get(partition_ordinal)
1625 .and_then(|partition| partition.docs.modern())
1626 .cloned()
1627 .ok_or_else(|| {
1628 Error::internal(format!(
1629 "deferred FTS candidates reference missing modern partition ordinal {partition_ordinal}"
1630 ))
1631 })?;
1632 let doc_ids = entries
1633 .iter()
1634 .map(|(_, doc_id)| *doc_id)
1635 .collect::<Vec<_>>();
1636 largest_read_bytes =
1637 largest_read_bytes.max(documents.estimated_address_read_bytes(&doc_ids));
1638 address_reads.push(async move {
1639 let resolved = documents.resolve_addresses(&doc_ids).await?;
1640 Result::Ok((entries, resolved))
1641 });
1642 }
1643 let concurrency = address_read_concurrency(self.store.io_parallelism(), largest_read_bytes);
1644 let mut address_reads = stream::iter(address_reads).buffer_unordered(concurrency);
1645 while let Some((entries, resolved)) = address_reads.try_next().await? {
1646 for ((rank, _), address) in entries.into_iter().zip(resolved) {
1647 addresses[rank] = address;
1648 }
1649 }
1650 let scores = ranked
1651 .into_iter()
1652 .map(|Reverse(candidate)| candidate.score.0)
1653 .collect();
1654 Ok((addresses, scores))
1655 }
1656
1657 async fn load_legacy_index(
1658 store: Arc<dyn IndexStore>,
1659 frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1660 index_cache: &LanceCache,
1661 ) -> Result<Arc<Self>> {
1662 log::warn!("loading legacy FTS index");
1663 let tokens_fut = tokio::spawn({
1664 let store = store.clone();
1665 async move {
1666 let token_reader = store.open_index_file(TOKENS_FILE).await?;
1667 let tokenizer = token_reader
1668 .schema()
1669 .metadata
1670 .get("tokenizer")
1671 .map(|s| serde_json::from_str::<InvertedIndexParams>(s))
1672 .transpose()?
1673 .unwrap_or_default();
1674 let tokens = TokenSet::load(token_reader, TokenSetFormat::Arrow).await?;
1675 Result::Ok((tokenizer, tokens))
1676 }
1677 });
1678 let invert_list_fut = tokio::spawn({
1679 let store = store.clone();
1680 let index_cache_clone = index_cache.clone();
1681 async move {
1682 let invert_list_reader = store.open_index_file(INVERT_LIST_FILE).await?;
1683 let invert_list =
1684 PostingListReader::try_new(invert_list_reader, &index_cache_clone).await?;
1685 Result::Ok(Arc::new(invert_list))
1686 }
1687 });
1688 let docs_fut = tokio::spawn({
1689 let store = store.clone();
1690 async move {
1691 let docs_reader = store.open_index_file(DOCS_FILE).await?;
1692 let docs = DocSet::load(docs_reader, true, frag_reuse_index).await?;
1693 Result::Ok(docs)
1694 }
1695 });
1696
1697 let (tokenizer_config, tokens) = tokens_fut.await??;
1698 let inverted_list = invert_list_fut.await??;
1699 let docs = docs_fut.await??;
1700
1701 let tokenizer = tokenizer_config.build()?;
1702
1703 Ok(Arc::new(Self {
1704 params: tokenizer_config,
1705 store: store.clone(),
1706 tokenizer,
1707 token_set_format: TokenSetFormat::Arrow,
1708 format_version: InvertedListFormatVersion::V1,
1709 partitions: vec![Arc::new(InvertedPartition {
1710 id: 0,
1711 store,
1712 tokens,
1713 inverted_list,
1714 docs: PartitionDocumentStore::Legacy(Arc::new(docs)),
1715 token_set_format: TokenSetFormat::Arrow,
1716 })],
1717 corpus_stats: Arc::new(OnceCell::new()),
1718 prewarm_state: Arc::new(Mutex::new(InvertedPrewarmState::default())),
1719 document_projections_resident: Arc::new(AtomicBool::new(false)),
1720 deleted_fragments: RoaringBitmap::new(),
1721 }))
1722 }
1723
1724 pub fn is_legacy(&self) -> bool {
1725 self.partitions.len() == 1 && self.partitions[0].docs.legacy().is_some()
1726 }
1727
1728 pub async fn load_params(store: &dyn IndexStore) -> Result<InvertedIndexParams> {
1731 match store.open_index_file(METADATA_FILE).await {
1732 Ok(reader) => {
1733 let params = reader
1734 .schema()
1735 .metadata
1736 .get("params")
1737 .ok_or(Error::index("params not found in metadata".to_owned()))?;
1738 Ok(serde_json::from_str::<InvertedIndexParams>(params)?)
1739 }
1740 Err(metadata_error) => {
1741 let Ok(reader) = store.open_index_file(TOKENS_FILE).await else {
1746 return Err(metadata_error);
1747 };
1748 Ok(reader
1749 .schema()
1750 .metadata
1751 .get("tokenizer")
1752 .map(|s| serde_json::from_str::<InvertedIndexParams>(s))
1753 .transpose()?
1754 .unwrap_or_default())
1755 }
1756 }
1757 }
1758
1759 pub async fn load(
1760 store: Arc<dyn IndexStore>,
1761 frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1762 index_cache: &LanceCache,
1763 ) -> Result<Arc<Self>>
1764 where
1765 Self: Sized,
1766 {
1767 match store.open_index_file(METADATA_FILE).await {
1772 Ok(reader) => {
1773 let params = reader
1774 .schema()
1775 .metadata
1776 .get("params")
1777 .ok_or(Error::index("params not found in metadata".to_owned()))?;
1778 let params = serde_json::from_str::<InvertedIndexParams>(params)?;
1779 let partitions = reader
1780 .schema()
1781 .metadata
1782 .get("partitions")
1783 .ok_or(Error::index("partitions not found in metadata".to_owned()))?;
1784 let partitions: Vec<u64> = serde_json::from_str(partitions)?;
1785 let token_set_format = reader
1786 .schema()
1787 .metadata
1788 .get(TOKEN_SET_FORMAT_KEY)
1789 .map(|name| TokenSetFormat::from_str(name))
1790 .transpose()?
1791 .unwrap_or(TokenSetFormat::Arrow);
1792 let format_version = parse_format_version_from_metadata(&reader.schema().metadata)?;
1793
1794 let deleted_fragments = if reader.num_rows() > 0 {
1796 let metadata_batch = reader.read_range(0..1, None).await?;
1797 if let Some(col) = metadata_batch.column_by_name(DELETED_FRAGMENTS_COL) {
1798 let arr = col.as_binary_opt::<i32>().expect_ok()?;
1799 RoaringBitmap::deserialize_from(arr.value(0))?
1800 } else {
1801 RoaringBitmap::new()
1802 }
1803 } else {
1804 RoaringBitmap::new()
1805 };
1806
1807 let format = token_set_format;
1808 let partitions = partitions.into_iter().enumerate().map(|(priority, id)| {
1809 let store = store.with_io_priority(priority as u64);
1810 let frag_reuse_index_clone = frag_reuse_index.clone();
1811 let index_cache_for_part =
1812 index_cache.with_key_prefix(format!("part-{}", id).as_str());
1813 let token_set_format = format;
1814 async move {
1815 Result::Ok(Arc::new(
1816 InvertedPartition::load(
1817 store,
1818 id,
1819 frag_reuse_index_clone,
1820 &index_cache_for_part,
1821 token_set_format,
1822 )
1823 .await?,
1824 ))
1825 }
1826 });
1827 let partitions = stream::iter(partitions)
1828 .buffer_unordered(store.io_parallelism())
1829 .try_collect::<Vec<_>>()
1830 .await?;
1831
1832 let tokenizer = params.build()?;
1833 Ok(Arc::new(Self {
1834 params,
1835 store,
1836 tokenizer,
1837 token_set_format,
1838 format_version,
1839 partitions,
1840 corpus_stats: Arc::new(OnceCell::new()),
1841 prewarm_state: Arc::new(Mutex::new(InvertedPrewarmState::default())),
1842 document_projections_resident: Arc::new(AtomicBool::new(false)),
1843 deleted_fragments,
1844 }))
1845 }
1846 Err(_) => {
1847 Self::load_legacy_index(store, frag_reuse_index, index_cache).await
1849 }
1850 }
1851 }
1852}
1853
1854#[async_trait]
1855impl Index for InvertedIndex {
1856 fn as_any(&self) -> &dyn std::any::Any {
1857 self
1858 }
1859
1860 fn as_index(self: Arc<Self>) -> Arc<dyn Index> {
1861 self
1862 }
1863
1864 fn statistics(&self) -> Result<serde_json::Value> {
1865 let num_tokens = self
1866 .partitions
1867 .iter()
1868 .map(|part| part.tokens.len())
1869 .sum::<usize>();
1870 let num_docs = self
1871 .partitions
1872 .iter()
1873 .map(|part| part.docs.len())
1874 .sum::<usize>();
1875 Ok(serde_json::json!({
1876 "params": self.params,
1877 "num_tokens": num_tokens,
1878 "num_docs": num_docs,
1879 }))
1880 }
1881
1882 async fn prewarm(&self) -> Result<()> {
1883 self.prewarm_with_options(&FtsPrewarmOptions::default())
1884 .await
1885 }
1886
1887 fn index_type(&self) -> crate::IndexType {
1888 crate::IndexType::Inverted
1889 }
1890
1891 async fn calculate_included_frags(&self) -> Result<RoaringBitmap> {
1892 unimplemented!()
1893 }
1894}
1895
1896const PREWARM_CHUNK_TARGET_BYTES: u64 = 128 << 20;
1900
1901const PREWARM_MAX_CHUNK_TOKENS: usize = 256 * 1024;
1903
1904const PREWARM_MIN_CHUNK_TOKENS: usize = 1;
1906
1907static LANCE_FTS_POSTING_GROUP_MAX_TOKENS: LazyLock<usize> = LazyLock::new(|| {
1911 std::env::var("LANCE_FTS_POSTING_GROUP_MAX_TOKENS")
1912 .unwrap_or_else(|_| "128".to_string())
1913 .parse()
1914 .expect("failed to parse LANCE_FTS_POSTING_GROUP_MAX_TOKENS")
1915});
1916
1917fn runtime_posting_group_tokens() -> usize {
1918 (*LANCE_FTS_POSTING_GROUP_MAX_TOKENS).max(1)
1919}
1920
1921#[derive(Debug, Clone, DeepSizeOf)]
1925enum PostingGrouping {
1926 None,
1928 SyntheticFixed { group_size: u32 },
1930}
1931
1932impl PostingGrouping {
1933 fn for_reader(is_legacy_layout: bool, token_count: usize) -> Self {
1934 if is_legacy_layout || token_count == 0 {
1935 return Self::None;
1936 }
1937
1938 let group_size = u32::try_from(runtime_posting_group_tokens())
1939 .unwrap_or(u32::MAX)
1940 .max(1);
1941 Self::SyntheticFixed { group_size }
1942 }
1943
1944 fn is_grouped(&self) -> bool {
1945 !matches!(self, Self::None)
1946 }
1947
1948 fn range_for_token(&self, token_id: u32, token_count: usize) -> Option<(u32, u32)> {
1949 match self {
1950 Self::None => None,
1951 Self::SyntheticFixed { group_size } => {
1952 let token_count = u32::try_from(token_count).unwrap_or(u32::MAX);
1953 let start = (token_id / *group_size) * *group_size;
1954 let end = start.saturating_add(*group_size).min(token_count);
1955 Some((start, end))
1956 }
1957 }
1958 }
1959
1960 fn aligned_chunk_end(&self, token_count: usize, tok_start: usize, desired_end: usize) -> usize {
1961 match self {
1962 Self::None => desired_end,
1963 Self::SyntheticFixed { group_size } => synthetic_group_aligned_chunk_end(
1964 usize::try_from(*group_size).unwrap_or(usize::MAX).max(1),
1965 token_count,
1966 tok_start,
1967 desired_end,
1968 ),
1969 }
1970 }
1971
1972 fn ranges_for_chunk(
1973 &self,
1974 tok_start: usize,
1975 tok_end: usize,
1976 token_count: usize,
1977 ) -> Vec<(u32, u32)> {
1978 match self {
1979 Self::None => Vec::new(),
1980 Self::SyntheticFixed { group_size } => synthetic_group_ranges_for_chunk(
1981 usize::try_from(*group_size).unwrap_or(usize::MAX).max(1),
1982 tok_start,
1983 tok_end,
1984 token_count,
1985 ),
1986 }
1987 }
1988}
1989
1990fn prewarm_chunk_tokens(token_count: usize, file_size_bytes: u64) -> usize {
1992 if token_count == 0 {
1993 return PREWARM_MIN_CHUNK_TOKENS;
1994 }
1995 let bytes_per_token = (file_size_bytes / token_count as u64).max(1); let by_bytes = (PREWARM_CHUNK_TARGET_BYTES / bytes_per_token) as usize;
1997 by_bytes.clamp(PREWARM_MIN_CHUNK_TOKENS, PREWARM_MAX_CHUNK_TOKENS)
1998}
1999
2000fn synthetic_group_aligned_chunk_end(
2001 group_size: usize,
2002 token_count: usize,
2003 tok_start: usize,
2004 desired_end: usize,
2005) -> usize {
2006 if desired_end >= token_count {
2007 return token_count;
2008 }
2009
2010 let boundary = desired_end - (desired_end % group_size);
2011 if boundary > tok_start {
2012 boundary
2013 } else {
2014 tok_start.saturating_add(group_size).min(token_count)
2015 }
2016}
2017
2018fn synthetic_group_ranges_for_chunk(
2019 group_size: usize,
2020 tok_start: usize,
2021 tok_end: usize,
2022 token_count: usize,
2023) -> Vec<(u32, u32)> {
2024 let mut ranges = Vec::new();
2025 let mut start = tok_start - (tok_start % group_size);
2026 if start < tok_start {
2027 start = start.saturating_add(group_size).min(token_count);
2028 }
2029 while start < tok_end {
2030 let end = start.saturating_add(group_size).min(token_count);
2031 ranges.push((
2032 u32::try_from(start).unwrap_or(u32::MAX),
2033 u32::try_from(end).unwrap_or(u32::MAX),
2034 ));
2035 start = end;
2036 }
2037 ranges
2038}
2039
2040fn prewarm_chunk_ranges(
2041 grouping: &PostingGrouping,
2042 token_count: usize,
2043 chunk_tokens: usize,
2044) -> Vec<(usize, usize)> {
2045 let mut ranges = Vec::new();
2046 let mut tok_start = 0usize;
2047 while tok_start < token_count {
2048 let mut tok_end = (tok_start + chunk_tokens).min(token_count);
2049 if grouping.is_grouped() {
2051 tok_end = grouping.aligned_chunk_end(token_count, tok_start, tok_end);
2052 }
2053 ranges.push((tok_start, tok_end));
2054 tok_start = tok_end;
2055 }
2056 ranges
2057}
2058
2059impl InvertedIndex {
2060 pub async fn prewarm_with_options(&self, options: &FtsPrewarmOptions) -> Result<()> {
2061 let mut state = self.prewarm_state.lock().await;
2062 if state.satisfies(options.with_position)
2063 && (self.is_legacy() || self.document_projections_resident_now())
2064 {
2065 return Ok(());
2066 }
2067 let with_position = options.with_position || state.positions_ready;
2068 state.query_ready = false;
2069 state.positions_ready = false;
2070 self.document_projections_resident
2071 .store(false, Ordering::Release);
2072 self.prewarm_query_state(with_position).await?;
2073 state.query_ready = true;
2074 state.positions_ready = with_position;
2075 Ok(())
2076 }
2077
2078 async fn prewarm_query_state(&self, with_position: bool) -> Result<()> {
2079 let chunk_concurrency = self.store.io_parallelism().max(1);
2080 let prewarm_started = Instant::now();
2081 info!(
2082 partition_count = self.partitions.len(),
2083 with_position, chunk_concurrency, "fts index prewarm started"
2084 );
2085 for part in &self.partitions {
2086 let partition_started = Instant::now();
2087 info!(
2088 partition_id = part.id(),
2089 token_count = part.tokens.len(),
2090 with_position,
2091 chunk_concurrency,
2092 "fts partition prewarm started"
2093 );
2094 if let Err(err) = part
2095 .inverted_list
2096 .prewarm_posting_lists(with_position, chunk_concurrency)
2097 .await
2098 {
2099 warn!(
2100 partition_id = part.id(),
2101 error = %err,
2102 elapsed_ms = partition_started.elapsed().as_millis() as u64,
2103 "fts partition posting list prewarm failed"
2104 );
2105 return Err(err);
2106 }
2107 info!(
2108 partition_id = part.id(),
2109 elapsed_ms = partition_started.elapsed().as_millis() as u64,
2110 "fts partition posting lists prewarmed"
2111 );
2112 let docs_started = Instant::now();
2113 if let Err(err) = part.docs.prewarm().await {
2114 warn!(
2115 partition_id = part.id(),
2116 error = %err,
2117 elapsed_ms = docs_started.elapsed().as_millis() as u64,
2118 total_elapsed_ms = partition_started.elapsed().as_millis() as u64,
2119 "fts partition docset prewarm failed"
2120 );
2121 return Err(err);
2122 }
2123 info!(
2124 partition_id = part.id(),
2125 docset_elapsed_ms = docs_started.elapsed().as_millis() as u64,
2126 elapsed_ms = partition_started.elapsed().as_millis() as u64,
2127 "fts partition prewarm finished"
2128 );
2129 }
2130 self.aggregate_corpus_stats().await?;
2131 let query_ready = self.partitions.iter().all(|partition| {
2132 partition.docs.query_ready()
2133 && partition.inverted_list.modern_posting_validation_ready()
2134 });
2135 if !query_ready {
2136 return Err(Error::internal(
2137 "FTS prewarm completed without publishing a query-ready document and posting state"
2138 .to_owned(),
2139 ));
2140 }
2141 self.document_projections_resident
2142 .store(true, Ordering::Release);
2143 info!(
2144 partition_count = self.partitions.len(),
2145 query_ready,
2146 elapsed_ms = prewarm_started.elapsed().as_millis() as u64,
2147 "fts index prewarm finished"
2148 );
2149 Ok(())
2150 }
2151 async fn do_search(&self, text: &str) -> Result<RecordBatch> {
2153 let params = FtsSearchParams::new();
2154 let mut tokenizer = self.tokenizer.clone();
2155 let tokens = collect_query_tokens(text, &mut tokenizer);
2156
2157 let (doc_ids, _) = self
2158 .bm25_search(
2159 Arc::new(tokens),
2160 params.into(),
2161 Operator::And,
2162 Arc::new(NoFilter),
2163 Arc::new(NoOpMetricsCollector),
2164 None,
2165 )
2166 .boxed()
2167 .await?;
2168
2169 Ok(RecordBatch::try_new(
2170 ROW_ID_SCHEMA.clone(),
2171 vec![Arc::new(UInt64Array::from(doc_ids))],
2172 )?)
2173 }
2174}
2175
2176#[async_trait]
2177impl ScalarIndex for InvertedIndex {
2178 #[instrument(level = "debug", skip_all)]
2180 async fn search(
2181 &self,
2182 query: &dyn AnyQuery,
2183 _metrics: &dyn MetricsCollector,
2184 ) -> Result<SearchResult> {
2185 let query = query.as_any().downcast_ref::<TokenQuery>().unwrap();
2186
2187 match query {
2188 TokenQuery::TokensContains(text) => {
2189 let records = self.do_search(text).await?;
2190 let row_ids = records
2191 .column(0)
2192 .as_any()
2193 .downcast_ref::<UInt64Array>()
2194 .unwrap();
2195 let row_ids = row_ids.iter().flatten().collect_vec();
2196 Ok(SearchResult::at_most(RowAddrTreeMap::from_iter(row_ids)))
2197 }
2198 }
2199 }
2200
2201 fn can_remap(&self) -> bool {
2202 true
2203 }
2204
2205 async fn remap(
2206 &self,
2207 mapping: &RowAddrRemap,
2208 dest_store: &dyn IndexStore,
2209 ) -> Result<CreatedIndex> {
2210 let files = self
2211 .to_builder()
2212 .remap(mapping, self.store.clone(), dest_store)
2213 .await?;
2214
2215 let details = pbold::InvertedIndexDetails::try_from(&self.params)?;
2216
2217 Ok(CreatedIndex {
2218 index_details: prost_types::Any::from_msg(&details).unwrap(),
2219 index_version: self.index_version(),
2220 files,
2221 })
2222 }
2223
2224 async fn update(
2225 &self,
2226 new_data: SendableRecordBatchStream,
2227 dest_store: &dyn IndexStore,
2228 old_data_filter: Option<crate::scalar::OldIndexDataFilter>,
2229 ) -> Result<CreatedIndex> {
2230 let files = self
2231 .to_builder()
2232 .update(new_data, dest_store, old_data_filter)
2233 .await?;
2234
2235 let details = pbold::InvertedIndexDetails::try_from(&self.params)?;
2236
2237 Ok(CreatedIndex {
2238 index_details: prost_types::Any::from_msg(&details).unwrap(),
2239 index_version: self.index_version(),
2240 files,
2241 })
2242 }
2243
2244 fn update_criteria(&self) -> UpdateCriteria {
2245 let criteria = TrainingCriteria::new(TrainingOrdering::None).with_row_id();
2246 if self.is_legacy() {
2247 UpdateCriteria::requires_old_data(criteria)
2248 } else {
2249 UpdateCriteria::only_new_data(criteria)
2250 }
2251 }
2252
2253 fn derive_index_params(&self) -> Result<ScalarIndexParams> {
2254 let mut params = self.params.clone();
2255 if params.base_tokenizer.is_empty() {
2256 params.base_tokenizer = "simple".to_string();
2258 }
2259 params = params.format_version(self.format_version());
2260
2261 let params_json = params.to_training_json()?.to_string();
2262
2263 Ok(ScalarIndexParams {
2264 index_type: BuiltinIndexType::Inverted.as_str().to_string(),
2265 params: Some(params_json),
2266 })
2267 }
2268}
2269
2270#[derive(Debug, Clone, DeepSizeOf)]
2271pub struct InvertedPartition {
2272 id: u64,
2274 store: Arc<dyn IndexStore>,
2275 pub(crate) tokens: TokenSet,
2276 pub(crate) inverted_list: Arc<PostingListReader>,
2277 pub(super) docs: PartitionDocumentStore,
2280 token_set_format: TokenSetFormat,
2281}
2282
2283impl InvertedPartition {
2284 pub fn belongs_to_fragment(&self, fragment_mask: u64) -> bool {
2295 (self.id() & fragment_mask) == fragment_mask
2296 }
2297
2298 pub fn id(&self) -> u64 {
2299 self.id
2300 }
2301
2302 pub fn store(&self) -> &dyn IndexStore {
2303 self.store.as_ref()
2304 }
2305
2306 pub fn is_legacy(&self) -> bool {
2307 self.inverted_list.is_legacy_layout()
2308 }
2309
2310 pub async fn load(
2311 store: Arc<dyn IndexStore>,
2312 id: u64,
2313 frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
2314 index_cache: &LanceCache,
2315 token_set_format: TokenSetFormat,
2316 ) -> Result<Self> {
2317 let token_file = store.open_index_file(&token_file_path(id)).await?;
2318 let tokens = TokenSet::load(token_file, token_set_format).await?;
2319 let invert_list_file = store.open_index_file(&posting_file_path(id)).await?;
2320 let mut inverted_list = PostingListReader::try_new(invert_list_file, index_cache).await?;
2321 let docs_path = doc_file_path(id);
2322 let docs_reader = store.open_index_file(&docs_path).await?;
2323 let docs = PartitionDocuments::try_new(
2324 store.clone(),
2325 docs_path,
2326 id,
2327 WeakLanceCache::from(index_cache),
2328 docs_reader.as_ref(),
2329 frag_reuse_index,
2330 inverted_list.block_size() == MAX_POSTING_BLOCK_SIZE,
2332 )?;
2333 inverted_list.modern_num_docs = Some(docs.len());
2334
2335 Ok(Self {
2336 id,
2337 store,
2338 tokens,
2339 inverted_list: Arc::new(inverted_list),
2340 docs: PartitionDocumentStore::Modern(Arc::new(docs)),
2341 token_set_format,
2342 })
2343 }
2344
2345 fn map(&self, token: &str) -> Option<u32> {
2346 self.tokens.get(token)
2347 }
2348
2349 pub fn expand_fuzzy(&self, tokens: &Tokens, params: &FtsSearchParams) -> Result<Tokens> {
2350 let mut new_tokens = Vec::with_capacity(min(tokens.len(), params.max_expansions));
2351 let mut new_positions = Vec::with_capacity(new_tokens.capacity());
2352 let mut seen = HashSet::new();
2353 for token_idx in 0..tokens.len() {
2354 let remaining = params.max_expansions.saturating_sub(new_tokens.len());
2355 if remaining == 0 {
2356 break;
2357 }
2358 let token = tokens.get_token(token_idx);
2359 let position = tokens.position(token_idx);
2360 let base_prefix_len = tokens.token_type().prefix_len(token) as u32;
2361 let mut candidates = BTreeSet::new();
2362 self.collect_fuzzy_candidates(
2363 token,
2364 base_prefix_len,
2365 params,
2366 remaining,
2367 &mut candidates,
2368 )?;
2369 for candidate in candidates {
2370 if new_tokens.len() >= params.max_expansions {
2371 break;
2372 }
2373 if seen.insert((candidate.clone(), position)) {
2374 new_tokens.push(candidate);
2375 new_positions.push(position);
2376 }
2377 }
2378 }
2379 Ok(Tokens::with_positions(
2380 new_tokens,
2381 new_positions,
2382 tokens.token_type().clone(),
2383 ))
2384 }
2385
2386 fn collect_fuzzy_candidates(
2394 &self,
2395 token: &str,
2396 base_prefix_len: u32,
2397 params: &FtsSearchParams,
2398 limit: usize,
2399 candidates: &mut BTreeSet<String>,
2400 ) -> Result<()> {
2401 let fuzziness = match params.fuzziness {
2402 Some(fuzziness) => fuzziness,
2403 None => MatchQuery::auto_fuzziness(token),
2404 };
2405 let lev = fst::automaton::Levenshtein::new(token, fuzziness)
2406 .map_err(|e| Error::index(format!("failed to construct the fuzzy query: {}", e)))?;
2407
2408 if let TokenMap::Fst(ref map) = self.tokens.tokens {
2409 let mut expanded = Vec::new();
2410 match base_prefix_len + params.prefix_length {
2411 0 => take_fst_keys(map.search(lev), &mut expanded, limit),
2412 prefix_length => {
2413 let prefix = &token[..min(prefix_length as usize, token.len())];
2414 let prefix = fst::automaton::Str::new(prefix).starts_with();
2415 take_fst_keys(map.search(lev.intersection(prefix)), &mut expanded, limit)
2416 }
2417 }
2418 candidates.extend(expanded);
2419 Ok(())
2420 } else {
2421 Err(Error::index(
2422 "tokens is not fst, which is not expected".to_owned(),
2423 ))
2424 }
2425 }
2426
2427 #[inline]
2428 fn grouped_score_upper_bound(
2429 query_weight: f32,
2430 union_freq: u32,
2431 doc_length: u32,
2432 scorer: &MemBM25Scorer,
2433 ) -> f32 {
2434 query_weight * scorer.doc_weight(union_freq, doc_length)
2438 }
2439
2440 fn grouped_block_max_scores(
2441 doc_ids: &[u32],
2442 frequencies: &[u32],
2443 block_size: usize,
2444 docs: &LoadedDocLengths,
2445 query_weight: f32,
2446 scorer: &MemBM25Scorer,
2447 ) -> Vec<f32> {
2448 doc_ids
2449 .chunks(block_size)
2450 .zip(frequencies.chunks(block_size))
2451 .map(|(doc_ids, frequencies)| {
2452 doc_ids
2453 .iter()
2454 .zip(frequencies)
2455 .map(|(doc_id, freq)| {
2456 Self::grouped_score_upper_bound(
2457 query_weight,
2458 *freq,
2459 docs.scoring_num_tokens(*doc_id),
2460 scorer,
2461 )
2462 })
2463 .fold(0.0, f32::max)
2464 })
2465 .collect()
2466 }
2467
2468 fn union_plain_posting_lists(
2469 postings: Vec<PostingList>,
2470 docs: &LoadedDocLengths,
2471 query_weight: f32,
2472 scorer: &MemBM25Scorer,
2473 ) -> Result<PostingList> {
2474 let mut freqs_by_row_id = BTreeMap::new();
2475 for posting in postings {
2476 for (row_id, freq, _) in posting.iter() {
2477 let entry = freqs_by_row_id.entry(row_id).or_insert(0u32);
2478 *entry = entry.checked_add(freq).ok_or_else(|| {
2479 Error::index(format!("posting frequency overflow for row id {}", row_id))
2480 })?;
2481 }
2482 }
2483 let mut row_ids = Vec::with_capacity(freqs_by_row_id.len());
2484 let mut frequencies = Vec::with_capacity(freqs_by_row_id.len());
2485 let mut max_score = 0.0_f32;
2486 for (row_id, freq) in freqs_by_row_id {
2487 max_score = max_score.max(Self::grouped_score_upper_bound(
2488 query_weight,
2489 freq,
2490 docs.num_tokens_by_row_id(row_id),
2491 scorer,
2492 ));
2493 row_ids.push(row_id);
2494 frequencies.push(freq as f32);
2495 }
2496 Ok(PostingList::Plain(PlainPostingList::new(
2497 ScalarBuffer::from(row_ids),
2498 ScalarBuffer::from(frequencies),
2499 Some(max_score),
2500 None,
2501 )))
2502 }
2503
2504 fn union_plain_posting_lists_with_positions(
2505 postings: Vec<PostingList>,
2506 docs: &LoadedDocLengths,
2507 query_weight: f32,
2508 scorer: &MemBM25Scorer,
2509 ) -> Result<PostingList> {
2510 let mut positions_by_row_id = BTreeMap::<u64, Vec<u32>>::new();
2511 for posting in postings {
2512 for (row_id, _, positions) in posting.iter() {
2513 let positions = positions.ok_or_else(|| {
2514 Error::index("cannot union grouped phrase terms without positions".to_string())
2515 })?;
2516 positions_by_row_id
2517 .entry(row_id)
2518 .or_default()
2519 .extend(positions);
2520 }
2521 }
2522 if positions_by_row_id.is_empty() {
2523 return Ok(PostingList::Plain(PlainPostingList::new(
2524 ScalarBuffer::from(Vec::<u64>::new()),
2525 ScalarBuffer::from(Vec::<f32>::new()),
2526 None,
2527 None,
2528 )));
2529 }
2530
2531 let mut row_ids = Vec::with_capacity(positions_by_row_id.len());
2532 let mut frequencies = Vec::with_capacity(positions_by_row_id.len());
2533 let mut positions_builder = ListBuilder::new(Int32Builder::new());
2534 let mut max_score = 0.0_f32;
2535 for (row_id, mut positions) in positions_by_row_id {
2536 positions.sort_unstable();
2537 let frequency = positions.len() as u32;
2538 max_score = max_score.max(Self::grouped_score_upper_bound(
2539 query_weight,
2540 frequency,
2541 docs.num_tokens_by_row_id(row_id),
2542 scorer,
2543 ));
2544 row_ids.push(row_id);
2545 frequencies.push(frequency as f32);
2546 for position in positions {
2547 positions_builder.values().append_value(position as i32);
2548 }
2549 positions_builder.append(true);
2550 }
2551
2552 Ok(PostingList::Plain(PlainPostingList::new(
2553 ScalarBuffer::from(row_ids),
2554 ScalarBuffer::from(frequencies),
2555 Some(max_score),
2556 Some(positions_builder.finish()),
2557 )))
2558 }
2559
2560 fn union_compressed_posting_lists(
2561 postings: Vec<PostingList>,
2562 docs: &LoadedDocLengths,
2563 query_weight: f32,
2564 scorer: &MemBM25Scorer,
2565 ) -> Result<PostingList> {
2566 let block_size = postings
2567 .iter()
2568 .find_map(|posting| match posting {
2569 PostingList::Compressed(posting) => Some(posting.block_size),
2570 PostingList::Plain(_) => None,
2571 })
2572 .unwrap_or(LEGACY_BLOCK_SIZE);
2573 let mut freqs_by_doc_id = BTreeMap::new();
2574 for posting in postings {
2575 for (doc_id, freq, _) in posting.iter() {
2576 let doc_id = u32::try_from(doc_id).map_err(|_| {
2577 Error::index(format!(
2578 "compressed posting doc id {} exceeds u32::MAX",
2579 doc_id
2580 ))
2581 })?;
2582 let entry = freqs_by_doc_id.entry(doc_id).or_insert(0u32);
2583 *entry = entry.checked_add(freq).ok_or_else(|| {
2584 Error::index(format!("posting frequency overflow for doc id {}", doc_id))
2585 })?;
2586 }
2587 }
2588 if freqs_by_doc_id.is_empty() {
2589 return Ok(PostingList::Plain(PlainPostingList::new(
2590 ScalarBuffer::from(Vec::<u64>::new()),
2591 ScalarBuffer::from(Vec::<f32>::new()),
2592 None,
2593 None,
2594 )));
2595 }
2596
2597 let mut builder = PostingListBuilder::new_with_block_size(false, block_size);
2598 let mut doc_ids = Vec::with_capacity(freqs_by_doc_id.len());
2599 let mut frequencies = Vec::with_capacity(freqs_by_doc_id.len());
2600 for (doc_id, freq) in freqs_by_doc_id {
2601 builder.add(doc_id, PositionRecorder::Count(freq));
2602 doc_ids.push(doc_id);
2603 frequencies.push(freq);
2604 }
2605 let block_max_scores = Self::grouped_block_max_scores(
2606 &doc_ids,
2607 &frequencies,
2608 block_size,
2609 docs,
2610 query_weight,
2611 scorer,
2612 );
2613 let batch = builder.to_batch(block_max_scores)?;
2614 let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
2615 let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
2616 PostingList::from_batch(&batch, Some(max_score), Some(length))
2617 }
2618
2619 fn union_compressed_posting_lists_with_positions(
2620 postings: Vec<PostingList>,
2621 docs: &LoadedDocLengths,
2622 query_weight: f32,
2623 scorer: &MemBM25Scorer,
2624 ) -> Result<PostingList> {
2625 let block_size = postings
2626 .iter()
2627 .find_map(|posting| match posting {
2628 PostingList::Compressed(posting) => Some(posting.block_size),
2629 PostingList::Plain(_) => None,
2630 })
2631 .unwrap_or(LEGACY_BLOCK_SIZE);
2632 let mut positions_by_doc_id = BTreeMap::<u32, Vec<u32>>::new();
2633 for posting in postings {
2634 for (doc_id, _, positions) in posting.iter() {
2635 let doc_id = u32::try_from(doc_id).map_err(|_| {
2636 Error::index(format!(
2637 "compressed posting doc id {} exceeds u32::MAX",
2638 doc_id
2639 ))
2640 })?;
2641 let positions = positions.ok_or_else(|| {
2642 Error::index("cannot union grouped phrase terms without positions".to_string())
2643 })?;
2644 positions_by_doc_id
2645 .entry(doc_id)
2646 .or_default()
2647 .extend(positions);
2648 }
2649 }
2650 if positions_by_doc_id.is_empty() {
2651 return Ok(PostingList::Plain(PlainPostingList::new(
2652 ScalarBuffer::from(Vec::<u64>::new()),
2653 ScalarBuffer::from(Vec::<f32>::new()),
2654 None,
2655 None,
2656 )));
2657 }
2658
2659 let mut builder = PostingListBuilder::new_with_block_size(true, block_size);
2660 let mut doc_ids = Vec::with_capacity(positions_by_doc_id.len());
2661 let mut frequencies = Vec::with_capacity(positions_by_doc_id.len());
2662 for (doc_id, mut positions) in positions_by_doc_id {
2663 positions.sort_unstable();
2664 let frequency = positions.len() as u32;
2665 builder.add(doc_id, PositionRecorder::Position(positions.into()));
2666 doc_ids.push(doc_id);
2667 frequencies.push(frequency);
2668 }
2669 let block_max_scores = Self::grouped_block_max_scores(
2670 &doc_ids,
2671 &frequencies,
2672 block_size,
2673 docs,
2674 query_weight,
2675 scorer,
2676 );
2677 let batch = builder.to_batch(block_max_scores)?;
2678 let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
2679 let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
2680 PostingList::from_batch(&batch, Some(max_score), Some(length))
2681 }
2682
2683 fn union_posting_lists(
2684 postings: Vec<PostingList>,
2685 docs: &LoadedDocLengths,
2686 with_positions: bool,
2687 query_weight: f32,
2688 scorer: &MemBM25Scorer,
2689 ) -> Result<PostingList> {
2690 let has_plain = postings
2691 .iter()
2692 .any(|posting| matches!(posting, PostingList::Plain(_)));
2693 let has_compressed = postings
2694 .iter()
2695 .any(|posting| matches!(posting, PostingList::Compressed(_)));
2696 match (has_plain, has_compressed) {
2697 (true, true) => Err(Error::index(
2698 "cannot union mixed plain and compressed posting lists".to_owned(),
2699 )),
2700 (true, false) if with_positions => {
2701 Self::union_plain_posting_lists_with_positions(postings, docs, query_weight, scorer)
2702 }
2703 (true, false) => Self::union_plain_posting_lists(postings, docs, query_weight, scorer),
2704 (false, true) if with_positions => Self::union_compressed_posting_lists_with_positions(
2705 postings,
2706 docs,
2707 query_weight,
2708 scorer,
2709 ),
2710 (false, true) => {
2711 Self::union_compressed_posting_lists(postings, docs, query_weight, scorer)
2712 }
2713 (false, false) => Ok(PostingList::Plain(PlainPostingList::new(
2714 ScalarBuffer::from(Vec::<u64>::new()),
2715 ScalarBuffer::from(Vec::<f32>::new()),
2716 None,
2717 None,
2718 ))),
2719 }
2720 }
2721
2722 #[instrument(level = "debug", skip_all)]
2726 async fn load_posting_lists(
2727 &self,
2728 tokens: &Tokens,
2729 params: &FtsSearchParams,
2730 operator: Operator,
2731 impact_scorer: &MemBM25Scorer,
2732 metrics: &dyn MetricsCollector,
2733 ) -> Result<LoadedPostings> {
2734 let is_phrase_query = params.phrase_slop.is_some();
2735 let is_and_query = operator == Operator::And;
2736 let required_positions = (is_and_query || is_phrase_query).then(|| {
2737 (0..tokens.len())
2738 .map(|index| tokens.position(index))
2739 .collect::<HashSet<_>>()
2740 });
2741 let tokens = tokens.clone();
2746 let token_positions = (0..tokens.len())
2747 .map(|index| tokens.position(index))
2748 .collect::<Vec<_>>();
2749 let mut seen_positions = HashSet::with_capacity(token_positions.len());
2750 let exact_scoring_required = token_positions
2751 .iter()
2752 .any(|position| !seen_positions.insert(*position));
2753 let mut token_ids = Vec::with_capacity(tokens.len());
2754 let mut matched_positions = required_positions.as_ref().map(|_| HashSet::new());
2755 for (index, token) in tokens.into_iter().enumerate() {
2756 let token_id = self.map(&token);
2757 if let Some(token_id) = token_id {
2758 let position = token_positions[index];
2759 if let Some(matched_positions) = matched_positions.as_mut() {
2760 matched_positions.insert(position);
2761 }
2762 token_ids.push((token_id, token, position));
2763 }
2764 }
2765 if token_ids.is_empty() {
2766 return Ok(LoadedPostings::empty());
2767 }
2768 if let Some(required_positions) = required_positions.as_ref()
2769 && let Some(matched_positions) = matched_positions.as_ref()
2770 && !required_positions.is_subset(matched_positions)
2771 {
2772 return Ok(LoadedPostings::empty());
2773 }
2774
2775 token_ids.sort_unstable_by_key(|(token_id, _, position)| (*position, *token_id));
2776 token_ids.dedup_by(|lhs, rhs| lhs.0 == rhs.0 && lhs.2 == rhs.2);
2777
2778 let num_docs = self.docs.len();
2779 let loaded_postings = stream::iter(token_ids)
2780 .map(|(token_id, token, position)| async move {
2781 let posting = self
2782 .inverted_list
2783 .posting_list(token_id, is_phrase_query, metrics)
2784 .await?;
2785
2786 Result::Ok((token_id, token, position, posting))
2787 })
2788 .buffered(self.store.io_parallelism())
2789 .try_collect::<Vec<_>>()
2790 .await?;
2791
2792 let needs_union = loaded_postings
2793 .windows(2)
2794 .any(|window| window[0].2 == window[1].2);
2795 if (is_and_query || is_phrase_query)
2796 && !needs_union
2797 && loaded_postings
2798 .iter()
2799 .any(|(_, _, _, posting)| posting.is_empty())
2800 {
2801 return Ok(LoadedPostings::empty());
2802 }
2803
2804 if !needs_union {
2805 let impact_safe = loaded_postings
2806 .iter()
2807 .all(|(_, _, _, posting)| posting.has_impacts());
2808 return Ok(LoadedPostings {
2809 postings: loaded_postings
2810 .into_iter()
2811 .map(|(token_id, token, position, posting)| {
2812 let needs_scorer_upper_bound =
2813 exact_scoring_required && !posting.has_impacts();
2814 let query_weight = if impact_safe || exact_scoring_required {
2815 impact_scorer.query_weight(&token)
2816 } else {
2817 idf(posting.len(), num_docs)
2818 };
2819 let posting = PostingIterator::with_query_weight(
2820 token,
2821 token_id,
2822 position,
2823 query_weight,
2824 posting,
2825 num_docs,
2826 );
2827 if needs_scorer_upper_bound {
2828 posting.with_scorer_upper_bound()
2829 } else {
2830 posting
2831 }
2832 })
2833 .collect(),
2834 grouped_expansions: Vec::new(),
2835 impact_safe,
2836 exact_scoring_required,
2837 });
2838 }
2839
2840 let docs_for_union = if needs_union {
2841 Some(match &self.docs {
2842 PartitionDocumentStore::Legacy(docs) => LoadedDocLengths::Legacy(docs.clone()),
2843 PartitionDocumentStore::Modern(documents) => {
2844 LoadedDocLengths::Modern(documents.lengths().await?)
2845 }
2846 })
2847 } else {
2848 None
2849 };
2850
2851 let mut grouped_postings = Vec::new();
2854 let mut grouped_expansions = Vec::new();
2855 let mut iter = loaded_postings.into_iter().peekable();
2856 while let Some((token_id, token, position, posting)) = iter.next() {
2857 let mut group = vec![(token_id, token, posting)];
2858 while matches!(iter.peek(), Some((_, _, next_position, _)) if *next_position == position)
2859 {
2860 let (token_id, token, _, posting) = iter.next().expect("peeked item must exist");
2861 group.push((token_id, token, posting));
2862 }
2863
2864 let (token_id, token, posting) = if group.len() == 1 {
2865 group.pop().expect("single-item group must exist")
2866 } else {
2867 let token_id = group[0].0;
2868 let token = group[0].1.clone();
2869 let terms = group
2870 .iter()
2871 .map(|(_, token, posting)| {
2872 GroupedTermScorer::new(impact_scorer.query_weight(token), posting)
2873 })
2874 .collect::<Vec<_>>();
2875 let terms = Arc::<[GroupedTermScorer]>::from(terms);
2876 let query_weight = terms.iter().map(GroupedTermScorer::query_weight).sum();
2877 grouped_expansions.push(GroupedExpansionTerms {
2878 position,
2879 terms: terms.clone(),
2880 });
2881 let postings = group
2882 .into_iter()
2883 .map(|(_, _, posting)| posting)
2884 .collect::<Vec<_>>();
2885 let docs = docs_for_union.as_ref().ok_or_else(|| {
2886 Error::index("union docs were not loaded for grouped query terms".to_string())
2887 })?;
2888 let posting = Self::union_posting_lists(
2889 postings,
2890 docs,
2891 is_phrase_query,
2892 query_weight,
2893 impact_scorer,
2894 )?;
2895 if posting.is_empty() && (is_and_query || is_phrase_query) {
2896 return Ok(LoadedPostings::empty());
2897 }
2898 grouped_postings.push(
2899 PostingIterator::with_query_weight(
2900 token,
2901 token_id,
2902 position,
2903 query_weight,
2904 posting,
2905 num_docs,
2906 )
2907 .with_grouped_terms(terms),
2908 );
2909 continue;
2910 };
2911 if posting.is_empty() {
2912 if is_and_query || is_phrase_query {
2913 return Ok(LoadedPostings::empty());
2914 }
2915 continue;
2916 }
2917
2918 let query_weight = impact_scorer.query_weight(&token);
2919 let needs_scorer_upper_bound = !posting.has_impacts();
2920 let posting = PostingIterator::with_query_weight(
2921 token,
2922 token_id,
2923 position,
2924 query_weight,
2925 posting,
2926 num_docs,
2927 );
2928 grouped_postings.push(if needs_scorer_upper_bound {
2929 posting.with_scorer_upper_bound()
2930 } else {
2931 posting
2932 });
2933 }
2934
2935 Ok(LoadedPostings {
2936 postings: grouped_postings,
2937 grouped_expansions,
2938 impact_safe: false,
2939 exact_scoring_required: true,
2940 })
2941 }
2942
2943 #[allow(clippy::too_many_arguments)]
2944 fn bm25_search_legacy(
2945 &self,
2946 docs: &DocSet,
2947 params: &FtsSearchParams,
2948 operator: Operator,
2949 mask: &RowAddrMask,
2950 postings: Vec<PostingIterator>,
2951 impact_scorer: Option<Arc<MemBM25Scorer>>,
2952 metrics: &dyn MetricsCollector,
2953 shared_threshold: Arc<AtomicU32>,
2954 ) -> Result<Vec<DocCandidate<u64>>> {
2955 let documents = LegacyWandDocuments::new(docs, mask);
2956 self.bm25_search_with_documents(
2957 &documents,
2958 params,
2959 operator,
2960 postings,
2961 impact_scorer,
2962 metrics,
2963 shared_threshold,
2964 )
2965 }
2966
2967 #[allow(clippy::too_many_arguments)]
2968 fn bm25_search_modern(
2969 &self,
2970 lengths: &DocLengths,
2971 visibility: &DocVisibility,
2972 params: &FtsSearchParams,
2973 operator: Operator,
2974 postings: Vec<PostingIterator>,
2975 impact_scorer: Option<Arc<MemBM25Scorer>>,
2976 metrics: &dyn MetricsCollector,
2977 shared_threshold: Arc<AtomicU32>,
2978 ) -> Result<Vec<DocCandidate<DocId>>> {
2979 if visibility.is_all() {
2980 let documents = ModernWandDocuments::all(lengths);
2981 self.bm25_search_with_documents(
2982 &documents,
2983 params,
2984 operator,
2985 postings,
2986 impact_scorer,
2987 metrics,
2988 shared_threshold,
2989 )
2990 } else {
2991 let documents = ModernWandDocuments::filtered(lengths, visibility);
2992 self.bm25_search_with_documents(
2993 &documents,
2994 params,
2995 operator,
2996 postings,
2997 impact_scorer,
2998 metrics,
2999 shared_threshold,
3000 )
3001 }
3002 }
3003
3004 #[instrument(level = "debug", skip_all)]
3005 #[allow(clippy::too_many_arguments)]
3006 fn bm25_search_with_documents<D: WandDocuments>(
3007 &self,
3008 documents: &D,
3009 params: &FtsSearchParams,
3010 operator: Operator,
3011 postings: Vec<PostingIterator>,
3012 impact_scorer: Option<Arc<MemBM25Scorer>>,
3013 metrics: &dyn MetricsCollector,
3014 shared_threshold: Arc<AtomicU32>,
3015 ) -> Result<Vec<DocCandidate<D::Candidate>>> {
3016 if postings.is_empty() {
3017 return Ok(Vec::new());
3018 }
3019
3020 let hits = if let Some(scorer) = impact_scorer {
3021 let mut wand = Wand::new(operator, postings.into_iter(), documents, scorer)
3022 .with_shared_threshold(shared_threshold);
3023 wand.search(params, metrics)?
3024 } else {
3025 let scorer = IndexBM25Scorer::new(std::iter::once(self));
3026 let mut wand = Wand::new(operator, postings.into_iter(), documents, scorer)
3027 .with_shared_threshold(shared_threshold);
3028 wand.search(params, metrics)?
3029 };
3030 Ok(hits)
3031 }
3032
3033 pub async fn into_builder(self) -> Result<InnerBuilder> {
3034 let mut builder = InnerBuilder::new_with_posting_tail_codec_and_block_size(
3035 self.id,
3036 self.inverted_list.has_positions(),
3037 self.token_set_format,
3038 self.inverted_list.posting_tail_codec(),
3039 self.inverted_list.block_size(),
3040 );
3041 builder.tokens = self.tokens.into_mutable();
3042 builder.docs = self.docs.load_build_docset().await?;
3043
3044 builder
3045 .posting_lists
3046 .reserve_exact(self.inverted_list.len());
3047 for posting_list in self
3048 .inverted_list
3049 .read_all(self.inverted_list.has_positions())
3050 .await?
3051 {
3052 let posting_list = posting_list?;
3053 builder
3054 .posting_lists
3055 .push(posting_list.into_builder(&builder.docs));
3056 }
3057 Ok(builder)
3058 }
3059}
3060
3061#[derive(Debug, Clone)]
3064pub enum TokenMap {
3065 HashMap(HashMap<String, u32>),
3066 Fst(fst::Map<Vec<u8>>),
3067}
3068
3069impl Default for TokenMap {
3070 fn default() -> Self {
3071 Self::HashMap(HashMap::new())
3072 }
3073}
3074
3075impl DeepSizeOf for TokenMap {
3076 fn deep_size_of_children(&self, ctx: &mut lance_core::deepsize::Context) -> usize {
3077 match self {
3078 Self::HashMap(map) => map.deep_size_of_children(ctx),
3079 Self::Fst(map) => map.as_fst().size(),
3080 }
3081 }
3082}
3083
3084impl TokenMap {
3085 pub fn len(&self) -> usize {
3086 match self {
3087 Self::HashMap(map) => map.len(),
3088 Self::Fst(map) => map.len(),
3089 }
3090 }
3091
3092 pub fn is_empty(&self) -> bool {
3093 self.len() == 0
3094 }
3095}
3096
3097#[derive(Debug, Clone, Default, DeepSizeOf)]
3099pub struct TokenSet {
3100 pub(crate) tokens: TokenMap,
3102 pub(crate) next_id: u32,
3103 total_length: usize,
3104}
3105
3106impl TokenSet {
3107 pub fn into_mut(self) -> Self {
3108 let tokens = match self.tokens {
3109 TokenMap::HashMap(map) => map,
3110 TokenMap::Fst(map) => {
3111 let mut new_map = HashMap::with_capacity(map.len());
3112 let mut stream = map.into_stream();
3113 while let Some((token, token_id)) = stream.next() {
3114 new_map.insert(String::from_utf8_lossy(token).into_owned(), token_id as u32);
3115 }
3116
3117 new_map
3118 }
3119 };
3120
3121 Self {
3122 tokens: TokenMap::HashMap(tokens),
3123 next_id: self.next_id,
3124 total_length: self.total_length,
3125 }
3126 }
3127
3128 pub fn len(&self) -> usize {
3129 self.tokens.len()
3130 }
3131
3132 pub fn is_empty(&self) -> bool {
3133 self.len() == 0
3134 }
3135
3136 pub fn to_batch(self, format: TokenSetFormat) -> Result<RecordBatch> {
3137 match format {
3138 TokenSetFormat::Arrow => self.into_arrow_batch(),
3139 TokenSetFormat::Fst => self.into_fst_batch(),
3140 }
3141 }
3142
3143 fn into_arrow_batch(self) -> Result<RecordBatch> {
3144 let mut token_builder = StringBuilder::with_capacity(self.tokens.len(), self.total_length);
3145 let mut token_id_builder = UInt32Builder::with_capacity(self.tokens.len());
3146
3147 match self.tokens {
3148 TokenMap::Fst(map) => {
3149 let mut stream = map.stream();
3150 while let Some((token, token_id)) = stream.next() {
3151 token_builder.append_value(String::from_utf8_lossy(token));
3152 token_id_builder.append_value(token_id as u32);
3153 }
3154 }
3155 TokenMap::HashMap(map) => {
3156 for (token, token_id) in map.into_iter().sorted_unstable() {
3157 token_builder.append_value(token);
3158 token_id_builder.append_value(token_id);
3159 }
3160 }
3161 }
3162
3163 let token_col = token_builder.finish();
3164 let token_id_col = token_id_builder.finish();
3165
3166 let schema = arrow_schema::Schema::new(vec![
3167 arrow_schema::Field::new(TOKEN_COL, DataType::Utf8, false),
3168 arrow_schema::Field::new(TOKEN_ID_COL, DataType::UInt32, false),
3169 ]);
3170
3171 let batch = RecordBatch::try_new(
3172 Arc::new(schema),
3173 vec![
3174 Arc::new(token_col) as ArrayRef,
3175 Arc::new(token_id_col) as ArrayRef,
3176 ],
3177 )?;
3178 Ok(batch)
3179 }
3180
3181 fn into_fst_batch(mut self) -> Result<RecordBatch> {
3182 let fst_map = match std::mem::take(&mut self.tokens) {
3183 TokenMap::Fst(map) => map,
3184 TokenMap::HashMap(map) => Self::build_fst_from_map(map)?,
3185 };
3186 let bytes = fst_map.into_fst().into_inner();
3187
3188 let mut fst_builder = LargeBinaryBuilder::with_capacity(1, bytes.len());
3189 fst_builder.append_value(bytes);
3190 let fst_col = fst_builder.finish();
3191
3192 let mut next_id_builder = UInt32Builder::with_capacity(1);
3193 next_id_builder.append_value(self.next_id);
3194 let next_id_col = next_id_builder.finish();
3195
3196 let mut total_length_builder = UInt64Builder::with_capacity(1);
3197 total_length_builder.append_value(self.total_length as u64);
3198 let total_length_col = total_length_builder.finish();
3199
3200 let schema = arrow_schema::Schema::new(vec![
3201 arrow_schema::Field::new(TOKEN_FST_BYTES_COL, DataType::LargeBinary, false),
3202 arrow_schema::Field::new(TOKEN_NEXT_ID_COL, DataType::UInt32, false),
3203 arrow_schema::Field::new(TOKEN_TOTAL_LENGTH_COL, DataType::UInt64, false),
3204 ]);
3205
3206 let batch = RecordBatch::try_new(
3207 Arc::new(schema),
3208 vec![
3209 Arc::new(fst_col) as ArrayRef,
3210 Arc::new(next_id_col) as ArrayRef,
3211 Arc::new(total_length_col) as ArrayRef,
3212 ],
3213 )?;
3214 Ok(batch)
3215 }
3216
3217 fn build_fst_from_map(map: HashMap<String, u32>) -> Result<fst::Map<Vec<u8>>> {
3218 let mut entries: Vec<_> = map.into_iter().collect();
3219 entries.sort_unstable_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs));
3220 let mut builder = fst::MapBuilder::memory();
3221 for (token, token_id) in entries {
3222 builder
3223 .insert(&token, token_id as u64)
3224 .map_err(|e| Error::index(format!("failed to insert token {}: {}", token, e)))?;
3225 }
3226 Ok(builder.into_map())
3227 }
3228
3229 pub async fn load(reader: Arc<dyn IndexReader>, format: TokenSetFormat) -> Result<Self> {
3230 match format {
3231 TokenSetFormat::Arrow => Self::load_arrow(reader).await,
3232 TokenSetFormat::Fst => Self::load_fst(reader).await,
3233 }
3234 }
3235
3236 async fn load_arrow(reader: Arc<dyn IndexReader>) -> Result<Self> {
3237 let batch = reader.read_range(0..reader.num_rows(), None).await?;
3238
3239 let (tokens, next_id, total_length) = spawn_blocking(move || {
3240 let mut next_id = 0;
3241 let mut total_length = 0;
3242 let mut tokens = fst::MapBuilder::memory();
3243
3244 let token_col = batch[TOKEN_COL].as_string::<i32>();
3245 let token_id_col = batch[TOKEN_ID_COL].as_primitive::<datatypes::UInt32Type>();
3246
3247 for (token, &token_id) in token_col.iter().zip(token_id_col.values().iter()) {
3248 let token =
3249 token.ok_or(Error::index("found null token in token set".to_owned()))?;
3250 next_id = next_id.max(token_id + 1);
3251 total_length += token.len();
3252 tokens.insert(token, token_id as u64).map_err(|e| {
3253 Error::index(format!("failed to insert token {}: {}", token, e))
3254 })?;
3255 }
3256
3257 Ok::<_, Error>((tokens.into_map(), next_id, total_length))
3258 })
3259 .await
3260 .map_err(|err| Error::execution(format!("failed to spawn blocking task: {}", err)))??;
3261
3262 Ok(Self {
3263 tokens: TokenMap::Fst(tokens),
3264 next_id,
3265 total_length,
3266 })
3267 }
3268
3269 async fn load_fst(reader: Arc<dyn IndexReader>) -> Result<Self> {
3270 let batch = reader.read_range(0..reader.num_rows(), None).await?;
3271 if batch.num_rows() == 0 {
3272 return Err(Error::index("token set batch is empty".to_owned()));
3273 }
3274
3275 let fst_col = batch[TOKEN_FST_BYTES_COL].as_binary::<i64>();
3276 let bytes = fst_col.value(0);
3277 let map = fst::Map::new(bytes.to_vec())
3278 .map_err(|e| Error::index(format!("failed to load fst tokens: {}", e)))?;
3279
3280 let total_length_col =
3281 batch[TOKEN_TOTAL_LENGTH_COL].as_primitive::<datatypes::UInt64Type>();
3282
3283 let next_id = map.len() as u32;
3287
3288 let total_length = total_length_col
3289 .values()
3290 .first()
3291 .copied()
3292 .ok_or(Error::index(
3293 "token total length column is empty".to_owned(),
3294 ))?;
3295
3296 Ok(Self {
3297 tokens: TokenMap::Fst(map),
3298 next_id,
3299 total_length: usize::try_from(total_length).map_err(|_| {
3300 Error::index(format!(
3301 "token total length {} overflows usize",
3302 total_length
3303 ))
3304 })?,
3305 })
3306 }
3307
3308 pub fn add(&mut self, token: String) -> u32 {
3309 let next_id = self.next_id();
3310 let len = token.len();
3311 let token_id = match self.tokens {
3312 TokenMap::HashMap(ref mut map) => *map.entry(token).or_insert(next_id),
3313 _ => unreachable!("tokens must be HashMap while indexing"),
3314 };
3315
3316 if token_id == next_id {
3318 self.next_id += 1;
3319 self.total_length += len;
3320 }
3321
3322 token_id
3323 }
3324
3325 pub(crate) fn get_or_add(&mut self, token: &str) -> u32 {
3326 let next_id = self.next_id;
3327 match self.tokens {
3328 TokenMap::HashMap(ref mut map) => {
3329 if let Some(&token_id) = map.get(token) {
3330 return token_id;
3331 }
3332
3333 map.insert(token.to_owned(), next_id);
3334 }
3335 _ => unreachable!("tokens must be HashMap while indexing"),
3336 }
3337
3338 self.next_id += 1;
3339 self.total_length += token.len();
3340 next_id
3341 }
3342
3343 pub(crate) fn into_mutable(self) -> Self {
3344 let Self {
3345 tokens,
3346 next_id,
3347 total_length,
3348 } = self;
3349 match tokens {
3350 TokenMap::HashMap(_) => Self {
3351 tokens,
3352 next_id,
3353 total_length,
3354 },
3355 TokenMap::Fst(map) => {
3356 let mut mutable = HashMap::new();
3357 let mut stream = map.stream();
3358 while let Some((token, token_id)) = stream.next() {
3359 mutable.insert(String::from_utf8_lossy(token).into_owned(), token_id as u32);
3360 }
3361 Self {
3362 tokens: TokenMap::HashMap(mutable),
3363 next_id,
3364 total_length,
3365 }
3366 }
3367 }
3368 }
3369
3370 pub fn get(&self, token: &str) -> Option<u32> {
3371 match self.tokens {
3372 TokenMap::HashMap(ref map) => map.get(token).copied(),
3373 TokenMap::Fst(ref map) => map.get(token).map(|id| id as u32),
3374 }
3375 }
3376
3377 pub fn remap(&mut self, removed_token_ids: &[u32]) {
3379 if removed_token_ids.is_empty() {
3380 return;
3381 }
3382
3383 let mut map = match std::mem::take(&mut self.tokens) {
3384 TokenMap::HashMap(map) => map,
3385 TokenMap::Fst(map) => {
3386 let mut new_map = HashMap::with_capacity(map.len());
3387 let mut stream = map.into_stream();
3388 while let Some((token, token_id)) = stream.next() {
3389 new_map.insert(String::from_utf8_lossy(token).into_owned(), token_id as u32);
3390 }
3391
3392 new_map
3393 }
3394 };
3395
3396 let mut retained_length = 0;
3397 map.retain(
3398 |token, token_id| match removed_token_ids.binary_search(token_id) {
3399 Ok(_) => false,
3400 Err(index) => {
3401 *token_id -= index as u32;
3402 retained_length += token.len();
3403 true
3404 }
3405 },
3406 );
3407
3408 self.tokens = TokenMap::HashMap(map);
3409
3410 self.next_id = self.tokens.len() as u32;
3415 self.total_length = retained_length;
3416 }
3417
3418 pub fn next_id(&self) -> u32 {
3419 self.next_id
3420 }
3421
3422 pub(crate) fn memory_size(&self) -> usize {
3423 match &self.tokens {
3424 TokenMap::HashMap(map) => {
3425 self.total_length
3426 + map.capacity()
3427 * (std::mem::size_of::<String>()
3428 + std::mem::size_of::<u32>()
3429 + std::mem::size_of::<usize>())
3430 }
3431 TokenMap::Fst(map) => map.as_fst().size(),
3432 }
3433 }
3434}
3435
3436pub struct PostingListReader {
3437 reader: Arc<dyn IndexReader>,
3438
3439 metadata: PostingMetadata,
3443
3444 has_position: bool,
3445 has_impacts: bool,
3446 posting_tail_codec: PostingTailCodec,
3447 block_size: usize,
3448 positions_layout: PositionsLayout,
3449
3450 grouping: PostingGrouping,
3454
3455 modern_doc_id_validations: Option<Arc<[OnceCell<()>]>>,
3459 modern_postings_validated: AtomicBool,
3461 modern_num_docs: Option<usize>,
3462
3463 index_cache: WeakLanceCache,
3464}
3465
3466enum PostingMetadata {
3472 LegacyV1 {
3475 offsets: Vec<usize>,
3476 max_scores: Option<Vec<f32>>,
3477 },
3478 V2 {
3483 metadata: OnceCell<LoadedPostingMetadata>,
3484 },
3485}
3486
3487#[derive(Debug, Clone)]
3488struct LoadedPostingMetadata {
3489 max_scores: Vec<f32>,
3490 lengths: Vec<u32>,
3491}
3492
3493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3494enum PositionsLayout {
3495 None,
3496 LegacyPerDoc,
3497 SharedStream(PositionStreamCodec),
3498}
3499
3500impl std::fmt::Debug for PostingListReader {
3501 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3502 let mut s = f.debug_struct("InvertedListReader");
3503 match &self.metadata {
3504 PostingMetadata::LegacyV1 {
3505 offsets,
3506 max_scores,
3507 } => {
3508 s.field("layout", &"legacy_v1")
3509 .field("offsets", offsets)
3510 .field("max_scores", max_scores);
3511 }
3512 PostingMetadata::V2 { metadata } => {
3513 s.field("layout", &"v2")
3514 .field("metadata_loaded", &metadata.initialized());
3515 }
3516 }
3517 s.finish()
3518 }
3519}
3520
3521impl DeepSizeOf for PostingListReader {
3522 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
3523 let metadata_size = match &self.metadata {
3524 PostingMetadata::LegacyV1 {
3525 offsets,
3526 max_scores,
3527 } => offsets.deep_size_of_children(context) + max_scores.deep_size_of_children(context),
3528 PostingMetadata::V2 { metadata } => metadata
3529 .get()
3530 .map(|loaded| {
3531 loaded.max_scores.deep_size_of_children(context)
3532 + loaded.lengths.deep_size_of_children(context)
3533 })
3534 .unwrap_or(0),
3535 };
3536 let validation_size = self
3537 .modern_doc_id_validations
3538 .as_ref()
3539 .map(|validations| {
3540 validations
3541 .len()
3542 .saturating_mul(std::mem::size_of::<OnceCell<()>>())
3543 })
3544 .unwrap_or(0);
3545 metadata_size + self.grouping.deep_size_of_children(context) + validation_size
3546 }
3547}
3548
3549impl PostingListReader {
3550 pub(crate) async fn try_new(
3551 reader: Arc<dyn IndexReader>,
3552 index_cache: &LanceCache,
3553 ) -> Result<Self> {
3554 let positions_layout = if reader.schema().field(COMPRESSED_POSITION_COL).is_some() {
3555 PositionsLayout::SharedStream(parse_shared_position_codec(&reader.schema().metadata)?)
3556 } else if reader.schema().field(POSITION_COL).is_some() {
3557 PositionsLayout::LegacyPerDoc
3558 } else {
3559 PositionsLayout::None
3560 };
3561 let posting_tail_codec = parse_posting_tail_codec(&reader.schema().metadata)?;
3562 let block_size = parse_posting_block_size(&reader.schema().metadata)?;
3563 let has_position = positions_layout != PositionsLayout::None;
3564 let has_impacts = reader.schema().field(IMPACT_COL).is_some();
3565 let metadata = if reader.schema().field(POSTING_COL).is_none() {
3566 let (offsets, max_scores) = Self::load_metadata(reader.schema())?;
3567 PostingMetadata::LegacyV1 {
3568 offsets,
3569 max_scores,
3570 }
3571 } else {
3572 PostingMetadata::V2 {
3573 metadata: OnceCell::new(),
3574 }
3575 };
3576
3577 let is_legacy_layout = matches!(&metadata, PostingMetadata::LegacyV1 { .. });
3578 let grouping = PostingGrouping::for_reader(is_legacy_layout, reader.num_rows());
3579 let modern_doc_id_validations = (!is_legacy_layout).then(|| {
3580 (0..reader.num_rows())
3581 .map(|_| OnceCell::new())
3582 .collect::<Vec<_>>()
3583 .into()
3584 });
3585
3586 Ok(Self {
3587 reader,
3588 metadata,
3589 has_position,
3590 has_impacts,
3591 posting_tail_codec,
3592 block_size,
3593 positions_layout,
3594 grouping,
3595 modern_doc_id_validations,
3596 modern_postings_validated: AtomicBool::new(false),
3597 modern_num_docs: None,
3598 index_cache: WeakLanceCache::from(index_cache),
3599 })
3600 }
3601
3602 fn load_metadata(
3605 schema: &lance_core::datatypes::Schema,
3606 ) -> Result<(Vec<usize>, Option<Vec<f32>>)> {
3607 let offsets = schema
3608 .metadata
3609 .get("offsets")
3610 .ok_or(Error::index("offsets not found in metadata".to_owned()))?;
3611 let offsets = serde_json::from_str(offsets)?;
3612
3613 let max_scores = schema
3614 .metadata
3615 .get("max_scores")
3616 .map(|max_scores| serde_json::from_str(max_scores))
3617 .transpose()?;
3618 Ok((offsets, max_scores))
3619 }
3620
3621 pub fn len(&self) -> usize {
3623 match &self.metadata {
3624 PostingMetadata::LegacyV1 { offsets, .. } => offsets.len(),
3625 PostingMetadata::V2 { .. } => self.reader.num_rows(),
3626 }
3627 }
3628
3629 pub fn is_empty(&self) -> bool {
3630 self.len() == 0
3631 }
3632
3633 pub(crate) fn has_positions(&self) -> bool {
3634 self.has_position
3635 }
3636
3637 pub(crate) fn posting_tail_codec(&self) -> PostingTailCodec {
3638 self.posting_tail_codec
3639 }
3640
3641 pub(crate) fn block_size(&self) -> usize {
3642 self.block_size
3643 }
3644
3645 fn is_legacy_layout(&self) -> bool {
3646 matches!(self.metadata, PostingMetadata::LegacyV1 { .. })
3647 }
3648
3649 pub(crate) fn posting_len(&self, token_id: u32) -> usize {
3654 let token_id = token_id as usize;
3655 match &self.metadata {
3656 PostingMetadata::LegacyV1 { offsets, .. } => {
3657 let next_offset = offsets
3658 .get(token_id + 1)
3659 .copied()
3660 .unwrap_or(self.reader.num_rows());
3661 next_offset - offsets[token_id]
3662 }
3663 PostingMetadata::V2 { metadata } => {
3664 let metadata = metadata
3665 .get()
3666 .expect("v2 posting metadata must be bulk-loaded before sync posting_len; call ensure_metadata_loaded first");
3667 metadata.lengths[token_id] as usize
3668 }
3669 }
3670 }
3671
3672 pub(crate) async fn posting_len_for_token(
3678 &self,
3679 token_id: u32,
3680 metrics: Option<&dyn MetricsCollector>,
3681 ) -> Result<usize> {
3682 match &self.metadata {
3683 PostingMetadata::LegacyV1 { .. } => Ok(self.posting_len(token_id)),
3684 PostingMetadata::V2 { metadata } => {
3685 if let Some(metadata) = metadata.get() {
3686 return Ok(metadata.lengths[token_id as usize] as usize);
3687 }
3688 let (_, length) = self.posting_metadata_for_token(token_id, metrics).await?;
3689 length
3690 .map(|len| len as usize)
3691 .ok_or_else(|| Error::index("posting length metadata missing".to_string()))
3692 }
3693 }
3694 }
3695
3696 pub(crate) async fn posting_metadata_for_token(
3703 &self,
3704 token_id: u32,
3705 metrics: Option<&dyn MetricsCollector>,
3706 ) -> Result<(Option<f32>, Option<u32>)> {
3707 match &self.metadata {
3708 PostingMetadata::LegacyV1 { max_scores, .. } => {
3709 Ok((max_scores.as_ref().map(|m| m[token_id as usize]), None))
3710 }
3711 PostingMetadata::V2 { metadata } => {
3712 if let Some(loaded) = metadata.get() {
3713 return Ok((
3714 Some(loaded.max_scores[token_id as usize]),
3715 Some(loaded.lengths[token_id as usize]),
3716 ));
3717 }
3718 let result = self
3719 .index_cache
3720 .get_or_insert_with_key_hit(PostingMetadataKey { token_id }, || async move {
3721 let token_id = token_id as usize;
3722 let batch = self
3723 .reader
3724 .read_range(token_id..token_id + 1, Some(&[MAX_SCORE_COL, LENGTH_COL]))
3725 .await?;
3726 let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
3727 let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
3728 Ok(PostingMetadataValue { max_score, length })
3729 })
3730 .await;
3731 if let Some(metrics) = metrics {
3732 match &result {
3733 Ok((_, true)) => metrics.record_index_cache_hit(),
3734 _ => metrics.record_index_cache_miss(),
3735 }
3736 }
3737 let metadata = result.map(|(value, _)| value)?;
3738 Ok((Some(metadata.max_score), Some(metadata.length)))
3739 }
3740 }
3741 }
3742
3743 pub(crate) async fn ensure_metadata_loaded(&self) -> Result<()> {
3747 let PostingMetadata::V2 { metadata } = &self.metadata else {
3748 return Ok(());
3749 };
3750 metadata
3751 .get_or_try_init(|| async {
3752 let batch = self
3753 .reader
3754 .read_range(
3755 0..self.reader.num_rows(),
3756 Some(&[MAX_SCORE_COL, LENGTH_COL]),
3757 )
3758 .await?;
3759 let max_scores = batch[MAX_SCORE_COL]
3760 .as_primitive::<Float32Type>()
3761 .values()
3762 .to_vec();
3763 let lengths = batch[LENGTH_COL]
3764 .as_primitive::<UInt32Type>()
3765 .values()
3766 .to_vec();
3767 Ok::<LoadedPostingMetadata, Error>(LoadedPostingMetadata {
3768 max_scores,
3769 lengths,
3770 })
3771 })
3772 .await?;
3773 Ok(())
3774 }
3775
3776 pub(crate) async fn posting_batch(
3777 &self,
3778 token_id: u32,
3779 with_position: bool,
3780 ) -> Result<RecordBatch> {
3781 if self.is_legacy_layout() {
3782 self.posting_batch_legacy(token_id, with_position).await
3783 } else {
3784 let token_id = token_id as usize;
3785 let mut columns = if with_position {
3786 match self.positions_layout {
3787 PositionsLayout::SharedStream(_) => {
3788 vec![
3789 POSTING_COL,
3790 COMPRESSED_POSITION_COL,
3791 POSITION_BLOCK_OFFSET_COL,
3792 ]
3793 }
3794 PositionsLayout::LegacyPerDoc => vec![POSTING_COL, POSITION_COL],
3795 PositionsLayout::None => vec![POSTING_COL],
3796 }
3797 } else {
3798 vec![POSTING_COL]
3799 };
3800 if self.has_impacts {
3801 columns.push(IMPACT_COL);
3802 }
3803 let batch = self
3804 .reader
3805 .read_range(token_id..token_id + 1, Some(&columns))
3806 .await?;
3807 Ok(batch)
3808 }
3809 }
3810
3811 async fn posting_batch_legacy(
3812 &self,
3813 token_id: u32,
3814 with_position: bool,
3815 ) -> Result<RecordBatch> {
3816 let mut columns = vec![ROW_ID, FREQUENCY_COL];
3817 if with_position {
3818 columns.push(POSITION_COL);
3819 }
3820
3821 let length = self.posting_len(token_id);
3822 let PostingMetadata::LegacyV1 { offsets, .. } = &self.metadata else {
3823 unreachable!("posting_batch_legacy is only reachable on legacy v1 layout");
3824 };
3825 let token_id = token_id as usize;
3826 let offset = offsets[token_id];
3827 let batch = self
3828 .reader
3829 .read_range(offset..offset + length, Some(&columns))
3830 .await?;
3831 Ok(batch)
3832 }
3833
3834 #[instrument(level = "debug", skip(self, metrics))]
3835 pub(crate) async fn posting_list(
3836 &self,
3837 token_id: u32,
3838 is_phrase_query: bool,
3839 metrics: &dyn MetricsCollector,
3840 ) -> Result<PostingList> {
3841 let mut posting = match self.group_range_for_token(token_id) {
3842 Some((start, end)) => {
3845 let result = self
3846 .index_cache
3847 .get_or_insert_with_key_hit(
3848 posting_list_group_cache_key(start, end, self.has_impacts),
3849 || async move {
3850 metrics.record_part_load();
3851 info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_SCALAR_PART, index_type="inverted", part_id=start);
3852 self.load_posting_list_group(start, end).await
3853 },
3854 )
3855 .await;
3856 match &result {
3857 Ok((_, true)) => metrics.record_index_cache_hit(),
3858 _ => metrics.record_index_cache_miss(),
3859 }
3860 let (group, _) = result?;
3861 let (max_score, length) = if group.needs_external_metadata() {
3862 self.posting_metadata_for_token(token_id, Some(metrics))
3863 .await?
3864 } else {
3865 (None, None)
3866 };
3867 let slot = (token_id - start) as usize;
3868 group
3869 .posting_list(slot, max_score, length)?
3870 .ok_or_else(|| {
3871 Error::index(format!(
3872 "token {token_id} maps to slot {slot} outside posting group [{start}, {end})"
3873 ))
3874 })?
3875 }
3876 None => {
3879 let result = self
3880 .index_cache
3881 .get_or_insert_with_key_hit(
3882 posting_list_cache_key(token_id, self.has_impacts),
3883 || async move {
3884 metrics.record_part_load();
3885 info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_SCALAR_PART, index_type="inverted", part_id=token_id);
3886 let (batch, (max_score, length)) = futures::try_join!(
3891 self.posting_batch(token_id, false),
3892 self.posting_metadata_for_token(token_id, Some(metrics)),
3893 )?;
3894 self.posting_list_from_batch(&batch, max_score, length)
3895 },
3896 )
3897 .await;
3898 match &result {
3899 Ok((_, true)) => metrics.record_index_cache_hit(),
3900 _ => metrics.record_index_cache_miss(),
3901 }
3902 result?.0.as_ref().clone()
3903 }
3904 };
3905
3906 if !self.modern_posting_is_validated(token_id)? {
3907 self.ensure_modern_posting_validated(token_id, &posting)
3908 .await?;
3909 }
3910
3911 if is_phrase_query && !posting.has_position() {
3912 let positions = self.read_positions(token_id, metrics).await?;
3914 posting.set_positions(positions);
3915 }
3916
3917 Ok(posting)
3918 }
3919
3920 async fn ensure_modern_posting_validated(
3921 &self,
3922 token_id: u32,
3923 posting: &PostingList,
3924 ) -> Result<()> {
3925 let (Some(validations), Some(num_docs)) =
3926 (&self.modern_doc_id_validations, self.modern_num_docs)
3927 else {
3928 return Ok(());
3929 };
3930 let validation = validations.get(token_id as usize).ok_or_else(|| {
3931 Error::index(format!(
3932 "modern FTS token id {token_id} is outside validation state [0, {})",
3933 validations.len()
3934 ))
3935 })?;
3936 validation
3937 .get_or_try_init(|| async {
3938 Self::validate_modern_posting(token_id, posting, num_docs)
3939 })
3940 .await
3941 .map(|_| ())
3942 }
3943
3944 #[inline]
3945 fn modern_posting_is_validated(&self, token_id: u32) -> Result<bool> {
3946 if self.modern_postings_validated.load(Ordering::Acquire) {
3947 return Ok(true);
3948 }
3949 let (Some(validations), Some(_)) = (&self.modern_doc_id_validations, self.modern_num_docs)
3950 else {
3951 return Ok(true);
3952 };
3953 let validation = validations.get(token_id as usize).ok_or_else(|| {
3954 Error::index(format!(
3955 "modern FTS token id {token_id} is outside validation state [0, {})",
3956 validations.len()
3957 ))
3958 })?;
3959 Ok(validation.get().is_some())
3960 }
3961
3962 fn validate_modern_posting(
3963 token_id: u32,
3964 posting: &PostingList,
3965 num_docs: usize,
3966 ) -> Result<()> {
3967 validate_modern_posting_doc_ids(posting, &format!("token id {token_id}"), num_docs)
3968 }
3969
3970 async fn publish_modern_posting_validated(&self, token_id: u32) -> Result<()> {
3971 let Some(validations) = &self.modern_doc_id_validations else {
3972 return Ok(());
3973 };
3974 let validation = validations.get(token_id as usize).ok_or_else(|| {
3975 Error::index(format!(
3976 "modern FTS token id {token_id} is outside validation state [0, {})",
3977 validations.len()
3978 ))
3979 })?;
3980 validation
3981 .get_or_try_init(|| async { Result::Ok(()) })
3982 .await
3983 .map(|_| ())
3984 }
3985
3986 fn modern_posting_validation_ready(&self) -> bool {
3987 if self.modern_postings_validated.load(Ordering::Acquire) {
3988 return true;
3989 }
3990 let ready = self
3991 .modern_doc_id_validations
3992 .as_ref()
3993 .is_none_or(|validations| validations.iter().all(|state| state.get().is_some()));
3994 if ready {
3995 self.modern_postings_validated
3996 .store(true, Ordering::Release);
3997 }
3998 ready
3999 }
4000
4001 fn group_range_for_token(&self, token_id: u32) -> Option<(u32, u32)> {
4006 self.grouping.range_for_token(token_id, self.len())
4007 }
4008
4009 async fn load_posting_list_group(&self, start: u32, end: u32) -> Result<PostingListGroup> {
4013 let mut columns = vec![POSTING_COL, MAX_SCORE_COL, LENGTH_COL];
4014 if self.has_impacts {
4015 columns.push(IMPACT_COL);
4016 }
4017 let batch = self
4018 .reader
4019 .read_range(start as usize..end as usize, Some(&columns))
4020 .await?;
4021 PostingListGroup::new_packed_with_block_size(
4022 batch.shrink_to_fit()?,
4023 self.posting_tail_codec,
4024 self.block_size,
4025 )
4026 }
4027
4028 fn posting_list_from_batch_parts(
4029 batch: &RecordBatch,
4030 max_score: Option<f32>,
4031 length: Option<u32>,
4032 posting_tail_codec: PostingTailCodec,
4033 block_size: usize,
4034 positions_layout: PositionsLayout,
4035 ) -> Result<PostingList> {
4036 let posting_list = PostingList::from_batch_with_tail_codec_and_positions_layout(
4037 batch,
4038 max_score,
4039 length,
4040 posting_tail_codec,
4041 block_size,
4042 positions_layout,
4043 )?;
4044 Ok(posting_list)
4045 }
4046
4047 pub(crate) fn posting_list_from_batch(
4048 &self,
4049 batch: &RecordBatch,
4050 max_score: Option<f32>,
4051 length: Option<u32>,
4052 ) -> Result<PostingList> {
4053 Self::posting_list_from_batch_parts(
4054 batch,
4055 max_score,
4056 length,
4057 self.posting_tail_codec,
4058 self.block_size,
4059 self.positions_layout,
4060 )
4061 }
4062
4063 fn build_prewarm_posting_lists_chunk(
4067 chunk_batch: RecordBatch,
4068 chunk: PrewarmChunk<'_>,
4069 ctx: &PrewarmBuildCtx<'_>,
4070 ) -> Result<Vec<(u32, PostingList)>> {
4071 let mut posting_lists = Vec::with_capacity(chunk.token_count);
4072 for local in 0..chunk.token_count {
4073 let global = chunk.tok_start + local;
4074 let row_batch = if let Some(chunk_offsets) = chunk.offsets {
4075 let base = chunk_offsets[0];
4078 let start = chunk_offsets[local] - base;
4079 let end = if local + 1 < chunk_offsets.len() {
4080 chunk_offsets[local + 1] - base
4081 } else {
4082 chunk.end_row - base
4083 };
4084 chunk_batch.slice(start, end - start)
4085 } else {
4086 chunk_batch.slice(local, 1)
4088 };
4089 let row_batch = row_batch.shrink_to_fit()?;
4090 let posting_list = Self::posting_list_from_batch_parts(
4091 &row_batch,
4092 ctx.max_scores.map(|scores| scores[global]),
4093 ctx.lengths.map(|lengths| lengths[global]),
4094 ctx.posting_tail_codec,
4095 ctx.block_size,
4096 ctx.positions_layout,
4097 )?;
4098 posting_lists.push((global as u32, posting_list));
4099 }
4100
4101 Ok(posting_lists)
4102 }
4103
4104 async fn read_chunk_batch(
4107 &self,
4108 tok_start: usize,
4109 tok_end: usize,
4110 with_position: bool,
4111 ) -> Result<RecordBatch> {
4112 let columns = self.posting_columns(with_position);
4113 let row_range = match &self.metadata {
4114 PostingMetadata::LegacyV1 { offsets, .. } => {
4115 let start = offsets[tok_start];
4116 let end = offsets
4117 .get(tok_end)
4118 .copied()
4119 .unwrap_or_else(|| self.reader.num_rows());
4120 start..end
4121 }
4122 PostingMetadata::V2 { .. } => tok_start..tok_end,
4123 };
4124 let batch = self.reader.read_range(row_range, Some(&columns)).await?;
4125 Ok(batch)
4126 }
4127
4128 async fn prewarm_posting_lists(
4129 &self,
4130 with_position: bool,
4131 chunk_concurrency: usize,
4132 ) -> Result<()> {
4133 self.prewarm_posting_lists_chunked(with_position, None, chunk_concurrency)
4134 .await?;
4135 Ok(())
4136 }
4137
4138 async fn prewarm_posting_lists_chunked(
4142 &self,
4143 with_position: bool,
4144 chunk_tokens_override: Option<usize>,
4145 chunk_concurrency: usize,
4146 ) -> Result<usize> {
4147 if with_position && !self.has_positions() {
4148 return Err(Error::invalid_input(
4149 "cannot prewarm positions for an inverted index that was built without positions; recreate the index with with_position=true".to_owned(),
4150 ));
4151 }
4152
4153 self.ensure_metadata_loaded().await?;
4156
4157 let grouping = self.grouping.clone();
4161 let use_packed_groups = grouping.is_grouped() && !with_position;
4162 let state = (!use_packed_groups).then(|| self.chunk_build_state());
4166 let token_count = self.len();
4167 let posting_data_size_bytes = self.posting_data_size_bytes();
4168 let chunk_tokens = chunk_tokens_override
4169 .unwrap_or_else(|| prewarm_chunk_tokens(token_count, posting_data_size_bytes))
4170 .max(1);
4171 let chunk_ranges = prewarm_chunk_ranges(&grouping, token_count, chunk_tokens);
4172 let chunk_count = chunk_ranges.len();
4173 let chunk_concurrency = chunk_concurrency.max(1);
4174
4175 let read_build_start = Instant::now();
4176 stream::iter(chunk_ranges)
4177 .map(|(tok_start, tok_end)| {
4178 let state = state.as_ref();
4179 let grouping = &grouping;
4180 async move {
4181 if use_packed_groups {
4182 let groups = self
4183 .build_packed_chunk_groups(tok_start, tok_end, token_count, grouping)
4184 .await?;
4185 for (start, end, group) in groups {
4186 self.index_cache
4187 .insert_with_key(
4188 &posting_list_group_cache_key(start, end, self.has_impacts),
4189 Arc::new(group),
4190 )
4191 .await;
4192 }
4193 } else {
4194 let state = state.expect(
4195 "materialized prewarm must initialize posting-list build state",
4196 );
4197 let posting_lists = self
4198 .build_chunk_postings(tok_start, tok_end, with_position, state)
4199 .await?;
4200 self.publish_chunk_postings(
4201 posting_lists,
4202 grouping,
4203 tok_start,
4204 tok_end,
4205 token_count,
4206 with_position,
4207 )
4208 .await;
4209 }
4210 Result::Ok(())
4211 }
4212 })
4213 .buffer_unordered(chunk_concurrency)
4214 .try_collect::<()>()
4215 .await?;
4216 let read_build_elapsed = read_build_start.elapsed();
4217
4218 info!(
4219 legacy_layout = self.is_legacy_layout(),
4220 with_position,
4221 token_count,
4222 chunk_count,
4223 chunk_tokens,
4224 chunk_concurrency,
4225 posting_data_size_bytes,
4226 read_build_ms = read_build_elapsed.as_secs_f64() * 1000.0,
4227 "posting list prewarm timing"
4228 );
4229
4230 Ok(chunk_count)
4231 }
4232
4233 fn chunk_build_state(&self) -> ChunkBuildState {
4236 let (offsets, max_scores, lengths) = match &self.metadata {
4237 PostingMetadata::LegacyV1 {
4238 offsets,
4239 max_scores,
4240 } => (Some(offsets.clone()), max_scores.clone(), None),
4241 PostingMetadata::V2 { metadata } => (
4242 None,
4243 metadata.get().map(|loaded| loaded.max_scores.clone()),
4244 metadata.get().map(|loaded| loaded.lengths.clone()),
4245 ),
4246 };
4247 ChunkBuildState {
4248 offsets: offsets.map(Arc::new),
4249 max_scores: max_scores.map(Arc::new),
4250 lengths: lengths.map(Arc::new),
4251 posting_tail_codec: self.posting_tail_codec,
4252 block_size: self.block_size,
4253 positions_layout: self.positions_layout,
4254 }
4255 }
4256
4257 async fn build_chunk_postings(
4261 &self,
4262 tok_start: usize,
4263 tok_end: usize,
4264 with_position: bool,
4265 state: &ChunkBuildState,
4266 ) -> Result<Vec<(u32, PostingList)>> {
4267 let chunk_token_count = tok_end - tok_start;
4268 let chunk_batch = self
4269 .read_chunk_batch(tok_start, tok_end, with_position)
4270 .await?;
4271
4272 let (chunk_offsets, chunk_end_row) = match state.offsets.as_ref() {
4273 Some(offsets) => {
4274 let end_row = offsets
4275 .get(tok_end)
4276 .copied()
4277 .unwrap_or_else(|| self.reader.num_rows());
4278 (Some(offsets[tok_start..tok_end].to_vec()), end_row)
4279 }
4280 None => (None, tok_end),
4282 };
4283 let max_scores = state.max_scores.clone();
4284 let lengths = state.lengths.clone();
4285 let posting_tail_codec = state.posting_tail_codec;
4286 let block_size = state.block_size;
4287 let positions_layout = state.positions_layout;
4288 let num_docs = self.modern_num_docs;
4289 let posting_lists = spawn_blocking(move || {
4290 let ctx = PrewarmBuildCtx {
4291 max_scores: max_scores.as_deref().map(|v| v.as_slice()),
4292 lengths: lengths.as_deref().map(|v| v.as_slice()),
4293 posting_tail_codec,
4294 block_size,
4295 positions_layout,
4296 };
4297 let chunk = PrewarmChunk {
4298 tok_start,
4299 token_count: chunk_token_count,
4300 offsets: chunk_offsets.as_deref(),
4301 end_row: chunk_end_row,
4302 };
4303 let posting_lists = Self::build_prewarm_posting_lists_chunk(chunk_batch, chunk, &ctx)?;
4304 if let Some(num_docs) = num_docs {
4305 for (token_id, posting) in &posting_lists {
4306 Self::validate_modern_posting(*token_id, posting, num_docs)?;
4307 }
4308 }
4309 Result::Ok(posting_lists)
4310 })
4311 .await
4312 .map_err(|err| {
4313 Error::internal(format!(
4314 "Failed to build prewarm posting lists in blocking task: {err}"
4315 ))
4316 })??;
4317 for (token_id, _) in &posting_lists {
4318 self.publish_modern_posting_validated(*token_id).await?;
4319 }
4320 debug_assert_eq!(posting_lists.len(), chunk_token_count);
4323 debug_assert!(
4324 posting_lists
4325 .iter()
4326 .enumerate()
4327 .all(|(i, (token_id, _))| *token_id as usize == tok_start + i)
4328 );
4329 Ok(posting_lists)
4330 }
4331
4332 async fn build_packed_chunk_groups(
4336 &self,
4337 tok_start: usize,
4338 tok_end: usize,
4339 token_count: usize,
4340 grouping: &PostingGrouping,
4341 ) -> Result<Vec<(u32, u32, PostingListGroup)>> {
4342 debug_assert!(grouping.is_grouped());
4343 debug_assert!(!self.is_legacy_layout());
4344
4345 let chunk_batch = self.read_chunk_batch(tok_start, tok_end, false).await?;
4346 let ranges = grouping.ranges_for_chunk(tok_start, tok_end, token_count);
4347 let posting_tail_codec = self.posting_tail_codec;
4348 let block_size = self.block_size;
4349 let num_docs = self.modern_num_docs;
4350 let (chunk_max_scores, chunk_lengths) = match &self.metadata {
4351 PostingMetadata::V2 { metadata } => {
4352 let loaded = metadata.get().ok_or_else(|| {
4353 Error::internal("packed prewarm requires loaded posting metadata".to_owned())
4354 })?;
4355 (
4356 loaded.max_scores[tok_start..tok_end].to_vec(),
4357 loaded.lengths[tok_start..tok_end].to_vec(),
4358 )
4359 }
4360 PostingMetadata::LegacyV1 { .. } => {
4361 return Err(Error::internal(
4362 "packed prewarm is not supported for legacy posting metadata".to_owned(),
4363 ));
4364 }
4365 };
4366
4367 let groups = spawn_blocking(move || {
4368 let mut groups = Vec::with_capacity(ranges.len());
4369 for (start, end) in ranges {
4370 let start_usize = start as usize;
4371 let end_usize = end as usize;
4372 let local_start = start_usize - tok_start;
4373 let group_len = end_usize - start_usize;
4374 let group_batch = chunk_batch.slice(local_start, group_len).shrink_to_fit()?;
4375 let group = PostingListGroup::new_packed_with_block_size(
4376 group_batch,
4377 posting_tail_codec,
4378 block_size,
4379 )?;
4380 if let Some(num_docs) = num_docs {
4381 for token_id in start..end {
4382 let chunk_slot = token_id as usize - tok_start;
4383 let posting = group
4384 .posting_list(
4385 (token_id - start) as usize,
4386 Some(chunk_max_scores[chunk_slot]),
4387 Some(chunk_lengths[chunk_slot]),
4388 )?
4389 .ok_or_else(|| {
4390 Error::index(format!(
4391 "token {token_id} is missing from prewarm posting group [{start}, {end})"
4392 ))
4393 })?;
4394 Self::validate_modern_posting(token_id, &posting, num_docs)?;
4395 }
4396 }
4397 groups.push((start, end, group));
4398 }
4399 Result::Ok(groups)
4400 })
4401 .await
4402 .map_err(|err| {
4403 Error::internal(format!(
4404 "Failed to build packed prewarm posting groups in blocking task: {err}"
4405 ))
4406 })??;
4407 for (start, end, _) in &groups {
4408 for token_id in *start..*end {
4409 self.publish_modern_posting_validated(token_id).await?;
4410 }
4411 }
4412 Ok(groups)
4413 }
4414
4415 async fn publish_chunk_postings(
4420 &self,
4421 posting_lists: Vec<(u32, PostingList)>,
4422 grouping: &PostingGrouping,
4423 tok_start: usize,
4424 tok_end: usize,
4425 token_count: usize,
4426 with_position: bool,
4427 ) {
4428 match grouping {
4429 PostingGrouping::None => {
4430 for (token_id, mut posting_list) in posting_lists {
4431 self.cache_positions(&mut posting_list, token_id, with_position)
4432 .await;
4433 self.index_cache
4434 .insert_with_key(
4435 &posting_list_cache_key(token_id, self.has_impacts),
4436 Arc::new(posting_list),
4437 )
4438 .await;
4439 }
4440 }
4441 PostingGrouping::SyntheticFixed { .. } => {
4442 let mut chunk_postings = Vec::with_capacity(posting_lists.len());
4443 for (token_id, mut posting_list) in posting_lists {
4444 self.cache_positions(&mut posting_list, token_id, with_position)
4445 .await;
4446 chunk_postings.push(posting_list);
4447 }
4448 for (start, end) in grouping.ranges_for_chunk(tok_start, tok_end, token_count) {
4453 let start_usize = start as usize;
4454 let lo = start_usize - tok_start;
4455 let hi = end as usize - tok_start;
4456 let group = PostingListGroup::new(chunk_postings[lo..hi].to_vec());
4457 self.index_cache
4458 .insert_with_key(
4459 &posting_list_group_cache_key(start, end, self.has_impacts),
4460 Arc::new(group),
4461 )
4462 .await;
4463 }
4464 }
4465 }
4466 }
4467
4468 async fn cache_positions(
4471 &self,
4472 posting_list: &mut PostingList,
4473 token_id: u32,
4474 with_position: bool,
4475 ) {
4476 if with_position && let Some(positions) = posting_list.take_positions() {
4477 self.index_cache
4478 .insert_with_key(&PositionKey { token_id }, Arc::new(Positions(positions)))
4479 .await;
4480 }
4481 }
4482
4483 pub(crate) fn posting_data_size_bytes(&self) -> u64 {
4487 if let Some(size) = self.reader.file_size_bytes() {
4488 return size;
4489 }
4490 const ESTIMATED_BYTES_PER_ROW: u64 = 16;
4493 (self.reader.num_rows() as u64).saturating_mul(ESTIMATED_BYTES_PER_ROW)
4494 }
4495
4496 pub(crate) async fn read_batch(&self, with_position: bool) -> Result<RecordBatch> {
4497 let columns = self.posting_columns(with_position);
4498 let batch = self
4499 .reader
4500 .read_range(0..self.reader.num_rows(), Some(&columns))
4501 .await?;
4502 Ok(batch)
4503 }
4504
4505 pub(crate) async fn read_all(
4506 &self,
4507 with_position: bool,
4508 ) -> Result<impl Iterator<Item = Result<PostingList>> + '_> {
4509 self.ensure_metadata_loaded().await?;
4513 let batch = self.read_batch(with_position).await?;
4514 Ok((0..self.len()).map(move |i| {
4515 let token_id = i as u32;
4516 let range = self.posting_list_range(token_id);
4517 let batch = batch.slice(i, range.end - range.start);
4518 let (max_score, length) = self.bulk_metadata_for_token(token_id);
4519 self.posting_list_from_batch(&batch, max_score, length)
4520 }))
4521 }
4522
4523 fn bulk_metadata_for_token(&self, token_id: u32) -> (Option<f32>, Option<u32>) {
4528 match &self.metadata {
4529 PostingMetadata::LegacyV1 { max_scores, .. } => {
4530 (max_scores.as_ref().map(|m| m[token_id as usize]), None)
4531 }
4532 PostingMetadata::V2 { metadata } => {
4533 let loaded = metadata.get().expect(
4534 "v2 metadata must be bulk-loaded before bulk_metadata_for_token; call ensure_metadata_loaded first",
4535 );
4536 (
4537 Some(loaded.max_scores[token_id as usize]),
4538 Some(loaded.lengths[token_id as usize]),
4539 )
4540 }
4541 }
4542 }
4543
4544 async fn read_positions(
4545 &self,
4546 token_id: u32,
4547 metrics: &dyn MetricsCollector,
4548 ) -> Result<CompressedPositionStorage> {
4549 let result = self.index_cache.get_or_insert_with_key_hit(PositionKey { token_id }, || async move {
4550 let positions = match self.positions_layout {
4551 PositionsLayout::None => {
4552 return Err(Error::invalid_input(
4553 "position is not found but required for phrase queries, try recreating the index with position".to_owned(),
4554 ));
4555 }
4556 PositionsLayout::LegacyPerDoc => {
4557 let batch = self
4558 .reader
4559 .read_range(self.posting_list_range(token_id), Some(&[POSITION_COL]))
4560 .await
4561 .map_err(|e| match e {
4562 Error::Schema { .. } => Error::invalid_input("position is not found but required for phrase queries, try recreating the index with position".to_owned()),
4563 e => e,
4564 })?;
4565 CompressedPositionStorage::LegacyPerDoc(
4566 batch[POSITION_COL].as_list::<i32>().value(0).as_list::<i32>().clone(),
4567 )
4568 }
4569 PositionsLayout::SharedStream(codec) => {
4570 let batch = self
4571 .reader
4572 .read_range(
4573 self.posting_list_range(token_id),
4574 Some(&[COMPRESSED_POSITION_COL, POSITION_BLOCK_OFFSET_COL]),
4575 )
4576 .await
4577 .map_err(|e| match e {
4578 Error::Schema { .. } => Error::invalid_input("position is not found but required for phrase queries, try recreating the index with position".to_owned()),
4579 e => e,
4580 })?;
4581 let bytes = bytes::Bytes::from(
4582 batch[COMPRESSED_POSITION_COL]
4583 .as_binary::<i64>()
4584 .value(0)
4585 .to_vec(),
4586 );
4587 let block_offsets = batch[POSITION_BLOCK_OFFSET_COL]
4588 .as_list::<i32>()
4589 .value(0)
4590 .as_primitive::<UInt32Type>()
4591 .values()
4592 .to_vec();
4593 CompressedPositionStorage::SharedStream(SharedPositionStream::new(
4594 codec,
4595 block_offsets,
4596 bytes,
4597 ))
4598 }
4599 };
4600 Result::Ok(Positions(positions))
4601 }).await;
4602 match &result {
4603 Ok((_, true)) => metrics.record_index_cache_hit(),
4604 _ => metrics.record_index_cache_miss(),
4605 }
4606 let (positions, _) = result?;
4607 Ok(positions.0.clone())
4608 }
4609
4610 fn posting_list_range(&self, token_id: u32) -> Range<usize> {
4611 match &self.metadata {
4612 PostingMetadata::LegacyV1 { offsets, .. } => {
4613 let offset = offsets[token_id as usize];
4614 let posting_len = self.posting_len(token_id);
4615 offset..offset + posting_len
4616 }
4617 PostingMetadata::V2 { .. } => {
4618 let token_id = token_id as usize;
4619 token_id..token_id + 1
4620 }
4621 }
4622 }
4623
4624 fn posting_columns(&self, with_position: bool) -> Vec<&'static str> {
4625 let mut base_columns = if self.is_legacy_layout() {
4626 vec![ROW_ID, FREQUENCY_COL]
4627 } else {
4628 vec![POSTING_COL]
4629 };
4630 if with_position {
4631 match self.positions_layout {
4632 PositionsLayout::None => {}
4633 PositionsLayout::LegacyPerDoc => base_columns.push(POSITION_COL),
4634 PositionsLayout::SharedStream(_) => {
4635 base_columns.push(COMPRESSED_POSITION_COL);
4636 base_columns.push(POSITION_BLOCK_OFFSET_COL);
4637 }
4638 }
4639 }
4640 if self.has_impacts {
4641 base_columns.push(IMPACT_COL);
4642 }
4643 base_columns
4644 }
4645}
4646
4647struct ChunkBuildState {
4650 offsets: Option<Arc<Vec<usize>>>,
4651 max_scores: Option<Arc<Vec<f32>>>,
4652 lengths: Option<Arc<Vec<u32>>>,
4653 posting_tail_codec: PostingTailCodec,
4654 block_size: usize,
4655 positions_layout: PositionsLayout,
4656}
4657
4658struct PrewarmBuildCtx<'a> {
4662 max_scores: Option<&'a [f32]>,
4663 lengths: Option<&'a [u32]>,
4664 posting_tail_codec: PostingTailCodec,
4665 block_size: usize,
4666 positions_layout: PositionsLayout,
4667}
4668
4669struct PrewarmChunk<'a> {
4673 tok_start: usize,
4674 token_count: usize,
4675 offsets: Option<&'a [usize]>,
4677 end_row: usize,
4679}
4680
4681#[derive(Clone)]
4684pub struct Positions(pub(super) CompressedPositionStorage);
4685
4686fn sliced_cache_bytes(array: &dyn Array) -> usize {
4692 let validity_bytes = array
4693 .nulls()
4694 .map(|nulls| nulls.len().div_ceil(8))
4695 .unwrap_or(0);
4696 match array.data_type() {
4697 DataType::LargeBinary => {
4698 let array = array.as_binary::<i64>();
4699 let data_bytes = if array.is_empty() {
4700 0
4701 } else {
4702 let offsets = array.value_offsets();
4703 (offsets[array.len()] - offsets[0]) as usize
4704 };
4705 data_bytes + (array.len() + 1) * std::mem::size_of::<i64>() + validity_bytes
4706 }
4707 DataType::List(_) => {
4708 let array = array.as_list::<i32>();
4709 let (child_start, child_end) = if array.is_empty() {
4710 (0, 0)
4711 } else {
4712 let offsets = array.value_offsets();
4713 (offsets[0] as usize, offsets[array.len()] as usize)
4714 };
4715 let offset_bytes = (array.len() + 1) * std::mem::size_of::<i32>();
4716 let child = array.values().slice(child_start, child_end - child_start);
4717 offset_bytes + validity_bytes + sliced_cache_bytes(child.as_ref())
4718 }
4719 other => match other.primitive_width() {
4723 Some(width) => array.len() * width + validity_bytes,
4724 None => array.get_buffer_memory_size(),
4725 },
4726 }
4727}
4728
4729impl DeepSizeOf for Positions {
4730 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
4731 self.0.deep_size_of_children(context)
4732 }
4733}
4734
4735#[derive(Debug, Clone)]
4737pub struct PostingListKey {
4738 pub token_id: u32,
4739}
4740
4741impl CacheKey for PostingListKey {
4742 type ValueType = PostingList;
4743
4744 fn key(&self) -> std::borrow::Cow<'_, str> {
4745 format!("postings-{}", self.token_id).into()
4746 }
4747
4748 fn type_name() -> &'static str {
4749 "PostingList"
4750 }
4751
4752 fn schema() -> CacheKeySchema {
4753 CacheKeySchema::new("lance.scalar.inverted.posting-list-key", 1)
4754 }
4755
4756 fn write_key(&self, builder: &mut KeyBuilder) {
4757 builder.write_u32(self.token_id);
4758 }
4759
4760 fn codec() -> Option<CacheCodec> {
4761 Some(CacheCodec::from_impl::<PostingList>())
4762 }
4763}
4764
4765#[derive(Debug, Clone)]
4770pub struct PostingListGroupKey {
4771 pub start: u32,
4772 pub end: u32,
4773}
4774
4775impl CacheKey for PostingListGroupKey {
4776 type ValueType = PostingListGroup;
4777
4778 fn key(&self) -> std::borrow::Cow<'_, str> {
4779 format!("postings-{}-{}", self.start, self.end).into()
4780 }
4781
4782 fn type_name() -> &'static str {
4783 "PostingListGroup"
4784 }
4785
4786 fn schema() -> CacheKeySchema {
4787 CacheKeySchema::new("lance.scalar.inverted.posting-list-group-key", 1)
4788 }
4789
4790 fn write_key(&self, builder: &mut KeyBuilder) {
4791 builder.write_u32(self.start);
4792 builder.write_u32(self.end);
4793 }
4794
4795 fn codec() -> Option<CacheCodec> {
4796 Some(CacheCodec::from_impl::<PostingListGroup>())
4797 }
4798}
4799
4800#[derive(Debug, Clone)]
4803struct ImpactAwareCacheKey<K> {
4804 inner: K,
4805 has_impacts: bool,
4806}
4807
4808impl<K: CacheKey> CacheKey for ImpactAwareCacheKey<K> {
4809 type ValueType = K::ValueType;
4810
4811 fn key(&self) -> std::borrow::Cow<'_, str> {
4812 if self.has_impacts {
4813 format!("{}-impacts", self.inner.key()).into()
4814 } else {
4815 self.inner.key()
4816 }
4817 }
4818
4819 fn type_name() -> &'static str {
4820 K::type_name()
4821 }
4822
4823 fn stable_type_id() -> &'static str {
4824 K::stable_type_id()
4825 }
4826
4827 fn schema() -> CacheKeySchema {
4828 CacheKeySchema::new("lance.scalar.inverted.impact-aware-key", 1)
4829 }
4830
4831 fn write_key(&self, builder: &mut KeyBuilder) {
4832 let inner_schema = K::schema();
4833 builder.write_str(K::stable_type_id());
4834 builder.write_str(inner_schema.id());
4835 builder.write_u32(inner_schema.version());
4836 builder.write_variant(if self.has_impacts { 1 } else { 0 });
4837 self.inner.write_key(builder);
4838 }
4839
4840 fn codec() -> Option<CacheCodec> {
4841 K::codec()
4842 }
4843}
4844
4845fn posting_list_cache_key(token_id: u32, has_impacts: bool) -> ImpactAwareCacheKey<PostingListKey> {
4846 ImpactAwareCacheKey {
4847 inner: PostingListKey { token_id },
4848 has_impacts,
4849 }
4850}
4851
4852fn posting_list_group_cache_key(
4853 start: u32,
4854 end: u32,
4855 has_impacts: bool,
4856) -> ImpactAwareCacheKey<PostingListGroupKey> {
4857 ImpactAwareCacheKey {
4858 inner: PostingListGroupKey { start, end },
4859 has_impacts,
4860 }
4861}
4862
4863#[derive(Debug, Clone, DeepSizeOf)]
4864struct PostingMetadataValue {
4865 max_score: f32,
4866 length: u32,
4867}
4868
4869#[derive(Debug, Clone)]
4870struct PostingMetadataKey {
4871 token_id: u32,
4872}
4873
4874impl CacheKey for PostingMetadataKey {
4875 type ValueType = PostingMetadataValue;
4876
4877 fn key(&self) -> std::borrow::Cow<'_, str> {
4878 format!("posting-metadata-{}", self.token_id).into()
4879 }
4880
4881 fn type_name() -> &'static str {
4882 "PostingMetadata"
4883 }
4884
4885 fn schema() -> CacheKeySchema {
4886 CacheKeySchema::new("lance.scalar.inverted.posting-metadata-key", 1)
4887 }
4888
4889 fn write_key(&self, builder: &mut KeyBuilder) {
4890 builder.write_u32(self.token_id);
4891 }
4892}
4893
4894#[derive(Debug, Clone)]
4895pub struct PositionKey {
4896 pub token_id: u32,
4897}
4898
4899impl CacheKey for PositionKey {
4900 type ValueType = Positions;
4901
4902 fn key(&self) -> std::borrow::Cow<'_, str> {
4903 format!("positions-{}", self.token_id).into()
4904 }
4905
4906 fn type_name() -> &'static str {
4907 "Position"
4908 }
4909
4910 fn schema() -> CacheKeySchema {
4911 CacheKeySchema::new("lance.scalar.inverted.position-key", 1)
4912 }
4913
4914 fn write_key(&self, builder: &mut KeyBuilder) {
4915 builder.write_u32(self.token_id);
4916 }
4917
4918 fn codec() -> Option<CacheCodec> {
4919 Some(CacheCodec::from_impl::<Positions>())
4920 }
4921}
4922
4923#[derive(Debug, Clone, PartialEq)]
4924pub enum CompressedPositionStorage {
4925 LegacyPerDoc(ListArray),
4926 SharedStream(SharedPositionStream),
4927}
4928
4929impl DeepSizeOf for CompressedPositionStorage {
4930 fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
4931 match self {
4932 Self::LegacyPerDoc(positions) => sliced_cache_bytes(positions),
4933 Self::SharedStream(stream) => stream.size(),
4934 }
4935 }
4936}
4937
4938#[derive(Debug, Clone, PartialEq, Eq, Default)]
4939pub struct SharedPositionStream {
4940 codec: PositionStreamCodec,
4941 block_offsets: Arc<[u32]>,
4942 bytes: bytes::Bytes,
4945}
4946
4947impl SharedPositionStream {
4948 pub fn new(codec: PositionStreamCodec, block_offsets: Vec<u32>, bytes: bytes::Bytes) -> Self {
4949 Self {
4950 codec,
4951 block_offsets: Arc::from(block_offsets.into_boxed_slice()),
4952 bytes,
4953 }
4954 }
4955
4956 pub fn codec(&self) -> PositionStreamCodec {
4957 self.codec
4958 }
4959
4960 pub fn block_count(&self) -> usize {
4961 self.block_offsets.len()
4962 }
4963
4964 pub fn block_range(&self, index: usize) -> Range<usize> {
4965 let start = self.block_offsets[index] as usize;
4966 let end = self
4967 .block_offsets
4968 .get(index + 1)
4969 .map(|offset| *offset as usize)
4970 .unwrap_or(self.bytes.len());
4971 start..end
4972 }
4973
4974 pub fn block(&self, index: usize) -> &[u8] {
4975 let range = self.block_range(index);
4976 &self.bytes[range]
4977 }
4978
4979 pub fn bytes(&self) -> &[u8] {
4980 &self.bytes
4981 }
4982
4983 pub fn block_offsets(&self) -> &[u32] {
4984 self.block_offsets.as_ref()
4985 }
4986
4987 pub fn size(&self) -> usize {
4988 self.block_offsets.len() * std::mem::size_of::<u32>() + self.bytes.len()
4989 }
4990}
4991
4992#[derive(Debug, Clone)]
5000pub struct PostingListGroup {
5001 pub(super) storage: PostingListGroupStorage,
5002}
5003
5004#[derive(Debug, Clone)]
5005pub(super) enum PostingListGroupStorage {
5006 Packed(PackedPostingListGroup),
5007 Materialized(Vec<PostingList>),
5008}
5009
5010#[derive(Debug, Clone)]
5011pub(super) struct PackedPostingListGroup {
5012 pub(super) batch: RecordBatch,
5013 pub(super) posting_tail_codec: PostingTailCodec,
5014 pub(super) block_size: usize,
5015 first_docs_states: Arc<[OnceLock<Box<[u32]>>]>,
5016 first_docs_state_capacity_bytes: usize,
5017 impact_states: Option<Arc<[OnceLock<Box<ImpactSkipData>>]>>,
5018 impact_state_capacity_bytes: usize,
5019}
5020
5021impl DeepSizeOf for PostingListGroup {
5022 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
5023 match &self.storage {
5024 PostingListGroupStorage::Packed(group) => group
5025 .batch
5026 .columns()
5027 .iter()
5028 .map(|column| sliced_cache_bytes(column.as_ref()))
5029 .sum::<usize>()
5030 .saturating_add(group.first_docs_state_capacity_bytes)
5031 .saturating_add(group.impact_state_capacity_bytes),
5032 PostingListGroupStorage::Materialized(posting_lists) => {
5033 posting_lists.deep_size_of_children(context)
5034 }
5035 }
5036 }
5037}
5038
5039impl PostingListGroup {
5040 pub(super) fn new(posting_lists: Vec<PostingList>) -> Self {
5041 Self {
5042 storage: PostingListGroupStorage::Materialized(posting_lists),
5043 }
5044 }
5045
5046 pub(super) fn new_packed(
5047 batch: RecordBatch,
5048 posting_tail_codec: PostingTailCodec,
5049 ) -> Result<Self> {
5050 let block_size = parse_posting_block_size(batch.schema_ref().metadata())?;
5051 Self::new_packed_with_block_size(batch, posting_tail_codec, block_size)
5052 }
5053
5054 fn new_packed_with_block_size(
5055 batch: RecordBatch,
5056 posting_tail_codec: PostingTailCodec,
5057 block_size: usize,
5058 ) -> Result<Self> {
5059 validate_block_size(block_size)?;
5060 if let Some(encoded_block_size) = batch.schema_ref().metadata().get(POSTING_BLOCK_SIZE_KEY)
5061 {
5062 let encoded_block_size = encoded_block_size.parse::<usize>().map_err(|err| {
5063 Error::index(format!(
5064 "invalid {POSTING_BLOCK_SIZE_KEY} metadata value {encoded_block_size:?}: {err}"
5065 ))
5066 })?;
5067 if encoded_block_size != block_size {
5068 return Err(Error::index(format!(
5069 "packed posting group {POSTING_BLOCK_SIZE_KEY}={encoded_block_size} does not match block_size={block_size}"
5070 )));
5071 }
5072 }
5073
5074 let mut schema = batch.schema().as_ref().clone();
5079 schema
5080 .metadata
5081 .insert(POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string());
5082 let batch = batch.with_schema(Arc::new(schema))?;
5083 let postings = batch
5084 .column_by_name(POSTING_COL)
5085 .and_then(|column| column.as_list_opt::<i32>())
5086 .ok_or_else(|| {
5087 Error::index(format!(
5088 "packed posting group column {POSTING_COL} must be List<LargeBinary>"
5089 ))
5090 })?;
5091 if postings.values().data_type() != &DataType::LargeBinary {
5092 return Err(Error::index(format!(
5093 "packed posting group column {POSTING_COL} must contain LargeBinary values, got {}",
5094 postings.values().data_type()
5095 )));
5096 }
5097 if postings.null_count() != 0 {
5098 return Err(Error::index(
5099 "packed posting group column must not contain nulls".to_string(),
5100 ));
5101 }
5102 let total_posting_blocks = (0..batch.num_rows())
5103 .map(|slot| postings.value_length(slot) as usize)
5104 .sum::<usize>();
5105 let first_docs_states: Arc<[OnceLock<Box<[u32]>>]> = (0..batch.num_rows())
5106 .map(|_| OnceLock::new())
5107 .collect::<Vec<_>>()
5108 .into();
5109 let first_docs_state_capacity_bytes = first_docs_states
5113 .len()
5114 .saturating_mul(std::mem::size_of::<OnceLock<Box<[u32]>>>())
5115 .saturating_add(total_posting_blocks.saturating_mul(std::mem::size_of::<u32>()));
5116 let (impact_states, impact_state_capacity_bytes) = if let Some(impacts) =
5117 batch.column_by_name(IMPACT_COL)
5118 {
5119 let impacts = impacts.as_list_opt::<i32>().ok_or_else(|| {
5120 Error::index(format!(
5121 "packed posting group column {IMPACT_COL} must be List<LargeBinary>"
5122 ))
5123 })?;
5124 if impacts.values().data_type() != &DataType::LargeBinary {
5125 return Err(Error::index(format!(
5126 "packed posting group column {IMPACT_COL} must contain LargeBinary values, got {}",
5127 impacts.values().data_type()
5128 )));
5129 }
5130 if impacts.null_count() != 0 {
5131 return Err(Error::index(format!(
5132 "packed posting group column {IMPACT_COL} must not contain nulls"
5133 )));
5134 }
5135 let mut derived_cache_bytes = 0usize;
5136 for slot in 0..batch.num_rows() {
5137 let posting_blocks = postings.value_length(slot) as usize;
5138 let impact_entries = impacts.value_length(slot) as usize;
5139 let expected_impact_entries =
5140 posting_blocks.saturating_add(posting_blocks.div_ceil(IMPACT_LEVEL1_BLOCKS));
5141 if impact_entries != expected_impact_entries {
5142 return Err(Error::index(format!(
5143 "packed posting group impact slot {slot} has {impact_entries} entries, expected {expected_impact_entries} for {posting_blocks} posting blocks"
5144 )));
5145 }
5146 derived_cache_bytes = derived_cache_bytes.saturating_add(
5147 ImpactSkipData::derived_cache_bytes_for_entries(impact_entries),
5148 );
5149 }
5150
5151 let states: Arc<[OnceLock<Box<ImpactSkipData>>]> = (0..batch.num_rows())
5152 .map(|_| OnceLock::new())
5153 .collect::<Vec<_>>()
5154 .into();
5155 let per_slot_bytes = std::mem::size_of::<OnceLock<Box<ImpactSkipData>>>()
5159 .saturating_add(std::mem::size_of::<ImpactSkipData>());
5160 let capacity_bytes = states
5161 .len()
5162 .saturating_mul(per_slot_bytes)
5163 .saturating_add(derived_cache_bytes);
5164 (Some(states), capacity_bytes)
5165 } else {
5166 (None, 0)
5167 };
5168 match (
5169 batch.column_by_name(MAX_SCORE_COL),
5170 batch.column_by_name(LENGTH_COL),
5171 ) {
5172 (None, None) => {}
5173 (Some(max_scores), Some(lengths)) => {
5174 let max_scores = max_scores
5175 .as_primitive_opt::<Float32Type>()
5176 .ok_or_else(|| {
5177 Error::index(format!(
5178 "packed posting group column {MAX_SCORE_COL} must be Float32"
5179 ))
5180 })?;
5181 let lengths = lengths.as_primitive_opt::<UInt32Type>().ok_or_else(|| {
5182 Error::index(format!(
5183 "packed posting group column {LENGTH_COL} must be UInt32"
5184 ))
5185 })?;
5186 if max_scores.null_count() != 0 || lengths.null_count() != 0 {
5187 return Err(Error::index(
5188 "packed posting group metadata columns must not contain nulls".to_string(),
5189 ));
5190 }
5191 }
5192 _ => {
5193 return Err(Error::index(format!(
5194 "packed posting group must contain both {MAX_SCORE_COL} and {LENGTH_COL}, or neither"
5195 )));
5196 }
5197 }
5198
5199 Ok(Self {
5200 storage: PostingListGroupStorage::Packed(PackedPostingListGroup {
5201 batch,
5202 posting_tail_codec,
5203 block_size,
5204 first_docs_states,
5205 first_docs_state_capacity_bytes,
5206 impact_states,
5207 impact_state_capacity_bytes,
5208 }),
5209 })
5210 }
5211
5212 pub(super) fn len(&self) -> usize {
5213 match &self.storage {
5214 PostingListGroupStorage::Packed(group) => group.batch.num_rows(),
5215 PostingListGroupStorage::Materialized(posting_lists) => posting_lists.len(),
5216 }
5217 }
5218
5219 #[cfg(test)]
5220 pub(super) fn is_packed(&self) -> bool {
5221 matches!(&self.storage, PostingListGroupStorage::Packed(_))
5222 }
5223
5224 fn needs_external_metadata(&self) -> bool {
5225 match &self.storage {
5226 PostingListGroupStorage::Packed(group) => {
5227 group.batch.column_by_name(MAX_SCORE_COL).is_none()
5228 }
5229 PostingListGroupStorage::Materialized(_) => false,
5230 }
5231 }
5232
5233 pub(super) fn posting_list(
5237 &self,
5238 slot: usize,
5239 max_score: Option<f32>,
5240 length: Option<u32>,
5241 ) -> Result<Option<PostingList>> {
5242 match &self.storage {
5243 PostingListGroupStorage::Materialized(posting_lists) => {
5244 Ok(posting_lists.get(slot).cloned())
5245 }
5246 PostingListGroupStorage::Packed(group) => {
5247 if slot >= group.batch.num_rows() {
5248 return Ok(None);
5249 }
5250 let postings = group
5251 .batch
5252 .column_by_name(POSTING_COL)
5253 .and_then(|column| column.as_list_opt::<i32>())
5254 .ok_or_else(|| {
5255 Error::index(format!(
5256 "packed posting group column {POSTING_COL} must be List<LargeBinary>"
5257 ))
5258 })?;
5259 let blocks = postings.value(slot);
5260 let blocks = blocks.as_binary_opt::<i64>().ok_or_else(|| {
5261 Error::index(format!(
5262 "packed posting group slot {slot} is not LargeBinary"
5263 ))
5264 })?;
5265 let max_score = match group.batch.column_by_name(MAX_SCORE_COL) {
5266 Some(column) => column
5267 .as_primitive_opt::<Float32Type>()
5268 .expect("packed group metadata was validated at construction")
5269 .value(slot),
5270 None => max_score.ok_or_else(|| {
5271 Error::index("packed posting group requires max-score metadata".to_string())
5272 })?,
5273 };
5274 let length = match group.batch.column_by_name(LENGTH_COL) {
5275 Some(column) => column
5276 .as_primitive_opt::<UInt32Type>()
5277 .expect("packed group metadata was validated at construction")
5278 .value(slot),
5279 None => length.ok_or_else(|| {
5280 Error::index("packed posting group requires length metadata".to_string())
5281 })?,
5282 };
5283 let impacts = match (
5284 group.impact_states.as_ref(),
5285 group.batch.column_by_name(IMPACT_COL),
5286 ) {
5287 (Some(states), Some(column)) => {
5288 let state = states.get(slot).ok_or_else(|| {
5289 Error::index(format!(
5290 "packed posting group impact state missing slot {slot}"
5291 ))
5292 })?;
5293 let impact_lists = column.as_list_opt::<i32>().ok_or_else(|| {
5294 Error::index(format!(
5295 "packed posting group column {IMPACT_COL} must be List<LargeBinary>"
5296 ))
5297 })?;
5298 let entries = impact_lists.value(slot);
5299 let entries = entries.as_binary_opt::<i64>().ok_or_else(|| {
5300 Error::index(format!(
5301 "packed posting group impact slot {slot} is not LargeBinary"
5302 ))
5303 })?;
5304 let impacts =
5305 state.get_or_init(|| {
5306 Box::new(ImpactSkipData::new(entries.clone(), blocks.len()).expect(
5307 "packed impact entry count was validated at construction",
5308 ))
5309 });
5310 Some(impacts.as_ref().clone())
5311 }
5312 (None, None) => None,
5313 _ => {
5314 return Err(Error::internal(
5315 "packed posting group impact column/state mismatch".to_string(),
5316 ));
5317 }
5318 };
5319 Ok(Some(PostingList::Compressed(
5320 CompressedPostingList::new(
5321 blocks.clone(),
5322 max_score,
5323 length,
5324 group.posting_tail_codec,
5325 group.block_size,
5326 None,
5327 impacts,
5328 )
5329 .with_packed_first_docs(group.first_docs_states.clone(), slot),
5330 )))
5331 }
5332 }
5333 }
5334}
5335
5336#[derive(Debug, Clone, DeepSizeOf)]
5337#[allow(clippy::large_enum_variant)]
5338pub enum PostingList {
5339 Plain(PlainPostingList),
5340 Compressed(CompressedPostingList),
5341}
5342
5343impl PostingList {
5344 pub fn from_batch(
5345 batch: &RecordBatch,
5346 max_score: Option<f32>,
5347 length: Option<u32>,
5348 ) -> Result<Self> {
5349 let posting_tail_codec = parse_posting_tail_codec(batch.schema_ref().metadata())?;
5350 let block_size = parse_posting_block_size(batch.schema_ref().metadata())?;
5351 Self::from_batch_with_tail_codec(batch, max_score, length, posting_tail_codec, block_size)
5352 }
5353
5354 pub fn from_batch_with_tail_codec(
5355 batch: &RecordBatch,
5356 max_score: Option<f32>,
5357 length: Option<u32>,
5358 posting_tail_codec: PostingTailCodec,
5359 block_size: usize,
5360 ) -> Result<Self> {
5361 let positions_layout = if batch.column_by_name(COMPRESSED_POSITION_COL).is_some() {
5362 PositionsLayout::SharedStream(parse_shared_position_codec(
5363 batch.schema_ref().metadata(),
5364 )?)
5365 } else if batch.column_by_name(POSITION_COL).is_some() {
5366 PositionsLayout::LegacyPerDoc
5367 } else {
5368 PositionsLayout::None
5369 };
5370 Self::from_batch_with_tail_codec_and_positions_layout(
5371 batch,
5372 max_score,
5373 length,
5374 posting_tail_codec,
5375 block_size,
5376 positions_layout,
5377 )
5378 }
5379
5380 fn from_batch_with_tail_codec_and_positions_layout(
5381 batch: &RecordBatch,
5382 max_score: Option<f32>,
5383 length: Option<u32>,
5384 posting_tail_codec: PostingTailCodec,
5385 block_size: usize,
5386 positions_layout: PositionsLayout,
5387 ) -> Result<Self> {
5388 match batch.column_by_name(POSTING_COL) {
5389 Some(_) => {
5390 debug_assert!(max_score.is_some() && length.is_some());
5391 let shared_position_codec = match positions_layout {
5392 PositionsLayout::SharedStream(codec) => Some(codec),
5393 _ => None,
5394 };
5395 let posting = CompressedPostingList::from_batch(
5396 batch,
5397 max_score.unwrap(),
5398 length.unwrap(),
5399 posting_tail_codec,
5400 block_size,
5401 shared_position_codec,
5402 )?;
5403 Ok(Self::Compressed(posting))
5404 }
5405 None => {
5406 let posting = PlainPostingList::from_batch(batch, max_score);
5407 Ok(Self::Plain(posting))
5408 }
5409 }
5410 }
5411
5412 pub fn iter(&self) -> PostingListIterator<'_> {
5413 PostingListIterator::new(self)
5414 }
5415
5416 pub fn has_position(&self) -> bool {
5417 match self {
5418 Self::Plain(posting) => posting.positions.is_some(),
5419 Self::Compressed(posting) => posting.positions.is_some(),
5420 }
5421 }
5422
5423 pub fn has_impacts(&self) -> bool {
5424 match self {
5425 Self::Plain(_) => false,
5426 Self::Compressed(posting) => posting.impacts.is_some(),
5427 }
5428 }
5429
5430 pub fn set_positions(&mut self, positions: CompressedPositionStorage) {
5431 match self {
5432 Self::Plain(posting) => match positions {
5433 CompressedPositionStorage::LegacyPerDoc(positions) => {
5434 posting.positions = Some(positions)
5435 }
5436 CompressedPositionStorage::SharedStream(_) => {
5437 unreachable!("shared position stream is not supported for plain postings")
5438 }
5439 },
5440 Self::Compressed(posting) => {
5441 posting.positions = Some(positions);
5442 }
5443 }
5444 }
5445
5446 pub fn take_positions(&mut self) -> Option<CompressedPositionStorage> {
5447 match self {
5448 Self::Plain(posting) => posting
5449 .positions
5450 .take()
5451 .map(CompressedPositionStorage::LegacyPerDoc),
5452 Self::Compressed(posting) => posting.positions.take(),
5453 }
5454 }
5455
5456 pub fn max_score(&self) -> Option<f32> {
5457 match self {
5458 Self::Plain(posting) => posting.max_score,
5459 Self::Compressed(posting) => Some(posting.max_score),
5460 }
5461 }
5462
5463 pub fn len(&self) -> usize {
5464 match self {
5465 Self::Plain(posting) => posting.len(),
5466 Self::Compressed(posting) => posting.length as usize,
5467 }
5468 }
5469
5470 pub fn is_empty(&self) -> bool {
5471 self.len() == 0
5472 }
5473
5474 pub fn into_builder(self, docs: &DocSet) -> PostingListBuilder {
5475 let posting_tail_codec = match &self {
5476 Self::Plain(_) => PostingTailCodec::Fixed32,
5477 Self::Compressed(posting) => posting.posting_tail_codec,
5478 };
5479 let block_size = match &self {
5480 Self::Plain(_) => LEGACY_BLOCK_SIZE,
5481 Self::Compressed(posting) => posting.block_size,
5482 };
5483 let mut builder = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
5484 self.has_position(),
5485 posting_tail_codec,
5486 block_size,
5487 );
5488 match self {
5489 Self::Plain(posting) => {
5491 struct Item {
5495 doc_id: u32,
5496 positions: PositionRecorder,
5497 }
5498 let doc_ids = docs
5499 .row_ids
5500 .iter()
5501 .enumerate()
5502 .map(|(doc_id, row_id)| (*row_id, doc_id as u32))
5503 .collect::<HashMap<_, _>>();
5504 let mut items = Vec::with_capacity(posting.len());
5505 for (row_id, freq, positions) in posting.iter() {
5506 let freq = freq as u32;
5507 let positions = match positions {
5508 Some(positions) => {
5509 PositionRecorder::Position(positions.collect::<Vec<_>>().into())
5510 }
5511 None => PositionRecorder::Count(freq),
5512 };
5513 items.push(Item {
5514 doc_id: doc_ids[&row_id],
5515 positions,
5516 });
5517 }
5518 items.sort_unstable_by_key(|item| item.doc_id);
5519 for item in items {
5520 builder.add(item.doc_id, item.positions);
5521 }
5522 }
5523 Self::Compressed(posting) => {
5524 posting.iter().for_each(|(doc_id, freq, positions)| {
5525 let positions = match positions {
5526 Some(positions) => {
5527 PositionRecorder::Position(positions.collect::<Vec<_>>().into())
5528 }
5529 None => PositionRecorder::Count(freq),
5530 };
5531 builder.add(doc_id, positions);
5532 });
5533 }
5534 }
5535 builder
5536 }
5537}
5538
5539#[derive(Debug, PartialEq, Clone)]
5540pub struct PlainPostingList {
5541 pub row_ids: ScalarBuffer<u64>,
5542 pub frequencies: ScalarBuffer<f32>,
5543 pub max_score: Option<f32>,
5544 pub positions: Option<ListArray>, }
5546
5547impl DeepSizeOf for PlainPostingList {
5548 fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
5549 self.row_ids.len() * std::mem::size_of::<u64>()
5550 + self.frequencies.len() * std::mem::size_of::<f32>()
5551 + self
5552 .positions
5553 .as_ref()
5554 .map(|positions| sliced_cache_bytes(positions))
5555 .unwrap_or(0)
5556 }
5557}
5558
5559impl PlainPostingList {
5560 pub fn new(
5561 row_ids: ScalarBuffer<u64>,
5562 frequencies: ScalarBuffer<f32>,
5563 max_score: Option<f32>,
5564 positions: Option<ListArray>,
5565 ) -> Self {
5566 Self {
5567 row_ids,
5568 frequencies,
5569 max_score,
5570 positions,
5571 }
5572 }
5573
5574 pub fn from_batch(batch: &RecordBatch, max_score: Option<f32>) -> Self {
5575 let row_ids = batch[ROW_ID].as_primitive::<UInt64Type>().values().clone();
5576 let frequencies = batch[FREQUENCY_COL]
5577 .as_primitive::<Float32Type>()
5578 .values()
5579 .clone();
5580 let positions = batch
5581 .column_by_name(POSITION_COL)
5582 .map(|col| col.as_list::<i32>().clone());
5583
5584 Self::new(row_ids, frequencies, max_score, positions)
5585 }
5586
5587 pub fn len(&self) -> usize {
5588 self.row_ids.len()
5589 }
5590
5591 pub fn is_empty(&self) -> bool {
5592 self.len() == 0
5593 }
5594
5595 pub fn iter(&self) -> PlainPostingListIterator<'_> {
5596 Box::new(
5597 self.row_ids
5598 .iter()
5599 .zip(self.frequencies.iter())
5600 .enumerate()
5601 .map(|(idx, (doc_id, freq))| {
5602 (
5603 *doc_id,
5604 *freq,
5605 self.positions.as_ref().map(|p| {
5606 let start = p.value_offsets()[idx] as usize;
5607 let end = p.value_offsets()[idx + 1] as usize;
5608 Box::new(
5609 p.values().as_primitive::<Int32Type>().values()[start..end]
5610 .iter()
5611 .map(|pos| *pos as u32),
5612 ) as _
5613 }),
5614 )
5615 }),
5616 )
5617 }
5618
5619 #[inline]
5620 pub fn doc(&self, i: usize) -> LocatedDocInfo {
5621 LocatedDocInfo::new(self.row_ids[i], self.frequencies[i])
5622 }
5623
5624 pub fn positions(&self, index: usize) -> Option<Arc<dyn Array>> {
5625 self.positions
5626 .as_ref()
5627 .map(|positions| positions.value(index))
5628 }
5629
5630 pub fn max_score(&self) -> Option<f32> {
5631 self.max_score
5632 }
5633
5634 pub fn row_id(&self, i: usize) -> u64 {
5635 self.row_ids[i]
5636 }
5637}
5638
5639#[derive(Debug, Clone)]
5640enum FirstDocsState {
5641 Standalone(Arc<OnceLock<Box<[u32]>>>),
5642 Packed {
5643 states: Arc<[OnceLock<Box<[u32]>>]>,
5644 slot: usize,
5645 },
5646}
5647
5648impl FirstDocsState {
5649 fn standalone() -> Self {
5650 Self::Standalone(Arc::new(OnceLock::new()))
5651 }
5652
5653 fn state(&self) -> &OnceLock<Box<[u32]>> {
5654 match self {
5655 Self::Standalone(state) => state,
5656 Self::Packed { states, slot } => &states[*slot],
5657 }
5658 }
5659
5660 fn get_or_init(&self, initialize: impl FnOnce() -> Box<[u32]>) -> &[u32] {
5661 self.state().get_or_init(initialize)
5662 }
5663
5664 fn capacity_bytes(
5665 &self,
5666 block_count: usize,
5667 context: &mut lance_core::deepsize::Context,
5668 ) -> usize {
5669 if context.mark_seen(self.state() as *const _ as usize) {
5670 std::mem::size_of::<OnceLock<Box<[u32]>>>()
5671 .saturating_add(block_count.saturating_mul(std::mem::size_of::<u32>()))
5672 } else {
5673 0
5674 }
5675 }
5676
5677 #[cfg(test)]
5678 fn shares_state_with(&self, other: &Self) -> bool {
5679 std::ptr::eq(self.state(), other.state())
5680 }
5681}
5682
5683#[derive(Debug, Clone)]
5684pub struct CompressedPostingList {
5685 pub max_score: f32,
5686 pub length: u32,
5687 pub blocks: LargeBinaryArray,
5691 pub posting_tail_codec: PostingTailCodec,
5692 pub block_size: usize,
5693 pub positions: Option<CompressedPositionStorage>,
5694 pub(crate) impacts: Option<ImpactSkipData>,
5695 first_docs: FirstDocsState,
5698}
5699
5700impl PartialEq for CompressedPostingList {
5701 fn eq(&self, other: &Self) -> bool {
5702 self.max_score == other.max_score
5703 && self.length == other.length
5704 && self.blocks == other.blocks
5705 && self.posting_tail_codec == other.posting_tail_codec
5706 && self.block_size == other.block_size
5707 && self.positions == other.positions
5708 && self.impacts == other.impacts
5709 }
5710}
5711
5712impl DeepSizeOf for CompressedPostingList {
5713 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
5714 sliced_cache_bytes(&self.blocks)
5715 + self
5716 .positions
5717 .as_ref()
5718 .map(|positions| positions.deep_size_of_children(context))
5719 .unwrap_or(0)
5720 + self
5721 .impacts
5722 .as_ref()
5723 .map(|impacts| {
5724 sliced_cache_bytes(impacts.entries())
5725 .saturating_add(impacts.derived_cache_bytes())
5726 })
5727 .unwrap_or(0)
5728 + self.first_docs.capacity_bytes(self.blocks.len(), context)
5729 }
5730}
5731
5732impl CompressedPostingList {
5733 pub(crate) fn new(
5734 blocks: LargeBinaryArray,
5735 max_score: f32,
5736 length: u32,
5737 posting_tail_codec: PostingTailCodec,
5738 block_size: usize,
5739 positions: Option<CompressedPositionStorage>,
5740 impacts: Option<ImpactSkipData>,
5741 ) -> Self {
5742 debug_assert!(block_size.is_power_of_two());
5743 Self {
5744 max_score,
5745 length,
5746 blocks,
5747 posting_tail_codec,
5748 block_size,
5749 positions,
5750 impacts,
5751 first_docs: FirstDocsState::standalone(),
5752 }
5753 }
5754
5755 fn with_packed_first_docs(mut self, states: Arc<[OnceLock<Box<[u32]>>]>, slot: usize) -> Self {
5756 debug_assert!(slot < states.len());
5757 self.first_docs = FirstDocsState::Packed { states, slot };
5758 self
5759 }
5760
5761 #[inline]
5765 pub(crate) fn block_shift(&self) -> u32 {
5766 self.block_size.trailing_zeros()
5767 }
5768
5769 #[inline]
5770 pub(crate) fn block_mask(&self) -> usize {
5771 self.block_size - 1
5772 }
5773
5774 pub fn from_batch(
5775 batch: &RecordBatch,
5776 max_score: f32,
5777 length: u32,
5778 posting_tail_codec: PostingTailCodec,
5779 block_size: usize,
5780 shared_position_codec: Option<PositionStreamCodec>,
5781 ) -> Result<Self> {
5782 debug_assert_eq!(batch.num_rows(), 1);
5783 let blocks = batch[POSTING_COL]
5784 .as_list::<i32>()
5785 .value(0)
5786 .as_binary::<i64>()
5787 .clone();
5788 let positions = if let Some(col) = batch.column_by_name(COMPRESSED_POSITION_COL) {
5789 let bytes = bytes::Bytes::from(col.as_binary::<i64>().value(0).to_vec());
5790 let block_offsets = batch[POSITION_BLOCK_OFFSET_COL]
5791 .as_list::<i32>()
5792 .value(0)
5793 .as_primitive::<UInt32Type>()
5794 .values()
5795 .to_vec();
5796 let codec = shared_position_codec.unwrap_or_else(|| {
5797 parse_shared_position_codec(batch.schema_ref().metadata())
5798 .expect("shared position stream codec metadata should be valid")
5799 });
5800 Some(CompressedPositionStorage::SharedStream(
5801 SharedPositionStream::new(codec, block_offsets, bytes),
5802 ))
5803 } else {
5804 batch.column_by_name(POSITION_COL).map(|col| {
5805 CompressedPositionStorage::LegacyPerDoc(
5806 col.as_list::<i32>().value(0).as_list::<i32>().clone(),
5807 )
5808 })
5809 };
5810 let impacts = batch
5811 .column_by_name(IMPACT_COL)
5812 .map(|col| {
5813 let entries = col.as_list::<i32>().value(0).as_binary::<i64>().clone();
5814 ImpactSkipData::new(entries, blocks.len())
5815 })
5816 .transpose()?;
5817
5818 Ok(Self {
5819 max_score,
5820 length,
5821 blocks,
5822 posting_tail_codec,
5823 block_size,
5824 positions,
5825 impacts,
5826 first_docs: FirstDocsState::standalone(),
5827 })
5828 }
5829
5830 pub fn iter(&self) -> CompressedPostingListIterator {
5831 CompressedPostingListIterator::new(
5832 self.length as usize,
5833 self.blocks.clone(),
5834 self.posting_tail_codec,
5835 self.positions.clone(),
5836 self.block_size,
5837 )
5838 }
5839
5840 pub fn block_max_score(&self, block_idx: usize) -> f32 {
5841 if super::encoding::posting_block_score_prefix_len(self.block_size) == 0 {
5846 return self.max_score;
5847 }
5848 let block = self.blocks.value(block_idx);
5849 block[0..4].try_into().map(f32::from_le_bytes).unwrap()
5850 }
5851
5852 #[inline]
5853 pub fn block_least_doc_id(&self, block_idx: usize) -> u32 {
5854 self.block_first_docs()[block_idx]
5855 }
5856
5857 pub(crate) fn block_first_docs(&self) -> &[u32] {
5862 self.first_docs.get_or_init(|| {
5863 (0..self.blocks.len())
5864 .map(|block_idx| {
5865 let block = self.blocks.value(block_idx);
5866 let remainder = self.length as usize % self.block_size;
5867 if block_idx + 1 == self.blocks.len() && remainder > 0 {
5868 return super::encoding::read_posting_tail_first_doc(
5869 block,
5870 self.posting_tail_codec,
5871 self.block_size,
5872 );
5873 }
5874 let prefix = super::encoding::posting_block_score_prefix_len(self.block_size);
5875 block[prefix..prefix + 4]
5876 .try_into()
5877 .map(u32::from_le_bytes)
5878 .unwrap()
5879 })
5880 .collect::<Vec<_>>()
5881 .into_boxed_slice()
5882 })
5883 }
5884
5885 #[cfg(test)]
5886 fn shares_first_docs_with(&self, other: &Self) -> bool {
5887 self.first_docs.shares_state_with(&other.first_docs)
5888 }
5889}
5890
5891#[derive(Debug, Clone, PartialEq, Eq, Default)]
5892struct EncodedBlocks {
5893 offsets: Vec<u32>,
5894 bytes: Vec<u8>,
5895}
5896
5897impl EncodedBlocks {
5898 fn len(&self) -> usize {
5899 self.offsets.len()
5900 }
5901
5902 fn size(&self) -> usize {
5903 self.offsets.capacity() * std::mem::size_of::<u32>() + self.bytes.capacity()
5904 }
5905
5906 fn push_full_block(&mut self, doc_ids: &[u32], frequencies: &[u32]) -> Result<usize> {
5907 let start = self.bytes.len();
5908 self.offsets.push(start as u32);
5909 super::encoding::encode_full_posting_block_into(doc_ids, frequencies, &mut self.bytes)?;
5910 Ok(self.bytes.len() - start)
5911 }
5912
5913 fn block(&self, index: usize) -> &[u8] {
5914 let (start, end) = self.block_range(index);
5915 &self.bytes[start..end]
5916 }
5917
5918 fn block_range(&self, index: usize) -> (usize, usize) {
5919 let start = self.offsets[index] as usize;
5920 let end = self
5921 .offsets
5922 .get(index + 1)
5923 .map(|offset| *offset as usize)
5924 .unwrap_or(self.bytes.len());
5925 (start, end)
5926 }
5927
5928 fn set_block_score(&mut self, index: usize, score: f32) {
5929 let (start, _) = self.block_range(index);
5930 self.bytes[start..start + 4].copy_from_slice(&score.to_le_bytes());
5931 }
5932
5933 fn append_remainder_block_with_codec(
5934 &mut self,
5935 doc_ids: &[u32],
5936 frequencies: &[u32],
5937 codec: PostingTailCodec,
5938 block_size: usize,
5939 ) -> Result<()> {
5940 self.offsets.push(self.bytes.len() as u32);
5941 super::encoding::encode_remainder_posting_block_into(
5942 doc_ids,
5943 frequencies,
5944 codec,
5945 block_size,
5946 &mut self.bytes,
5947 )
5948 }
5949
5950 fn into_array(mut self) -> LargeBinaryArray {
5951 let mut offsets = Vec::with_capacity(self.offsets.len() + 1);
5952 offsets.extend(self.offsets.into_iter().map(i64::from));
5953 offsets.push(self.bytes.len() as i64);
5954 LargeBinaryArray::new(
5955 OffsetBuffer::new(ScalarBuffer::from(offsets)),
5956 Buffer::from_vec(std::mem::take(&mut self.bytes)),
5957 None,
5958 )
5959 }
5960
5961 fn iter(&self) -> impl Iterator<Item = &[u8]> {
5962 (0..self.len()).map(|index| self.block(index))
5963 }
5964}
5965
5966#[derive(Debug, Clone, PartialEq, Eq, Default)]
5967struct EncodedPositionBlocks {
5968 offsets: Vec<u32>,
5969 bytes: Vec<u8>,
5970}
5971
5972impl EncodedPositionBlocks {
5973 fn size(&self) -> usize {
5974 self.offsets.capacity() * std::mem::size_of::<u32>() + self.bytes.capacity()
5975 }
5976
5977 fn block(&self, index: usize) -> &[u8] {
5978 let start = self.offsets[index] as usize;
5979 let end = self
5980 .offsets
5981 .get(index + 1)
5982 .map(|offset| *offset as usize)
5983 .unwrap_or(self.bytes.len());
5984 &self.bytes[start..end]
5985 }
5986
5987 fn push_encoded_block(&mut self, block: &[u8]) -> usize {
5988 let start = self.bytes.len();
5989 self.offsets.push(start as u32);
5990 self.bytes.extend_from_slice(block);
5991 self.bytes.len() - start
5992 }
5993
5994 fn into_stream(self) -> SharedPositionStream {
5995 SharedPositionStream::new(
5996 PositionStreamCodec::PackedDelta,
5997 self.offsets,
5998 bytes::Bytes::from(self.bytes),
5999 )
6000 }
6001}
6002
6003#[derive(Debug)]
6004pub struct PostingListBuilder {
6005 with_positions: bool,
6006 posting_tail_codec: PostingTailCodec,
6007 encoded_blocks: Option<Box<EncodedBlocks>>,
6008 encoded_position_blocks: Option<Box<EncodedPositionBlocks>>,
6009 tail_entries: Vec<RawDocInfo>,
6010 tail_positions: PositionBlockBuilder,
6011 open_doc_id: Option<u32>,
6012 open_doc_frequency: u32,
6013 open_doc_last_position: Option<u32>,
6014 block_size: usize,
6015 memory_size_bytes: u32,
6016 len: u32,
6017}
6018
6019pub(super) struct PostingListBatchBuilder {
6020 schema: SchemaRef,
6021 postings: ListBuilder<LargeBinaryBuilder>,
6022 impacts: Option<ListBuilder<LargeBinaryBuilder>>,
6023 max_scores: Float32Builder,
6024 lengths: UInt32Builder,
6025 positions: BatchPositionsBuilder,
6026 len: usize,
6027}
6028
6029enum BatchPositionsBuilder {
6030 None,
6031 Legacy(ListBuilder<ListBuilder<LargeBinaryBuilder>>),
6032 Shared {
6033 bytes: LargeBinaryBuilder,
6034 block_offsets: ListBuilder<UInt32Builder>,
6035 },
6036}
6037
6038struct PostingListParts<'a> {
6039 with_positions: bool,
6040 posting_tail_codec: PostingTailCodec,
6041 block_size: usize,
6042 length: usize,
6043 encoded_blocks: EncodedBlocks,
6044 encoded_position_blocks: EncodedPositionBlocks,
6045 tail_entries: &'a [RawDocInfo],
6046 tail_position_block: Option<Vec<u8>>,
6047}
6048
6049impl PostingListBatchBuilder {
6050 pub fn new(
6051 schema: SchemaRef,
6052 with_positions: bool,
6053 format_version: InvertedListFormatVersion,
6054 capacity: usize,
6055 ) -> Self {
6056 let positions = if !with_positions {
6057 BatchPositionsBuilder::None
6058 } else if format_version.uses_shared_position_stream() {
6059 BatchPositionsBuilder::Shared {
6060 bytes: LargeBinaryBuilder::with_capacity(capacity, 0),
6061 block_offsets: ListBuilder::with_capacity(UInt32Builder::new(), capacity),
6062 }
6063 } else {
6064 BatchPositionsBuilder::Legacy(ListBuilder::with_capacity(
6065 ListBuilder::new(LargeBinaryBuilder::new()),
6066 capacity,
6067 ))
6068 };
6069 let impacts = schema
6070 .field_with_name(IMPACT_COL)
6071 .ok()
6072 .map(|_| ListBuilder::with_capacity(LargeBinaryBuilder::new(), capacity));
6073 Self {
6074 schema,
6075 postings: ListBuilder::with_capacity(LargeBinaryBuilder::new(), capacity),
6076 impacts,
6077 max_scores: Float32Builder::with_capacity(capacity),
6078 lengths: UInt32Builder::with_capacity(capacity),
6079 positions,
6080 len: 0,
6081 }
6082 }
6083
6084 pub fn len(&self) -> usize {
6085 self.len
6086 }
6087
6088 pub fn is_empty(&self) -> bool {
6089 self.len == 0
6090 }
6091
6092 fn append(
6093 &mut self,
6094 compressed: LargeBinaryArray,
6095 impacts: Option<&ImpactSkipData>,
6096 max_score: f32,
6097 length: u32,
6098 positions: Option<&CompressedPositionStorage>,
6099 ) -> Result<()> {
6100 {
6101 let values = self.postings.values();
6102 for index in 0..compressed.len() {
6103 values.append_value(compressed.value(index));
6104 }
6105 }
6106 self.postings.append(true);
6107 if let Some(impacts_builder) = &mut self.impacts {
6108 let impacts = impacts.ok_or_else(|| {
6109 Error::index(format!(
6110 "impacts builder missing impact data for posting length {}",
6111 length
6112 ))
6113 })?;
6114 let values = impacts_builder.values();
6115 for index in 0..impacts.entries().len() {
6116 values.append_value(impacts.entries().value(index));
6117 }
6118 impacts_builder.append(true);
6119 }
6120 self.max_scores.append_value(max_score);
6121 self.lengths.append_value(length);
6122
6123 match &mut self.positions {
6124 BatchPositionsBuilder::None => {}
6125 BatchPositionsBuilder::Shared {
6126 bytes,
6127 block_offsets,
6128 } => {
6129 let positions = positions.ok_or_else(|| {
6130 Error::index(format!(
6131 "positions builder missing position data for posting length {}",
6132 length
6133 ))
6134 })?;
6135 let CompressedPositionStorage::SharedStream(positions) = positions else {
6136 return Err(Error::index(
6137 "shared positions builder received legacy positions".to_owned(),
6138 ));
6139 };
6140 bytes.append_value(positions.bytes());
6141 let offsets_builder = block_offsets.values();
6142 for &offset in positions.block_offsets() {
6143 offsets_builder.append_value(offset);
6144 }
6145 block_offsets.append(true);
6146 }
6147 BatchPositionsBuilder::Legacy(position_lists) => {
6148 let positions = positions.ok_or_else(|| {
6149 Error::index(format!(
6150 "positions builder missing position data for posting length {}",
6151 length
6152 ))
6153 })?;
6154 let CompressedPositionStorage::LegacyPerDoc(positions) = positions else {
6155 return Err(Error::index(
6156 "legacy positions builder received shared position stream".to_owned(),
6157 ));
6158 };
6159 let docs_builder = position_lists.values();
6160 for doc_idx in 0..positions.len() {
6161 let doc_positions = positions.value(doc_idx);
6162 let compressed_positions = doc_positions.as_binary::<i64>();
6163 for block_idx in 0..compressed_positions.len() {
6164 docs_builder
6165 .values()
6166 .append_value(compressed_positions.value(block_idx));
6167 }
6168 docs_builder.append(true);
6169 }
6170 position_lists.append(true);
6171 }
6172 }
6173
6174 self.len += 1;
6175 Ok(())
6176 }
6177
6178 pub fn finish(&mut self) -> Result<RecordBatch> {
6179 let mut columns = vec![
6180 Arc::new(self.postings.finish()) as ArrayRef,
6181 Arc::new(self.max_scores.finish()) as ArrayRef,
6182 Arc::new(self.lengths.finish()) as ArrayRef,
6183 ];
6184 if let Some(impacts) = &mut self.impacts {
6185 columns.push(Arc::new(impacts.finish()) as ArrayRef);
6186 }
6187 match &mut self.positions {
6188 BatchPositionsBuilder::None => {}
6189 BatchPositionsBuilder::Legacy(position_lists) => {
6190 columns.push(Arc::new(position_lists.finish()) as ArrayRef);
6191 }
6192 BatchPositionsBuilder::Shared {
6193 bytes,
6194 block_offsets,
6195 } => {
6196 columns.push(Arc::new(bytes.finish()) as ArrayRef);
6197 columns.push(Arc::new(block_offsets.finish()) as ArrayRef);
6198 }
6199 }
6200 self.len = 0;
6201 RecordBatch::try_new(self.schema.clone(), columns).map_err(Error::from)
6202 }
6203}
6204
6205impl PostingListBuilder {
6206 pub fn size(&self) -> u64 {
6207 self.memory_size_bytes as u64
6208 }
6209
6210 pub fn has_positions(&self) -> bool {
6211 self.with_positions
6212 }
6213
6214 pub fn new(with_position: bool) -> Self {
6215 Self::new_with_posting_tail_codec_and_block_size(
6216 with_position,
6217 current_fts_format_version().posting_tail_codec(),
6218 LEGACY_BLOCK_SIZE,
6219 )
6220 }
6221
6222 pub fn new_with_posting_tail_codec(
6223 with_position: bool,
6224 posting_tail_codec: PostingTailCodec,
6225 ) -> Self {
6226 Self::new_with_posting_tail_codec_and_block_size(
6227 with_position,
6228 posting_tail_codec,
6229 LEGACY_BLOCK_SIZE,
6230 )
6231 }
6232
6233 pub fn new_with_block_size(with_position: bool, block_size: usize) -> Self {
6234 Self::new_with_posting_tail_codec_and_block_size(
6235 with_position,
6236 current_fts_format_version().posting_tail_codec(),
6237 block_size,
6238 )
6239 }
6240
6241 pub fn new_with_posting_tail_codec_and_block_size(
6242 with_position: bool,
6243 posting_tail_codec: PostingTailCodec,
6244 block_size: usize,
6245 ) -> Self {
6246 validate_block_size(block_size).expect("invalid posting list block size");
6247 Self {
6248 with_positions: with_position,
6249 posting_tail_codec,
6250 encoded_blocks: None,
6251 encoded_position_blocks: None,
6252 tail_entries: Vec::new(),
6253 tail_positions: PositionBlockBuilder::default(),
6254 open_doc_id: None,
6255 open_doc_frequency: 0,
6256 open_doc_last_position: None,
6257 block_size,
6258 len: 0,
6259 memory_size_bytes: 0,
6260 }
6261 }
6262
6263 pub fn len(&self) -> usize {
6264 self.len as usize
6265 }
6266
6267 pub fn is_empty(&self) -> bool {
6268 self.len == 0
6269 }
6270
6271 pub fn iter(&self) -> std::vec::IntoIter<(u32, u32, Option<Vec<u32>>)> {
6272 self.collect_entries().into_iter()
6273 }
6274
6275 pub fn for_each_entry<E>(
6276 &self,
6277 mut visit: impl FnMut(u32, u32, Option<Vec<u32>>) -> std::result::Result<(), E>,
6278 ) -> std::result::Result<(), E> {
6279 let mut doc_ids = Vec::with_capacity(self.block_size);
6280 let mut frequencies = Vec::with_capacity(self.block_size);
6281 let mut decoded_positions = Vec::new();
6282 let mut position_block_index = 0usize;
6283
6284 if let Some(encoded_blocks) = self.encoded_blocks.as_deref() {
6285 for block in encoded_blocks.iter() {
6286 doc_ids.clear();
6287 frequencies.clear();
6288 super::encoding::decode_full_posting_block(
6289 block,
6290 &mut doc_ids,
6291 &mut frequencies,
6292 self.block_size,
6293 );
6294 decoded_positions.clear();
6295 if self.with_positions {
6296 let position_blocks = self
6297 .encoded_position_blocks
6298 .as_deref()
6299 .expect("positions must exist for posting list");
6300 super::encoding::decode_position_stream_block(
6301 position_blocks.block(position_block_index),
6302 &frequencies,
6303 PositionStreamCodec::PackedDelta,
6304 &mut decoded_positions,
6305 )
6306 .expect("position stream decoding should succeed");
6307 position_block_index += 1;
6308 }
6309 let mut offset = 0usize;
6310 for (doc_id, frequency) in doc_ids.iter().copied().zip(frequencies.iter().copied())
6311 {
6312 let positions = self.with_positions.then(|| {
6313 let end = offset + frequency as usize;
6314 let doc_positions = decoded_positions[offset..end].to_vec();
6315 offset = end;
6316 doc_positions
6317 });
6318 visit(doc_id, frequency, positions)?;
6319 }
6320 }
6321 }
6322
6323 let mut decoded_tail_positions = Vec::new();
6324 if self.with_positions && !self.tail_entries.is_empty() {
6325 let tail_frequencies = self
6326 .tail_entries
6327 .iter()
6328 .map(|entry| entry.frequency)
6329 .collect::<Vec<_>>();
6330 self.tail_positions
6331 .decode_into(tail_frequencies.as_slice(), &mut decoded_tail_positions)
6332 .expect("tail position stream decoding should succeed");
6333 }
6334 let mut tail_offset = 0usize;
6335 for entry in &self.tail_entries {
6336 let positions = self.with_positions.then(|| {
6337 let end = tail_offset + entry.frequency as usize;
6338 let doc_positions = decoded_tail_positions[tail_offset..end].to_vec();
6339 tail_offset = end;
6340 doc_positions
6341 });
6342 visit(entry.doc_id, entry.frequency, positions)?;
6343 }
6344
6345 Ok(())
6346 }
6347
6348 pub fn add(&mut self, doc_id: u32, term_positions: PositionRecorder) {
6349 debug_assert!(
6350 self.open_doc_id.is_none(),
6351 "cannot add closed doc while a positions doc is still open"
6352 );
6353 let tail_entries_capacity_before = self.tail_entries.capacity();
6354 self.tail_entries
6355 .push(RawDocInfo::new(doc_id, term_positions.len()));
6356 let tail_entries_capacity_after = self.tail_entries.capacity();
6357 if tail_entries_capacity_after > tail_entries_capacity_before {
6358 self.add_memory_bytes(
6359 (tail_entries_capacity_after - tail_entries_capacity_before)
6360 * std::mem::size_of::<RawDocInfo>(),
6361 );
6362 }
6363 if let PositionRecorder::Position(positions_in_doc) = term_positions {
6364 debug_assert!(self.with_positions);
6365 let old_size = self.tail_positions.size();
6366 self.tail_positions
6367 .append_doc_positions(positions_in_doc.as_slice())
6368 .expect("position stream encoding should succeed");
6369 self.adjust_tail_positions_size(old_size);
6370 }
6371 self.len += 1;
6372
6373 if self.tail_entries.len() == self.block_size {
6374 self.flush_tail_block()
6375 .expect("posting list block compression should succeed");
6376 }
6377 }
6378
6379 pub fn add_occurrence(&mut self, doc_id: u32, position: u32) -> Result<bool> {
6380 if !self.with_positions {
6381 return Err(Error::index(
6382 "cannot append streamed positions to a posting list without positions".to_owned(),
6383 ));
6384 }
6385
6386 match self.open_doc_id {
6387 Some(open_doc_id) if open_doc_id == doc_id => {
6388 let old_size = self.tail_positions.size();
6389 self.tail_positions
6390 .append_position(position, self.open_doc_last_position)?;
6391 self.adjust_tail_positions_size(old_size);
6392 self.open_doc_frequency += 1;
6393 self.open_doc_last_position = Some(position);
6394 Ok(false)
6395 }
6396 Some(open_doc_id) => Err(Error::index(format!(
6397 "posting list received doc {} before finishing open doc {}",
6398 doc_id, open_doc_id
6399 ))),
6400 None => {
6401 let old_size = self.tail_positions.size();
6402 self.tail_positions.append_position(position, None)?;
6403 self.adjust_tail_positions_size(old_size);
6404 self.open_doc_id = Some(doc_id);
6405 self.open_doc_frequency = 1;
6406 self.open_doc_last_position = Some(position);
6407 self.len += 1;
6408 Ok(true)
6409 }
6410 }
6411 }
6412
6413 pub fn finish_open_doc(&mut self, doc_id: u32) -> Result<()> {
6414 if !self.with_positions {
6415 return Ok(());
6416 }
6417 match self.open_doc_id {
6418 Some(open_doc_id) if open_doc_id == doc_id => {
6419 let tail_entries_capacity_before = self.tail_entries.capacity();
6420 self.tail_entries
6421 .push(RawDocInfo::new(doc_id, self.open_doc_frequency));
6422 let tail_entries_capacity_after = self.tail_entries.capacity();
6423 if tail_entries_capacity_after > tail_entries_capacity_before {
6424 self.add_memory_bytes(
6425 (tail_entries_capacity_after - tail_entries_capacity_before)
6426 * std::mem::size_of::<RawDocInfo>(),
6427 );
6428 }
6429 self.open_doc_id = None;
6430 self.open_doc_frequency = 0;
6431 self.open_doc_last_position = None;
6432 if self.tail_entries.len() == self.block_size {
6433 self.flush_tail_block()?;
6434 }
6435 Ok(())
6436 }
6437 Some(open_doc_id) => Err(Error::index(format!(
6438 "attempted to finish doc {} while doc {} is still open",
6439 doc_id, open_doc_id
6440 ))),
6441 None => Ok(()),
6442 }
6443 }
6444
6445 fn collect_entries(&self) -> Vec<(u32, u32, Option<Vec<u32>>)> {
6446 let mut entries = Vec::with_capacity(self.len());
6447 self.for_each_entry(|doc_id, frequency, positions| {
6448 entries.push((doc_id, frequency, positions));
6449 Ok::<(), ()>(())
6450 })
6451 .expect("collecting posting list entries should not fail");
6452 entries
6453 }
6454
6455 fn encoded_blocks_mut(&mut self) -> &mut EncodedBlocks {
6456 if self.encoded_blocks.is_none() {
6457 self.encoded_blocks = Some(Box::default());
6458 self.add_memory_bytes(std::mem::size_of::<EncodedBlocks>());
6459 }
6460 self.encoded_blocks
6461 .as_deref_mut()
6462 .expect("encoded blocks must exist")
6463 }
6464
6465 fn encoded_position_blocks_mut(&mut self) -> &mut EncodedPositionBlocks {
6466 if self.encoded_position_blocks.is_none() {
6467 self.encoded_position_blocks = Some(Box::default());
6468 self.add_memory_bytes(std::mem::size_of::<EncodedPositionBlocks>());
6469 }
6470 self.encoded_position_blocks
6471 .as_deref_mut()
6472 .expect("encoded position blocks must exist")
6473 }
6474
6475 fn flush_tail_block(&mut self) -> Result<()> {
6476 if self.tail_entries.is_empty() {
6477 return Ok(());
6478 }
6479 debug_assert!(
6480 self.open_doc_id.is_none(),
6481 "cannot flush a posting block while a document is still open"
6482 );
6483 debug_assert_eq!(self.tail_entries.len(), self.block_size);
6484 let doc_ids = self
6485 .tail_entries
6486 .iter()
6487 .map(|entry| entry.doc_id)
6488 .collect::<Vec<_>>();
6489 let frequencies = self
6490 .tail_entries
6491 .iter()
6492 .map(|entry| entry.frequency)
6493 .collect::<Vec<_>>();
6494 let encoded_blocks_size_before = self
6495 .encoded_blocks
6496 .as_ref()
6497 .map(|encoded_blocks| encoded_blocks.size())
6498 .unwrap_or(0usize);
6499 self.encoded_blocks_mut()
6500 .push_full_block(&doc_ids, &frequencies)?;
6501 let encoded_blocks_size_after = self
6502 .encoded_blocks
6503 .as_ref()
6504 .map(|encoded_blocks| encoded_blocks.size())
6505 .unwrap_or(0usize);
6506 if encoded_blocks_size_after > encoded_blocks_size_before {
6507 self.add_memory_bytes(encoded_blocks_size_after - encoded_blocks_size_before);
6508 }
6509 if self.with_positions {
6510 let encoded_positions_size_before = self
6511 .encoded_position_blocks
6512 .as_ref()
6513 .map(|encoded| encoded.size())
6514 .unwrap_or(0usize);
6515 let released_tail_positions_bytes = self.tail_positions.size();
6516 let tail_position_block = std::mem::take(&mut self.tail_positions).finish();
6517 self.encoded_position_blocks_mut()
6518 .push_encoded_block(tail_position_block.as_slice());
6519 let encoded_positions_size_after = self
6520 .encoded_position_blocks
6521 .as_ref()
6522 .map(|encoded| encoded.size())
6523 .unwrap_or(0usize);
6524 if released_tail_positions_bytes > 0 {
6525 self.subtract_memory_bytes(released_tail_positions_bytes);
6526 }
6527 if encoded_positions_size_after > encoded_positions_size_before {
6528 self.add_memory_bytes(encoded_positions_size_after - encoded_positions_size_before);
6529 }
6530 }
6531 self.tail_entries.clear();
6532 Ok(())
6533 }
6534
6535 fn adjust_tail_positions_size(&mut self, old_size: usize) {
6536 let new_size = self.tail_positions.size();
6537 if new_size > old_size {
6538 self.add_memory_bytes(new_size - old_size);
6539 } else if old_size > new_size {
6540 self.subtract_memory_bytes(old_size - new_size);
6541 }
6542 }
6543
6544 fn add_memory_bytes(&mut self, bytes: usize) {
6545 self.memory_size_bytes = self
6546 .memory_size_bytes
6547 .checked_add(
6548 u32::try_from(bytes).expect("posting list memory size delta overflowed u32"),
6549 )
6550 .expect("posting list memory size overflowed u32");
6551 }
6552
6553 fn subtract_memory_bytes(&mut self, bytes: usize) {
6554 self.memory_size_bytes = self
6555 .memory_size_bytes
6556 .checked_sub(
6557 u32::try_from(bytes).expect("posting list memory size delta overflowed u32"),
6558 )
6559 .expect("posting list memory size underflowed u32");
6560 }
6561
6562 fn build_position_columns(
6563 positions: Option<CompressedPositionStorage>,
6564 ) -> Result<Vec<ArrayRef>> {
6565 let Some(positions) = positions else {
6566 return Ok(Vec::new());
6567 };
6568 match positions {
6569 CompressedPositionStorage::LegacyPerDoc(positions) => {
6570 Ok(vec![Arc::new(ListArray::try_new(
6571 Arc::new(Field::new("item", positions.data_type().clone(), true)),
6572 OffsetBuffer::new(ScalarBuffer::from(vec![0_i32, positions.len() as i32])),
6573 Arc::new(positions) as ArrayRef,
6574 None,
6575 )?) as ArrayRef])
6576 }
6577 CompressedPositionStorage::SharedStream(positions) => {
6578 let mut columns = Vec::with_capacity(2);
6579 columns.push(
6580 Arc::new(LargeBinaryArray::from(vec![Some(positions.bytes())])) as ArrayRef,
6581 );
6582
6583 let mut offsets_builder = ListBuilder::new(UInt32Builder::new());
6584 for &offset in positions.block_offsets() {
6585 offsets_builder.values().append_value(offset);
6586 }
6587 offsets_builder.append(true);
6588 columns.push(Arc::new(offsets_builder.finish()) as ArrayRef);
6589 Ok(columns)
6590 }
6591 }
6592 }
6593
6594 fn build_batch(
6595 self,
6596 compressed: LargeBinaryArray,
6597 impacts: Option<ImpactSkipData>,
6598 max_score: f32,
6599 schema: SchemaRef,
6600 positions: Option<CompressedPositionStorage>,
6601 ) -> Result<RecordBatch> {
6602 let length = self.len();
6603 let offsets = OffsetBuffer::new(ScalarBuffer::from(vec![0, compressed.len() as i32]));
6604 let mut columns = vec![
6605 Arc::new(ListArray::try_new(
6606 Arc::new(Field::new("item", datatypes::DataType::LargeBinary, true)),
6607 offsets,
6608 Arc::new(compressed),
6609 None,
6610 )?) as ArrayRef,
6611 Arc::new(Float32Array::from_iter_values(std::iter::once(max_score))) as ArrayRef,
6612 Arc::new(UInt32Array::from_iter_values(std::iter::once(
6613 length as u32,
6614 ))) as ArrayRef,
6615 ];
6616 if schema.field_with_name(IMPACT_COL).is_ok() {
6617 let impacts = impacts.ok_or_else(|| {
6618 Error::index(format!(
6619 "impact column requested without impact data for posting length {}",
6620 length
6621 ))
6622 })?;
6623 let impact_offsets =
6624 OffsetBuffer::new(ScalarBuffer::from(vec![0, impacts.entries().len() as i32]));
6625 columns.push(Arc::new(ListArray::try_new(
6626 Arc::new(Field::new("item", datatypes::DataType::LargeBinary, true)),
6627 impact_offsets,
6628 Arc::new(impacts.entries().clone()),
6629 None,
6630 )?) as ArrayRef);
6631 }
6632 columns.extend(Self::build_position_columns(positions)?);
6633
6634 let batch = RecordBatch::try_new(schema, columns)?;
6635 Ok(batch)
6636 }
6637
6638 fn build_legacy_positions(&self) -> Result<ListArray> {
6639 let mut positions_builder = ListBuilder::new(LargeBinaryBuilder::new());
6640 self.for_each_entry(|_doc_id, frequency, positions| {
6641 let positions = positions.ok_or_else(|| {
6642 Error::index(format!(
6643 "legacy position writer missing positions for frequency {}",
6644 frequency
6645 ))
6646 })?;
6647 let compressed = super::encoding::compress_positions(positions.as_slice())?;
6648 for block_idx in 0..compressed.len() {
6649 positions_builder
6650 .values()
6651 .append_value(compressed.value(block_idx));
6652 }
6653 positions_builder.append(true);
6654 Ok::<(), Error>(())
6655 })?;
6656 Ok(positions_builder.finish())
6657 }
6658
6659 pub(super) fn append_to_batch_with_docs(
6660 self,
6661 docs: &DocSet,
6662 batch_builder: &mut PostingListBatchBuilder,
6663 format_version: InvertedListFormatVersion,
6664 ) -> Result<()> {
6665 let legacy_positions =
6666 if self.with_positions && !format_version.uses_shared_position_stream() {
6667 Some(self.build_legacy_positions()?)
6668 } else {
6669 None
6670 };
6671 let Self {
6672 with_positions,
6673 posting_tail_codec,
6674 encoded_blocks,
6675 encoded_position_blocks,
6676 tail_entries,
6677 tail_positions,
6678 open_doc_id,
6679 open_doc_frequency,
6680 open_doc_last_position,
6681 block_size,
6682 len,
6683 ..
6684 } = self;
6685 debug_assert!(open_doc_id.is_none());
6686 debug_assert_eq!(open_doc_frequency, 0);
6687 debug_assert!(open_doc_last_position.is_none());
6688 let parts = PostingListParts {
6689 with_positions,
6690 posting_tail_codec,
6691 block_size,
6692 length: len as usize,
6693 encoded_blocks: encoded_blocks
6694 .map(|encoded_blocks| *encoded_blocks)
6695 .unwrap_or_default(),
6696 encoded_position_blocks: encoded_position_blocks
6697 .map(|encoded_positions| *encoded_positions)
6698 .unwrap_or_default(),
6699 tail_entries: tail_entries.as_slice(),
6700 tail_position_block: with_positions.then(|| tail_positions.finish()),
6701 };
6702 let (compressed, shared_positions, max_score, impacts) =
6703 Self::build_compressed_with_scores_from_parts(parts, docs)?;
6704 let positions = match legacy_positions {
6705 Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)),
6706 None => shared_positions.map(CompressedPositionStorage::SharedStream),
6707 };
6708 batch_builder.append(
6709 compressed,
6710 Some(&impacts),
6711 max_score,
6712 len,
6713 positions.as_ref(),
6714 )
6715 }
6716
6717 fn extend_tail_components(
6718 tail_entries: &[RawDocInfo],
6719 doc_ids: &mut Vec<u32>,
6720 frequencies: &mut Vec<u32>,
6721 ) {
6722 doc_ids.clear();
6723 frequencies.clear();
6724 doc_ids.extend(tail_entries.iter().map(|entry| entry.doc_id));
6725 frequencies.extend(tail_entries.iter().map(|entry| entry.frequency));
6726 }
6727
6728 fn build_compressed_with_scores_from_parts(
6729 parts: PostingListParts<'_>,
6730 docs: &DocSet,
6731 ) -> Result<(
6732 LargeBinaryArray,
6733 Option<SharedPositionStream>,
6734 f32,
6735 ImpactSkipData,
6736 )> {
6737 let PostingListParts {
6738 with_positions,
6739 posting_tail_codec,
6740 length,
6741 block_size,
6742 mut encoded_blocks,
6743 mut encoded_position_blocks,
6744 tail_entries,
6745 tail_position_block,
6746 } = parts;
6747 let avgdl = docs.average_length();
6748 let idf_scale = idf(length, docs.len()) * (K1 + 1.0);
6749 let mut max_score = f32::MIN;
6750 let mut doc_ids = Vec::with_capacity(block_size);
6751 let mut frequencies = Vec::with_capacity(block_size);
6752 let mut impact_block = Vec::with_capacity(block_size);
6753 let mut impact_builder =
6754 ImpactSkipDataBuilder::with_capacity(length.div_ceil(block_size), block_size);
6755
6756 for index in 0..encoded_blocks.len() {
6757 let block = encoded_blocks.block(index);
6758 doc_ids.clear();
6759 frequencies.clear();
6760 super::encoding::decode_full_posting_block(
6761 block,
6762 &mut doc_ids,
6763 &mut frequencies,
6764 block_size,
6765 );
6766 let block_score = compute_block_score_and_impact_block(
6767 docs,
6768 avgdl,
6769 idf_scale,
6770 doc_ids.iter().copied(),
6771 frequencies.iter().copied(),
6772 &mut impact_block,
6773 );
6774 impact_builder.append_block(impact_block.as_slice())?;
6775 max_score = max_score.max(block_score);
6776 if super::encoding::posting_block_score_prefix_len(block_size) > 0 {
6777 encoded_blocks.set_block_score(index, block_score);
6778 }
6779 }
6780
6781 if !tail_entries.is_empty() {
6782 Self::extend_tail_components(tail_entries, &mut doc_ids, &mut frequencies);
6783 let block_score = compute_block_score_and_impact_block(
6784 docs,
6785 avgdl,
6786 idf_scale,
6787 doc_ids.iter().copied(),
6788 frequencies.iter().copied(),
6789 &mut impact_block,
6790 );
6791 impact_builder.append_block(impact_block.as_slice())?;
6792 max_score = max_score.max(block_score);
6793 encoded_blocks.append_remainder_block_with_codec(
6794 doc_ids.as_slice(),
6795 frequencies.as_slice(),
6796 posting_tail_codec,
6797 block_size,
6798 )?;
6799 if super::encoding::posting_block_score_prefix_len(block_size) > 0 {
6800 encoded_blocks.set_block_score(encoded_blocks.len() - 1, block_score);
6801 }
6802 if with_positions {
6803 encoded_position_blocks.push_encoded_block(
6804 tail_position_block
6805 .as_deref()
6806 .expect("tail position block must exist for postings with positions"),
6807 );
6808 }
6809 }
6810
6811 let impacts = impact_builder.finish()?;
6812 Ok((
6813 encoded_blocks.into_array(),
6814 with_positions.then(|| encoded_position_blocks.into_stream()),
6815 max_score,
6816 impacts,
6817 ))
6818 }
6819
6820 #[allow(clippy::too_many_arguments)]
6821 fn build_compressed_with_block_scores_from_parts(
6822 with_positions: bool,
6823 posting_tail_codec: PostingTailCodec,
6824 block_size: usize,
6825 mut encoded_blocks: EncodedBlocks,
6826 mut encoded_position_blocks: EncodedPositionBlocks,
6827 tail_entries: &[RawDocInfo],
6828 tail_position_block: Option<Vec<u8>>,
6829 mut block_max_scores: impl Iterator<Item = f32>,
6830 ) -> Result<(LargeBinaryArray, Option<SharedPositionStream>, f32)> {
6831 let has_score_prefix = super::encoding::posting_block_score_prefix_len(block_size) > 0;
6832 let mut max_score = f32::MIN;
6833 let mut doc_ids = Vec::with_capacity(BLOCK_SIZE);
6834 let mut frequencies = Vec::with_capacity(BLOCK_SIZE);
6835
6836 for index in 0..encoded_blocks.len() {
6837 let block_score = block_max_scores
6838 .next()
6839 .ok_or_else(|| Error::index("missing block max score".to_owned()))?;
6840 max_score = max_score.max(block_score);
6841 if has_score_prefix {
6842 encoded_blocks.set_block_score(index, block_score);
6843 }
6844 }
6845
6846 if !tail_entries.is_empty() {
6847 let block_score = block_max_scores
6848 .next()
6849 .ok_or_else(|| Error::index("missing tail block max score".to_owned()))?;
6850 max_score = max_score.max(block_score);
6851 Self::extend_tail_components(tail_entries, &mut doc_ids, &mut frequencies);
6852 encoded_blocks.append_remainder_block_with_codec(
6853 doc_ids.as_slice(),
6854 frequencies.as_slice(),
6855 posting_tail_codec,
6856 block_size,
6857 )?;
6858 if has_score_prefix {
6859 encoded_blocks.set_block_score(encoded_blocks.len() - 1, block_score);
6860 }
6861 if with_positions {
6862 encoded_position_blocks.push_encoded_block(
6863 tail_position_block
6864 .as_deref()
6865 .expect("tail position block must exist for postings with positions"),
6866 );
6867 }
6868 }
6869
6870 Ok((
6871 encoded_blocks.into_array(),
6872 with_positions.then(|| encoded_position_blocks.into_stream()),
6873 max_score,
6874 ))
6875 }
6876
6877 pub fn to_batch(self, block_max_scores: Vec<f32>) -> Result<RecordBatch> {
6878 let format_version = InvertedListFormatVersion::from_posting_tail_codec_and_block_size(
6879 self.posting_tail_codec,
6880 self.block_size,
6881 )?;
6882 let schema = inverted_list_schema_for_version_with_block_size_and_impacts(
6883 self.has_positions(),
6884 format_version,
6885 self.block_size,
6886 false,
6887 );
6888 let legacy_positions =
6889 if self.with_positions && !format_version.uses_shared_position_stream() {
6890 Some(self.build_legacy_positions()?)
6891 } else {
6892 None
6893 };
6894 let Self {
6895 with_positions,
6896 posting_tail_codec,
6897 encoded_blocks,
6898 encoded_position_blocks,
6899 tail_entries,
6900 tail_positions,
6901 open_doc_id,
6902 open_doc_frequency,
6903 open_doc_last_position,
6904 block_size,
6905 len,
6906 ..
6907 } = self;
6908 debug_assert!(open_doc_id.is_none());
6909 debug_assert_eq!(open_doc_frequency, 0);
6910 debug_assert!(open_doc_last_position.is_none());
6911 let (compressed, shared_positions, max_score) =
6912 Self::build_compressed_with_block_scores_from_parts(
6913 with_positions,
6914 posting_tail_codec,
6915 block_size,
6916 encoded_blocks
6917 .map(|encoded_blocks| *encoded_blocks)
6918 .unwrap_or_default(),
6919 encoded_position_blocks
6920 .map(|encoded_positions| *encoded_positions)
6921 .unwrap_or_default(),
6922 tail_entries.as_slice(),
6923 with_positions.then(|| tail_positions.finish()),
6924 block_max_scores.into_iter(),
6925 )?;
6926 let builder = Self {
6927 with_positions,
6928 posting_tail_codec,
6929 encoded_blocks: None,
6930 encoded_position_blocks: None,
6931 tail_entries: Vec::new(),
6932 tail_positions: PositionBlockBuilder::default(),
6933 open_doc_id: None,
6934 open_doc_frequency: 0,
6935 open_doc_last_position: None,
6936 block_size,
6937 memory_size_bytes: 0,
6938 len,
6939 };
6940 let positions = match legacy_positions {
6941 Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)),
6942 None => shared_positions.map(CompressedPositionStorage::SharedStream),
6943 };
6944 builder.build_batch(compressed, None, max_score, schema, positions)
6945 }
6946
6947 pub fn to_batch_with_docs(self, docs: &DocSet, schema: SchemaRef) -> Result<RecordBatch> {
6948 let format_version = parse_format_version_from_metadata(schema.metadata())?;
6949 let legacy_positions =
6950 if self.with_positions && !format_version.uses_shared_position_stream() {
6951 Some(self.build_legacy_positions()?)
6952 } else {
6953 None
6954 };
6955 let Self {
6956 with_positions,
6957 posting_tail_codec,
6958 encoded_blocks,
6959 encoded_position_blocks,
6960 tail_entries,
6961 tail_positions,
6962 open_doc_id,
6963 open_doc_frequency,
6964 open_doc_last_position,
6965 block_size,
6966 len,
6967 ..
6968 } = self;
6969 debug_assert!(open_doc_id.is_none());
6970 debug_assert_eq!(open_doc_frequency, 0);
6971 debug_assert!(open_doc_last_position.is_none());
6972 let parts = PostingListParts {
6973 with_positions,
6974 posting_tail_codec,
6975 block_size,
6976 length: len as usize,
6977 encoded_blocks: encoded_blocks
6978 .map(|encoded_blocks| *encoded_blocks)
6979 .unwrap_or_default(),
6980 encoded_position_blocks: encoded_position_blocks
6981 .map(|encoded_positions| *encoded_positions)
6982 .unwrap_or_default(),
6983 tail_entries: tail_entries.as_slice(),
6984 tail_position_block: with_positions.then(|| tail_positions.finish()),
6985 };
6986 let (compressed, shared_positions, max_score, impacts) =
6987 Self::build_compressed_with_scores_from_parts(parts, docs)?;
6988 let builder = Self {
6989 with_positions,
6990 posting_tail_codec,
6991 encoded_blocks: None,
6992 encoded_position_blocks: None,
6993 tail_entries: Vec::new(),
6994 tail_positions: PositionBlockBuilder::default(),
6995 open_doc_id: None,
6996 open_doc_frequency: 0,
6997 open_doc_last_position: None,
6998 block_size,
6999 memory_size_bytes: 0,
7000 len,
7001 };
7002 let positions = match legacy_positions {
7003 Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)),
7004 None => shared_positions.map(CompressedPositionStorage::SharedStream),
7005 };
7006 builder.build_batch(compressed, Some(impacts), max_score, schema, positions)
7007 }
7008
7009 pub fn remap(&mut self, removed: &[u32]) {
7010 let mut cursor = 0;
7011 let mut new_builder = Self::new_with_posting_tail_codec_and_block_size(
7012 self.has_positions(),
7013 self.posting_tail_codec,
7014 self.block_size,
7015 );
7016 for (doc_id, freq, positions) in self.iter() {
7017 while cursor < removed.len() && removed[cursor] < doc_id {
7018 cursor += 1;
7019 }
7020 if cursor < removed.len() && removed[cursor] == doc_id {
7021 continue;
7022 }
7023 let positions = match positions {
7024 Some(positions) => PositionRecorder::Position(positions.into()),
7025 None => PositionRecorder::Count(freq),
7026 };
7027 new_builder.add(doc_id - cursor as u32, positions);
7028 }
7029
7030 *self = new_builder;
7031 }
7032}
7033
7034fn compute_block_score_and_impact_block(
7035 docs: &DocSet,
7036 avgdl: f32,
7037 idf_scale: f32,
7038 doc_ids: impl Iterator<Item = u32>,
7039 frequencies: impl Iterator<Item = u32>,
7040 impact_block: &mut Vec<(u32, u32, u32)>,
7041) -> f32 {
7042 impact_block.clear();
7043 let mut block_max_score = f32::MIN;
7044 for (doc_id, freq) in doc_ids.zip(frequencies) {
7045 let doc_len = docs.num_tokens(doc_id);
7046 let doc_norm = K1 * (1.0 - B + B * doc_len as f32 / avgdl);
7047 let freq_f32 = freq as f32;
7048 let score = freq_f32 / (freq_f32 + doc_norm);
7049 block_max_score = block_max_score.max(score);
7050 impact_block.push((doc_id, freq, doc_len));
7051 }
7052 block_max_score * idf_scale
7053}
7054
7055#[derive(Debug, Clone, DeepSizeOf, Copy)]
7056pub enum DocInfo {
7057 Located(LocatedDocInfo),
7058 Raw(RawDocInfo),
7059}
7060
7061impl DocInfo {
7062 pub fn doc_id(&self) -> u64 {
7063 match self {
7064 Self::Raw(info) => info.doc_id as u64,
7065 Self::Located(info) => info.row_id,
7066 }
7067 }
7068
7069 pub fn frequency(&self) -> u32 {
7070 match self {
7071 Self::Raw(info) => info.frequency,
7072 Self::Located(info) => info.frequency as u32,
7073 }
7074 }
7075}
7076
7077impl Eq for DocInfo {}
7078
7079impl PartialEq for DocInfo {
7080 fn eq(&self, other: &Self) -> bool {
7081 self.doc_id() == other.doc_id()
7082 }
7083}
7084
7085impl PartialOrd for DocInfo {
7086 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
7087 Some(self.cmp(other))
7088 }
7089}
7090
7091impl Ord for DocInfo {
7092 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
7093 self.doc_id().cmp(&other.doc_id())
7094 }
7095}
7096
7097#[derive(Debug, Clone, Default, DeepSizeOf, Copy)]
7098pub struct LocatedDocInfo {
7099 pub row_id: u64,
7100 pub frequency: f32,
7101}
7102
7103impl LocatedDocInfo {
7104 pub fn new(row_id: u64, frequency: f32) -> Self {
7105 Self { row_id, frequency }
7106 }
7107}
7108
7109impl Eq for LocatedDocInfo {}
7110
7111impl PartialEq for LocatedDocInfo {
7112 fn eq(&self, other: &Self) -> bool {
7113 self.row_id == other.row_id
7114 }
7115}
7116
7117impl PartialOrd for LocatedDocInfo {
7118 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
7119 Some(self.cmp(other))
7120 }
7121}
7122
7123impl Ord for LocatedDocInfo {
7124 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
7125 self.row_id.cmp(&other.row_id)
7126 }
7127}
7128
7129#[derive(Debug, Clone, Default, DeepSizeOf, Copy)]
7130pub struct RawDocInfo {
7131 pub doc_id: u32,
7132 pub frequency: u32,
7133}
7134
7135impl RawDocInfo {
7136 pub fn new(doc_id: u32, frequency: u32) -> Self {
7137 Self { doc_id, frequency }
7138 }
7139}
7140
7141impl Eq for RawDocInfo {}
7142
7143impl PartialEq for RawDocInfo {
7144 fn eq(&self, other: &Self) -> bool {
7145 self.doc_id == other.doc_id
7146 }
7147}
7148
7149impl PartialOrd for RawDocInfo {
7150 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
7151 Some(self.cmp(other))
7152 }
7153}
7154
7155impl Ord for RawDocInfo {
7156 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
7157 self.doc_id.cmp(&other.doc_id)
7158 }
7159}
7160
7161pub(super) fn quantize_doc_length(value: u32) -> u8 {
7167 let num_bits = 32 - value.leading_zeros();
7168 if num_bits < 4 {
7169 value as u8
7170 } else {
7171 let shift = num_bits - 4;
7172 (((value >> shift) as u8) & 0x07) | (((shift + 1) as u8) << 3)
7173 }
7174}
7175
7176#[inline]
7177pub(super) fn dequantize_doc_length(code: u8) -> u32 {
7178 DEQUANTIZED_DOC_LENGTHS[code as usize]
7179}
7180
7181pub(super) static DEQUANTIZED_DOC_LENGTHS: [u32; 256] = build_dequantized_doc_lengths();
7182
7183const fn build_dequantized_doc_lengths() -> [u32; 256] {
7184 let mut table = [0u32; 256];
7185 let mut code = 0usize;
7186 while code < 256 {
7187 let bits = (code & 0x07) as u64;
7188 let shift = (code >> 3) as i64 - 1;
7189 let decoded = if shift < 0 {
7190 bits
7191 } else {
7192 (bits | 0x08) << shift
7193 };
7194 table[code] = if decoded > u32::MAX as u64 {
7197 u32::MAX
7198 } else {
7199 decoded as u32
7200 };
7201 code += 1;
7202 }
7203 table
7204}
7205
7206#[derive(Debug, Clone)]
7207enum NumTokens {
7208 Owned(Vec<u32>),
7209 Shared(ScalarBuffer<u32>),
7210}
7211
7212impl Default for NumTokens {
7213 fn default() -> Self {
7214 Self::Owned(Vec::new())
7215 }
7216}
7217
7218impl std::ops::Deref for NumTokens {
7219 type Target = [u32];
7220
7221 fn deref(&self) -> &Self::Target {
7222 match self {
7223 Self::Owned(values) => values,
7224 Self::Shared(values) => values,
7225 }
7226 }
7227}
7228
7229impl DeepSizeOf for NumTokens {
7230 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
7231 match self {
7232 Self::Owned(values) => values.deep_size_of_children(context),
7233 Self::Shared(values) => values.deep_size_of_children(context),
7234 }
7235 }
7236}
7237
7238impl NumTokens {
7239 fn with_capacity(capacity: usize) -> Self {
7240 Self::Owned(Vec::with_capacity(capacity))
7241 }
7242
7243 fn into_owned(self) -> Vec<u32> {
7244 match self {
7245 Self::Owned(values) => values,
7246 Self::Shared(values) => values.to_vec(),
7247 }
7248 }
7249
7250 fn push(&mut self, value: u32) {
7251 match self {
7252 Self::Owned(values) => values.push(value),
7253 Self::Shared(values) => {
7254 let mut owned = values.to_vec();
7255 owned.push(value);
7256 *self = Self::Owned(owned);
7257 }
7258 }
7259 }
7260
7261 fn memory_size(&self) -> usize {
7262 match self {
7263 Self::Owned(values) => values.capacity() * std::mem::size_of::<u32>(),
7264 Self::Shared(values) => values.inner().capacity(),
7265 }
7266 }
7267}
7268
7269#[derive(Debug, Clone, Default)]
7272pub struct DocSet {
7273 row_ids: Vec<u64>,
7274 num_tokens: NumTokens,
7275 inv: Vec<(u64, u32)>,
7277
7278 total_tokens: u64,
7279
7280 scoring_quantized: bool,
7285 norms: Arc<std::sync::OnceLock<Box<[u8]>>>,
7286}
7287
7288impl DeepSizeOf for DocSet {
7289 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
7290 self.row_ids.deep_size_of_children(context)
7291 + self.num_tokens.deep_size_of_children(context)
7292 + self.inv.deep_size_of_children(context)
7293 + self
7294 .norms
7295 .get()
7296 .map(|slab| std::mem::size_of_val(slab.as_ref()))
7297 .unwrap_or(0)
7298 }
7299}
7300
7301impl DocSet {
7302 #[inline]
7303 pub fn len(&self) -> usize {
7304 self.num_tokens.len()
7309 }
7310
7311 pub fn is_empty(&self) -> bool {
7312 self.len() == 0
7313 }
7314
7315 #[inline]
7321 pub fn has_row_ids(&self) -> bool {
7322 !self.row_ids.is_empty()
7323 }
7324
7325 pub fn iter(&self) -> impl Iterator<Item = (&u64, &u32)> {
7326 self.row_ids.iter().zip(self.num_tokens.iter())
7327 }
7328
7329 pub fn row_id(&self, doc_id: u32) -> u64 {
7330 self.row_ids[doc_id as usize]
7331 }
7332
7333 pub fn doc_ids(&self, row_id: u64) -> impl Iterator<Item = u64> + '_ {
7341 if self.inv.is_empty() {
7342 let found = self.row_ids.binary_search(&row_id).is_ok();
7344 Either::Left(found.then_some(row_id).into_iter())
7345 } else {
7346 let lo = self.inv.partition_point(|entry| entry.0 < row_id);
7349 let hi = self.inv.partition_point(|entry| entry.0 <= row_id);
7350 Either::Right(self.inv[lo..hi].iter().map(|entry| entry.1 as u64))
7351 }
7352 }
7353 pub fn total_tokens_num(&self) -> u64 {
7354 self.total_tokens
7355 }
7356
7357 #[inline]
7358 pub fn average_length(&self) -> f32 {
7359 self.total_tokens as f32 / self.len() as f32
7360 }
7361
7362 pub fn calculate_block_max_scores<'a>(
7363 &self,
7364 doc_ids: impl Iterator<Item = &'a u32>,
7365 freqs: impl Iterator<Item = &'a u32>,
7366 ) -> Vec<f32> {
7367 self.calculate_block_max_scores_with_block_size(doc_ids, freqs, LEGACY_BLOCK_SIZE)
7368 }
7369
7370 pub fn calculate_block_max_scores_with_block_size<'a>(
7371 &self,
7372 doc_ids: impl Iterator<Item = &'a u32>,
7373 freqs: impl Iterator<Item = &'a u32>,
7374 block_size: usize,
7375 ) -> Vec<f32> {
7376 validate_block_size(block_size).expect("invalid posting list block size");
7377 let avgdl = self.average_length();
7378 let length = doc_ids.size_hint().0;
7379 let num_blocks = length.div_ceil(block_size);
7380 let mut block_max_scores = Vec::with_capacity(num_blocks);
7381 let idf_scale = idf(length, self.len()) * (K1 + 1.0);
7382 let mut max_score = f32::MIN;
7383 for (i, (doc_id, freq)) in doc_ids.zip(freqs).enumerate() {
7384 let doc_norm = K1 * (1.0 - B + B * self.num_tokens(*doc_id) as f32 / avgdl);
7385 let freq = *freq as f32;
7386 let score = freq / (freq + doc_norm);
7387 if score > max_score {
7388 max_score = score;
7389 }
7390 if (i + 1) % block_size == 0 {
7391 max_score *= idf_scale;
7392 block_max_scores.push(max_score);
7393 max_score = f32::MIN;
7394 }
7395 }
7396 if !length.is_multiple_of(block_size) {
7397 max_score *= idf_scale;
7398 block_max_scores.push(max_score);
7399 }
7400 block_max_scores
7401 }
7402
7403 pub fn to_batch(&self) -> Result<RecordBatch> {
7404 let row_id_col = UInt64Array::from_iter_values(self.row_ids.iter().cloned());
7405 let num_tokens_col = UInt32Array::from_iter_values(self.num_tokens.iter().cloned());
7406
7407 let schema = arrow_schema::Schema::new(vec![
7408 arrow_schema::Field::new(ROW_ID, DataType::UInt64, false),
7409 arrow_schema::Field::new(NUM_TOKEN_COL, DataType::UInt32, false),
7410 ]);
7411
7412 let batch = RecordBatch::try_new(
7413 Arc::new(schema),
7414 vec![
7415 Arc::new(row_id_col) as ArrayRef,
7416 Arc::new(num_tokens_col) as ArrayRef,
7417 ],
7418 )?;
7419 Ok(batch)
7420 }
7421
7422 pub async fn load(
7423 reader: Arc<dyn IndexReader>,
7424 is_legacy: bool,
7425 frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
7426 ) -> Result<Self> {
7427 let batch = reader.read_range(0..reader.num_rows(), None).await?;
7428 let row_id_col = batch[ROW_ID].as_primitive::<datatypes::UInt64Type>();
7429 let num_tokens_col = batch[NUM_TOKEN_COL].as_primitive::<datatypes::UInt32Type>();
7430 Self::from_columns(row_id_col, num_tokens_col, is_legacy, frag_reuse_index)
7431 }
7432
7433 pub fn from_num_tokens_only(num_tokens_col: &arrow_array::UInt32Array) -> Self {
7439 let total_tokens = num_tokens_col.values().iter().map(|&n| n as u64).sum();
7440 Self::from_cached_num_tokens(num_tokens_col, total_tokens)
7441 }
7442
7443 pub(crate) fn from_cached_num_tokens(
7447 num_tokens_col: &arrow_array::UInt32Array,
7448 total_tokens: u64,
7449 ) -> Self {
7450 Self {
7451 row_ids: Vec::new(),
7452 num_tokens: NumTokens::Shared(num_tokens_col.values().clone()),
7453 inv: Vec::new(),
7454 total_tokens,
7455 scoring_quantized: false,
7456 norms: Arc::new(std::sync::OnceLock::new()),
7457 }
7458 }
7459
7460 pub fn from_columns(
7463 row_id_col: &UInt64Array,
7464 num_tokens_col: &arrow_array::UInt32Array,
7465 is_legacy: bool,
7466 frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
7467 ) -> Result<Self> {
7468 if is_legacy {
7470 let (row_ids, num_tokens): (Vec<_>, Vec<_>) = row_id_col
7471 .values()
7472 .iter()
7473 .filter_map(|id| {
7474 if let Some(frag_reuse_index_ref) = frag_reuse_index.as_ref() {
7475 frag_reuse_index_ref.remap_row_id(*id)
7476 } else {
7477 Some(*id)
7478 }
7479 })
7480 .zip(num_tokens_col.values().iter())
7481 .sorted_unstable_by_key(|x| x.0)
7482 .unzip();
7483
7484 let total_tokens = num_tokens.iter().map(|&x| x as u64).sum();
7485 return Ok(Self {
7486 row_ids,
7487 num_tokens: NumTokens::Owned(num_tokens),
7488 inv: Vec::new(),
7489 total_tokens,
7490 scoring_quantized: false,
7491 norms: Arc::new(std::sync::OnceLock::new()),
7492 });
7493 }
7494
7495 if let Some(frag_reuse_index_ref) = frag_reuse_index.as_ref() {
7509 let mut row_ids = Vec::with_capacity(row_id_col.len());
7510 let num_tokens = num_tokens_col.values().to_vec();
7511 let mut inv = Vec::with_capacity(row_id_col.len());
7512 for (doc_id, row_id) in row_id_col.values().iter().enumerate() {
7513 match frag_reuse_index_ref.remap_row_id(*row_id) {
7514 Some(new_row_id) => {
7515 row_ids.push(new_row_id);
7516 inv.push((new_row_id, doc_id as u32));
7517 }
7518 None => {
7519 row_ids.push(RowAddress::TOMBSTONE_ROW);
7522 }
7523 }
7524 }
7525 inv.sort_unstable_by_key(|entry| entry.0);
7526
7527 let total_tokens = num_tokens.iter().map(|&x| x as u64).sum();
7528 return Ok(Self {
7529 row_ids,
7530 num_tokens: NumTokens::Owned(num_tokens),
7531 inv,
7532 total_tokens,
7533 scoring_quantized: false,
7534 norms: Arc::new(std::sync::OnceLock::new()),
7535 });
7536 }
7537
7538 let row_ids = row_id_col.values().to_vec();
7539 let num_tokens = num_tokens_col.values().to_vec();
7540 let mut inv: Vec<(u64, u32)> = row_ids
7541 .iter()
7542 .enumerate()
7543 .map(|(doc_id, row_id)| (*row_id, doc_id as u32))
7544 .collect();
7545 if !row_ids.is_sorted() {
7546 inv.sort_unstable_by_key(|entry| entry.0);
7547 }
7548 let total_tokens = num_tokens.iter().map(|&x| x as u64).sum();
7549 Ok(Self {
7550 row_ids,
7551 num_tokens: NumTokens::Owned(num_tokens),
7552 inv,
7553 total_tokens,
7554 scoring_quantized: false,
7555 norms: Arc::new(std::sync::OnceLock::new()),
7556 })
7557 }
7558
7559 pub fn remap(&mut self, mapping: &RowAddrRemap) -> Vec<u32> {
7562 let mut removed = Vec::new();
7563 let len = self.len();
7564 let row_ids = std::mem::replace(&mut self.row_ids, Vec::with_capacity(len));
7565 let num_tokens =
7566 std::mem::replace(&mut self.num_tokens, NumTokens::with_capacity(len)).into_owned();
7567 self.invalidate_norms();
7568 self.total_tokens = 0;
7569 for (doc_id, (row_id, num_token)) in std::iter::zip(row_ids, num_tokens).enumerate() {
7570 match mapping.get(row_id) {
7571 Some(Some(new_row_id)) => {
7572 self.row_ids.push(new_row_id);
7573 self.num_tokens.push(num_token);
7574 self.total_tokens += num_token as u64;
7575 }
7576 Some(None) => {
7577 removed.push(doc_id as u32);
7578 }
7579 None => {
7580 self.row_ids.push(row_id);
7581 self.num_tokens.push(num_token);
7582 self.total_tokens += num_token as u64;
7583 }
7584 }
7585 }
7586 removed
7587 }
7588
7589 #[inline]
7590 pub fn num_tokens(&self, doc_id: u32) -> u32 {
7591 self.num_tokens[doc_id as usize]
7592 }
7593
7594 pub fn set_quantized_scoring(&mut self, quantized: bool) {
7596 self.scoring_quantized = quantized;
7597 }
7598
7599 pub fn scoring_norms(&self) -> Option<&[u8]> {
7602 if !self.scoring_quantized {
7603 return None;
7604 }
7605 Some(
7606 self.norms
7607 .get_or_init(|| {
7608 self.num_tokens
7609 .iter()
7610 .map(|&n| quantize_doc_length(n))
7611 .collect()
7612 })
7613 .as_ref(),
7614 )
7615 }
7616
7617 #[inline]
7620 pub fn scoring_num_tokens(&self, doc_id: u32) -> u32 {
7621 match self.scoring_norms() {
7622 Some(norms) => dequantize_doc_length(norms[doc_id as usize]),
7623 None => self.num_tokens[doc_id as usize],
7624 }
7625 }
7626
7627 #[inline]
7630 pub fn num_tokens_by_row_id(&self, row_id: u64) -> u32 {
7631 self.row_ids
7632 .binary_search(&row_id)
7633 .map(|idx| self.num_tokens[idx])
7634 .unwrap_or(0)
7635 }
7636
7637 pub fn append(&mut self, row_id: u64, num_tokens: u32) -> u32 {
7640 self.row_ids.push(row_id);
7641 self.num_tokens.push(num_tokens);
7642 self.total_tokens += num_tokens as u64;
7643 self.invalidate_norms();
7644 self.row_ids.len() as u32 - 1
7645 }
7646
7647 fn invalidate_norms(&mut self) {
7650 if self.norms.get().is_some() {
7651 self.norms = Arc::new(std::sync::OnceLock::new());
7652 }
7653 }
7654
7655 pub(crate) fn memory_size(&self) -> usize {
7656 self.row_ids.capacity() * std::mem::size_of::<u64>()
7657 + self.num_tokens.memory_size()
7658 + self.inv.capacity() * std::mem::size_of::<(u64, u32)>()
7659 }
7660}
7661
7662pub fn flat_full_text_search(
7663 batches: &[&RecordBatch],
7664 doc_col: &str,
7665 query: &str,
7666 tokenizer: Option<Box<dyn LanceTokenizer>>,
7667) -> Result<Vec<u64>> {
7668 if batches.is_empty() {
7669 return Ok(vec![]);
7670 }
7671
7672 if is_phrase_query(query) {
7673 return Err(Error::invalid_input(
7674 "phrase query is not supported for flat full text search, try using FTS index",
7675 ));
7676 }
7677
7678 match batches[0][doc_col].data_type() {
7679 DataType::Utf8 => do_flat_full_text_search::<i32>(batches, doc_col, query, tokenizer),
7680 DataType::LargeUtf8 => do_flat_full_text_search::<i64>(batches, doc_col, query, tokenizer),
7681 DataType::List(_) => {
7682 do_flat_full_text_search_list::<i32>(batches, doc_col, query, tokenizer)
7683 }
7684 DataType::LargeList(_) => {
7685 do_flat_full_text_search_list::<i64>(batches, doc_col, query, tokenizer)
7686 }
7687 data_type => Err(Error::invalid_input(format!(
7688 "unsupported data type {} for inverted index",
7689 data_type
7690 ))),
7691 }
7692}
7693
7694fn do_flat_full_text_search<Offset: OffsetSizeTrait>(
7695 batches: &[&RecordBatch],
7696 doc_col: &str,
7697 query: &str,
7698 tokenizer: Option<Box<dyn LanceTokenizer>>,
7699) -> Result<Vec<u64>> {
7700 let mut results = Vec::new();
7701 let mut tokenizer =
7702 tokenizer.unwrap_or_else(|| InvertedIndexParams::default().build().unwrap());
7703 let query_tokens = collect_query_tokens(query, &mut tokenizer);
7704
7705 for batch in batches {
7706 let row_id_array = batch[ROW_ID].as_primitive::<UInt64Type>();
7707 let doc_array = batch[doc_col].as_string::<Offset>();
7708 for i in 0..row_id_array.len() {
7709 let doc = doc_array.value(i);
7710 if has_query_token(doc, &mut tokenizer, &query_tokens) {
7711 results.push(row_id_array.value(i));
7712 assert!(doc.contains(query));
7715 }
7716 }
7717 }
7718
7719 Ok(results)
7720}
7721
7722fn do_flat_full_text_search_list<ListOffset: OffsetSizeTrait>(
7723 batches: &[&RecordBatch],
7724 doc_col: &str,
7725 query: &str,
7726 tokenizer: Option<Box<dyn LanceTokenizer>>,
7727) -> Result<Vec<u64>> {
7728 let mut results = Vec::new();
7729 let mut tokenizer =
7730 tokenizer.unwrap_or_else(|| InvertedIndexParams::default().build().unwrap());
7731 let query_tokens = collect_query_tokens(query, &mut tokenizer);
7732
7733 for batch in batches {
7734 let row_id_array = batch[ROW_ID].as_primitive::<UInt64Type>();
7735 let doc_array = batch[doc_col].as_list::<ListOffset>();
7736 match doc_array.value_type() {
7737 DataType::Utf8 | DataType::LargeUtf8 => {}
7738 data_type => {
7739 return Err(Error::invalid_input(format!(
7740 "unsupported list item data type {} for inverted index",
7741 data_type
7742 )));
7743 }
7744 }
7745 for i in 0..row_id_array.len() {
7746 if doc_array.is_null(i) {
7747 continue;
7748 }
7749 let elements = doc_array.value(i);
7750 if iter_str_array(elements.as_ref())
7751 .flatten()
7752 .any(|element| has_query_token(element, &mut tokenizer, &query_tokens))
7753 {
7754 results.push(row_id_array.value(i));
7755 }
7756 }
7757 }
7758
7759 Ok(results)
7760}
7761
7762const FLAT_ROW_ID_COL_IDX: usize = 0;
7763const FLAT_ALL_TOKENS_COL_IDX: usize = 1;
7764const FLAT_QUERY_TOKEN_COUNTS_COL_IDX: usize = 2;
7765
7766const BYTES_ACCUMULATED_WARNING_THRESHOLD: u64 = 1024 * 1024 * 1024; async fn tokenize_and_count(
7783 input: impl Stream<Item = DataFusionResult<RecordBatch>> + Send,
7784 tokenizer: Box<dyn LanceTokenizer>,
7785 query_tokens: Arc<Tokens>,
7786 doc_col_idx: usize,
7787 elapsed_compute: Option<Time>,
7788) -> DataFusionResult<RecordBatch> {
7789 let output_schema = Arc::new(Schema::new(vec![
7790 ROW_ID_FIELD.clone(),
7791 Field::new("all_tokens", DataType::UInt64, false),
7792 Field::new(
7793 "query_token_counts",
7794 DataType::FixedSizeList(
7795 Arc::new(Field::new("item", DataType::UInt64, true)),
7796 query_tokens.len() as i32,
7797 ),
7798 false,
7799 ),
7800 ]));
7801 let output_schema_clone = output_schema.clone();
7802 let query_token_indices = Arc::new(query_token_indices(query_tokens.as_ref()));
7803 let bytes_accumulated = Arc::new(AtomicU64::new(0));
7804 let bytes_warning_emitted = Arc::new(AtomicBool::new(false));
7805
7806 let batches = input
7807 .map(move |batch| {
7808 let mut tokenizer = tokenizer.box_clone();
7809 let output_schema = output_schema.clone();
7810 let query_tokens = query_tokens.clone();
7811 let query_token_indices = query_token_indices.clone();
7812 let bytes_accumulated = bytes_accumulated.clone();
7813 let bytes_warning_emitted = bytes_warning_emitted.clone();
7814 let elapsed_compute = elapsed_compute.clone();
7815 spawn_cpu(move || {
7816 let start = std::time::Instant::now();
7820 let batch = batch?;
7821 let row_id_array = batch[ROW_ID].as_primitive::<UInt64Type>();
7822 let mut row_ids = UInt64Builder::with_capacity(batch.num_rows());
7823 let mut all_token_counts = UInt64Builder::with_capacity(batch.num_rows());
7824 let mut query_token_counts = FixedSizeListBuilder::with_capacity(
7825 UInt64Builder::with_capacity(batch.num_rows() * query_tokens.len()),
7826 query_tokens.len() as i32,
7827 batch.num_rows(),
7828 );
7829 let mut temp_query_token_counts = Vec::with_capacity(query_tokens.len());
7830 let mut count_text = |doc: &str, temp_query_token_counts: &mut Vec<u64>| -> u64 {
7831 let mut stream = tokenizer.token_stream_for_doc(doc);
7832 let mut all_tokens = 0;
7833 while let Some(token) = stream.next() {
7834 all_tokens += 1;
7835 if let Some(token_indices) = query_token_indices.get(&token.text) {
7836 for token_index in token_indices {
7837 temp_query_token_counts[*token_index] += 1;
7838 }
7839 }
7840 }
7841 all_tokens
7842 };
7843 let mut append_counts =
7844 |row_id: u64, all_tokens: u64, temp_query_token_counts: &[u64]| {
7845 row_ids.append_value(row_id);
7846 all_token_counts.append_value(all_tokens);
7847 for count in temp_query_token_counts.iter().copied() {
7848 query_token_counts.values().append_value(count);
7849 }
7850 query_token_counts.append(true);
7851 };
7852 match batch.column(doc_col_idx).data_type() {
7853 DataType::Utf8 | DataType::LargeUtf8 => {
7854 let doc_iter = iter_str_array(batch.column(doc_col_idx));
7855 for (doc, row_id) in doc_iter.zip(row_id_array.values().iter()) {
7856 temp_query_token_counts.clear();
7857 temp_query_token_counts
7858 .extend(std::iter::repeat_n(0, query_tokens.len()));
7859
7860 let Some(doc) = doc else {
7861 continue;
7862 };
7863
7864 let all_tokens = count_text(doc, &mut temp_query_token_counts);
7865 if all_tokens > 0 {
7866 append_counts(*row_id, all_tokens, &temp_query_token_counts);
7867 }
7868 }
7869 }
7870 DataType::List(_) => {
7871 tokenize_and_count_list::<i32>(
7872 batch.column(doc_col_idx),
7873 row_id_array,
7874 &mut count_text,
7875 &mut append_counts,
7876 &mut temp_query_token_counts,
7877 query_tokens.len(),
7878 )?;
7879 }
7880 DataType::LargeList(_) => {
7881 tokenize_and_count_list::<i64>(
7882 batch.column(doc_col_idx),
7883 row_id_array,
7884 &mut count_text,
7885 &mut append_counts,
7886 &mut temp_query_token_counts,
7887 query_tokens.len(),
7888 )?;
7889 }
7890 data_type => {
7891 return DataFusionResult::Err(datafusion_common::DataFusionError::Execution(
7892 format!("unsupported data type {} for flat full text search", data_type),
7893 ));
7894 }
7895 }
7896 let row_ids = row_ids.finish();
7897 let all_token_counts = all_token_counts.finish();
7898 let query_token_counts = query_token_counts.finish();
7899 let result_batch = RecordBatch::try_new(
7900 output_schema,
7901 vec![
7902 Arc::new(row_ids) as ArrayRef,
7903 Arc::new(all_token_counts) as ArrayRef,
7904 Arc::new(query_token_counts) as ArrayRef,
7905 ],
7906 )?;
7907 let bytes_accumulated = bytes_accumulated.fetch_add(result_batch.get_array_memory_size() as u64, Ordering::Relaxed);
7908 if bytes_accumulated > BYTES_ACCUMULATED_WARNING_THRESHOLD && !bytes_warning_emitted.swap(true, Ordering::Relaxed) {
7909 tracing::warn!("Flat full text search is accumulating a large number of bytes. Consider using an FTS index instead.");
7910 }
7911
7912 if let Some(t) = &elapsed_compute {
7913 t.add_duration(start.elapsed());
7914 }
7915 DataFusionResult::Ok(result_batch)
7916 })
7917 })
7918 .buffered(get_num_compute_intensive_cpus())
7919 .try_collect::<Vec<_>>()
7920 .await?;
7921
7922 Ok(arrow::compute::concat_batches(
7923 &output_schema_clone,
7924 &batches,
7925 )?)
7926}
7927
7928fn tokenize_and_count_list<ListOffset: OffsetSizeTrait>(
7929 doc_col: &ArrayRef,
7930 row_id_array: &arrow_array::PrimitiveArray<UInt64Type>,
7931 count_text: &mut impl FnMut(&str, &mut Vec<u64>) -> u64,
7932 append_counts: &mut impl FnMut(u64, u64, &[u64]),
7933 temp_query_token_counts: &mut Vec<u64>,
7934 query_tokens_len: usize,
7935) -> DataFusionResult<()> {
7936 let doc_array = doc_col.as_list::<ListOffset>();
7937 match doc_array.value_type() {
7938 DataType::Utf8 | DataType::LargeUtf8 => {}
7939 data_type => {
7940 return Err(datafusion_common::DataFusionError::Execution(format!(
7941 "unsupported list item data type {} for flat full text search",
7942 data_type
7943 )));
7944 }
7945 }
7946
7947 for i in 0..row_id_array.len() {
7948 if doc_array.is_null(i) {
7949 continue;
7950 }
7951
7952 temp_query_token_counts.clear();
7953 temp_query_token_counts.extend(std::iter::repeat_n(0, query_tokens_len));
7954
7955 let elements = doc_array.value(i);
7956 let mut all_tokens = 0;
7957 for element in iter_str_array(elements.as_ref()).flatten() {
7958 all_tokens += count_text(element, temp_query_token_counts);
7959 }
7960
7961 if all_tokens > 0 {
7962 append_counts(row_id_array.value(i), all_tokens, temp_query_token_counts);
7963 }
7964 }
7965
7966 Ok(())
7967}
7968
7969fn query_token_indices(query_tokens: &Tokens) -> HashMap<String, Vec<usize>> {
7970 let mut indices = HashMap::new();
7971 for idx in 0..query_tokens.len() {
7972 indices
7973 .entry(query_tokens.get_token(idx).to_string())
7974 .or_insert_with(Vec::new)
7975 .push(idx);
7976 }
7977 indices
7978}
7979
7980fn initialize_scorer(
7985 base_scorer: Option<&MemBM25Scorer>,
7986 query_tokens: &Tokens,
7987 counted_input: &RecordBatch,
7988) -> MemBM25Scorer {
7989 let mut total_tokens = 0;
7990 let mut num_docs = 0;
7991 let mut all_token_counts = vec![0; query_tokens.len()];
7992
7993 if let Some(base_scorer) = base_scorer {
7994 total_tokens += base_scorer.total_tokens;
7995 num_docs += base_scorer.num_docs;
7996 for (token_index, token) in query_tokens.into_iter().enumerate() {
7997 all_token_counts[token_index] = base_scorer.num_docs_containing_token(token) as u64;
7998 }
7999 }
8000
8001 num_docs += counted_input.num_rows();
8002 total_tokens += arrow::compute::sum(
8003 counted_input
8004 .column(FLAT_ALL_TOKENS_COL_IDX)
8005 .as_primitive::<UInt64Type>(),
8006 )
8007 .unwrap_or_default();
8008
8009 let mut input_token_counters = counted_input
8010 .column(FLAT_QUERY_TOKEN_COUNTS_COL_IDX)
8011 .as_fixed_size_list()
8012 .values()
8013 .as_primitive::<UInt64Type>()
8014 .values()
8015 .iter()
8016 .copied();
8017
8018 for _ in 0..counted_input.num_rows() {
8019 for token_count in all_token_counts.iter_mut() {
8020 if input_token_counters.next().unwrap_or_default() > 0 {
8021 *token_count += 1;
8022 }
8023 }
8024 }
8025
8026 let token_counts_map = all_token_counts
8027 .into_iter()
8028 .enumerate()
8029 .map(|(token_index, count)| {
8030 (
8031 query_tokens.get_token(token_index).to_string(),
8032 count as usize,
8033 )
8034 })
8035 .collect::<HashMap<String, usize>>();
8036 MemBM25Scorer::new(total_tokens, num_docs, token_counts_map)
8037}
8038
8039fn flat_bm25_score(
8040 query_tokens: &Tokens,
8041 counted_input: &RecordBatch,
8042 scorer: &MemBM25Scorer,
8043 operator: Operator,
8044) -> Result<RecordBatch> {
8045 let mut row_ids_builder = UInt64Builder::with_capacity(counted_input.num_rows());
8046 let mut scores_builder = Float32Builder::with_capacity(counted_input.num_rows());
8047 let query_groups = query_position_groups(query_tokens);
8048
8049 let mut row_ids_iter = counted_input
8050 .column(FLAT_ROW_ID_COL_IDX)
8051 .as_primitive::<UInt64Type>()
8052 .values()
8053 .iter()
8054 .copied();
8055 let mut all_token_counts_iter = counted_input
8056 .column(FLAT_ALL_TOKENS_COL_IDX)
8057 .as_primitive::<UInt64Type>()
8058 .values()
8059 .iter()
8060 .copied();
8061 let mut query_token_counts_iter = counted_input
8062 .column(FLAT_QUERY_TOKEN_COUNTS_COL_IDX)
8063 .as_fixed_size_list()
8064 .values()
8065 .as_primitive::<UInt64Type>()
8066 .values()
8067 .iter()
8068 .copied();
8069 for _ in 0..counted_input.num_rows() {
8070 let num_tokens_in_doc = all_token_counts_iter.next().expect_ok()?;
8071 let row_id = row_ids_iter.next().expect_ok()?;
8072 let mut query_token_counts = Vec::with_capacity(query_tokens.len());
8073 for _ in query_tokens {
8074 query_token_counts.push(query_token_counts_iter.next().expect_ok()?);
8075 }
8076 if num_tokens_in_doc == 0 {
8077 continue;
8078 }
8079 if operator == Operator::And
8080 && !query_groups
8081 .iter()
8082 .all(|group| group.iter().any(|idx| query_token_counts[*idx] > 0))
8083 {
8084 continue;
8085 }
8086 let doc_norm = K1 * (1.0 - B + B * num_tokens_in_doc as f32 / scorer.avg_doc_length());
8087 let mut score = 0.0;
8088 for (token, freq) in query_tokens.into_iter().zip(query_token_counts) {
8089 let freq = freq as f32;
8090 let idf = idf(scorer.num_docs_containing_token(token), scorer.num_docs());
8091 score += idf * (freq * (K1 + 1.0) / (freq + doc_norm));
8092 }
8093 if score > 0.0 {
8094 row_ids_builder.append_value(row_id);
8095 scores_builder.append_value(score);
8096 }
8097 }
8098
8099 let row_ids = row_ids_builder.finish();
8100 let scores = scores_builder.finish();
8101 let batch = RecordBatch::try_new(
8102 FTS_SCHEMA.clone(),
8103 vec![Arc::new(row_ids) as ArrayRef, Arc::new(scores) as ArrayRef],
8104 )?;
8105 Ok(batch)
8106}
8107
8108fn query_position_groups(query_tokens: &Tokens) -> Vec<Vec<usize>> {
8109 let mut groups = Vec::new();
8110 let mut current_position = None;
8111 for idx in 0..query_tokens.len() {
8112 let position = query_tokens.position(idx);
8113 if current_position != Some(position) {
8114 current_position = Some(position);
8115 groups.push(Vec::new());
8116 }
8117 groups
8118 .last_mut()
8119 .expect("a group should exist after pushing for position")
8120 .push(idx);
8121 }
8122 groups
8123}
8124
8125#[deprecated(
8126 note = "use `flat_bm25_search_stream_with_metrics` to record CPU compute \
8127 time on a metric handle; pass `None` for the old behavior"
8128)]
8129pub async fn flat_bm25_search_stream(
8130 input: SendableRecordBatchStream,
8131 doc_col: String,
8132 query: String,
8133 tokenizer: Box<dyn LanceTokenizer>,
8134 base_scorer: Option<MemBM25Scorer>,
8135 target_batch_size: usize,
8136) -> DataFusionResult<SendableRecordBatchStream> {
8137 flat_bm25_search_stream_with_metrics(
8138 input,
8139 doc_col,
8140 query,
8141 tokenizer,
8142 base_scorer,
8143 target_batch_size,
8144 None,
8145 )
8146 .await
8147}
8148
8149pub async fn flat_bm25_search_stream_with_metrics(
8155 input: SendableRecordBatchStream,
8156 doc_col: String,
8157 query: String,
8158 tokenizer: Box<dyn LanceTokenizer>,
8159 base_scorer: Option<MemBM25Scorer>,
8160 target_batch_size: usize,
8161 elapsed_compute: Option<Time>,
8162) -> DataFusionResult<SendableRecordBatchStream> {
8163 flat_bm25_search_stream_with_metrics_and_operator(
8164 input,
8165 doc_col,
8166 query,
8167 tokenizer,
8168 base_scorer,
8169 target_batch_size,
8170 Operator::Or,
8171 elapsed_compute,
8172 )
8173 .await
8174}
8175
8176#[allow(clippy::too_many_arguments)]
8205pub async fn flat_bm25_search_stream_with_metrics_and_operator(
8206 input: SendableRecordBatchStream,
8207 doc_col: String,
8208 query: String,
8209 tokenizer: Box<dyn LanceTokenizer>,
8210 base_scorer: Option<MemBM25Scorer>,
8211 target_batch_size: usize,
8212 operator: Operator,
8213 elapsed_compute: Option<Time>,
8214) -> DataFusionResult<SendableRecordBatchStream> {
8215 let mut tokenizer = tokenizer;
8216
8217 let pre_await_start = std::time::Instant::now();
8219 let query_tokens = Arc::new(collect_query_tokens(&query, &mut tokenizer));
8220
8221 if query_tokens.is_empty() {
8226 return Ok(Box::pin(RecordBatchStreamAdapter::new(
8227 FTS_SCHEMA.clone(),
8228 stream::empty::<DataFusionResult<RecordBatch>>(),
8229 )));
8230 }
8231
8232 let input_schema = input.schema();
8233 let doc_col_idx = input_schema.index_of(&doc_col)?;
8234
8235 const ACCUMULATE_BYTES: usize = 256 * 1024;
8237 const SLICE_BYTES: usize = 512 * 1024;
8239
8240 let chunked = lance_arrow::stream::rechunk_stream_by_size(
8243 input,
8244 input_schema,
8245 ACCUMULATE_BYTES,
8246 SLICE_BYTES,
8247 );
8248 if let Some(t) = &elapsed_compute {
8249 t.add_duration(pre_await_start.elapsed());
8250 }
8251
8252 let counted_input = tokenize_and_count(
8257 chunked,
8258 tokenizer,
8259 query_tokens.clone(),
8260 doc_col_idx,
8261 elapsed_compute.clone(),
8262 )
8263 .await?;
8264
8265 let post_await_start = std::time::Instant::now();
8268 let scorer = initialize_scorer(base_scorer.as_ref(), query_tokens.as_ref(), &counted_input);
8269 let scores = flat_bm25_score(query_tokens.as_ref(), &counted_input, &scorer, operator)?;
8270
8271 let num_out_batches = scores.num_rows().div_ceil(target_batch_size);
8273 let mut batches = Vec::with_capacity(num_out_batches);
8274 for i in 0..num_out_batches {
8275 let start = i * target_batch_size;
8276 let len = (scores.num_rows() - start).min(target_batch_size);
8277 batches.push(Ok(scores.slice(start, len)));
8278 }
8279 if let Some(t) = &elapsed_compute {
8280 t.add_duration(post_await_start.elapsed());
8281 }
8282 Ok(Box::pin(RecordBatchStreamAdapter::new(
8283 FTS_SCHEMA.clone(),
8284 stream::iter(batches),
8285 )))
8286}
8287
8288pub fn is_phrase_query(query: &str) -> bool {
8289 query.starts_with('\"') && query.ends_with('\"')
8290}
8291
8292#[cfg(test)]
8293mod tests {
8294 use crate::scalar::inverted::document_tokenizer::DocType;
8295 use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
8296 use futures::stream;
8297 use lance_core::cache::{LanceCache, QuickCacheBackend};
8298 use lance_core::utils::tempfile::TempObjDir;
8299 use lance_io::object_store::ObjectStore;
8300
8301 use crate::metrics::{LocalMetricsCollector, NoOpMetricsCollector};
8302 use crate::prefilter::NoFilter;
8303 use crate::scalar::ScalarIndex;
8304 use crate::scalar::inverted::builder::{
8305 InnerBuilder, InvertedIndexBuilder, PositionRecorder, doc_file_path, inverted_list_schema,
8306 inverted_list_schema_for_version_with_block_size,
8307 inverted_list_schema_for_version_with_block_size_and_impacts, posting_file_path,
8308 token_file_path,
8309 };
8310 use crate::scalar::inverted::encoding::{
8311 compress_positions, compress_posting_list_with_tail_codec,
8312 decompress_posting_list_with_tail_codec, encode_position_stream_block_into,
8313 };
8314 use crate::scalar::inverted::query::{FtsSearchParams, Operator};
8315 use crate::scalar::lance_format::LanceIndexStore;
8316 use arrow::array::{
8317 AsArray, GenericListBuilder, GenericStringBuilder, Int32Builder, LargeBinaryBuilder,
8318 ListBuilder, UInt32Builder,
8319 };
8320 use arrow::datatypes::{Float32Type, UInt32Type};
8321 use arrow_array::{ArrayRef, Float32Array, RecordBatch, StringArray, UInt32Array, UInt64Array};
8322 use arrow_schema::{DataType, Field, Schema};
8323 use std::collections::HashMap;
8324 use std::sync::Arc;
8325 use std::sync::atomic::{AtomicU32, Ordering};
8326
8327 use crate::scalar::inverted::tokenizer::document_tokenizer::TextTokenizer;
8328 use lance_tokenizer::{Language, SimpleTokenizer, StopWordFilter, TextAnalyzer};
8329
8330 use super::*;
8331
8332 #[test]
8333 fn address_read_concurrency_respects_payload_budget() {
8334 assert_eq!(address_read_concurrency(64, 0), 64);
8335 assert_eq!(address_read_concurrency(64, 8 * 1024 * 1024), 8);
8336 assert_eq!(address_read_concurrency(64, 16 * 1024 * 1024), 4);
8337 assert_eq!(
8338 address_read_concurrency(64, 2 * MAX_CONCURRENT_ADDRESS_READ_BYTES),
8339 1
8340 );
8341 }
8342
8343 #[derive(Debug)]
8344 struct MetadataAccessDeniedStore {
8345 inner: Arc<dyn IndexStore>,
8346 }
8347
8348 impl DeepSizeOf for MetadataAccessDeniedStore {
8349 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
8350 self.inner.deep_size_of_children(context)
8351 }
8352 }
8353
8354 #[async_trait]
8355 impl IndexStore for MetadataAccessDeniedStore {
8356 fn as_any(&self) -> &dyn std::any::Any {
8357 self
8358 }
8359
8360 fn clone_arc(&self) -> Arc<dyn IndexStore> {
8361 Arc::new(Self {
8362 inner: self.inner.clone(),
8363 })
8364 }
8365
8366 fn io_parallelism(&self) -> usize {
8367 self.inner.io_parallelism()
8368 }
8369
8370 async fn new_index_file(
8371 &self,
8372 name: &str,
8373 schema: Arc<Schema>,
8374 ) -> Result<Box<dyn crate::scalar::IndexWriter>> {
8375 self.inner.new_index_file(name, schema).await
8376 }
8377
8378 async fn open_index_file(&self, name: &str) -> Result<Arc<dyn IndexReader>> {
8379 if name == METADATA_FILE {
8380 Err(Error::io("metadata access denied"))
8381 } else {
8382 self.inner.open_index_file(name).await
8383 }
8384 }
8385
8386 fn with_io_priority(&self, io_priority: u64) -> Arc<dyn IndexStore> {
8387 Arc::new(Self {
8388 inner: self.inner.with_io_priority(io_priority),
8389 })
8390 }
8391
8392 async fn copy_index_file(
8393 &self,
8394 name: &str,
8395 dest_store: &dyn IndexStore,
8396 ) -> Result<crate::scalar::IndexFile> {
8397 self.inner.copy_index_file(name, dest_store).await
8398 }
8399
8400 async fn rename_index_file(
8401 &self,
8402 name: &str,
8403 new_name: &str,
8404 ) -> Result<crate::scalar::IndexFile> {
8405 self.inner.rename_index_file(name, new_name).await
8406 }
8407
8408 async fn delete_index_file(&self, name: &str) -> Result<()> {
8409 self.inner.delete_index_file(name).await
8410 }
8411
8412 async fn list_files_with_sizes(&self) -> Result<Vec<crate::scalar::IndexFile>> {
8413 self.inner.list_files_with_sizes().await
8414 }
8415 }
8416
8417 #[tokio::test]
8418 async fn params_legacy_fallback_probes_tokens_after_metadata_access_denied() {
8419 let tmpdir = TempObjDir::default();
8420 let inner: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
8421 ObjectStore::local().into(),
8422 tmpdir.clone(),
8423 Arc::new(LanceCache::no_cache()),
8424 ));
8425 let expected = InvertedIndexParams::default();
8426 let metadata = HashMap::from([(
8427 "tokenizer".to_owned(),
8428 serde_json::to_string(&expected).unwrap(),
8429 )]);
8430 let mut writer = inner
8431 .new_index_file(TOKENS_FILE, Arc::new(Schema::empty()))
8432 .await
8433 .unwrap();
8434 writer.finish_with_metadata(metadata).await.unwrap();
8435 let store = MetadataAccessDeniedStore { inner };
8436
8437 let actual = InvertedIndex::load_params(&store).await.unwrap();
8438 assert_eq!(
8439 serde_json::to_value(actual).unwrap(),
8440 serde_json::to_value(expected).unwrap()
8441 );
8442 }
8443
8444 #[tokio::test]
8445 async fn params_legacy_probe_preserves_metadata_error_when_tokens_are_missing() {
8446 let tmpdir = TempObjDir::default();
8447 let inner: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
8448 ObjectStore::local().into(),
8449 tmpdir.clone(),
8450 Arc::new(LanceCache::no_cache()),
8451 ));
8452 let store = MetadataAccessDeniedStore { inner };
8453
8454 let error = InvertedIndex::load_params(&store).await.unwrap_err();
8455 assert!(matches!(error, Error::IO { .. }));
8456 assert!(error.to_string().contains("metadata access denied"));
8457 }
8458
8459 #[tokio::test]
8460 async fn params_metadata_ignores_unknown_fields() {
8461 let tmpdir = TempObjDir::default();
8462 let store = Arc::new(LanceIndexStore::new(
8463 ObjectStore::local().into(),
8464 tmpdir.clone(),
8465 Arc::new(LanceCache::no_cache()),
8466 ));
8467 let expected = InvertedIndexParams::default();
8468 let mut params = serde_json::to_value(&expected).unwrap();
8469 let params = params.as_object_mut().unwrap();
8470 params.insert("skip_merge".to_owned(), true.into());
8471 params.insert(
8472 "future_parameter".to_owned(),
8473 serde_json::json!({ "enabled": true }),
8474 );
8475 let metadata =
8476 HashMap::from([("params".to_owned(), serde_json::to_string(params).unwrap())]);
8477 let mut writer = store
8478 .new_index_file(METADATA_FILE, Arc::new(Schema::empty()))
8479 .await
8480 .unwrap();
8481 writer.finish_with_metadata(metadata).await.unwrap();
8482
8483 let actual = InvertedIndex::load_params(store.as_ref()).await.unwrap();
8484 assert_eq!(
8485 serde_json::to_value(actual).unwrap(),
8486 serde_json::to_value(expected).unwrap()
8487 );
8488 }
8489
8490 async fn write_single_partition_index(
8491 store: Arc<LanceIndexStore>,
8492 params: InvertedIndexParams,
8493 token_set_format: TokenSetFormat,
8494 token: &str,
8495 row_id: u64,
8496 ) -> Result<Arc<InvertedIndex>> {
8497 let block_size = params.posting_block_size();
8498 let format_version = params.resolved_format_version();
8499 let mut partition = InnerBuilder::new_with_format_version_and_block_size(
8500 0,
8501 false,
8502 token_set_format,
8503 format_version,
8504 block_size,
8505 );
8506 partition.tokens.add(token.to_owned());
8507 let mut posting_list = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
8508 false,
8509 format_version.posting_tail_codec(),
8510 block_size,
8511 );
8512 posting_list.add(0, PositionRecorder::Count(1));
8513 partition.posting_lists.push(posting_list);
8514 partition.docs.append(row_id, 1);
8515 partition.write(store.as_ref()).await?;
8516
8517 let metadata = HashMap::from([
8518 (
8519 "partitions".to_owned(),
8520 serde_json::to_string(&vec![0_u64]).unwrap(),
8521 ),
8522 ("params".to_owned(), serde_json::to_string(¶ms).unwrap()),
8523 (
8524 TOKEN_SET_FORMAT_KEY.to_owned(),
8525 token_set_format.to_string(),
8526 ),
8527 (
8528 POSTING_TAIL_CODEC_KEY.to_owned(),
8529 format_version.posting_tail_codec().as_str().to_owned(),
8530 ),
8531 (
8532 FTS_FORMAT_VERSION_KEY.to_owned(),
8533 format_version.index_version().to_string(),
8534 ),
8535 (POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string()),
8536 ]);
8537 let mut writer = store
8538 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
8539 .await?;
8540 writer.finish_with_metadata(metadata).await?;
8541
8542 InvertedIndex::load(store, None, &LanceCache::no_cache()).await
8543 }
8544
8545 fn empty_doc_stream() -> SendableRecordBatchStream {
8546 let schema = Arc::new(Schema::new(vec![
8547 Field::new("doc", DataType::Utf8, true),
8548 Field::new(ROW_ID, DataType::UInt64, false),
8549 ]));
8550 Box::pin(RecordBatchStreamAdapter::new(
8551 schema,
8552 stream::iter(Vec::<datafusion::error::Result<RecordBatch>>::new()),
8553 ))
8554 }
8555
8556 #[test]
8557 fn test_posting_block_size_schema_metadata() {
8558 assert_eq!(parse_posting_block_size(&HashMap::new()).unwrap(), 128);
8559
8560 let metadata = HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), "512".to_owned())]);
8561 let err = parse_posting_block_size(&metadata).unwrap_err();
8562 assert!(err.to_string().contains("block_size"));
8563
8564 let metadata = HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), "129".to_owned())]);
8565 let err = parse_posting_block_size(&metadata).unwrap_err();
8566 assert!(err.to_string().contains("block_size"));
8567 }
8568
8569 #[test]
8570 fn test_num_tokens_only_reuses_sliced_arrow_storage() {
8571 let docs = {
8572 let source = UInt32Array::from(vec![999, 7, 16, 1024, 888]);
8573 let sliced = source.slice(1, 3);
8574 let mut docs = DocSet::from_num_tokens_only(&sliced);
8575
8576 let NumTokens::Shared(values) = &docs.num_tokens else {
8577 panic!("num-tokens-only DocSet must retain shared Arrow storage");
8578 };
8579 assert!(values.ptr_eq(sliced.values()));
8580 assert_eq!(values.as_ref(), &[7, 16, 1024]);
8581 assert_eq!(docs.total_tokens_num(), 1047);
8582 docs.set_quantized_scoring(true);
8583 assert_eq!(docs.scoring_norms().unwrap().len(), 3);
8584 assert_eq!(
8585 docs.scoring_num_tokens(0),
8586 dequantize_doc_length(quantize_doc_length(7))
8587 );
8588 assert_eq!(
8589 docs.scoring_num_tokens(2),
8590 dequantize_doc_length(quantize_doc_length(1024))
8591 );
8592 docs
8593 };
8594
8595 assert_eq!(docs.len(), 3);
8596 assert_eq!(docs.num_tokens(0), 7);
8597 assert_eq!(docs.num_tokens(2), 1024);
8598 }
8599
8600 #[test]
8601 fn test_cached_num_tokens_uses_supplied_total_and_full_stays_owned() {
8602 const CACHED_TOTAL_MARKER: u64 = 123_456;
8603
8604 let num_tokens = UInt32Array::from(vec![3, 5, 8]);
8605 let docs = DocSet::from_cached_num_tokens(&num_tokens, CACHED_TOTAL_MARKER);
8606 assert_eq!(docs.total_tokens_num(), CACHED_TOTAL_MARKER);
8607 assert!(matches!(&docs.num_tokens, NumTokens::Shared(_)));
8608
8609 let row_ids = UInt64Array::from(vec![10, 20, 30]);
8610 let full = DocSet::from_columns(&row_ids, &num_tokens, false, None).unwrap();
8611 assert!(matches!(&full.num_tokens, NumTokens::Owned(_)));
8612 assert_eq!(full.total_tokens_num(), 16);
8613 assert_eq!(full.row_id(1), 20);
8614 }
8615
8616 #[test]
8617 fn test_posting_builder_writes_impacts_for_supported_block_sizes() {
8618 for block_size in [128, 256] {
8619 let format_version = default_fts_format_version_for_block_size(block_size).unwrap();
8620 let num_docs = block_size * 33 + 1;
8621 let mut docs = DocSet::default();
8622 let mut posting = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
8623 false,
8624 format_version.posting_tail_codec(),
8625 block_size,
8626 );
8627 for doc_id in 0..num_docs {
8628 docs.append(doc_id as u64, (doc_id % 5 + 1) as u32);
8629 posting.add(
8630 doc_id as u32,
8631 PositionRecorder::Count((doc_id % 3 + 1) as u32),
8632 );
8633 }
8634 let schema =
8635 inverted_list_schema_for_version_with_block_size(false, format_version, block_size);
8636 let batch = posting.to_batch_with_docs(&docs, schema).unwrap();
8637 assert!(batch.column_by_name(IMPACT_COL).is_some());
8638 let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
8639 let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
8640 let posting = PostingList::from_batch(&batch, Some(max_score), Some(length)).unwrap();
8641 let PostingList::Compressed(posting) = posting else {
8642 panic!("expected compressed posting list");
8643 };
8644 let impacts = posting.impacts.expect("posting should include impacts");
8645 assert_eq!(impacts.level0_len(), posting.blocks.len());
8646 assert_eq!(impacts.level1_len(), posting.blocks.len().div_ceil(32));
8647 assert_eq!(
8648 impacts.entries().len(),
8649 impacts.level0_len() + impacts.level1_len()
8650 );
8651 }
8652 }
8653
8654 #[test]
8655 fn test_posting_builder_without_impact_column_roundtrips_without_impacts() {
8656 let mut posting = PostingListBuilder::new(false);
8657 for doc_id in 0..BLOCK_SIZE + 3 {
8658 posting.add(doc_id as u32, PositionRecorder::Count(1));
8659 }
8660 let batch = posting.to_batch(vec![1.0, 1.0]).unwrap();
8661 assert!(batch.column_by_name(IMPACT_COL).is_none());
8662 let posting =
8663 PostingList::from_batch(&batch, Some(1.0), Some((BLOCK_SIZE + 3) as u32)).unwrap();
8664 assert!(!posting.has_impacts());
8665 }
8666
8667 #[tokio::test]
8668 async fn test_build_search_uses_configured_posting_block_size() {
8669 let tmpdir = TempObjDir::default();
8670 let store = Arc::new(LanceIndexStore::new(
8671 ObjectStore::local().into(),
8672 tmpdir.clone(),
8673 Arc::new(LanceCache::no_cache()),
8674 ));
8675
8676 let params = InvertedIndexParams::default().block_size(256).unwrap();
8677 let format_version = params.resolved_format_version();
8678 let block_size = params.posting_block_size();
8679 let num_docs = block_size + 7;
8680
8681 let mut builder = InnerBuilder::new_with_format_version_and_block_size(
8682 0,
8683 false,
8684 TokenSetFormat::default(),
8685 format_version,
8686 block_size,
8687 );
8688 builder.tokens.add("needle".to_owned());
8689 let mut posting_list = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
8690 false,
8691 format_version.posting_tail_codec(),
8692 block_size,
8693 );
8694 for doc_id in 0..num_docs {
8695 posting_list.add(doc_id as u32, PositionRecorder::Count(1));
8696 builder.docs.append(1_000 + doc_id as u64, 1);
8697 }
8698 builder.posting_lists.push(posting_list);
8699 builder.write(store.as_ref()).await.unwrap();
8700 write_test_metadata(&store, vec![0], params).await;
8701
8702 let cache = Arc::new(LanceCache::with_capacity(4096));
8703 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
8704 .await
8705 .unwrap();
8706 assert_eq!(index.partitions[0].inverted_list.block_size(), block_size);
8707
8708 let posting = index.partitions[0]
8709 .inverted_list
8710 .posting_list(0, false, &NoOpMetricsCollector)
8711 .await
8712 .unwrap();
8713 let PostingList::Compressed(posting) = posting else {
8714 panic!("expected compressed posting list");
8715 };
8716 assert_eq!(posting.block_size, block_size);
8717 assert_eq!(posting.blocks.len(), num_docs.div_ceil(block_size));
8718 let impacts = posting
8719 .impacts
8720 .as_ref()
8721 .expect("newly written posting list should include impacts");
8722 assert_eq!(impacts.level0_len(), posting.blocks.len());
8723 assert_eq!(impacts.level1_len(), posting.blocks.len().div_ceil(32));
8724 assert_eq!(
8725 impacts.entries().len(),
8726 impacts.level0_len() + impacts.level1_len()
8727 );
8728
8729 let tokens = Arc::new(Tokens::new(vec!["needle".to_owned()], DocType::Text));
8730 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
8731 let prefilter = Arc::new(NoFilter);
8732 let metrics = Arc::new(NoOpMetricsCollector);
8733 let (row_ids, scores) = index
8734 .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
8735 .await
8736 .unwrap();
8737
8738 assert_eq!(row_ids.len(), 10);
8739 assert_eq!(scores.len(), 10);
8740 assert!(row_ids.iter().all(|row_id| *row_id >= 1_000));
8741 }
8742
8743 #[tokio::test]
8744 async fn test_posting_builder_remap() {
8745 let posting_tail_codec = PostingTailCodec::Fixed32;
8746 let mut builder =
8747 PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
8748 let n = BLOCK_SIZE + 3;
8749 for i in 0..n {
8750 builder.add(i as u32, PositionRecorder::Count(1));
8751 }
8752 let removed = vec![5, 7];
8753 builder.remap(&removed);
8754
8755 let mut expected =
8756 PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
8757 for i in 0..n - removed.len() {
8758 expected.add(i as u32, PositionRecorder::Count(1));
8759 }
8760 let expected_entries = expected.iter().collect::<Vec<_>>();
8761 let actual_entries = builder.iter().collect::<Vec<_>>();
8762 assert_eq!(actual_entries, expected_entries);
8763
8764 let batch = builder.to_batch(vec![1.0, 2.0]).unwrap();
8767 let (doc_ids, freqs) = decompress_posting_list_with_tail_codec(
8768 (n - removed.len()) as u32,
8769 batch[POSTING_COL]
8770 .as_list::<i32>()
8771 .value(0)
8772 .as_binary::<i64>(),
8773 posting_tail_codec,
8774 )
8775 .unwrap();
8776 assert!(
8777 doc_ids
8778 .iter()
8779 .zip(expected_entries.iter().map(|(doc_id, _, _)| doc_id))
8780 .all(|(a, b)| a == b)
8781 );
8782 assert!(
8783 freqs
8784 .iter()
8785 .zip(expected_entries.iter().map(|(_, freq, _)| freq))
8786 .all(|(a, b)| a == b)
8787 );
8788 }
8789
8790 #[test]
8791 fn test_posting_builder_size_tracking_matches_structure() {
8792 fn tracked_memory_size(builder: &PostingListBuilder) -> u64 {
8793 let encoded_blocks_size = builder
8794 .encoded_blocks
8795 .iter()
8796 .map(|encoded_blocks| std::mem::size_of::<EncodedBlocks>() + encoded_blocks.size())
8797 .sum::<usize>();
8798 let encoded_positions_size = builder
8799 .encoded_position_blocks
8800 .as_ref()
8801 .map(|positions| std::mem::size_of::<EncodedPositionBlocks>() + positions.size())
8802 .unwrap_or(0usize);
8803 (encoded_blocks_size
8804 + builder.tail_entries.capacity() * std::mem::size_of::<RawDocInfo>()
8805 + builder.tail_positions.size()
8806 + encoded_positions_size) as u64
8807 }
8808
8809 let mut builder = PostingListBuilder::new(true);
8810 for doc_id in 0..(BLOCK_SIZE + 5) as u32 {
8811 builder.add(
8812 doc_id,
8813 PositionRecorder::Position(smallvec::smallvec![1, 3, 5]),
8814 );
8815 }
8816
8817 assert_eq!(builder.size(), tracked_memory_size(&builder));
8818 }
8819
8820 #[test]
8821 fn test_posting_builder_flush_releases_tail_position_capacity() {
8822 let mut builder = PostingListBuilder::new(true);
8823 let positions = smallvec::SmallVec::<[u32; 2]>::from_vec((0..1024).collect());
8824 for doc_id in 0..BLOCK_SIZE as u32 {
8825 builder.add(doc_id, PositionRecorder::Position(positions.clone()));
8826 }
8827
8828 assert_eq!(builder.tail_positions.size(), 0);
8829 assert_eq!(builder.size(), {
8830 let encoded_blocks_size = builder
8831 .encoded_blocks
8832 .iter()
8833 .map(|encoded_blocks| std::mem::size_of::<EncodedBlocks>() + encoded_blocks.size())
8834 .sum::<usize>();
8835 let encoded_positions_size = builder
8836 .encoded_position_blocks
8837 .as_ref()
8838 .map(|positions| std::mem::size_of::<EncodedPositionBlocks>() + positions.size())
8839 .unwrap_or(0usize);
8840 (encoded_blocks_size
8841 + builder.tail_entries.capacity() * std::mem::size_of::<RawDocInfo>()
8842 + builder.tail_positions.size()
8843 + encoded_positions_size) as u64
8844 });
8845 }
8846
8847 #[test]
8848 fn test_posting_builder_streamed_positions_roundtrip() {
8849 let mut builder = PostingListBuilder::new(true);
8850 assert!(builder.add_occurrence(0, 1).unwrap());
8851 assert!(!builder.add_occurrence(0, 4).unwrap());
8852 assert!(!builder.add_occurrence(0, 9).unwrap());
8853 builder.finish_open_doc(0).unwrap();
8854
8855 assert!(builder.add_occurrence(2, 3).unwrap());
8856 builder.finish_open_doc(2).unwrap();
8857
8858 let entries = builder.iter().collect::<Vec<_>>();
8859 assert_eq!(
8860 entries,
8861 vec![
8862 (0_u32, 3_u32, Some(vec![1_u32, 4_u32, 9_u32])),
8863 (2_u32, 1_u32, Some(vec![3_u32])),
8864 ]
8865 );
8866 }
8867
8868 #[test]
8869 fn test_shared_position_stream_clone_shares_block_offsets() {
8870 let stream = SharedPositionStream::new(
8871 PositionStreamCodec::PackedDelta,
8872 vec![0_u32, 4, 11],
8873 bytes::Bytes::from_static(b"shared position bytes"),
8874 );
8875 let original_offsets = stream.block_offsets().as_ptr();
8876
8877 let cloned = stream.clone();
8878
8879 assert_eq!(cloned.block_offsets(), stream.block_offsets());
8880 assert_eq!(cloned.block_offsets().as_ptr(), original_offsets);
8881 }
8882
8883 #[test]
8884 fn test_posting_builder_roundtrip_shared_positions() {
8885 let entries = vec![
8886 (0_u32, vec![1_u32, 5]),
8887 (2, vec![0, 4, 9]),
8888 (4, vec![7]),
8889 (8, vec![3, 10]),
8890 (13, vec![2, 11, 30]),
8891 ];
8892 let mut builder =
8893 PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::VarintDelta);
8894 for (doc_id, positions) in &entries {
8895 builder.add(
8896 *doc_id,
8897 PositionRecorder::Position(positions.clone().into()),
8898 );
8899 }
8900
8901 let batch = builder.to_batch(vec![1.0]).unwrap();
8902 assert!(batch.column_by_name(COMPRESSED_POSITION_COL).is_some());
8903 assert!(batch.column_by_name(POSITION_COL).is_none());
8904 assert_eq!(
8905 batch.schema_ref().metadata().get(POSTING_TAIL_CODEC_KEY),
8906 Some(&PostingTailCodec::VarintDelta.as_str().to_owned())
8907 );
8908 assert_eq!(
8909 batch.schema_ref().metadata().get(POSITIONS_LAYOUT_KEY),
8910 Some(&POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned())
8911 );
8912 assert_eq!(
8913 batch.schema_ref().metadata().get(POSITIONS_CODEC_KEY),
8914 Some(&PositionStreamCodec::PackedDelta.as_str().to_owned())
8915 );
8916
8917 let posting =
8918 PostingList::from_batch(&batch, Some(1.0), Some(entries.len() as u32)).unwrap();
8919 let actual = posting
8920 .iter()
8921 .map(|(doc_id, freq, positions)| {
8922 (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
8923 })
8924 .collect::<Vec<_>>();
8925 let expected = entries
8926 .iter()
8927 .map(|(doc_id, positions)| (*doc_id, positions.len() as u32, positions.clone()))
8928 .collect::<Vec<_>>();
8929 assert_eq!(actual, expected);
8930 }
8931
8932 #[test]
8933 fn test_posting_builder_roundtrip_legacy_positions() {
8934 let entries = vec![(0_u32, vec![1_u32, 5]), (2, vec![0, 4, 9]), (4, vec![7])];
8935 let mut builder =
8936 PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::Fixed32);
8937 for (doc_id, positions) in &entries {
8938 builder.add(
8939 *doc_id,
8940 PositionRecorder::Position(positions.clone().into()),
8941 );
8942 }
8943
8944 let batch = builder.to_batch(vec![1.0]).unwrap();
8945 assert!(batch.column_by_name(POSITION_COL).is_some());
8946 assert!(batch.column_by_name(COMPRESSED_POSITION_COL).is_none());
8947 assert_eq!(
8948 batch.schema_ref().metadata().get(POSTING_TAIL_CODEC_KEY),
8949 None
8950 );
8951 assert_eq!(
8952 batch.schema_ref().metadata().get(POSITIONS_LAYOUT_KEY),
8953 None
8954 );
8955 assert_eq!(batch.schema_ref().metadata().get(POSITIONS_CODEC_KEY), None);
8956
8957 let posting =
8958 PostingList::from_batch(&batch, Some(1.0), Some(entries.len() as u32)).unwrap();
8959 let actual = posting
8960 .iter()
8961 .map(|(doc_id, freq, positions)| {
8962 (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
8963 })
8964 .collect::<Vec<_>>();
8965 let expected = entries
8966 .iter()
8967 .map(|(doc_id, positions)| (*doc_id, positions.len() as u32, positions.clone()))
8968 .collect::<Vec<_>>();
8969 assert_eq!(actual, expected);
8970 }
8971
8972 #[test]
8973 fn test_resolve_fts_format_version_defaults_to_v2() {
8974 assert_eq!(
8975 resolve_fts_format_version(None).unwrap(),
8976 InvertedListFormatVersion::V2
8977 );
8978 assert_eq!(
8979 resolve_fts_format_version(Some("2")).unwrap(),
8980 InvertedListFormatVersion::V2
8981 );
8982 assert_eq!(
8983 resolve_fts_format_version(Some("3")).unwrap(),
8984 InvertedListFormatVersion::V3
8985 );
8986 assert!(resolve_fts_format_version(Some("4")).is_err());
8987 }
8988
8989 #[test]
8990 fn test_block_size_256_metadata_resolves_to_v3() {
8991 let metadata = HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), "256".to_owned())]);
8992 assert_eq!(
8993 parse_format_version_from_metadata(&metadata).unwrap(),
8994 InvertedListFormatVersion::V3
8995 );
8996 }
8997
8998 #[test]
8999 fn test_legacy_compressed_positions_still_readable() {
9000 let doc_ids = [1_u32, 3_u32];
9001 let frequencies = [2_u32, 3_u32];
9002 let posting = compress_posting_list_with_tail_codec(
9003 doc_ids.len(),
9004 doc_ids.iter(),
9005 frequencies.iter(),
9006 std::iter::once(1.0_f32),
9007 PostingTailCodec::Fixed32,
9008 )
9009 .unwrap();
9010
9011 let mut posting_builder = ListBuilder::new(LargeBinaryBuilder::new());
9012 for idx in 0..posting.len() {
9013 posting_builder.values().append_value(posting.value(idx));
9014 }
9015 posting_builder.append(true);
9016
9017 let mut positions_builder = ListBuilder::new(ListBuilder::new(LargeBinaryBuilder::new()));
9018 for positions in [vec![1_u32, 5_u32], vec![0_u32, 4_u32, 9_u32]] {
9019 let compressed = compress_positions(&positions).unwrap();
9020 let doc_builder = positions_builder.values();
9021 for idx in 0..compressed.len() {
9022 doc_builder.values().append_value(compressed.value(idx));
9023 }
9024 doc_builder.append(true);
9025 }
9026 positions_builder.append(true);
9027
9028 let schema = Arc::new(Schema::new(vec![
9029 Field::new(
9030 POSTING_COL,
9031 DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))),
9032 false,
9033 ),
9034 Field::new(MAX_SCORE_COL, DataType::Float32, false),
9035 Field::new(LENGTH_COL, DataType::UInt32, false),
9036 Field::new(
9037 POSITION_COL,
9038 DataType::List(Arc::new(Field::new(
9039 "item",
9040 DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))),
9041 true,
9042 ))),
9043 false,
9044 ),
9045 ]));
9046 let batch = RecordBatch::try_new(
9047 schema,
9048 vec![
9049 Arc::new(posting_builder.finish()) as ArrayRef,
9050 Arc::new(Float32Array::from(vec![1.0])) as ArrayRef,
9051 Arc::new(UInt32Array::from(vec![doc_ids.len() as u32])) as ArrayRef,
9052 Arc::new(positions_builder.finish()) as ArrayRef,
9053 ],
9054 )
9055 .unwrap();
9056
9057 let posting =
9058 PostingList::from_batch(&batch, Some(1.0), Some(doc_ids.len() as u32)).unwrap();
9059 let actual = posting
9060 .iter()
9061 .map(|(doc_id, freq, positions)| {
9062 (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
9063 })
9064 .collect::<Vec<_>>();
9065 assert_eq!(actual, vec![(1, 2, vec![1, 5]), (3, 3, vec![0, 4, 9]),]);
9066 }
9067
9068 #[test]
9069 fn test_shared_stream_v2_without_codec_still_readable() {
9070 let doc_ids = [1_u32, 3_u32];
9071 let frequencies = [2_u32, 3_u32];
9072 let posting = compress_posting_list_with_tail_codec(
9073 doc_ids.len(),
9074 doc_ids.iter(),
9075 frequencies.iter(),
9076 std::iter::once(1.0_f32),
9077 PostingTailCodec::Fixed32,
9078 )
9079 .unwrap();
9080
9081 let mut posting_builder = ListBuilder::new(LargeBinaryBuilder::new());
9082 for idx in 0..posting.len() {
9083 posting_builder.values().append_value(posting.value(idx));
9084 }
9085 posting_builder.append(true);
9086
9087 let positions = vec![1_u32, 5_u32, 0_u32, 4_u32, 9_u32];
9088 let mut encoded_positions = Vec::new();
9089 encode_position_stream_block_into(
9090 &positions,
9091 &frequencies,
9092 PositionStreamCodec::VarintDocDelta,
9093 &mut encoded_positions,
9094 )
9095 .unwrap();
9096
9097 let mut position_offsets = ListBuilder::new(UInt32Builder::new());
9098 position_offsets.values().append_value(0);
9099 position_offsets.append(true);
9100
9101 let schema = Arc::new(Schema::new_with_metadata(
9102 vec![
9103 Field::new(
9104 POSTING_COL,
9105 DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))),
9106 false,
9107 ),
9108 Field::new(MAX_SCORE_COL, DataType::Float32, false),
9109 Field::new(LENGTH_COL, DataType::UInt32, false),
9110 Field::new(COMPRESSED_POSITION_COL, DataType::LargeBinary, false),
9111 Field::new(
9112 POSITION_BLOCK_OFFSET_COL,
9113 DataType::List(Arc::new(Field::new("item", DataType::UInt32, true))),
9114 false,
9115 ),
9116 ],
9117 HashMap::from([(
9118 POSITIONS_LAYOUT_KEY.to_owned(),
9119 POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
9120 )]),
9121 ));
9122 let batch = RecordBatch::try_new(
9123 schema,
9124 vec![
9125 Arc::new(posting_builder.finish()) as ArrayRef,
9126 Arc::new(Float32Array::from(vec![1.0])) as ArrayRef,
9127 Arc::new(UInt32Array::from(vec![doc_ids.len() as u32])) as ArrayRef,
9128 Arc::new(arrow_array::LargeBinaryArray::from(vec![Some(
9129 encoded_positions.as_slice(),
9130 )])) as ArrayRef,
9131 Arc::new(position_offsets.finish()) as ArrayRef,
9132 ],
9133 )
9134 .unwrap();
9135
9136 let posting =
9137 PostingList::from_batch(&batch, Some(1.0), Some(doc_ids.len() as u32)).unwrap();
9138 let actual = posting
9139 .iter()
9140 .map(|(doc_id, freq, positions)| {
9141 (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
9142 })
9143 .collect::<Vec<_>>();
9144 assert_eq!(actual, vec![(1, 2, vec![1, 5]), (3, 3, vec![0, 4, 9]),]);
9145 }
9146
9147 #[test]
9148 fn test_shared_position_stream_is_smaller_for_sparse_positions() {
9149 let mut builder =
9150 PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::VarintDelta);
9151 let mut legacy_positions = Vec::with_capacity(BLOCK_SIZE * 4);
9152 for doc_id in 0..(BLOCK_SIZE * 4) as u32 {
9153 let mut positions = vec![doc_id * 3 + 1];
9154 if doc_id % 8 == 0 {
9155 positions.push(doc_id * 3 + 2);
9156 }
9157 builder.add(doc_id, PositionRecorder::Position(positions.clone().into()));
9158 legacy_positions.push(positions);
9159 }
9160
9161 let batch = builder.to_batch(vec![1.0; 4]).unwrap();
9162 let shared_positions_size = batch[COMPRESSED_POSITION_COL].get_buffer_memory_size()
9163 + batch[POSITION_BLOCK_OFFSET_COL].get_buffer_memory_size();
9164
9165 let mut positions_builder = ListBuilder::new(ListBuilder::new(LargeBinaryBuilder::new()));
9166 for positions in legacy_positions {
9167 let compressed = compress_positions(&positions).unwrap();
9168 let doc_builder = positions_builder.values();
9169 for idx in 0..compressed.len() {
9170 doc_builder.values().append_value(compressed.value(idx));
9171 }
9172 doc_builder.append(true);
9173 }
9174 positions_builder.append(true);
9175 let legacy_positions_size = positions_builder.finish().get_buffer_memory_size();
9176
9177 assert!(
9178 shared_positions_size < legacy_positions_size,
9179 "expected shared position stream to be smaller than legacy per-doc storage, shared={shared_positions_size}, legacy={legacy_positions_size}",
9180 );
9181 }
9182
9183 #[test]
9184 fn test_posting_list_batch_matches_docset_scoring() {
9185 let mut docs = DocSet::default();
9186 let num_docs = BLOCK_SIZE + 3;
9187 for doc_id in 0..num_docs as u32 {
9188 docs.append(doc_id as u64, doc_id % 7 + 1);
9189 }
9190
9191 let doc_ids = (0..num_docs as u32).collect::<Vec<_>>();
9192 let freqs = doc_ids
9193 .iter()
9194 .map(|doc_id| doc_id % 5 + 1)
9195 .collect::<Vec<_>>();
9196
9197 let mut builder_scores = PostingListBuilder::new(false);
9198 let mut builder_docs = PostingListBuilder::new(false);
9199 for (&doc_id, &freq) in doc_ids.iter().zip(freqs.iter()) {
9200 builder_scores.add(doc_id, PositionRecorder::Count(freq));
9201 builder_docs.add(doc_id, PositionRecorder::Count(freq));
9202 }
9203
9204 let block_max_scores = docs.calculate_block_max_scores(doc_ids.iter(), freqs.iter());
9205 let batch_scores = builder_scores.to_batch(block_max_scores).unwrap();
9206 let batch_docs = builder_docs
9207 .to_batch_with_docs(&docs, inverted_list_schema(false))
9208 .unwrap();
9209
9210 let scores_posting = batch_scores[POSTING_COL].as_list::<i32>().value(0);
9211 let scores_posting = scores_posting.as_binary::<i64>();
9212 let docs_posting = batch_docs[POSTING_COL].as_list::<i32>().value(0);
9213 let docs_posting = docs_posting.as_binary::<i64>();
9214 assert_eq!(scores_posting, docs_posting);
9215
9216 let score_left = batch_scores[MAX_SCORE_COL]
9217 .as_primitive::<Float32Type>()
9218 .value(0);
9219 let score_right = batch_docs[MAX_SCORE_COL]
9220 .as_primitive::<Float32Type>()
9221 .value(0);
9222 assert!((score_left - score_right).abs() < 1e-6);
9223
9224 let len_left = batch_scores[LENGTH_COL]
9225 .as_primitive::<UInt32Type>()
9226 .value(0);
9227 let len_right = batch_docs[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
9228 assert_eq!(len_left, len_right);
9229 }
9230
9231 #[tokio::test]
9232 async fn test_remap_to_empty_posting_list() {
9233 let tmpdir = TempObjDir::default();
9234 let store = Arc::new(LanceIndexStore::new(
9235 ObjectStore::local().into(),
9236 tmpdir.clone(),
9237 Arc::new(LanceCache::no_cache()),
9238 ));
9239
9240 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
9241
9242 builder.tokens.add("lance".to_owned());
9247 builder.tokens.add("lake".to_owned());
9248 builder.posting_lists.push(PostingListBuilder::new(false));
9249 builder.posting_lists.push(PostingListBuilder::new(false));
9250 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
9251 builder.posting_lists[1].add(1, PositionRecorder::Count(2));
9252 builder.posting_lists[1].add(2, PositionRecorder::Count(3));
9253 builder.docs.append(0, 1);
9254 builder.docs.append(1, 1);
9255 builder.docs.append(2, 1);
9256 builder.write(store.as_ref()).await.unwrap();
9257
9258 let index = InvertedPartition::load(
9259 store.clone(),
9260 0,
9261 None,
9262 &LanceCache::no_cache(),
9263 TokenSetFormat::default(),
9264 )
9265 .await
9266 .unwrap();
9267 let mut builder = index.into_builder().await.unwrap();
9268
9269 let mapping = HashMap::from([(0, None), (2, Some(3))]);
9270 builder.remap(&RowAddrRemap::direct(mapping)).await.unwrap();
9271
9272 assert_eq!(builder.tokens.len(), 1);
9274 assert_eq!(builder.tokens.get("lake"), Some(0));
9275 assert_eq!(builder.posting_lists.len(), 1);
9276 assert_eq!(builder.posting_lists[0].len(), 2);
9277 assert_eq!(builder.docs.len(), 2);
9278 assert_eq!(builder.docs.row_id(0), 1);
9279 assert_eq!(builder.docs.row_id(1), 3);
9280
9281 builder.write(store.as_ref()).await.unwrap();
9282
9283 let mapping = HashMap::from([(1, None), (3, None)]);
9285 builder.remap(&RowAddrRemap::direct(mapping)).await.unwrap();
9286
9287 assert_eq!(builder.tokens.len(), 0);
9288 assert_eq!(builder.posting_lists.len(), 0);
9289 assert_eq!(builder.docs.len(), 0);
9290
9291 builder.write(store.as_ref()).await.unwrap();
9292 }
9293
9294 #[tokio::test]
9295 async fn test_posting_cache_conflict_across_partitions() {
9296 let tmpdir = TempObjDir::default();
9297 let store = Arc::new(LanceIndexStore::new(
9298 ObjectStore::local().into(),
9299 tmpdir.clone(),
9300 Arc::new(LanceCache::no_cache()),
9301 ));
9302
9303 let mut builder1 = InnerBuilder::new(0, false, TokenSetFormat::default());
9305 builder1.tokens.add("test".to_owned());
9306 builder1.posting_lists.push(PostingListBuilder::new(false));
9307 builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
9308 builder1.docs.append(100, 1); builder1.write(store.as_ref()).await.unwrap();
9310
9311 let mut builder2 = InnerBuilder::new(1, false, TokenSetFormat::default());
9313 builder2.tokens.add("test".to_owned()); builder2.posting_lists.push(PostingListBuilder::new(false));
9315 builder2.posting_lists[0].add(0, PositionRecorder::Count(2));
9316 builder2.posting_lists[0].add(1, PositionRecorder::Count(1));
9317 builder2.posting_lists[0].add(2, PositionRecorder::Count(3));
9318 builder2.posting_lists[0].add(3, PositionRecorder::Count(1));
9319 builder2.docs.append(200, 2); builder2.docs.append(201, 1); builder2.docs.append(202, 3); builder2.docs.append(203, 1); builder2.write(store.as_ref()).await.unwrap();
9324
9325 let metadata = std::collections::HashMap::from_iter(vec![
9327 (
9328 "partitions".to_owned(),
9329 serde_json::to_string(&vec![0u64, 1u64]).unwrap(),
9330 ),
9331 (
9332 "params".to_owned(),
9333 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
9334 ),
9335 (
9336 TOKEN_SET_FORMAT_KEY.to_owned(),
9337 TokenSetFormat::default().to_string(),
9338 ),
9339 ]);
9340 let mut writer = store
9341 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9342 .await
9343 .unwrap();
9344 writer.finish_with_metadata(metadata).await.unwrap();
9345
9346 let cache = Arc::new(LanceCache::with_capacity(4096));
9348 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9349 .await
9350 .unwrap();
9351
9352 assert_eq!(index.partitions.len(), 2);
9354 assert_eq!(index.partitions[0].tokens.len(), 1);
9355 assert_eq!(index.partitions[1].tokens.len(), 1);
9356
9357 let pl_0_0 = index.partitions[0]
9363 .inverted_list
9364 .posting_len_for_token(0, None)
9365 .await
9366 .unwrap();
9367 let pl_1_0 = index.partitions[1]
9368 .inverted_list
9369 .posting_len_for_token(0, None)
9370 .await
9371 .unwrap();
9372 if index.partitions[0].id() == 0 {
9373 assert_eq!(pl_0_0, 1);
9374 assert_eq!(pl_1_0, 4);
9375 assert_eq!(index.partitions[0].docs.len(), 1);
9376 assert_eq!(index.partitions[1].docs.len(), 4);
9377 } else {
9378 assert_eq!(pl_0_0, 4);
9379 assert_eq!(pl_1_0, 1);
9380 assert_eq!(index.partitions[0].docs.len(), 4);
9381 assert_eq!(index.partitions[1].docs.len(), 1);
9382 }
9383
9384 index.prewarm().await.unwrap();
9386
9387 let tokens = Arc::new(Tokens::new(vec!["test".to_string()], DocType::Text));
9388 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
9389 let prefilter = Arc::new(NoFilter);
9390 let metrics = Arc::new(NoOpMetricsCollector);
9391
9392 let (row_ids, scores) = index
9393 .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
9394 .await
9395 .unwrap();
9396
9397 assert_eq!(row_ids.len(), 5, "row_ids: {:?}", row_ids);
9400 assert!(!row_ids.is_empty(), "Should find at least some documents");
9401 assert_eq!(row_ids.len(), scores.len());
9402
9403 for &score in &scores {
9405 assert!(score > 0.0, "All scores should be positive");
9406 }
9407
9408 assert!(
9410 row_ids.contains(&100),
9411 "Should contain row_id from partition 0"
9412 );
9413 assert!(
9414 row_ids.iter().any(|&id| id >= 200),
9415 "Should contain row_id from partition 1"
9416 );
9417 }
9418
9419 #[tokio::test]
9420 async fn test_modern_prewarm_packs_group_with_shared_posting_buffer() {
9421 let tmpdir = TempObjDir::default();
9422 let store = Arc::new(LanceIndexStore::new(
9423 ObjectStore::local().into(),
9424 tmpdir.clone(),
9425 Arc::new(LanceCache::no_cache()),
9426 ));
9427
9428 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
9429 builder.tokens.add("alpha".to_owned());
9430 builder.tokens.add("beta".to_owned());
9431 builder.posting_lists.push(PostingListBuilder::new(false));
9432 builder.posting_lists.push(PostingListBuilder::new(false));
9433 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
9434 builder.posting_lists[0].add(1, PositionRecorder::Count(2));
9435 builder.posting_lists[1].add(2, PositionRecorder::Count(3));
9436 builder.posting_lists[1].add(3, PositionRecorder::Count(4));
9437 builder.docs.append(100, 1);
9438 builder.docs.append(101, 2);
9439 builder.docs.append(102, 3);
9440 builder.docs.append(103, 4);
9441 builder.write(store.as_ref()).await.unwrap();
9442
9443 let metadata = std::collections::HashMap::from_iter(vec![
9444 (
9445 "partitions".to_owned(),
9446 serde_json::to_string(&vec![0u64]).unwrap(),
9447 ),
9448 (
9449 "params".to_owned(),
9450 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
9451 ),
9452 (
9453 TOKEN_SET_FORMAT_KEY.to_owned(),
9454 TokenSetFormat::default().to_string(),
9455 ),
9456 ]);
9457 let mut writer = store
9458 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9459 .await
9460 .unwrap();
9461 writer.finish_with_metadata(metadata).await.unwrap();
9462
9463 let cache = Arc::new(LanceCache::with_capacity(4096));
9464 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9465 .await
9466 .unwrap();
9467 let inverted_list = &index.partitions[0].inverted_list;
9468 assert!(
9469 !inverted_list.is_legacy_layout(),
9470 "test should use modern posting layout"
9471 );
9472 assert!(
9473 inverted_list.has_impacts,
9474 "modern posting fixture should include impact skip data"
9475 );
9476
9477 inverted_list.prewarm_posting_lists(false, 2).await.unwrap();
9478
9479 let (start, end) = inverted_list.group_range_for_token(0).unwrap();
9482 let group = inverted_list
9483 .index_cache
9484 .get_with_key(&posting_list_group_cache_key(
9485 start,
9486 end,
9487 inverted_list.has_impacts,
9488 ))
9489 .await
9490 .unwrap();
9491
9492 assert!(
9493 group.is_packed(),
9494 "no-position prewarm should pack v2 groups"
9495 );
9496 assert!(
9497 group.needs_external_metadata(),
9498 "prewarmed packed groups must not duplicate reader score/length metadata"
9499 );
9500 let (alpha_score, alpha_len) = inverted_list.bulk_metadata_for_token(0);
9501 let PostingList::Compressed(alpha) = group
9502 .posting_list(0, alpha_score, alpha_len)
9503 .unwrap()
9504 .unwrap()
9505 else {
9506 panic!("expected compressed posting list for token 0");
9507 };
9508 let PostingList::Compressed(alpha_again) = group
9509 .posting_list(0, alpha_score, alpha_len)
9510 .unwrap()
9511 .unwrap()
9512 else {
9513 panic!("expected compressed posting list for repeated token 0 access");
9514 };
9515 let (beta_score, beta_len) = inverted_list.bulk_metadata_for_token(1);
9516 let PostingList::Compressed(beta) = group
9517 .posting_list(1, beta_score, beta_len)
9518 .unwrap()
9519 .unwrap()
9520 else {
9521 panic!("expected compressed posting list for token 1");
9522 };
9523
9524 assert!(
9525 alpha.impacts.is_some() && beta.impacts.is_some(),
9526 "packed prewarm must preserve impact skip data"
9527 );
9528 assert!(
9529 alpha
9530 .impacts
9531 .as_ref()
9532 .unwrap()
9533 .shares_derived_state_with(alpha_again.impacts.as_ref().unwrap()),
9534 "repeated packed slot access must share decoded impact state"
9535 );
9536 assert!(
9537 alpha.shares_first_docs_with(&alpha_again),
9538 "repeated packed slot access must share decoded block heads"
9539 );
9540 assert_eq!(
9541 alpha.block_first_docs().as_ptr(),
9542 alpha_again.block_first_docs().as_ptr(),
9543 "packed block heads should be decoded only once per slot"
9544 );
9545 assert_eq!(
9546 alpha.blocks.values().as_ptr(),
9547 beta.blocks.values().as_ptr(),
9548 "packed posting views should share the group's values buffer"
9549 );
9550 }
9551
9552 #[tokio::test]
9553 async fn test_packed_prewarm_groups_do_not_retain_the_full_chunk() {
9554 let tmpdir = TempObjDir::default();
9555 let store = Arc::new(LanceIndexStore::new(
9556 ObjectStore::local().into(),
9557 tmpdir.clone(),
9558 Arc::new(LanceCache::no_cache()),
9559 ));
9560
9561 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
9562 for token_id in 0..4u32 {
9563 builder.tokens.add(format!("t{token_id}"));
9564 let mut posting = PostingListBuilder::new(false);
9565 posting.add(token_id, PositionRecorder::Count(1));
9566 builder.posting_lists.push(posting);
9567 builder.docs.append(1000 + token_id as u64, 1);
9568 }
9569 builder.write(store.as_ref()).await.unwrap();
9570
9571 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
9572 let cache = LanceCache::with_capacity(1 << 20);
9573 let mut posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
9574 posting_reader.grouping = PostingGrouping::SyntheticFixed { group_size: 2 };
9575
9576 assert_eq!(
9577 posting_reader
9578 .prewarm_posting_lists_chunked(false, Some(4), 1)
9579 .await
9580 .unwrap(),
9581 1,
9582 "the test must read both groups in one prewarm chunk"
9583 );
9584
9585 let first_group = posting_reader
9586 .index_cache
9587 .get_with_key(&posting_list_group_cache_key(
9588 0,
9589 2,
9590 posting_reader.has_impacts,
9591 ))
9592 .await
9593 .unwrap();
9594 let second_group = posting_reader
9595 .index_cache
9596 .get_with_key(&posting_list_group_cache_key(
9597 2,
9598 4,
9599 posting_reader.has_impacts,
9600 ))
9601 .await
9602 .unwrap();
9603 let (first_score, first_len) = posting_reader.bulk_metadata_for_token(0);
9604 let PostingList::Compressed(first) = first_group
9605 .posting_list(0, first_score, first_len)
9606 .unwrap()
9607 .unwrap()
9608 else {
9609 panic!("expected compressed posting list in first group");
9610 };
9611 let (neighbor_score, neighbor_len) = posting_reader.bulk_metadata_for_token(1);
9612 let PostingList::Compressed(first_neighbor) = first_group
9613 .posting_list(1, neighbor_score, neighbor_len)
9614 .unwrap()
9615 .unwrap()
9616 else {
9617 panic!("expected compressed posting list in first group");
9618 };
9619 let (second_score, second_len) = posting_reader.bulk_metadata_for_token(2);
9620 let PostingList::Compressed(second) = second_group
9621 .posting_list(0, second_score, second_len)
9622 .unwrap()
9623 .unwrap()
9624 else {
9625 panic!("expected compressed posting list in second group");
9626 };
9627
9628 assert_eq!(
9629 first.blocks.values().as_ptr(),
9630 first_neighbor.blocks.values().as_ptr(),
9631 "postings in one group should share the group's values buffer"
9632 );
9633 assert_ne!(
9634 first.blocks.values().as_ptr(),
9635 second.blocks.values().as_ptr(),
9636 "each group must own a compact buffer instead of retaining the full chunk"
9637 );
9638 }
9639
9640 #[test]
9641 fn test_prewarm_chunk_ranges_preserve_group_boundaries() {
9642 let grouping = PostingGrouping::SyntheticFixed { group_size: 4 };
9643 assert_eq!(
9644 prewarm_chunk_ranges(&grouping, 13, 5),
9645 vec![(0, 4), (4, 8), (8, 13)],
9646 "grouped chunks may contain multiple groups but must never split one"
9647 );
9648 assert_eq!(
9649 prewarm_chunk_ranges(&PostingGrouping::None, 13, 5),
9650 vec![(0, 5), (5, 10), (10, 13)],
9651 "ungrouped chunk ranges should use plain token ranges"
9652 );
9653 }
9654
9655 #[test]
9656 fn test_synthetic_grouping_preserves_fixed_boundaries() {
9657 let grouping = PostingGrouping::SyntheticFixed { group_size: 4 };
9658 assert_eq!(
9659 grouping.range_for_token(5, 10),
9660 Some((4, 8)),
9661 "synthetic token groups should be fixed-size ranges"
9662 );
9663 assert_eq!(
9664 grouping.range_for_token(9, 10),
9665 Some((8, 10)),
9666 "the final synthetic group should end at token_count"
9667 );
9668 assert_eq!(
9669 prewarm_chunk_ranges(&grouping, 10, 6),
9670 vec![(0, 4), (4, 10)],
9671 "prewarm chunks may contain multiple synthetic groups but must not split one"
9672 );
9673 assert_eq!(
9674 grouping.ranges_for_chunk(4, 10, 10),
9675 vec![(4, 8), (8, 10)],
9676 "publish selection should enumerate synthetic groups in a chunk"
9677 );
9678 }
9679
9680 #[rstest::rstest]
9686 #[case::v1(InvertedListFormatVersion::V1, LEGACY_BLOCK_SIZE)]
9687 #[case::v2(InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)]
9688 #[case::v3(InvertedListFormatVersion::V3, 256)]
9689 #[tokio::test]
9690 async fn test_prewarm_streams_in_chunks_preserves_content(
9691 #[case] format_version: InvertedListFormatVersion,
9692 #[case] block_size: usize,
9693 ) {
9694 let tmpdir = TempObjDir::default();
9695 let store = Arc::new(LanceIndexStore::new(
9696 ObjectStore::local().into(),
9697 tmpdir.clone(),
9698 Arc::new(LanceCache::no_cache()),
9699 ));
9700
9701 let num_tokens = runtime_posting_group_tokens() as u32 + 4;
9704 const DOCS_PER_TOKEN: u32 = 3;
9705 let posting_tail_codec = format_version.posting_tail_codec();
9706 let mut builder = InnerBuilder::new_with_format_version_and_block_size(
9707 0,
9708 false,
9709 TokenSetFormat::default(),
9710 format_version,
9711 block_size,
9712 );
9713 let mut expected: Vec<Vec<(u32, u32)>> = Vec::new();
9715 let mut doc_id = 0u64;
9716 for t in 0..num_tokens {
9717 builder.tokens.add(format!("tok_{t:03}"));
9718 let mut posting = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
9719 false,
9720 posting_tail_codec,
9721 block_size,
9722 );
9723 let mut docs = Vec::new();
9724 for _ in 0..DOCS_PER_TOKEN {
9725 posting.add(doc_id as u32, PositionRecorder::Count(1));
9726 builder.docs.append(doc_id, 1);
9727 docs.push((doc_id as u32, 1));
9728 doc_id += 1;
9729 }
9730 expected.push(docs);
9731 builder.posting_lists.push(posting);
9732 }
9733 builder.write(store.as_ref()).await.unwrap();
9734
9735 let params = InvertedIndexParams::default()
9736 .block_size(block_size)
9737 .unwrap();
9738 let metadata = std::collections::HashMap::from_iter(vec![
9739 (
9740 "partitions".to_owned(),
9741 serde_json::to_string(&vec![0u64]).unwrap(),
9742 ),
9743 ("params".to_owned(), serde_json::to_string(¶ms).unwrap()),
9744 (
9745 TOKEN_SET_FORMAT_KEY.to_owned(),
9746 TokenSetFormat::default().to_string(),
9747 ),
9748 (
9749 POSTING_TAIL_CODEC_KEY.to_owned(),
9750 posting_tail_codec.as_str().to_owned(),
9751 ),
9752 (
9753 FTS_FORMAT_VERSION_KEY.to_owned(),
9754 format_version.index_version().to_string(),
9755 ),
9756 (POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string()),
9757 ]);
9758 let mut writer = store
9759 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9760 .await
9761 .unwrap();
9762 writer.finish_with_metadata(metadata).await.unwrap();
9763
9764 let cache = Arc::new(LanceCache::with_capacity(1 << 20));
9765 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9766 .await
9767 .unwrap();
9768 let inverted_list = &index.partitions[0].inverted_list;
9769 assert_eq!(inverted_list.len(), num_tokens as usize);
9770 assert_eq!(inverted_list.block_size(), block_size);
9771
9772 const CHUNK_TOKENS: usize = 6;
9776 let chunk_count = inverted_list
9777 .prewarm_posting_lists_chunked(false, Some(CHUNK_TOKENS), 2)
9778 .await
9779 .unwrap();
9780
9781 assert!(
9785 chunk_count > 1,
9786 "single partition must be streamed in more than one chunk, got {chunk_count}"
9787 );
9788
9789 if block_size == 256 {
9790 let (start, end) = inverted_list.group_range_for_token(0).unwrap();
9791 let group = inverted_list
9792 .index_cache
9793 .get_with_key(&posting_list_group_cache_key(
9794 start,
9795 end,
9796 inverted_list.has_impacts,
9797 ))
9798 .await
9799 .expect("256-document blocks should populate the packed group cache");
9800 assert!(group.is_packed());
9801 let (max_score, length) = inverted_list.bulk_metadata_for_token(0);
9802 let PostingList::Compressed(posting) =
9803 group.posting_list(0, max_score, length).unwrap().unwrap()
9804 else {
9805 panic!("expected compressed posting list");
9806 };
9807 assert_eq!(posting.block_size, 256);
9808 assert!(
9809 posting.impacts.is_some(),
9810 "packed prewarm must preserve impact skip data"
9811 );
9812 }
9813
9814 for token_id in 0..num_tokens {
9817 let actual = inverted_list
9818 .posting_list(token_id, false, &NoOpMetricsCollector)
9819 .await
9820 .unwrap()
9821 .iter()
9822 .map(|(doc_id, freq, _positions)| (doc_id as u32, freq))
9823 .collect::<Vec<_>>();
9824 assert_eq!(
9825 actual, expected[token_id as usize],
9826 "token {token_id} posting list mismatch after chunked prewarm"
9827 );
9828 }
9829 }
9830
9831 #[tokio::test]
9835 async fn test_prewarm_streams_in_chunks_with_positions() {
9836 let tmpdir = TempObjDir::default();
9837 let store = Arc::new(LanceIndexStore::new(
9838 ObjectStore::local().into(),
9839 tmpdir.clone(),
9840 Arc::new(LanceCache::no_cache()),
9841 ));
9842
9843 let format_version = InvertedListFormatVersion::V2;
9844 let posting_tail_codec = format_version.posting_tail_codec();
9845 let num_tokens = runtime_posting_group_tokens() as u32 + 4;
9846 const DOCS_PER_TOKEN: u32 = 3;
9847 let mut builder = InnerBuilder::new_with_format_version(
9848 0,
9849 true,
9850 TokenSetFormat::default(),
9851 format_version,
9852 );
9853 let mut expected: Vec<Vec<(u32, u32, Vec<u32>)>> = Vec::new();
9855 let mut doc_id = 0u64;
9856 for t in 0..num_tokens {
9857 builder.tokens.add(format!("tok_{t:03}"));
9858 let mut posting =
9859 PostingListBuilder::new_with_posting_tail_codec(true, posting_tail_codec);
9860 let mut docs = Vec::new();
9861 for _ in 0..DOCS_PER_TOKEN {
9862 let positions = vec![t % 3, t % 3 + 2, t % 3 + 5];
9863 posting.add(
9864 doc_id as u32,
9865 PositionRecorder::Position(positions.clone().into()),
9866 );
9867 builder.docs.append(doc_id, positions.len() as u32);
9868 docs.push((doc_id as u32, positions.len() as u32, positions));
9869 doc_id += 1;
9870 }
9871 expected.push(docs);
9872 builder.posting_lists.push(posting);
9873 }
9874 builder.write(store.as_ref()).await.unwrap();
9875
9876 let metadata = std::collections::HashMap::from_iter(vec![
9877 (
9878 "partitions".to_owned(),
9879 serde_json::to_string(&vec![0u64]).unwrap(),
9880 ),
9881 (
9882 "params".to_owned(),
9883 serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
9884 ),
9885 (
9886 TOKEN_SET_FORMAT_KEY.to_owned(),
9887 TokenSetFormat::default().to_string(),
9888 ),
9889 (
9890 POSTING_TAIL_CODEC_KEY.to_owned(),
9891 posting_tail_codec.as_str().to_owned(),
9892 ),
9893 (
9894 POSITIONS_LAYOUT_KEY.to_owned(),
9895 POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
9896 ),
9897 (
9898 POSITIONS_CODEC_KEY.to_owned(),
9899 PositionStreamCodec::PackedDelta.as_str().to_owned(),
9900 ),
9901 ]);
9902 let mut writer = store
9903 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9904 .await
9905 .unwrap();
9906 writer.finish_with_metadata(metadata).await.unwrap();
9907
9908 let cache = Arc::new(LanceCache::with_capacity(1 << 20));
9909 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9910 .await
9911 .unwrap();
9912 let inverted_list = &index.partitions[0].inverted_list;
9913
9914 const CHUNK_TOKENS: usize = 5;
9915 let chunk_count = inverted_list
9916 .prewarm_posting_lists_chunked(true, Some(CHUNK_TOKENS), 2)
9917 .await
9918 .unwrap();
9919 assert!(
9920 chunk_count > 1,
9921 "partition must be streamed in more than one chunk, got {chunk_count}"
9922 );
9923
9924 for token_id in 0..num_tokens {
9925 let (start, end) = inverted_list.group_range_for_token(token_id).unwrap();
9927 let group = inverted_list
9928 .index_cache
9929 .get_with_key(&posting_list_group_cache_key(
9930 start,
9931 end,
9932 inverted_list.has_impacts,
9933 ))
9934 .await
9935 .unwrap();
9936 let slot = (token_id - start) as usize;
9937 assert!(
9938 !group.is_packed(),
9939 "with-position prewarm should retain the materialized fallback"
9940 );
9941 assert!(
9942 !group
9943 .posting_list(slot, None, None)
9944 .unwrap()
9945 .unwrap()
9946 .has_position(),
9947 "token {token_id} posting cache entry must be positions-free after prewarm"
9948 );
9949
9950 let actual = inverted_list
9953 .posting_list(token_id, true, &NoOpMetricsCollector)
9954 .await
9955 .unwrap()
9956 .iter()
9957 .map(|(doc_id, freq, positions)| {
9958 (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
9959 })
9960 .collect::<Vec<_>>();
9961 assert_eq!(
9962 actual, expected[token_id as usize],
9963 "token {token_id} posting list / positions mismatch after chunked prewarm"
9964 );
9965 }
9966 }
9967
9968 #[derive(Debug, Default)]
9972 struct PostingMetadataCounter {
9973 rows_read: std::sync::atomic::AtomicUsize,
9974 metadata_rows_read: std::sync::atomic::AtomicUsize,
9975 read_range_calls: std::sync::atomic::AtomicUsize,
9976 }
9977
9978 impl PostingMetadataCounter {
9979 fn rows_read(&self) -> usize {
9980 self.rows_read.load(std::sync::atomic::Ordering::Relaxed)
9981 }
9982 fn metadata_rows_read(&self) -> usize {
9983 self.metadata_rows_read
9984 .load(std::sync::atomic::Ordering::Relaxed)
9985 }
9986 fn read_range_calls(&self) -> usize {
9987 self.read_range_calls
9988 .load(std::sync::atomic::Ordering::Relaxed)
9989 }
9990 }
9991
9992 struct CountingPostingReader {
9993 inner: Arc<dyn IndexReader>,
9994 counter: Arc<PostingMetadataCounter>,
9995 }
9996
9997 #[async_trait]
9998 impl IndexReader for CountingPostingReader {
9999 async fn read_record_batch(&self, n: u64, batch_size: u64) -> Result<RecordBatch> {
10000 self.inner.read_record_batch(n, batch_size).await
10001 }
10002 async fn read_global_buffer(&self, index: u32) -> Result<bytes::Bytes> {
10003 self.inner.read_global_buffer(index).await
10004 }
10005 async fn read_range(
10006 &self,
10007 range: std::ops::Range<usize>,
10008 projection: Option<&[&str]>,
10009 ) -> Result<RecordBatch> {
10010 let n = range.end - range.start;
10011 self.counter
10012 .read_range_calls
10013 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
10014 self.counter
10015 .rows_read
10016 .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
10017 let touches_metadata = projection
10018 .map(|cols| cols.contains(&MAX_SCORE_COL) || cols.contains(&LENGTH_COL))
10019 .unwrap_or(false);
10020 if touches_metadata {
10021 self.counter
10022 .metadata_rows_read
10023 .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
10024 }
10025 self.inner.read_range(range, projection).await
10026 }
10027 async fn num_batches(&self, batch_size: u64) -> u32 {
10028 self.inner.num_batches(batch_size).await
10029 }
10030 fn num_rows(&self) -> usize {
10031 self.inner.num_rows()
10032 }
10033 fn schema(&self) -> &lance_core::datatypes::Schema {
10034 self.inner.schema()
10035 }
10036 }
10037
10038 #[derive(Debug)]
10039 struct CountingStore {
10040 inner: Arc<dyn IndexStore>,
10041 posting_file: String,
10042 counter: Arc<PostingMetadataCounter>,
10043 }
10044
10045 impl DeepSizeOf for CountingStore {
10046 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
10047 self.inner.deep_size_of_children(context)
10048 }
10049 }
10050
10051 #[async_trait]
10052 impl IndexStore for CountingStore {
10053 fn as_any(&self) -> &dyn std::any::Any {
10054 self
10055 }
10056 fn clone_arc(&self) -> Arc<dyn IndexStore> {
10057 Arc::new(Self {
10058 inner: self.inner.clone(),
10059 posting_file: self.posting_file.clone(),
10060 counter: self.counter.clone(),
10061 })
10062 }
10063 fn io_parallelism(&self) -> usize {
10064 self.inner.io_parallelism()
10065 }
10066 fn with_io_priority(&self, io_priority: u64) -> Arc<dyn IndexStore> {
10067 Arc::new(Self {
10068 inner: self.inner.with_io_priority(io_priority),
10069 posting_file: self.posting_file.clone(),
10070 counter: self.counter.clone(),
10071 })
10072 }
10073 async fn new_index_file(
10074 &self,
10075 name: &str,
10076 schema: Arc<arrow_schema::Schema>,
10077 ) -> Result<Box<dyn crate::scalar::IndexWriter>> {
10078 self.inner.new_index_file(name, schema).await
10079 }
10080 async fn open_index_file(&self, name: &str) -> Result<Arc<dyn IndexReader>> {
10081 let reader = self.inner.open_index_file(name).await?;
10082 if name == self.posting_file {
10083 Ok(Arc::new(CountingPostingReader {
10084 inner: reader,
10085 counter: self.counter.clone(),
10086 }))
10087 } else {
10088 Ok(reader)
10089 }
10090 }
10091 async fn copy_index_file(
10092 &self,
10093 name: &str,
10094 dest_store: &dyn IndexStore,
10095 ) -> Result<crate::scalar::IndexFile> {
10096 self.inner.copy_index_file(name, dest_store).await
10097 }
10098 async fn copy_index_file_to(
10099 &self,
10100 name: &str,
10101 new_name: &str,
10102 dest_store: &dyn IndexStore,
10103 ) -> Result<crate::scalar::IndexFile> {
10104 self.inner
10105 .copy_index_file_to(name, new_name, dest_store)
10106 .await
10107 }
10108 async fn rename_index_file(
10109 &self,
10110 name: &str,
10111 new_name: &str,
10112 ) -> Result<crate::scalar::IndexFile> {
10113 self.inner.rename_index_file(name, new_name).await
10114 }
10115 async fn delete_index_file(&self, name: &str) -> Result<()> {
10116 self.inner.delete_index_file(name).await
10117 }
10118 async fn list_files_with_sizes(&self) -> Result<Vec<crate::scalar::IndexFile>> {
10119 self.inner.list_files_with_sizes().await
10120 }
10121 }
10122
10123 async fn load_counted_v2_index(
10128 num_tokens: usize,
10129 cache: LanceCache,
10130 ) -> (Arc<InvertedIndex>, Arc<PostingMetadataCounter>, TempObjDir) {
10131 let tmpdir = TempObjDir::default();
10132 let inner_store = Arc::new(LanceIndexStore::new(
10133 ObjectStore::local().into(),
10134 tmpdir.clone(),
10135 Arc::new(LanceCache::no_cache()),
10136 ));
10137
10138 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10139 for i in 0..num_tokens {
10140 builder.tokens.add(format!("t{}", i));
10141 let mut pl = PostingListBuilder::new(false);
10142 pl.add(i as u32, PositionRecorder::Count(1));
10143 builder.posting_lists.push(pl);
10144 builder.docs.append(i as u64, 1);
10145 }
10146 builder.write(inner_store.as_ref()).await.unwrap();
10147
10148 let metadata = HashMap::from([
10149 (
10150 "partitions".to_owned(),
10151 serde_json::to_string(&vec![0u64]).unwrap(),
10152 ),
10153 (
10154 "params".to_owned(),
10155 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
10156 ),
10157 (
10158 TOKEN_SET_FORMAT_KEY.to_owned(),
10159 TokenSetFormat::default().to_string(),
10160 ),
10161 ]);
10162 let mut writer = inner_store
10163 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
10164 .await
10165 .unwrap();
10166 writer.finish_with_metadata(metadata).await.unwrap();
10167
10168 let counter = Arc::new(PostingMetadataCounter::default());
10169 let counting_store: Arc<dyn IndexStore> = Arc::new(CountingStore {
10170 inner: inner_store,
10171 posting_file: posting_file_path(0),
10172 counter: counter.clone(),
10173 });
10174 let index = InvertedIndex::load(counting_store, None, &cache)
10175 .await
10176 .unwrap();
10177 (index, counter, tmpdir)
10178 }
10179
10180 #[rstest::rstest]
10197 #[case::tokens_10(10)]
10198 #[case::tokens_100(100)]
10199 #[case::tokens_1000(1000)]
10200 #[tokio::test]
10201 async fn test_bm25_stats_for_terms_is_lazy(#[case] num_tokens: usize) {
10202 let (index, counter, _tmpdir) =
10203 load_counted_v2_index(num_tokens, LanceCache::no_cache()).await;
10204 assert!(
10205 !index.partitions[0].inverted_list.is_legacy_layout(),
10206 "this test only proves the lazy path for v2 indexes",
10207 );
10208
10209 assert_eq!(
10213 counter.read_range_calls(),
10214 0,
10215 "InvertedIndex::load must not read the posting file (was {} calls)",
10216 counter.read_range_calls(),
10217 );
10218 assert_eq!(counter.rows_read(), 0);
10219
10220 let (total_tokens, num_docs, dfs) = index
10221 .bm25_stats_for_terms(&["t0".to_string()], None)
10222 .await
10223 .unwrap();
10224 assert_eq!(total_tokens, num_tokens as u64);
10225 assert_eq!(num_docs, num_tokens);
10226 assert_eq!(dfs, vec![1]);
10227
10228 assert_eq!(
10232 counter.metadata_rows_read(),
10233 1,
10234 "stats path should read exactly 1 metadata row per (term, partition); \
10235 got {} (read_range_calls={}, rows_read={}, num_tokens={})",
10236 counter.metadata_rows_read(),
10237 counter.read_range_calls(),
10238 counter.rows_read(),
10239 num_tokens,
10240 );
10241 }
10242
10243 #[tokio::test]
10244 async fn test_bm25_stats_for_terms_reuses_posting_metadata_cache() {
10245 let cache = LanceCache::with_capacity(1024 * 1024);
10246 let (index, counter, _tmpdir) = load_counted_v2_index(100, cache.clone()).await;
10247
10248 let terms = ["t0".to_string()];
10249 let first = index.bm25_stats_for_terms(&terms, None).await.unwrap();
10250 assert_eq!(first, (100, 100, vec![1]));
10251 assert_eq!(counter.metadata_rows_read(), 1);
10252
10253 let second = index.bm25_stats_for_terms(&terms, None).await.unwrap();
10254 assert_eq!(second, first);
10255 assert_eq!(
10256 counter.metadata_rows_read(),
10257 1,
10258 "repeated stats for the same token should reuse cached posting metadata",
10259 );
10260 }
10261
10262 #[tokio::test]
10263 async fn test_bm25_stats_for_terms_records_metadata_cache_stats() {
10264 let cache = LanceCache::with_capacity(1024 * 1024);
10265 let (index, _counter, _tmpdir) = load_counted_v2_index(100, cache.clone()).await;
10266 assert!(
10267 !index.partitions[0].inverted_list.is_legacy_layout(),
10268 "this test only proves the v2 metadata boundary",
10269 );
10270
10271 let terms = ["t0".to_string(), "t1".to_string(), "t2".to_string()];
10272 let cold = LocalMetricsCollector::default();
10273 let cold_stats = index
10274 .bm25_stats_for_terms(&terms, Some(&cold))
10275 .await
10276 .unwrap();
10277 assert_eq!(cold_stats.2, vec![1, 1, 1]);
10278 assert_eq!(cold.index_cache_misses(), terms.len());
10279 assert_eq!(cold.index_cache_hits(), 0);
10280
10281 let warm = LocalMetricsCollector::default();
10282 let warm_stats = index
10283 .bm25_stats_for_terms(&terms, Some(&warm))
10284 .await
10285 .unwrap();
10286 assert_eq!(warm_stats, cold_stats);
10287 assert_eq!(warm.index_cache_misses(), 0);
10288 assert_eq!(warm.index_cache_hits(), terms.len());
10289 }
10290
10291 #[tokio::test]
10292 async fn test_aggregate_corpus_stats_reuses_cached_value() {
10293 let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
10294 assert!(index.corpus_stats.get().is_none());
10295
10296 let first = index.aggregate_corpus_stats().await.unwrap();
10297 assert_eq!(first, (100, 100));
10298 assert_eq!(index.corpus_stats.get().copied(), Some(first));
10299
10300 let second = index.aggregate_corpus_stats().await.unwrap();
10301 assert_eq!(second, first);
10302 }
10303
10304 #[tokio::test]
10305 async fn test_persisted_stats_do_not_load_document_columns() {
10306 let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
10307 assert!(!index.is_legacy());
10308 let partition = index.partitions[0].clone();
10309 let documents = partition.docs.modern().unwrap();
10310
10311 assert_eq!(index.aggregate_corpus_stats().await.unwrap(), (100, 100));
10312 assert_eq!(documents.cached_stats().unwrap().total_tokens, 100);
10313 assert!(!documents.lengths_loaded());
10314 assert!(!documents.projection_loaded());
10315
10316 let views = futures::future::join_all((0..8).map(|_| documents.lengths()))
10317 .await
10318 .into_iter()
10319 .collect::<Result<Vec<_>>>()
10320 .unwrap();
10321 let first = &views[0];
10322 assert!(views.iter().all(|view| Arc::ptr_eq(first, view)));
10323 assert_eq!(first.total_tokens(), 100);
10324
10325 let all_rows = RowAddrMask::all_rows();
10326 assert!(matches!(
10327 documents
10328 .visibility(Arc::new(all_rows), false)
10329 .await
10330 .unwrap(),
10331 DocVisibility::All
10332 ));
10333 assert!(!documents.projection_loaded());
10334
10335 let filtered = RowAddrMask::allow_nothing();
10336 let visibility = documents
10337 .visibility(Arc::new(filtered), true)
10338 .await
10339 .unwrap();
10340 assert!(visibility.is_empty());
10341 assert!(!documents.projection_loaded());
10342 assert_eq!(
10343 documents
10344 .resolve_addresses(&[DocId::new(0), DocId::new(99)])
10345 .await
10346 .unwrap(),
10347 [0, 99]
10348 );
10349 }
10350
10351 #[tokio::test]
10352 async fn test_no_hit_partition_does_not_load_document_columns() {
10353 let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
10354 let documents = index.partitions[0].docs.modern().unwrap();
10355 assert!(!documents.lengths_loaded());
10356 assert!(!documents.projection_loaded());
10357
10358 let tokens = Arc::new(Tokens::new(vec!["missing-token".to_owned()], DocType::Text));
10359 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
10360 let (row_ids, scores) = index
10361 .bm25_search(
10362 tokens,
10363 params,
10364 Operator::Or,
10365 Arc::new(NoFilter),
10366 Arc::new(NoOpMetricsCollector),
10367 None,
10368 )
10369 .await
10370 .unwrap();
10371
10372 assert!(row_ids.is_empty());
10373 assert!(scores.is_empty());
10374 assert!(!documents.lengths_loaded());
10375 assert!(!documents.projection_loaded());
10376 }
10377
10378 #[tokio::test]
10379 async fn test_concurrent_stats_and_lengths_initialization() {
10380 let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
10381 let docs = index.partitions[0].docs.modern().unwrap().clone();
10382
10383 let stats = futures::future::join_all((0..8).map(|_| docs.stats()));
10384 let views = futures::future::join_all((0..8).map(|_| docs.lengths()));
10385 let (stats, views) = tokio::join!(stats, views);
10386
10387 let stats = stats.into_iter().collect::<Result<Vec<_>>>().unwrap();
10388 assert!(stats.iter().all(|stats| stats.total_tokens == 100));
10389 let views = views.into_iter().collect::<Result<Vec<_>>>().unwrap();
10390 let first = &views[0];
10391 assert!(views.iter().all(|view| Arc::ptr_eq(first, view)));
10392 assert_eq!(docs.cached_stats().unwrap().total_tokens, 100);
10393 }
10394
10395 #[tokio::test]
10396 async fn test_grouped_posting_lists_read_one_group_per_neighborhood() {
10397 let runtime_group_size = runtime_posting_group_tokens().max(1);
10403 let queried_token_count = runtime_group_size.min(4);
10404 let queried_tokens = (0..queried_token_count as u32).collect::<Vec<_>>();
10405 let num_tokens = runtime_group_size
10406 .saturating_mul(2)
10407 .max(queried_token_count + 1)
10408 .min(1024);
10409 let (index, counter, _tmpdir) =
10410 load_counted_v2_index(num_tokens, LanceCache::no_cache()).await;
10411 let inverted_list = index.partitions[0].inverted_list.clone();
10412 assert!(
10413 !inverted_list.is_legacy_layout(),
10414 "this test only proves the lazy path for v2 indexes",
10415 );
10416 assert!(
10417 matches!(
10418 &inverted_list.grouping,
10419 PostingGrouping::SyntheticFixed { .. }
10420 ),
10421 "freshly written v2 index should use runtime synthetic groups",
10422 );
10423
10424 let metrics = Arc::new(NoOpMetricsCollector);
10428 for &token_id in &queried_tokens {
10429 inverted_list
10430 .posting_list(token_id, false, metrics.as_ref())
10431 .await
10432 .unwrap();
10433 }
10434
10435 let (start, end) = inverted_list.group_range_for_token(0).unwrap();
10436 let group_len = (end - start) as usize;
10437 assert!(
10438 (queried_tokens.len()..=num_tokens).contains(&group_len),
10439 "group [{start}, {end}) should cover the queried neighborhood and \
10440 stay bounded by the {num_tokens}-token table",
10441 );
10442 assert_eq!(
10443 counter.read_range_calls(),
10444 queried_tokens.len(),
10445 "each cold token should read exactly its own group, no bulk read",
10446 );
10447 assert_eq!(
10448 counter.metadata_rows_read(),
10449 queried_tokens.len() * group_len,
10450 "each query reads one group's metadata rows ({group_len}), not the \
10451 full {num_tokens}-row table",
10452 );
10453 }
10454
10455 async fn load_v2_index_with_grouped_postings(
10459 num_tokens: usize,
10460 docs_per_token: usize,
10461 ) -> (Arc<InvertedIndex>, Arc<LanceCache>) {
10462 let tmpdir = TempObjDir::default();
10463 let store = Arc::new(LanceIndexStore::new(
10464 ObjectStore::local().into(),
10465 tmpdir.clone(),
10466 Arc::new(LanceCache::no_cache()),
10467 ));
10468
10469 let num_docs = num_tokens * docs_per_token;
10470 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10471 for token_id in 0..num_tokens {
10472 builder.tokens.add(format!("t{token_id}"));
10473 let mut pl = PostingListBuilder::new(false);
10474 for d in 0..docs_per_token {
10475 let doc_id = (token_id * docs_per_token + d) as u32;
10476 pl.add(doc_id, PositionRecorder::Count(1));
10477 }
10478 builder.posting_lists.push(pl);
10479 }
10480 for doc in 0..num_docs {
10481 builder.docs.append(doc as u64, 1);
10482 }
10483 builder.write(store.as_ref()).await.unwrap();
10484
10485 let metadata = HashMap::from([
10486 (
10487 "partitions".to_owned(),
10488 serde_json::to_string(&vec![0u64]).unwrap(),
10489 ),
10490 (
10491 "params".to_owned(),
10492 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
10493 ),
10494 (
10495 TOKEN_SET_FORMAT_KEY.to_owned(),
10496 TokenSetFormat::default().to_string(),
10497 ),
10498 ]);
10499 let mut writer = store
10500 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
10501 .await
10502 .unwrap();
10503 writer.finish_with_metadata(metadata).await.unwrap();
10504
10505 let cache = Arc::new(LanceCache::with_capacity(1 << 30));
10508 let index = InvertedIndex::load(store, None, cache.as_ref())
10509 .await
10510 .unwrap();
10511 (index, cache)
10512 }
10513
10514 #[tokio::test]
10517 async fn test_packed_group_deep_size_is_smaller_than_materialized_graph() {
10518 let (index, _cache) = load_v2_index_with_grouped_postings(512, 1).await;
10519 let inverted_list = index.partitions[0].inverted_list.clone();
10520 assert!(!inverted_list.is_legacy_layout(), "expected v2 layout");
10521 assert!(
10522 matches!(
10523 &inverted_list.grouping,
10524 PostingGrouping::SyntheticFixed { .. }
10525 ),
10526 "expected grouped posting lists"
10527 );
10528
10529 inverted_list
10531 .posting_list(0, false, &NoOpMetricsCollector)
10532 .await
10533 .unwrap();
10534 let (start, end) = inverted_list.group_range_for_token(0).unwrap();
10535 let group = inverted_list
10536 .index_cache
10537 .get_with_key(&posting_list_group_cache_key(
10538 start,
10539 end,
10540 inverted_list.has_impacts,
10541 ))
10542 .await
10543 .unwrap();
10544 assert!(group.is_packed(), "cold v2 group should use packed storage");
10545 inverted_list.ensure_metadata_loaded().await.unwrap();
10546
10547 let mut distinct_buffers = std::collections::HashSet::new();
10548 let mut materialized = Vec::with_capacity(group.len());
10549 for slot in 0..group.len() {
10550 let (max_score, length) = inverted_list.bulk_metadata_for_token(start + slot as u32);
10551 let posting = group
10552 .posting_list(slot, max_score, length)
10553 .unwrap()
10554 .unwrap();
10555 let PostingList::Compressed(compressed) = posting else {
10556 panic!("expected compressed posting lists");
10557 };
10558 distinct_buffers.insert(compressed.blocks.values().as_ptr());
10559 materialized.push(PostingList::Compressed(compressed));
10560 }
10561 let posting_count = materialized.len();
10562
10563 assert!(
10564 posting_count > 1,
10565 "default grouping should pack multiple tiny postings into one group"
10566 );
10567 assert_eq!(
10568 distinct_buffers.len(),
10569 1,
10570 "read-path postings in a group should share one backing buffer"
10571 );
10572 let packed_size = group.deep_size_of();
10573 let materialized_size = PostingListGroup::new(materialized).deep_size_of();
10574 assert!(
10575 packed_size * 4 < materialized_size * 3,
10576 "packed group deep_size_of {packed_size}B should be at least 25% smaller than the \
10577 {materialized_size}B materialized graph for {posting_count} postings"
10578 );
10579 }
10580
10581 fn sliced_int32_list(
10596 num_sublists: usize,
10597 ints_per_sublist: usize,
10598 off: usize,
10599 len: usize,
10600 ) -> ListArray {
10601 let mut builder = ListBuilder::new(Int32Builder::new());
10602 for s in 0..num_sublists {
10603 for i in 0..ints_per_sublist {
10604 builder
10605 .values()
10606 .append_value((s * ints_per_sublist + i) as i32);
10607 }
10608 builder.append(true);
10609 }
10610 builder.finish().slice(off, len)
10611 }
10612
10613 #[test]
10614 fn test_compressed_posting_deep_size_counts_only_referenced_blocks_slice() {
10615 const ELEM_BYTES: usize = 256;
10616 const TOTAL_ELEMS: usize = 64;
10617 const SLICE_OFF: usize = 10;
10618 const SLICE_LEN: usize = 2;
10619
10620 let mut builder = LargeBinaryBuilder::new();
10621 for _ in 0..TOTAL_ELEMS {
10622 builder.append_value(vec![7u8; ELEM_BYTES]);
10623 }
10624 let full = builder.finish();
10625 let blocks = full.slice(SLICE_OFF, SLICE_LEN);
10626
10627 let posting = CompressedPostingList::new(
10628 blocks,
10629 1.0,
10630 SLICE_LEN as u32,
10631 PostingTailCodec::Fixed32,
10632 LEGACY_BLOCK_SIZE,
10633 None,
10634 None,
10635 );
10636
10637 let full_backing = full.get_buffer_memory_size();
10638 let slice_bytes = SLICE_LEN * ELEM_BYTES;
10639 let reported = posting.deep_size_of();
10640
10641 assert!(
10642 reported < full_backing / 4,
10643 "deep_size_of {reported}B must not count the {full_backing}B shared buffer"
10644 );
10645 assert!(
10646 reported <= slice_bytes * 2,
10647 "deep_size_of {reported}B should track the ~{slice_bytes}B referenced slice"
10648 );
10649 }
10650
10651 #[test]
10652 fn test_plain_posting_deep_size_counts_only_referenced_positions_slice() {
10653 const SUBLISTS: usize = 64;
10654 const INTS: usize = 64;
10655 const SLICE_LEN: usize = 2;
10656
10657 let positions = sliced_int32_list(SUBLISTS, INTS, 10, SLICE_LEN);
10658 let row_ids = ScalarBuffer::from(vec![0u64, 1]);
10659 let frequencies = ScalarBuffer::from(vec![1.0f32, 1.0]);
10660 let posting =
10661 PlainPostingList::new(row_ids, frequencies, Some(1.0), Some(positions.clone()));
10662
10663 let full_backing = positions.values().get_buffer_memory_size();
10664 let slice_bytes = SLICE_LEN * INTS * std::mem::size_of::<i32>();
10665 let reported = posting.deep_size_of();
10666
10667 assert!(
10668 reported < full_backing / 4,
10669 "deep_size_of {reported}B must not count the {full_backing}B shared positions buffer"
10670 );
10671 assert!(
10672 reported <= slice_bytes * 2 + 64,
10673 "deep_size_of {reported}B should track the ~{slice_bytes}B referenced slice"
10674 );
10675 }
10676
10677 #[test]
10678 fn test_legacy_per_doc_positions_deep_size_counts_only_referenced_slice() {
10679 const SUBLISTS: usize = 64;
10680 const INTS: usize = 64;
10681 const SLICE_LEN: usize = 2;
10682
10683 let positions = sliced_int32_list(SUBLISTS, INTS, 10, SLICE_LEN);
10684 let full_backing = positions.values().get_buffer_memory_size();
10685 let slice_bytes = SLICE_LEN * INTS * std::mem::size_of::<i32>();
10686
10687 let storage = CompressedPositionStorage::LegacyPerDoc(positions);
10688 let reported = storage.deep_size_of();
10689 assert!(
10690 reported < full_backing / 4,
10691 "CompressedPositionStorage deep_size_of {reported}B must not count the \
10692 {full_backing}B shared buffer"
10693 );
10694 assert!(
10695 reported <= slice_bytes * 2 + 64,
10696 "deep_size_of {reported}B should track the ~{slice_bytes}B referenced slice"
10697 );
10698
10699 let wrapped = Positions(storage).deep_size_of();
10701 assert!(
10702 wrapped < full_backing / 4,
10703 "Positions deep_size_of {wrapped}B must not count the {full_backing}B shared buffer"
10704 );
10705 }
10706
10707 #[tokio::test]
10708 async fn test_prewarm_with_positions_populates_separate_position_cache() {
10709 let tmpdir = TempObjDir::default();
10710 let store = Arc::new(LanceIndexStore::new(
10711 ObjectStore::local().into(),
10712 tmpdir.clone(),
10713 Arc::new(LanceCache::no_cache()),
10714 ));
10715
10716 let mut builder = InnerBuilder::new_with_format_version(
10717 0,
10718 true,
10719 TokenSetFormat::default(),
10720 InvertedListFormatVersion::V1,
10721 );
10722 builder.tokens.add("hello".to_owned());
10723 builder.tokens.add("world".to_owned());
10724 builder
10725 .posting_lists
10726 .push(PostingListBuilder::new_with_posting_tail_codec(
10727 true,
10728 PostingTailCodec::Fixed32,
10729 ));
10730 builder
10731 .posting_lists
10732 .push(PostingListBuilder::new_with_posting_tail_codec(
10733 true,
10734 PostingTailCodec::Fixed32,
10735 ));
10736 builder.posting_lists[0].add(0, PositionRecorder::Position(vec![0].into()));
10737 builder.posting_lists[1].add(0, PositionRecorder::Position(vec![1].into()));
10738 builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into()));
10739 builder.posting_lists[1].add(1, PositionRecorder::Position(vec![2].into()));
10740 builder.docs.append(100, 2);
10741 builder.docs.append(101, 2);
10742 builder.write(store.as_ref()).await.unwrap();
10743
10744 let metadata = std::collections::HashMap::from_iter(vec![
10745 (
10746 "partitions".to_owned(),
10747 serde_json::to_string(&vec![0_u64]).unwrap(),
10748 ),
10749 (
10750 "params".to_owned(),
10751 serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
10752 ),
10753 (
10754 TOKEN_SET_FORMAT_KEY.to_owned(),
10755 TokenSetFormat::default().to_string(),
10756 ),
10757 ]);
10758 let mut writer = store
10759 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
10760 .await
10761 .unwrap();
10762 writer.finish_with_metadata(metadata).await.unwrap();
10763
10764 let cache = Arc::new(LanceCache::with_backend(Arc::new(
10765 QuickCacheBackend::with_capacity(4096),
10766 )));
10767 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10768 .await
10769 .unwrap();
10770
10771 index
10772 .prewarm_with_options(&FtsPrewarmOptions::new().with_position(true))
10773 .await
10774 .unwrap();
10775
10776 let inverted_list = &index.partitions[0].inverted_list;
10777 let (start, end) = inverted_list.group_range_for_token(0).unwrap();
10781 let group = inverted_list
10782 .index_cache
10783 .get_with_key(&posting_list_group_cache_key(
10784 start,
10785 end,
10786 inverted_list.has_impacts,
10787 ))
10788 .await
10789 .unwrap();
10790 assert!(
10791 !group.is_packed(),
10792 "with-position prewarm should retain the materialized fallback"
10793 );
10794 assert!(
10795 !group
10796 .posting_list(0, None, None)
10797 .unwrap()
10798 .unwrap()
10799 .has_position(),
10800 "posting cache should remain positions-free after prewarm"
10801 );
10802
10803 let positions = inverted_list
10804 .index_cache
10805 .get_with_key(&PositionKey { token_id: 0 })
10806 .await
10807 .unwrap();
10808 assert!(
10809 matches!(
10810 positions.as_ref().0,
10811 CompressedPositionStorage::LegacyPerDoc(_)
10812 ),
10813 "positions should be stored in the dedicated position cache"
10814 );
10815
10816 drop(positions);
10817 drop(group);
10818 cache.clear().await;
10819 assert!(
10820 inverted_list
10821 .index_cache
10822 .get_with_key(&PositionKey { token_id: 0 })
10823 .await
10824 .is_none()
10825 );
10826
10827 index
10828 .prewarm_with_options(&FtsPrewarmOptions::default())
10829 .await
10830 .unwrap();
10831 assert!(index.prewarm_state.lock().await.satisfies(true));
10832 assert!(
10833 inverted_list
10834 .index_cache
10835 .get_with_key(&PositionKey { token_id: 0 })
10836 .await
10837 .is_some(),
10838 "re-prewarm after eviction must preserve the strongest requested mode"
10839 );
10840 }
10841
10842 #[tokio::test]
10843 async fn test_prewarm_with_v2_positions_preserves_shared_stream_codec() {
10844 let tmpdir = TempObjDir::default();
10845 let store = Arc::new(LanceIndexStore::new(
10846 ObjectStore::local().into(),
10847 tmpdir.clone(),
10848 Arc::new(LanceCache::no_cache()),
10849 ));
10850
10851 let format_version = InvertedListFormatVersion::V2;
10852 let posting_tail_codec = format_version.posting_tail_codec();
10853 let mut builder = InnerBuilder::new_with_format_version(
10854 0,
10855 true,
10856 TokenSetFormat::default(),
10857 format_version,
10858 );
10859 builder.tokens.add("body".to_owned());
10860
10861 let mut posting_list =
10862 PostingListBuilder::new_with_posting_tail_codec(true, posting_tail_codec);
10863 let expected = (0..(BLOCK_SIZE + 5) as u32)
10864 .map(|doc_id| {
10865 let positions = vec![doc_id % 3, doc_id % 3 + 2, doc_id % 3 + 5];
10866 posting_list.add(doc_id, PositionRecorder::Position(positions.clone().into()));
10867 builder.docs.append(30_000 + doc_id as u64, 20 + doc_id % 7);
10868 (doc_id, positions.len() as u32, positions)
10869 })
10870 .collect::<Vec<_>>();
10871 builder.posting_lists.push(posting_list);
10872 builder.write(store.as_ref()).await.unwrap();
10873
10874 let metadata = HashMap::from([
10875 (
10876 "partitions".to_owned(),
10877 serde_json::to_string(&vec![0_u64]).unwrap(),
10878 ),
10879 (
10880 "params".to_owned(),
10881 serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
10882 ),
10883 (
10884 TOKEN_SET_FORMAT_KEY.to_owned(),
10885 TokenSetFormat::default().to_string(),
10886 ),
10887 (
10888 POSTING_TAIL_CODEC_KEY.to_owned(),
10889 posting_tail_codec.as_str().to_owned(),
10890 ),
10891 (
10892 POSITIONS_LAYOUT_KEY.to_owned(),
10893 POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
10894 ),
10895 (
10896 POSITIONS_CODEC_KEY.to_owned(),
10897 PositionStreamCodec::PackedDelta.as_str().to_owned(),
10898 ),
10899 ]);
10900 let mut writer = store
10901 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
10902 .await
10903 .unwrap();
10904 writer.finish_with_metadata(metadata).await.unwrap();
10905
10906 let cache = Arc::new(LanceCache::with_capacity(4096));
10907 let index = InvertedIndex::load(store, None, cache.as_ref())
10908 .await
10909 .unwrap();
10910 index
10911 .prewarm_with_options(&FtsPrewarmOptions::new().with_position(true))
10912 .await
10913 .unwrap();
10914
10915 let actual = index.partitions[0]
10916 .inverted_list
10917 .posting_list(0, true, &NoOpMetricsCollector)
10918 .await
10919 .unwrap()
10920 .iter()
10921 .map(|(doc_id, freq, positions)| {
10922 (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
10923 })
10924 .collect::<Vec<_>>();
10925
10926 assert_eq!(actual, expected);
10927 }
10928
10929 #[test]
10930 fn test_block_max_scores_capacity_matches_block_count() {
10931 let mut docs = DocSet::default();
10932 let num_docs = BLOCK_SIZE * 3 + 7;
10933 let doc_ids = (0..num_docs as u32).collect::<Vec<_>>();
10934 for doc_id in &doc_ids {
10935 docs.append(*doc_id as u64, 1);
10936 }
10937
10938 let freqs = vec![1_u32; doc_ids.len()];
10939 let block_max_scores = docs.calculate_block_max_scores(doc_ids.iter(), freqs.iter());
10940 let expected_blocks = doc_ids.len().div_ceil(BLOCK_SIZE);
10941
10942 assert_eq!(block_max_scores.len(), expected_blocks);
10943 assert_eq!(block_max_scores.capacity(), expected_blocks);
10944 }
10945
10946 #[tokio::test]
10947 async fn test_bm25_search_uses_global_idf() {
10948 let tmpdir = TempObjDir::default();
10949 let store = Arc::new(LanceIndexStore::new(
10950 ObjectStore::local().into(),
10951 tmpdir.clone(),
10952 Arc::new(LanceCache::no_cache()),
10953 ));
10954
10955 let mut builder0 = InnerBuilder::new(0, false, TokenSetFormat::default());
10957 builder0.tokens.add("alpha".to_owned());
10958 builder0.tokens.add("beta".to_owned());
10959 builder0.posting_lists.push(PostingListBuilder::new(false));
10960 builder0.posting_lists.push(PostingListBuilder::new(false));
10961 builder0.posting_lists[0].add(0, PositionRecorder::Count(1));
10962 builder0.posting_lists[1].add(1, PositionRecorder::Count(1));
10963 builder0.posting_lists[1].add(2, PositionRecorder::Count(1));
10964 builder0.docs.append(100, 1);
10965 builder0.docs.append(101, 1);
10966 builder0.docs.append(102, 1);
10967 builder0.write(store.as_ref()).await.unwrap();
10968
10969 let mut builder1 = InnerBuilder::new(1, false, TokenSetFormat::default());
10971 builder1.tokens.add("alpha".to_owned());
10972 builder1.posting_lists.push(PostingListBuilder::new(false));
10973 builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
10974 builder1.docs.append(200, 1);
10975 builder1.write(store.as_ref()).await.unwrap();
10976
10977 let metadata = std::collections::HashMap::from_iter(vec![
10978 (
10979 "partitions".to_owned(),
10980 serde_json::to_string(&vec![0u64, 1u64]).unwrap(),
10981 ),
10982 (
10983 "params".to_owned(),
10984 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
10985 ),
10986 (
10987 TOKEN_SET_FORMAT_KEY.to_owned(),
10988 TokenSetFormat::default().to_string(),
10989 ),
10990 ]);
10991 let mut writer = store
10992 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
10993 .await
10994 .unwrap();
10995 writer.finish_with_metadata(metadata).await.unwrap();
10996
10997 let cache = Arc::new(LanceCache::with_capacity(4096));
10998 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10999 .await
11000 .unwrap();
11001
11002 let tokens = Arc::new(Tokens::new(vec!["alpha".to_string()], DocType::Text));
11003 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
11004 let prefilter = Arc::new(NoFilter);
11005 let metrics = Arc::new(NoOpMetricsCollector);
11006
11007 let (row_ids, scores) = index
11008 .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
11009 .await
11010 .unwrap();
11011
11012 assert_eq!(row_ids.len(), 2);
11013 assert!(row_ids.contains(&100));
11014 assert!(row_ids.contains(&200));
11015 assert_eq!(row_ids.len(), scores.len());
11016
11017 let expected_idf = idf(2, 4);
11018 for score in scores {
11019 assert!(
11020 (score - expected_idf).abs() < 1e-6,
11021 "score: {}, expected: {}",
11022 score,
11023 expected_idf
11024 );
11025 }
11026 }
11027
11028 async fn write_test_metadata(
11029 store: &Arc<LanceIndexStore>,
11030 partition_ids: Vec<u64>,
11031 params: InvertedIndexParams,
11032 ) {
11033 let format_version = params.resolved_format_version();
11034 let metadata = HashMap::from([
11035 (
11036 "partitions".to_owned(),
11037 serde_json::to_string(&partition_ids).unwrap(),
11038 ),
11039 ("params".to_owned(), serde_json::to_string(¶ms).unwrap()),
11040 (
11041 TOKEN_SET_FORMAT_KEY.to_owned(),
11042 TokenSetFormat::default().to_string(),
11043 ),
11044 (
11045 POSTING_TAIL_CODEC_KEY.to_owned(),
11046 format_version.posting_tail_codec().as_str().to_owned(),
11047 ),
11048 (
11049 FTS_FORMAT_VERSION_KEY.to_owned(),
11050 format_version.index_version().to_string(),
11051 ),
11052 (
11053 POSTING_BLOCK_SIZE_KEY.to_owned(),
11054 params.posting_block_size().to_string(),
11055 ),
11056 ]);
11057 let mut writer = store
11058 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
11059 .await
11060 .unwrap();
11061 writer.finish_with_metadata(metadata).await.unwrap();
11062 }
11063
11064 async fn write_test_partition_with_optional_impacts(
11065 store: &Arc<LanceIndexStore>,
11066 partition_id: u64,
11067 mut builder: InnerBuilder,
11068 token_set_format: TokenSetFormat,
11069 with_impacts: bool,
11070 ) {
11071 let format_version = InvertedListFormatVersion::V1;
11072 let block_size = LEGACY_BLOCK_SIZE;
11073 let docs = std::mem::take(&mut builder.docs);
11074 let schema = inverted_list_schema_for_version_with_block_size_and_impacts(
11075 false,
11076 format_version,
11077 block_size,
11078 with_impacts,
11079 );
11080
11081 let mut posting_writer = store
11082 .new_index_file(&posting_file_path(partition_id), schema.clone())
11083 .await
11084 .unwrap();
11085 for posting_list in std::mem::take(&mut builder.posting_lists) {
11086 let batch = posting_list
11087 .to_batch_with_docs(&docs, schema.clone())
11088 .unwrap();
11089 posting_writer.write_record_batch(batch).await.unwrap();
11090 }
11091 posting_writer.finish().await.unwrap();
11092
11093 let token_batch = std::mem::take(&mut builder.tokens)
11094 .to_batch(token_set_format)
11095 .unwrap();
11096 let mut token_writer = store
11097 .new_index_file(&token_file_path(partition_id), token_batch.schema())
11098 .await
11099 .unwrap();
11100 token_writer.write_record_batch(token_batch).await.unwrap();
11101 token_writer.finish().await.unwrap();
11102
11103 let doc_batch = docs.to_batch().unwrap();
11104 let mut doc_writer = store
11105 .new_index_file(&doc_file_path(partition_id), doc_batch.schema())
11106 .await
11107 .unwrap();
11108 doc_writer.write_record_batch(doc_batch).await.unwrap();
11109 doc_writer.finish().await.unwrap();
11110 }
11111
11112 async fn load_global_scoring_test_index(
11113 first_partition_has_impacts: bool,
11114 second_partition_has_impacts: bool,
11115 ) -> (TempObjDir, Arc<LanceCache>, Arc<InvertedIndex>) {
11116 let tmpdir = TempObjDir::default();
11117 let store = Arc::new(LanceIndexStore::new(
11118 ObjectStore::local().into(),
11119 tmpdir.clone(),
11120 Arc::new(LanceCache::no_cache()),
11121 ));
11122 let partition_specs = [
11123 (0, 100, 5_000, 101..111, 5_000, first_partition_has_impacts),
11124 (1, 200, 1_000, 201..301, 1, second_partition_has_impacts),
11125 ];
11126 for (
11127 partition_id,
11128 matching_row_id,
11129 matching_doc_length,
11130 other_row_ids,
11131 other_doc_length,
11132 with_impacts,
11133 ) in partition_specs
11134 {
11135 let mut builder = InnerBuilder::new_with_format_version(
11136 partition_id,
11137 false,
11138 TokenSetFormat::default(),
11139 InvertedListFormatVersion::V1,
11140 );
11141 builder.tokens.add("alpha".to_owned());
11142 builder
11143 .posting_lists
11144 .push(PostingListBuilder::new_with_posting_tail_codec(
11145 false,
11146 InvertedListFormatVersion::V1.posting_tail_codec(),
11147 ));
11148 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
11149 builder.docs.append(matching_row_id, matching_doc_length);
11150 for row_id in other_row_ids {
11151 builder.docs.append(row_id, other_doc_length);
11152 }
11153 write_test_partition_with_optional_impacts(
11154 &store,
11155 partition_id,
11156 builder,
11157 TokenSetFormat::default(),
11158 with_impacts,
11159 )
11160 .await;
11161 }
11162
11163 write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
11164 let cache = Arc::new(LanceCache::with_backend(Arc::new(
11165 QuickCacheBackend::with_capacity(4096),
11166 )));
11167 let index = InvertedIndex::load(store, None, cache.as_ref())
11168 .await
11169 .unwrap();
11170 (tmpdir, cache, index)
11171 }
11172
11173 #[tokio::test]
11174 async fn test_chunked_modern_search_preserves_cold_and_prewarmed_results() {
11175 let tmpdir = TempObjDir::default();
11176 let store = Arc::new(LanceIndexStore::new(
11177 ObjectStore::local().into(),
11178 tmpdir.clone(),
11179 Arc::new(LanceCache::no_cache()),
11180 ));
11181 let matching_partitions = 17_u64;
11182 for partition_id in 0..matching_partitions {
11183 let mut builder = InnerBuilder::new(partition_id, false, TokenSetFormat::default());
11184 builder.tokens.add("pipeline".to_owned());
11185 builder.posting_lists.push(PostingListBuilder::new(false));
11186 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
11187 builder.docs.append(partition_id * 1_000 + 7, 1);
11188 builder.write(store.as_ref()).await.unwrap();
11189 }
11190 let unmatched_partition = matching_partitions;
11191 let mut builder = InnerBuilder::new(unmatched_partition, false, TokenSetFormat::default());
11192 builder.tokens.add("unrelated".to_owned());
11193 builder.posting_lists.push(PostingListBuilder::new(false));
11194 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
11195 builder.docs.append(999_999, 1);
11196 builder.write(store.as_ref()).await.unwrap();
11197
11198 write_test_metadata(
11199 &store,
11200 (0..=unmatched_partition).collect(),
11201 InvertedIndexParams::default(),
11202 )
11203 .await;
11204 let cache = Arc::new(LanceCache::with_capacity(64 * 1024 * 1024));
11205 let index = InvertedIndex::load(store, None, cache.as_ref())
11206 .await
11207 .unwrap();
11208 let tokens = Arc::new(Tokens::new(vec!["pipeline".to_owned()], DocType::Text));
11209 let params =
11210 Arc::new(FtsSearchParams::new().with_limit(Some(matching_partitions as usize)));
11211
11212 let search = || {
11213 index.bm25_search(
11214 tokens.clone(),
11215 params.clone(),
11216 Operator::Or,
11217 Arc::new(NoFilter),
11218 Arc::new(NoOpMetricsCollector),
11219 None,
11220 )
11221 };
11222 let (mut cold_row_ids, cold_scores) = search().await.unwrap();
11223 cold_row_ids.sort_unstable();
11224 let expected = (0..matching_partitions)
11225 .map(|partition_id| partition_id * 1_000 + 7)
11226 .collect::<Vec<_>>();
11227 assert_eq!(cold_row_ids, expected);
11228 assert_eq!(cold_scores.len(), expected.len());
11229
11230 index
11231 .prewarm_with_options(&FtsPrewarmOptions::default())
11232 .await
11233 .unwrap();
11234 let (mut prewarmed_row_ids, prewarmed_scores) = search().await.unwrap();
11235 prewarmed_row_ids.sort_unstable();
11236 assert_eq!(prewarmed_row_ids, expected);
11237 assert_eq!(prewarmed_scores, cold_scores);
11238 }
11239
11240 #[tokio::test]
11241 async fn test_prewarmed_modern_search_uses_resident_address_projection() {
11242 let (_tmpdir, cache, index) = load_global_scoring_test_index(true, true).await;
11243 let tokens = Arc::new(Tokens::new(vec!["alpha".to_owned()], DocType::Text));
11244 let params = Arc::new(FtsSearchParams::new().with_limit(Some(2)));
11245
11246 assert!(!index.has_resident_document_projections());
11247 let deferred = index
11248 .bm25_search(
11249 tokens.clone(),
11250 params.clone(),
11251 Operator::Or,
11252 Arc::new(NoFilter),
11253 Arc::new(NoOpMetricsCollector),
11254 None,
11255 )
11256 .await
11257 .unwrap();
11258 assert!(!index.has_resident_document_projections());
11259
11260 index.partitions[0]
11261 .docs
11262 .modern()
11263 .unwrap()
11264 .prewarm()
11265 .await
11266 .unwrap();
11267 assert!(index.partitions[0].docs.query_ready());
11268 assert!(!index.has_resident_document_projections());
11269 let partially_resident = index
11270 .bm25_search(
11271 tokens.clone(),
11272 params.clone(),
11273 Operator::Or,
11274 Arc::new(NoFilter),
11275 Arc::new(NoOpMetricsCollector),
11276 None,
11277 )
11278 .await
11279 .unwrap();
11280 assert_eq!(partially_resident, deferred);
11281
11282 let prewarm_options = FtsPrewarmOptions::default();
11283 futures::future::join_all((0..8).map(|_| index.prewarm_with_options(&prewarm_options)))
11284 .await
11285 .into_iter()
11286 .collect::<Result<Vec<_>>>()
11287 .unwrap();
11288 assert!(index.document_projections_resident.load(Ordering::Acquire));
11289 assert!(index.has_resident_document_projections());
11290 assert!(index.corpus_stats.initialized());
11291 assert!(index.partitions.iter().all(|partition| {
11292 partition.docs.query_ready()
11293 && partition.inverted_list.modern_posting_validation_ready()
11294 }));
11295 assert!(index.prewarm_state.lock().await.satisfies(false));
11296
11297 let resident = index
11298 .bm25_search(
11299 tokens.clone(),
11300 params.clone(),
11301 Operator::Or,
11302 Arc::new(NoFilter),
11303 Arc::new(NoOpMetricsCollector),
11304 None,
11305 )
11306 .await
11307 .unwrap();
11308 assert_eq!(resident, deferred);
11309 assert_eq!(resident.0.len(), 2);
11310 assert!(resident.0.contains(&100));
11311 assert!(resident.0.contains(&200));
11312
11313 cache.clear().await;
11314 assert!(index.document_projections_resident.load(Ordering::Acquire));
11315 assert_eq!(cache.size().await, 0);
11316 let resident_address_owners = index
11317 .partitions
11318 .iter()
11319 .map(|partition| {
11320 partition
11321 .docs
11322 .modern()
11323 .unwrap()
11324 .address_buffer_handle()
11325 .strong_count()
11326 })
11327 .collect::<Vec<_>>();
11328 assert_eq!(resident_address_owners, vec![0, 0]);
11329 assert!(
11330 index
11331 .partitions
11332 .iter()
11333 .all(|partition| { !partition.docs.modern().unwrap().projection_resident() })
11334 );
11335
11336 let after_eviction = index
11337 .bm25_search(
11338 tokens.clone(),
11339 params.clone(),
11340 Operator::Or,
11341 Arc::new(NoFilter),
11342 Arc::new(NoOpMetricsCollector),
11343 None,
11344 )
11345 .await
11346 .unwrap();
11347 assert_eq!(after_eviction, deferred);
11348 assert!(!index.document_projections_resident.load(Ordering::Acquire));
11349
11350 cache.clear().await;
11351 index.prewarm_with_options(&prewarm_options).await.unwrap();
11352 assert!(index.document_projections_resident_now());
11353 assert!(index.document_projections_resident.load(Ordering::Acquire));
11354
11355 let re_prewarms_after_eviction = index
11356 .bm25_search(
11357 tokens,
11358 params,
11359 Operator::Or,
11360 Arc::new(NoFilter),
11361 Arc::new(NoOpMetricsCollector),
11362 None,
11363 )
11364 .await
11365 .unwrap();
11366 assert_eq!(re_prewarms_after_eviction, deferred);
11367 }
11368
11369 #[tokio::test]
11370 async fn test_resident_modern_search_loads_partition_stats_without_global_stats() {
11371 let (_tmpdir, _cache, index) = load_global_scoring_test_index(true, false).await;
11372 assert!(index.corpus_stats.get().is_none());
11373 assert!(
11374 index
11375 .partitions
11376 .iter()
11377 .all(|partition| partition.docs.cached_stats().is_none())
11378 );
11379
11380 for partition in &index.partitions {
11381 partition
11382 .docs
11383 .modern()
11384 .unwrap()
11385 .address_projection()
11386 .await
11387 .unwrap();
11388 }
11389 assert!(index.has_resident_document_projections());
11390
11391 let scorer = MemBM25Scorer::new(56_100, 112, HashMap::from([("alpha".to_owned(), 2)]));
11392 let result = index
11393 .bm25_search(
11394 Arc::new(Tokens::new(vec!["alpha".to_owned()], DocType::Text)),
11395 Arc::new(FtsSearchParams::new().with_limit(Some(2))),
11396 Operator::Or,
11397 Arc::new(NoFilter),
11398 Arc::new(NoOpMetricsCollector),
11399 Some(&scorer),
11400 )
11401 .await
11402 .unwrap();
11403
11404 assert_eq!(result.0.len(), 2);
11405 assert!(result.0.contains(&100));
11406 assert!(result.0.contains(&200));
11407 assert!(index.corpus_stats.get().is_none());
11408 assert!(
11409 index
11410 .partitions
11411 .iter()
11412 .all(|partition| partition.docs.cached_stats().is_some())
11413 );
11414 }
11415
11416 async fn search_test_impact_partition(
11417 partition: &InvertedPartition,
11418 tokens: &Tokens,
11419 params: &FtsSearchParams,
11420 scorer: Arc<MemBM25Scorer>,
11421 shared_threshold: Arc<AtomicU32>,
11422 ) -> Vec<DocCandidate<DocId>> {
11423 let LoadedPostings {
11424 postings,
11425 grouped_expansions,
11426 impact_safe,
11427 exact_scoring_required,
11428 } = partition
11429 .load_posting_lists(
11430 tokens,
11431 params,
11432 Operator::Or,
11433 scorer.as_ref(),
11434 &NoOpMetricsCollector,
11435 )
11436 .await
11437 .unwrap();
11438 assert!(impact_safe);
11439 assert!(!exact_scoring_required);
11440 assert!(grouped_expansions.is_empty());
11441
11442 let documents = partition.docs.modern().unwrap();
11443 let lengths = documents.lengths().await.unwrap();
11444 let visibility = documents.visibility(NoFilter.mask(), false).await.unwrap();
11445 partition
11446 .bm25_search_modern(
11447 lengths.as_ref(),
11448 &visibility,
11449 params,
11450 Operator::Or,
11451 postings,
11452 Some(scorer),
11453 &NoOpMetricsCollector,
11454 shared_threshold,
11455 )
11456 .unwrap()
11457 }
11458
11459 #[tokio::test]
11460 async fn test_impact_partitions_share_global_threshold_without_pruning_winner() {
11461 let (_tmpdir, _cache, index) = load_global_scoring_test_index(true, true).await;
11465 let first_partition = index
11466 .partitions
11467 .iter()
11468 .find(|partition| partition.id() == 0)
11469 .unwrap();
11470 let second_partition = index
11471 .partitions
11472 .iter()
11473 .find(|partition| partition.id() == 1)
11474 .unwrap();
11475
11476 let tokens = Arc::new(Tokens::new(vec!["alpha".to_owned()], DocType::Text));
11477 let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
11478 let scorer = Arc::new(
11479 index
11480 .bm25_base_scorer(tokens.as_ref(), params.as_ref(), None)
11481 .await
11482 .unwrap(),
11483 );
11484 first_partition
11485 .inverted_list
11486 .ensure_metadata_loaded()
11487 .await
11488 .unwrap();
11489 second_partition
11490 .inverted_list
11491 .ensure_metadata_loaded()
11492 .await
11493 .unwrap();
11494 let first_local_scorer = IndexBM25Scorer::new(std::iter::once(first_partition.as_ref()));
11495 let second_local_scorer = IndexBM25Scorer::new(std::iter::once(second_partition.as_ref()));
11496 let first_local_score =
11497 first_local_scorer.query_weight("alpha") * first_local_scorer.doc_weight(1, 5_000);
11498 let second_local_score =
11499 second_local_scorer.query_weight("alpha") * second_local_scorer.doc_weight(1, 1_000);
11500 assert!(first_local_score > second_local_score);
11501 let shared_threshold = Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()));
11502
11503 let first_candidates = search_test_impact_partition(
11506 first_partition,
11507 tokens.as_ref(),
11508 params.as_ref(),
11509 scorer.clone(),
11510 shared_threshold.clone(),
11511 )
11512 .await;
11513 assert_eq!(first_candidates.len(), 1);
11514 assert_eq!(first_candidates[0].document, DocId::new(0));
11515 let first_score =
11516 scorer.query_weight("alpha") * scorer.doc_weight(1, first_candidates[0].doc_length);
11517 let published_threshold = f32::from_bits(shared_threshold.load(Ordering::Relaxed));
11518 assert!(
11519 (published_threshold - first_score).abs() < 1e-6,
11520 "published threshold: {published_threshold}, expected global score: {first_score}"
11521 );
11522
11523 let second_candidates = search_test_impact_partition(
11524 second_partition,
11525 tokens.as_ref(),
11526 params.as_ref(),
11527 scorer.clone(),
11528 shared_threshold.clone(),
11529 )
11530 .await;
11531 assert_eq!(second_candidates.len(), 1);
11532 assert_eq!(second_candidates[0].document, DocId::new(0));
11533 let second_score =
11534 scorer.query_weight("alpha") * scorer.doc_weight(1, second_candidates[0].doc_length);
11535 assert!(
11536 second_score > first_score,
11537 "second score: {second_score}, first score: {first_score}"
11538 );
11539 assert!(
11540 (f32::from_bits(shared_threshold.load(Ordering::Relaxed)) - second_score).abs() < 1e-6
11541 );
11542
11543 let (row_ids, scores) = index
11544 .bm25_search(
11545 tokens,
11546 params,
11547 Operator::Or,
11548 Arc::new(NoFilter),
11549 Arc::new(NoOpMetricsCollector),
11550 None,
11551 )
11552 .await
11553 .unwrap();
11554 assert_eq!(row_ids, vec![200]);
11555 assert_eq!(scores.len(), 1);
11556 assert!((scores[0] - second_score).abs() < 1e-6);
11557 }
11558
11559 #[tokio::test]
11560 async fn test_mixed_impact_and_legacy_partitions_use_global_final_scores() {
11561 let (_tmpdir, _cache, index) = load_global_scoring_test_index(true, false).await;
11562
11563 let impact_partition = index
11564 .partitions
11565 .iter()
11566 .find(|partition| partition.id() == 0)
11567 .unwrap();
11568 let legacy_partition = index
11569 .partitions
11570 .iter()
11571 .find(|partition| partition.id() == 1)
11572 .unwrap();
11573
11574 let impact_posting = impact_partition
11575 .inverted_list
11576 .posting_list(0, false, &NoOpMetricsCollector)
11577 .await
11578 .unwrap();
11579 assert!(impact_posting.has_impacts());
11580
11581 let legacy_posting = legacy_partition
11582 .inverted_list
11583 .posting_list(0, false, &NoOpMetricsCollector)
11584 .await
11585 .unwrap();
11586 assert!(!legacy_posting.has_impacts());
11587
11588 let tokens = Arc::new(Tokens::new(vec!["alpha".to_string()], DocType::Text));
11589 let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
11590 let (row_ids, scores) = index
11591 .bm25_search(
11592 tokens.clone(),
11593 params.clone(),
11594 Operator::Or,
11595 Arc::new(NoFilter),
11596 Arc::new(NoOpMetricsCollector),
11597 None,
11598 )
11599 .await
11600 .unwrap();
11601
11602 assert_eq!(row_ids, vec![200]);
11603 assert_eq!(row_ids.len(), scores.len());
11604
11605 let scorer = index
11606 .bm25_base_scorer(tokens.as_ref(), params.as_ref(), None)
11607 .await
11608 .unwrap();
11609 let expected_score = scorer.query_weight("alpha") * scorer.doc_weight(1, 1_000);
11610 assert!(
11611 (scores[0] - expected_score).abs() < 1e-6,
11612 "score: {}, expected: {}",
11613 scores[0],
11614 expected_score
11615 );
11616 }
11617
11618 #[tokio::test]
11619 async fn test_two_legacy_partitions_keep_private_thresholds() {
11620 let (_tmpdir, _cache, index) = load_global_scoring_test_index(false, false).await;
11623 for partition in index.partitions.iter() {
11624 let posting = partition
11625 .inverted_list
11626 .posting_list(0, false, &NoOpMetricsCollector)
11627 .await
11628 .unwrap();
11629 assert!(!posting.has_impacts());
11630 }
11631
11632 let tokens = Arc::new(Tokens::new(vec!["alpha".to_string()], DocType::Text));
11633 let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
11634 let (row_ids, scores) = index
11635 .bm25_search(
11636 tokens.clone(),
11637 params.clone(),
11638 Operator::Or,
11639 Arc::new(NoFilter),
11640 Arc::new(NoOpMetricsCollector),
11641 None,
11642 )
11643 .await
11644 .unwrap();
11645
11646 assert_eq!(row_ids, vec![200]);
11647 assert_eq!(scores.len(), 1);
11648 let scorer = index
11649 .bm25_base_scorer(tokens.as_ref(), params.as_ref(), None)
11650 .await
11651 .unwrap();
11652 let expected_score = scorer.query_weight("alpha") * scorer.doc_weight(1, 1_000);
11653 assert!(
11654 (scores[0] - expected_score).abs() < 1e-6,
11655 "score: {}, expected global score: {}",
11656 scores[0],
11657 expected_score
11658 );
11659 }
11660
11661 #[tokio::test]
11662 async fn test_and_query_returns_empty_when_exact_term_missing() {
11663 let tmpdir = TempObjDir::default();
11664 let store = Arc::new(LanceIndexStore::new(
11665 ObjectStore::local().into(),
11666 tmpdir.clone(),
11667 Arc::new(LanceCache::no_cache()),
11668 ));
11669
11670 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11671 builder.tokens.add("alpha".to_owned());
11672 builder.posting_lists.push(PostingListBuilder::new(false));
11673 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
11674 builder.docs.append(100, 1);
11675 builder.write(store.as_ref()).await.unwrap();
11676
11677 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
11678 let cache = Arc::new(LanceCache::with_capacity(4096));
11679 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11680 .await
11681 .unwrap();
11682
11683 let tokens = Arc::new(Tokens::new(
11684 vec!["alpha".to_owned(), "missing".to_owned()],
11685 DocType::Text,
11686 ));
11687 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
11688 let prefilter = Arc::new(NoFilter);
11689 let metrics = Arc::new(NoOpMetricsCollector);
11690
11691 let (and_row_ids, _) = index
11692 .bm25_search(
11693 tokens.clone(),
11694 params.clone(),
11695 Operator::And,
11696 prefilter.clone(),
11697 metrics.clone(),
11698 None,
11699 )
11700 .await
11701 .unwrap();
11702 assert!(
11703 and_row_ids.is_empty(),
11704 "AND must not match when any required term is missing"
11705 );
11706
11707 let (or_row_ids, _) = index
11708 .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
11709 .await
11710 .unwrap();
11711 assert_eq!(
11712 or_row_ids,
11713 vec![100],
11714 "OR should still match the present term"
11715 );
11716 }
11717
11718 #[tokio::test]
11719 async fn test_and_query_accepts_same_position_alternatives() {
11720 let tmpdir = TempObjDir::default();
11721 let store = Arc::new(LanceIndexStore::new(
11722 ObjectStore::local().into(),
11723 tmpdir.clone(),
11724 Arc::new(LanceCache::no_cache()),
11725 ));
11726
11727 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11728 for token in ["getusername", "get", "user", "name"] {
11729 builder.tokens.add(token.to_owned());
11730 builder.posting_lists.push(PostingListBuilder::new(false));
11731 }
11732 builder.posting_lists[1].add(0, PositionRecorder::Count(1));
11736 builder.posting_lists[2].add(0, PositionRecorder::Count(1));
11737 builder.posting_lists[3].add(0, PositionRecorder::Count(1));
11738 builder.docs.append(100, 3);
11739
11740 builder.posting_lists[0].add(1, PositionRecorder::Count(1));
11741 builder.posting_lists[1].add(1, PositionRecorder::Count(1));
11742 builder.posting_lists[2].add(1, PositionRecorder::Count(1));
11743 builder.posting_lists[3].add(1, PositionRecorder::Count(1));
11744 builder.docs.append(101, 4);
11745 builder.write(store.as_ref()).await.unwrap();
11746
11747 write_test_metadata(&store, vec![0], InvertedIndexParams::code()).await;
11748 let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache())
11749 .await
11750 .unwrap();
11751
11752 let tokens = Arc::new(Tokens::with_positions(
11753 vec![
11754 "getusername".to_string(),
11755 "get".to_string(),
11756 "user".to_string(),
11757 "name".to_string(),
11758 ],
11759 vec![0, 0, 1, 2],
11760 DocType::Text,
11761 ));
11762 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
11763 let (mut row_ids, _) = index
11764 .bm25_search(
11765 tokens,
11766 params,
11767 Operator::And,
11768 Arc::new(NoFilter),
11769 Arc::new(NoOpMetricsCollector),
11770 None,
11771 )
11772 .await
11773 .unwrap();
11774 row_ids.sort_unstable();
11775 assert_eq!(row_ids, vec![100, 101]);
11776 }
11777
11778 #[tokio::test]
11779 async fn test_phrase_query_accepts_same_position_alternatives() {
11780 let tmpdir = TempObjDir::default();
11781 let store = Arc::new(LanceIndexStore::new(
11782 ObjectStore::local().into(),
11783 tmpdir.clone(),
11784 Arc::new(LanceCache::no_cache()),
11785 ));
11786
11787 let mut builder = InnerBuilder::new(0, true, TokenSetFormat::default());
11788 for token in ["getusername", "get", "user", "name"] {
11789 builder.tokens.add(token.to_owned());
11790 builder.posting_lists.push(PostingListBuilder::new(true));
11791 }
11792 builder.posting_lists[1].add(0, PositionRecorder::Position(vec![0].into()));
11796 builder.posting_lists[2].add(0, PositionRecorder::Position(vec![1].into()));
11797 builder.posting_lists[3].add(0, PositionRecorder::Position(vec![2].into()));
11798 builder.docs.append(100, 3);
11799
11800 builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into()));
11801 builder.posting_lists[1].add(1, PositionRecorder::Position(vec![0].into()));
11802 builder.posting_lists[2].add(1, PositionRecorder::Position(vec![1].into()));
11803 builder.posting_lists[3].add(1, PositionRecorder::Position(vec![2].into()));
11804 builder.docs.append(101, 3);
11805
11806 builder.posting_lists[0].add(2, PositionRecorder::Position(vec![0].into()));
11807 builder.posting_lists[2].add(2, PositionRecorder::Position(vec![2].into()));
11808 builder.posting_lists[3].add(2, PositionRecorder::Position(vec![3].into()));
11809 builder.docs.append(102, 3);
11810
11811 builder.write(store.as_ref()).await.unwrap();
11812
11813 write_test_metadata(
11814 &store,
11815 vec![0],
11816 InvertedIndexParams::code().with_position(true),
11817 )
11818 .await;
11819 let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache())
11820 .await
11821 .unwrap();
11822
11823 let tokens = Arc::new(Tokens::with_positions(
11824 vec![
11825 "getusername".to_string(),
11826 "get".to_string(),
11827 "user".to_string(),
11828 "name".to_string(),
11829 ],
11830 vec![0, 0, 1, 2],
11831 DocType::Text,
11832 ));
11833 let params = Arc::new(
11834 FtsSearchParams::new()
11835 .with_limit(Some(10))
11836 .with_phrase_slop(Some(0)),
11837 );
11838 let (mut row_ids, _) = index
11839 .bm25_search(
11840 tokens,
11841 params,
11842 Operator::And,
11843 Arc::new(NoFilter),
11844 Arc::new(NoOpMetricsCollector),
11845 None,
11846 )
11847 .await
11848 .unwrap();
11849 row_ids.sort_unstable();
11850 assert_eq!(row_ids, vec![100, 101]);
11851 }
11852
11853 const MANY_BATCH_TOKENS: u64 = 1000;
11857 const MANY_BATCH_ROW_ID_BASE: u64 = 1000;
11858
11859 async fn write_partition_spanning_many_batches(store: &dyn IndexStore) {
11862 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11863 for i in 0..MANY_BATCH_TOKENS {
11864 builder.tokens.add(format!("tok{i:05}"));
11866 let doc_id = builder.docs.append(MANY_BATCH_ROW_ID_BASE + i, 1);
11867 let mut posting_list = PostingListBuilder::new(false);
11868 posting_list.add(doc_id, PositionRecorder::Count(1));
11869 builder.posting_lists.push(posting_list);
11870 }
11871 builder
11872 .write(store)
11873 .await
11874 .expect("writing posting lists should succeed");
11875 }
11876
11877 #[tokio::test]
11889 async fn test_write_many_posting_list_batches_preserves_all_batches() {
11890 let tmpdir = TempObjDir::default();
11891 let store = Arc::new(LanceIndexStore::new(
11892 ObjectStore::local().into(),
11893 tmpdir.clone(),
11894 Arc::new(LanceCache::no_cache()),
11895 ));
11896
11897 write_partition_spanning_many_batches(store.as_ref()).await;
11898
11899 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
11900 let cache = Arc::new(LanceCache::with_capacity(4096));
11901 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11902 .await
11903 .unwrap();
11904
11905 for token_idx in [0u64, MANY_BATCH_TOKENS / 2, MANY_BATCH_TOKENS - 1] {
11908 let tokens = Arc::new(Tokens::new(
11909 vec![format!("tok{token_idx:05}")],
11910 DocType::Text,
11911 ));
11912 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
11913 let (row_ids, _) = index
11914 .bm25_search(
11915 tokens,
11916 params,
11917 Operator::Or,
11918 Arc::new(NoFilter),
11919 Arc::new(NoOpMetricsCollector),
11920 None,
11921 )
11922 .await
11923 .unwrap();
11924 assert_eq!(
11925 row_ids,
11926 vec![MANY_BATCH_ROW_ID_BASE + token_idx],
11927 "token tok{token_idx:05} should map to its single document"
11928 );
11929 }
11930 }
11931
11932 #[tokio::test]
11933 async fn test_and_query_skips_partition_missing_required_term() {
11934 let tmpdir = TempObjDir::default();
11935 let store = Arc::new(LanceIndexStore::new(
11936 ObjectStore::local().into(),
11937 tmpdir.clone(),
11938 Arc::new(LanceCache::no_cache()),
11939 ));
11940
11941 let mut builder0 = InnerBuilder::new(0, false, TokenSetFormat::default());
11942 builder0.tokens.add("alpha".to_owned());
11943 builder0.posting_lists.push(PostingListBuilder::new(false));
11944 builder0.posting_lists[0].add(0, PositionRecorder::Count(1));
11945 builder0.docs.append(100, 1);
11946 builder0.write(store.as_ref()).await.unwrap();
11947
11948 let mut builder1 = InnerBuilder::new(1, false, TokenSetFormat::default());
11949 builder1.tokens.add("alpha".to_owned());
11950 builder1.tokens.add("beta".to_owned());
11951 builder1.posting_lists.push(PostingListBuilder::new(false));
11952 builder1.posting_lists.push(PostingListBuilder::new(false));
11953 builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
11954 builder1.posting_lists[1].add(0, PositionRecorder::Count(1));
11955 builder1.docs.append(200, 2);
11956 builder1.write(store.as_ref()).await.unwrap();
11957
11958 write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
11959 let cache = Arc::new(LanceCache::with_capacity(4096));
11960 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11961 .await
11962 .unwrap();
11963
11964 let tokens = Arc::new(Tokens::new(
11965 vec!["alpha".to_owned(), "beta".to_owned()],
11966 DocType::Text,
11967 ));
11968 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
11969 let (mut row_ids, _) = index
11970 .bm25_search(
11971 tokens,
11972 params,
11973 Operator::And,
11974 Arc::new(NoFilter),
11975 Arc::new(NoOpMetricsCollector),
11976 None,
11977 )
11978 .await
11979 .unwrap();
11980 row_ids.sort_unstable();
11981 assert_eq!(
11982 row_ids,
11983 vec![200],
11984 "partition missing beta must not contribute alpha-only hits"
11985 );
11986 }
11987
11988 #[tokio::test]
11989 async fn test_fuzzy_and_groups_expansions_by_original_position() {
11990 let tmpdir = TempObjDir::default();
11991 let store = Arc::new(LanceIndexStore::new(
11992 ObjectStore::local().into(),
11993 tmpdir.clone(),
11994 Arc::new(LanceCache::no_cache()),
11995 ));
11996
11997 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11998 builder.tokens.add("alpha".to_owned());
11999 builder.tokens.add("alphi".to_owned());
12000 builder.tokens.add("beta".to_owned());
12001 builder.posting_lists.push(PostingListBuilder::new(false));
12002 builder.posting_lists.push(PostingListBuilder::new(false));
12003 builder.posting_lists.push(PostingListBuilder::new(false));
12004 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
12005 builder.posting_lists[1].add(1, PositionRecorder::Count(1));
12006 builder.posting_lists[2].add(0, PositionRecorder::Count(1));
12007 builder.posting_lists[2].add(1, PositionRecorder::Count(1));
12008 builder.docs.append(100, 2);
12009 builder.docs.append(101, 2);
12010 builder.write(store.as_ref()).await.unwrap();
12011
12012 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
12013 let cache = Arc::new(LanceCache::with_capacity(4096));
12014 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12015 .await
12016 .unwrap();
12017 let params = Arc::new(
12018 FtsSearchParams::new()
12019 .with_limit(Some(10))
12020 .with_fuzziness(Some(1)),
12021 );
12022
12023 let missing_position_tokens = Arc::new(Tokens::new(
12024 vec!["betx".to_owned(), "zzzzz".to_owned()],
12025 DocType::Text,
12026 ));
12027 let (missing_and_row_ids, _) = index
12028 .bm25_search(
12029 missing_position_tokens.clone(),
12030 params.clone(),
12031 Operator::And,
12032 Arc::new(NoFilter),
12033 Arc::new(NoOpMetricsCollector),
12034 None,
12035 )
12036 .await
12037 .unwrap();
12038 assert!(
12039 missing_and_row_ids.is_empty(),
12040 "fuzzy AND must require at least one expansion for every original position"
12041 );
12042
12043 let (mut or_row_ids, _) = index
12044 .bm25_search(
12045 missing_position_tokens,
12046 params.clone(),
12047 Operator::Or,
12048 Arc::new(NoFilter),
12049 Arc::new(NoOpMetricsCollector),
12050 None,
12051 )
12052 .await
12053 .unwrap();
12054 or_row_ids.sort_unstable();
12055 assert_eq!(
12056 or_row_ids,
12057 vec![100, 101],
12058 "OR should still match present fuzzy expansions"
12059 );
12060
12061 let grouped_tokens = Arc::new(Tokens::new(
12062 vec!["alphx".to_owned(), "betx".to_owned()],
12063 DocType::Text,
12064 ));
12065 let (mut grouped_row_ids, _) = index
12066 .bm25_search(
12067 grouped_tokens,
12068 params,
12069 Operator::And,
12070 Arc::new(NoFilter),
12071 Arc::new(NoOpMetricsCollector),
12072 None,
12073 )
12074 .await
12075 .unwrap();
12076 grouped_row_ids.sort_unstable();
12077 assert_eq!(
12078 grouped_row_ids,
12079 vec![100, 101],
12080 "each original fuzzy position should match any one of its expansions"
12081 );
12082 }
12083
12084 #[tokio::test]
12085 async fn test_fuzzy_expansion_cap_applies_to_whole_query() {
12086 let tmpdir = TempObjDir::default();
12087 let store = Arc::new(LanceIndexStore::new(
12088 ObjectStore::local().into(),
12089 tmpdir.clone(),
12090 Arc::new(LanceCache::no_cache()),
12091 ));
12092
12093 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12094 for token in ["alpha", "alphi", "beta", "beti"] {
12095 builder.tokens.add(token.to_owned());
12096 builder.posting_lists.push(PostingListBuilder::new(false));
12097 }
12098 for token_id in 0..4 {
12099 builder.posting_lists[token_id].add(token_id as u32, PositionRecorder::Count(1));
12100 builder.docs.append(100 + token_id as u64, 1);
12101 }
12102 builder.write(store.as_ref()).await.unwrap();
12103
12104 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
12105 let cache = Arc::new(LanceCache::with_capacity(4096));
12106 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12107 .await
12108 .unwrap();
12109 let partition = index.partitions[0].clone();
12110 let params = FtsSearchParams::new()
12111 .with_fuzziness(Some(1))
12112 .with_max_expansions(3);
12113 let tokens = Tokens::new(vec!["alphx".to_owned(), "betx".to_owned()], DocType::Text);
12114
12115 let expanded = partition.expand_fuzzy(&tokens, ¶ms).unwrap();
12116 let expanded_terms = (0..expanded.len())
12117 .map(|idx| (expanded.get_token(idx).to_owned(), expanded.position(idx)))
12118 .collect::<Vec<_>>();
12119
12120 assert_eq!(
12121 expanded_terms,
12122 vec![
12123 ("alpha".to_owned(), 0),
12124 ("alphi".to_owned(), 0),
12125 ("beta".to_owned(), 1),
12126 ],
12127 "max_expansions should cap the whole fuzzy query, not each token"
12128 );
12129 }
12130
12131 async fn write_variant_partition(
12134 store: &Arc<LanceIndexStore>,
12135 partition_id: u64,
12136 variants: &[&str],
12137 row_ids: &[u64],
12138 ) {
12139 let mut builder = InnerBuilder::new(partition_id, false, TokenSetFormat::default());
12140 for token in variants {
12141 builder.tokens.add((*token).to_owned());
12142 builder.posting_lists.push(PostingListBuilder::new(false));
12143 }
12144 for (local_idx, row_id) in row_ids.iter().enumerate() {
12145 builder.posting_lists[local_idx].add(local_idx as u32, PositionRecorder::Count(1));
12146 builder.docs.append(*row_id, 1);
12147 }
12148 builder.write(store.as_ref()).await.unwrap();
12149 }
12150
12151 #[tokio::test]
12152 async fn test_fuzzy_expansion_cap_is_global_across_partitions() {
12153 let tmpdir = TempObjDir::default();
12154 let store = Arc::new(LanceIndexStore::new(
12155 ObjectStore::local().into(),
12156 tmpdir.clone(),
12157 Arc::new(LanceCache::no_cache()),
12158 ));
12159
12160 write_variant_partition(&store, 0, &["alpha", "alphb"], &[100, 101]).await;
12161 write_variant_partition(&store, 1, &["alphc", "alphd"], &[102, 103]).await;
12162 write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
12163 let cache = Arc::new(LanceCache::with_capacity(4096));
12164 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12165 .await
12166 .unwrap();
12167
12168 let params = FtsSearchParams::new()
12169 .with_fuzziness(Some(1))
12170 .with_max_expansions(3);
12171 let tokens = Tokens::new(vec!["alphx".to_owned()], DocType::Text);
12172
12173 let expanded = index.expand_fuzzy_tokens(&tokens, ¶ms).unwrap();
12174 let expanded_terms = (0..expanded.len())
12175 .map(|idx| expanded.get_token(idx).to_owned())
12176 .collect::<Vec<_>>();
12177 assert_eq!(
12178 expanded_terms,
12179 vec!["alpha".to_owned(), "alphb".to_owned(), "alphc".to_owned()],
12180 "max_expansions must cap the whole query across partitions, \
12181 in lexicographic order"
12182 );
12183 }
12184
12185 #[tokio::test]
12186 async fn test_fuzzy_results_independent_of_partition_shape() {
12187 let single_dir = TempObjDir::default();
12191 let single_store = Arc::new(LanceIndexStore::new(
12192 ObjectStore::local().into(),
12193 single_dir.clone(),
12194 Arc::new(LanceCache::no_cache()),
12195 ));
12196 write_variant_partition(
12197 &single_store,
12198 0,
12199 &["alpha", "alphb", "alphc", "alphd"],
12200 &[100, 101, 102, 103],
12201 )
12202 .await;
12203 write_test_metadata(&single_store, vec![0], InvertedIndexParams::default()).await;
12204
12205 let split_dir = TempObjDir::default();
12206 let split_store = Arc::new(LanceIndexStore::new(
12207 ObjectStore::local().into(),
12208 split_dir.clone(),
12209 Arc::new(LanceCache::no_cache()),
12210 ));
12211 write_variant_partition(&split_store, 0, &["alpha", "alphb"], &[100, 101]).await;
12212 write_variant_partition(&split_store, 1, &["alphc", "alphd"], &[102, 103]).await;
12213 write_test_metadata(&split_store, vec![0, 1], InvertedIndexParams::default()).await;
12214
12215 let params = Arc::new(
12216 FtsSearchParams::new()
12217 .with_limit(Some(10))
12218 .with_fuzziness(Some(1))
12219 .with_max_expansions(3),
12220 );
12221
12222 let mut results = Vec::new();
12223 for store in [single_store, split_store] {
12224 let cache = LanceCache::with_capacity(4096);
12225 let index = InvertedIndex::load(store, None, &cache).await.unwrap();
12226 let tokens = Arc::new(Tokens::new(vec!["alphx".to_owned()], DocType::Text));
12227 let (row_ids, scores) = index
12228 .bm25_search(
12229 tokens,
12230 params.clone(),
12231 Operator::Or,
12232 Arc::new(NoFilter),
12233 Arc::new(NoOpMetricsCollector),
12234 None,
12235 )
12236 .await
12237 .unwrap();
12238 let mut scored = row_ids.into_iter().zip(scores).collect::<Vec<_>>();
12239 scored.sort_unstable_by_key(|(row_id, _)| *row_id);
12240 results.push(scored);
12241 }
12242
12243 assert_eq!(
12244 results[0]
12245 .iter()
12246 .map(|(row_id, _)| *row_id)
12247 .collect::<Vec<_>>(),
12248 vec![100, 101, 102],
12249 "a binding cap keeps the three lexicographically smallest variants"
12250 );
12251 assert_eq!(
12252 results[0], results[1],
12253 "fuzzy results must not depend on the partition shape"
12254 );
12255 }
12256
12257 #[tokio::test]
12258 async fn test_fuzzy_and_scores_grouped_expansions_by_matched_token() {
12259 let tmpdir = TempObjDir::default();
12260 let store = Arc::new(LanceIndexStore::new(
12261 ObjectStore::local().into(),
12262 tmpdir.clone(),
12263 Arc::new(LanceCache::no_cache()),
12264 ));
12265
12266 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12267 builder.tokens.add("alpha".to_owned());
12268 builder.tokens.add("alphi".to_owned());
12269 builder.tokens.add("beta".to_owned());
12270 builder.posting_lists.push(PostingListBuilder::new(false));
12271 builder.posting_lists.push(PostingListBuilder::new(false));
12272 builder.posting_lists.push(PostingListBuilder::new(false));
12273 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
12274 builder.posting_lists[0].add(2, PositionRecorder::Count(1));
12275 builder.posting_lists[0].add(3, PositionRecorder::Count(1));
12276 builder.posting_lists[0].add(4, PositionRecorder::Count(1));
12277 builder.posting_lists[0].add(5, PositionRecorder::Count(1));
12278 builder.posting_lists[1].add(1, PositionRecorder::Count(1));
12279 builder.posting_lists[2].add(0, PositionRecorder::Count(1));
12280 builder.posting_lists[2].add(1, PositionRecorder::Count(1));
12281 builder.docs.append(100, 2);
12282 builder.docs.append(101, 2);
12283 builder.docs.append(102, 1);
12284 builder.docs.append(103, 1);
12285 builder.docs.append(104, 1);
12286 builder.docs.append(105, 1);
12287 builder.write(store.as_ref()).await.unwrap();
12288
12289 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
12290 let cache = Arc::new(LanceCache::with_capacity(4096));
12291 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12292 .await
12293 .unwrap();
12294
12295 let tokens = Arc::new(Tokens::new(
12296 vec!["alphx".to_owned(), "betx".to_owned()],
12297 DocType::Text,
12298 ));
12299 let params = Arc::new(
12300 FtsSearchParams::new()
12301 .with_limit(Some(1))
12302 .with_fuzziness(Some(1)),
12303 );
12304 let (row_ids, _scores) = index
12305 .bm25_search(
12306 tokens,
12307 params,
12308 Operator::And,
12309 Arc::new(NoFilter),
12310 Arc::new(NoOpMetricsCollector),
12311 None,
12312 )
12313 .await
12314 .unwrap();
12315
12316 assert_eq!(
12317 row_ids,
12318 vec![101],
12319 "the rare matched expansion should outrank the common expansion"
12320 );
12321 }
12322
12323 #[rstest::rstest]
12324 #[case::and(Operator::And)]
12325 #[case::or(Operator::Or)]
12326 #[tokio::test]
12327 async fn test_grouped_scoring_keeps_exact_winner_outside_proxy_window(
12328 #[case] operator: Operator,
12329 ) {
12330 let tmpdir = TempObjDir::default();
12331 let store = Arc::new(LanceIndexStore::new(
12332 ObjectStore::local().into(),
12333 tmpdir.clone(),
12334 Arc::new(LanceCache::no_cache()),
12335 ));
12336
12337 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12338 builder.tokens.add("common".to_owned());
12339 builder.tokens.add("rare".to_owned());
12340 builder.posting_lists.push(PostingListBuilder::new(false));
12341 builder.posting_lists.push(PostingListBuilder::new(false));
12342 for doc_id in 0..3 {
12343 builder.posting_lists[0].add(doc_id, PositionRecorder::Count(1));
12344 builder.docs.append(100 + doc_id as u64, 1);
12345 }
12346 builder.posting_lists[1].add(3, PositionRecorder::Count(1));
12347 builder.docs.append(103, 2);
12348 builder.write(store.as_ref()).await.unwrap();
12349
12350 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
12351 let cache = Arc::new(LanceCache::with_capacity(4096));
12352 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12353 .await
12354 .unwrap();
12355
12356 let tokens = Arc::new(Tokens::with_positions(
12357 vec!["common".to_owned(), "rare".to_owned()],
12358 vec![0, 0],
12359 DocType::Text,
12360 ));
12361 let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
12362 let (row_ids, _scores) = index
12363 .bm25_search(
12364 tokens,
12365 params,
12366 operator,
12367 Arc::new(NoFilter),
12368 Arc::new(NoOpMetricsCollector),
12369 None,
12370 )
12371 .await
12372 .unwrap();
12373
12374 assert_eq!(
12375 row_ids,
12376 vec![103],
12377 "the rare term's exact IDF must win even when proxy scoring ranks it outside the old candidate cushion"
12378 );
12379 }
12380
12381 #[tokio::test]
12382 async fn test_fuzzy_and_grouped_rescore_keeps_wand_limit_bounded() {
12383 let tmpdir = TempObjDir::default();
12384 let store = Arc::new(LanceIndexStore::new(
12385 ObjectStore::local().into(),
12386 tmpdir.clone(),
12387 Arc::new(LanceCache::no_cache()),
12388 ));
12389
12390 let num_docs = BLOCK_SIZE * 2 + 4;
12391 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12392 builder.tokens.add("alpha".to_owned());
12393 builder.tokens.add("alphi".to_owned());
12394 builder.tokens.add("beta".to_owned());
12395 builder.posting_lists.push(PostingListBuilder::new(false));
12396 builder.posting_lists.push(PostingListBuilder::new(false));
12397 builder.posting_lists.push(PostingListBuilder::new(false));
12398
12399 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
12400 builder.posting_lists[1].add(1, PositionRecorder::Count(1));
12401 for doc_id in 0..num_docs {
12402 builder.posting_lists[2].add(doc_id as u32, PositionRecorder::Count(1));
12403 if doc_id >= 2 {
12404 builder.posting_lists[0].add(doc_id as u32, PositionRecorder::Count(1));
12405 }
12406 let num_tokens = if doc_id < 2 { 2 } else { 100 };
12407 builder.docs.append(100 + doc_id as u64, num_tokens);
12408 }
12409 builder.write(store.as_ref()).await.unwrap();
12410
12411 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
12412 let cache = Arc::new(LanceCache::with_capacity(4096));
12413 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12414 .await
12415 .unwrap();
12416
12417 let tokens = Arc::new(Tokens::new(
12418 vec!["alphx".to_owned(), "betx".to_owned()],
12419 DocType::Text,
12420 ));
12421 let params = Arc::new(
12422 FtsSearchParams::new()
12423 .with_limit(Some(1))
12424 .with_fuzziness(Some(1)),
12425 );
12426 let metrics = Arc::new(LocalMetricsCollector::default());
12427 let (row_ids, _scores) = index
12428 .bm25_search(
12429 tokens,
12430 params,
12431 Operator::And,
12432 Arc::new(NoFilter),
12433 metrics.clone(),
12434 None,
12435 )
12436 .await
12437 .unwrap();
12438
12439 assert_eq!(
12440 row_ids,
12441 vec![101],
12442 "final rescoring should still rank by the matched expansion"
12443 );
12444 let comparisons = metrics.comparisons.load(Ordering::Relaxed);
12445 assert!(
12446 comparisons < num_docs,
12447 "grouped fuzzy AND should not clear the WAND top-k bound and scan every candidate; comparisons={comparisons}, num_docs={num_docs}"
12448 );
12449 }
12450
12451 #[tokio::test]
12452 async fn test_phrase_query_reads_legacy_per_doc_positions() {
12453 let tmpdir = TempObjDir::default();
12454 let store = Arc::new(LanceIndexStore::new(
12455 ObjectStore::local().into(),
12456 tmpdir.clone(),
12457 Arc::new(LanceCache::no_cache()),
12458 ));
12459
12460 let mut builder = InnerBuilder::new_with_format_version(
12461 0,
12462 true,
12463 TokenSetFormat::default(),
12464 InvertedListFormatVersion::V1,
12465 );
12466 builder.tokens.add("hello".to_owned());
12467 builder.tokens.add("world".to_owned());
12468 builder
12469 .posting_lists
12470 .push(PostingListBuilder::new_with_posting_tail_codec(
12471 true,
12472 PostingTailCodec::Fixed32,
12473 ));
12474 builder
12475 .posting_lists
12476 .push(PostingListBuilder::new_with_posting_tail_codec(
12477 true,
12478 PostingTailCodec::Fixed32,
12479 ));
12480 builder.posting_lists[0].add(0, PositionRecorder::Position(vec![0].into()));
12481 builder.posting_lists[1].add(0, PositionRecorder::Position(vec![1].into()));
12482 builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into()));
12483 builder.posting_lists[1].add(1, PositionRecorder::Position(vec![2].into()));
12484 builder.docs.append(100, 2);
12485 builder.docs.append(101, 2);
12486 builder.write(store.as_ref()).await.unwrap();
12487
12488 let metadata = std::collections::HashMap::from_iter(vec![
12489 (
12490 "partitions".to_owned(),
12491 serde_json::to_string(&vec![0_u64]).unwrap(),
12492 ),
12493 (
12494 "params".to_owned(),
12495 serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
12496 ),
12497 (
12498 TOKEN_SET_FORMAT_KEY.to_owned(),
12499 TokenSetFormat::default().to_string(),
12500 ),
12501 ]);
12502 let mut writer = store
12503 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
12504 .await
12505 .unwrap();
12506 writer.finish_with_metadata(metadata).await.unwrap();
12507
12508 let cache = Arc::new(LanceCache::with_capacity(4096));
12509 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12510 .await
12511 .unwrap();
12512
12513 let tokens = Arc::new(Tokens::new(
12514 vec!["hello".to_owned(), "world".to_owned()],
12515 DocType::Text,
12516 ));
12517 let params = Arc::new(
12518 FtsSearchParams::new()
12519 .with_limit(Some(10))
12520 .with_phrase_slop(Some(0)),
12521 );
12522 let prefilter = Arc::new(NoFilter);
12523 let metrics = Arc::new(NoOpMetricsCollector);
12524
12525 let (row_ids, _scores) = index
12526 .bm25_search(tokens, params, Operator::And, prefilter, metrics, None)
12527 .await
12528 .unwrap();
12529
12530 assert_eq!(row_ids, vec![100]);
12531 }
12532
12533 async fn build_multi_partition_index(
12536 store: &Arc<LanceIndexStore>,
12537 num_partitions: u64,
12538 ) -> (Arc<InvertedIndex>, Arc<LanceCache>) {
12539 for id in 0..num_partitions {
12540 let mut builder = InnerBuilder::new_with_format_version(
12541 id,
12542 false,
12543 TokenSetFormat::default(),
12544 InvertedListFormatVersion::V1,
12545 );
12546 for t in 0..4u32 {
12549 builder.tokens.add(format!("tok_{id}_{t}"));
12550 let mut posting = PostingListBuilder::new_with_posting_tail_codec(
12551 false,
12552 PostingTailCodec::Fixed32,
12553 );
12554 let base = id * 1000 + t as u64 * 10;
12555 for d in 0..5u32 {
12556 posting.add(d, PositionRecorder::Count(1));
12557 builder.docs.append(base + d as u64, 4);
12558 }
12559 builder.posting_lists.push(posting);
12560 }
12561 builder.write(store.as_ref()).await.unwrap();
12562 }
12563
12564 let partition_ids: Vec<u64> = (0..num_partitions).collect();
12565 let metadata = std::collections::HashMap::from_iter(vec![
12566 (
12567 "partitions".to_owned(),
12568 serde_json::to_string(&partition_ids).unwrap(),
12569 ),
12570 (
12571 "params".to_owned(),
12572 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
12573 ),
12574 (
12575 TOKEN_SET_FORMAT_KEY.to_owned(),
12576 TokenSetFormat::default().to_string(),
12577 ),
12578 ]);
12579 let mut writer = store
12580 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
12581 .await
12582 .unwrap();
12583 writer.finish_with_metadata(metadata).await.unwrap();
12584
12585 let cache = Arc::new(LanceCache::with_capacity(1 << 20));
12588 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12589 .await
12590 .unwrap();
12591 (index, cache)
12592 }
12593
12594 #[tokio::test]
12598 async fn test_posting_data_size_bytes_uses_file_length() {
12599 let tmpdir = TempObjDir::default();
12600 let store = Arc::new(LanceIndexStore::new(
12601 ObjectStore::local().into(),
12602 tmpdir.clone(),
12603 Arc::new(LanceCache::no_cache()),
12604 ));
12605 let (index, _cache) = build_multi_partition_index(&store, 3).await;
12606 for part in &index.partitions {
12607 let est = part.inverted_list.posting_data_size_bytes();
12610 assert!(
12611 est > 0,
12612 "expected a non-zero posting-data size estimate, got {est}"
12613 );
12614 }
12615 }
12616
12617 #[tokio::test]
12624 async fn test_partitions_load_with_distinct_priorities() {
12625 let tmpdir = TempObjDir::default();
12626 let store = Arc::new(LanceIndexStore::new(
12627 ObjectStore::local().into(),
12628 tmpdir.clone(),
12629 Arc::new(LanceCache::no_cache()),
12630 ));
12631 let (index, _cache) = build_multi_partition_index(&store, 5).await;
12632
12633 let mut priorities: Vec<u64> = index
12634 .partitions
12635 .iter()
12636 .map(|part| {
12637 part.store
12638 .as_any()
12639 .downcast_ref::<LanceIndexStore>()
12640 .expect("partition store should be a LanceIndexStore")
12641 .io_priority()
12642 })
12643 .collect();
12644
12645 priorities.sort_unstable();
12651 assert_eq!(
12652 priorities,
12653 (0..index.partitions.len() as u64).collect::<Vec<_>>()
12654 );
12655 }
12656
12657 #[tokio::test]
12658 async fn test_update_preserves_v2_format_version() -> Result<()> {
12659 let src_dir = TempObjDir::default();
12660 let dest_dir = TempObjDir::default();
12661 let src_store = Arc::new(LanceIndexStore::new(
12662 ObjectStore::local().into(),
12663 src_dir.clone(),
12664 Arc::new(LanceCache::no_cache()),
12665 ));
12666 let dest_store = Arc::new(LanceIndexStore::new(
12667 ObjectStore::local().into(),
12668 dest_dir.clone(),
12669 Arc::new(LanceCache::no_cache()),
12670 ));
12671
12672 let format_version = InvertedListFormatVersion::V2;
12673 let posting_tail_codec = format_version.posting_tail_codec();
12674 let mut partition = InnerBuilder::new_with_format_version(
12675 0,
12676 false,
12677 TokenSetFormat::default(),
12678 format_version,
12679 );
12680 partition.tokens.add("hello".to_owned());
12681 let mut posting_list =
12682 PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
12683 posting_list.add(0, PositionRecorder::Count(1));
12684 partition.posting_lists.push(posting_list);
12685 partition.docs.append(100, 1);
12686 partition.write(src_store.as_ref()).await?;
12687
12688 let metadata = HashMap::from([
12689 (
12690 "partitions".to_owned(),
12691 serde_json::to_string(&vec![0_u64]).unwrap(),
12692 ),
12693 (
12694 "params".to_owned(),
12695 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
12696 ),
12697 (
12698 TOKEN_SET_FORMAT_KEY.to_owned(),
12699 TokenSetFormat::default().to_string(),
12700 ),
12701 (
12702 POSTING_TAIL_CODEC_KEY.to_owned(),
12703 posting_tail_codec.as_str().to_owned(),
12704 ),
12705 ]);
12706 let mut writer = src_store
12707 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
12708 .await
12709 .unwrap();
12710 writer.finish_with_metadata(metadata).await.unwrap();
12711
12712 let index = InvertedIndex::load(src_store, None, &LanceCache::no_cache()).await?;
12713 assert_eq!(index.format_version(), format_version);
12714 assert_eq!(index.index_version(), INVERTED_INDEX_VERSION_V2);
12715
12716 let schema = Arc::new(Schema::new(vec![
12717 Field::new("doc", DataType::Utf8, true),
12718 Field::new(ROW_ID, DataType::UInt64, false),
12719 ]));
12720 let docs = Arc::new(StringArray::from(vec![Some("hello again")]));
12721 let row_ids = Arc::new(UInt64Array::from(vec![101u64]));
12722 let batch = RecordBatch::try_new(schema.clone(), vec![docs, row_ids])?;
12723 let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)]));
12724 let created = index
12725 .update(Box::pin(stream), dest_store.as_ref(), None)
12726 .await?;
12727
12728 assert_eq!(created.index_version, INVERTED_INDEX_VERSION_V2);
12729
12730 let updated = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
12731 assert_eq!(updated.format_version(), format_version);
12732 assert_eq!(updated.index_version(), INVERTED_INDEX_VERSION_V2);
12733 assert_eq!(updated.partitions.len(), 2);
12734 for partition in &updated.partitions {
12735 assert_eq!(
12736 partition.inverted_list.posting_tail_codec(),
12737 posting_tail_codec
12738 );
12739 }
12740
12741 Ok(())
12742 }
12743
12744 #[tokio::test]
12745 async fn test_block_size_256_writes_v3_metadata_and_index_version() -> Result<()> {
12746 let src_dir = TempObjDir::default();
12747 let dest_dir = TempObjDir::default();
12748 let src_store = Arc::new(LanceIndexStore::new(
12749 ObjectStore::local().into(),
12750 src_dir.clone(),
12751 Arc::new(LanceCache::no_cache()),
12752 ));
12753 let dest_store = Arc::new(LanceIndexStore::new(
12754 ObjectStore::local().into(),
12755 dest_dir.clone(),
12756 Arc::new(LanceCache::no_cache()),
12757 ));
12758
12759 let params = InvertedIndexParams::default().block_size(256)?;
12760 let format_version = params.resolved_format_version();
12761 assert_eq!(format_version, InvertedListFormatVersion::V3);
12762
12763 let mut partition = InnerBuilder::new_with_format_version_and_block_size(
12764 0,
12765 false,
12766 TokenSetFormat::default(),
12767 format_version,
12768 params.posting_block_size(),
12769 );
12770 partition.tokens.add("hello".to_owned());
12771 let mut posting_list = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
12772 false,
12773 format_version.posting_tail_codec(),
12774 params.posting_block_size(),
12775 );
12776 posting_list.add(0, PositionRecorder::Count(1));
12777 partition.posting_lists.push(posting_list);
12778 partition.docs.append(100, 1);
12779 partition.write(src_store.as_ref()).await?;
12780
12781 write_test_metadata(&src_store, vec![0], params).await;
12782
12783 let index = InvertedIndex::load(src_store, None, &LanceCache::no_cache()).await?;
12784 assert_eq!(index.format_version(), InvertedListFormatVersion::V3);
12785 assert_eq!(index.index_version(), INVERTED_INDEX_VERSION_V3);
12786
12787 let created = index
12788 .update(empty_doc_stream(), dest_store.as_ref(), None)
12789 .await?;
12790 assert_eq!(created.index_version, INVERTED_INDEX_VERSION_V3);
12791
12792 let updated = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
12793 assert_eq!(updated.format_version(), InvertedListFormatVersion::V3);
12794 assert_eq!(updated.index_version(), INVERTED_INDEX_VERSION_V3);
12795
12796 Ok(())
12797 }
12798
12799 #[tokio::test]
12800 async fn test_merge_segments_preserves_arrow_token_set_format() -> Result<()> {
12801 let src_dir = TempObjDir::default();
12802 let dest_dir = TempObjDir::default();
12803 let src_store = Arc::new(LanceIndexStore::new(
12804 ObjectStore::local().into(),
12805 src_dir.clone(),
12806 Arc::new(LanceCache::no_cache()),
12807 ));
12808 let dest_store = Arc::new(LanceIndexStore::new(
12809 ObjectStore::local().into(),
12810 dest_dir.clone(),
12811 Arc::new(LanceCache::no_cache()),
12812 ));
12813
12814 let index = write_single_partition_index(
12815 src_store,
12816 InvertedIndexParams::default().format_version(InvertedListFormatVersion::V2),
12817 TokenSetFormat::Arrow,
12818 "hello",
12819 100,
12820 )
12821 .await?;
12822 assert_eq!(index.index_version(), 0);
12823 let created = InvertedIndex::merge_segments(
12824 &[index],
12825 empty_doc_stream(),
12826 dest_store.as_ref(),
12827 None,
12828 crate::progress::noop_progress(),
12829 )
12830 .await?;
12831
12832 assert_eq!(created.index_version, 0);
12833 let merged = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
12834 assert_eq!(merged.index_version(), 0);
12835 assert_eq!(merged.token_set_format, TokenSetFormat::Arrow);
12836
12837 let tokens = Arc::new(Tokens::new(vec!["hello".to_string()], DocType::Text));
12838 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
12839 let prefilter = Arc::new(NoFilter);
12840 let metrics = Arc::new(NoOpMetricsCollector);
12841 let (row_ids, _) = merged
12842 .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
12843 .await?;
12844 assert_eq!(row_ids, vec![100]);
12845
12846 Ok(())
12847 }
12848
12849 #[rstest::rstest]
12850 #[case::v1(InvertedListFormatVersion::V1, LEGACY_BLOCK_SIZE)]
12851 #[case::v2(InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)]
12852 #[case::v3_128(InvertedListFormatVersion::V3, LEGACY_BLOCK_SIZE)]
12853 #[case::v3_256(InvertedListFormatVersion::V3, 256)]
12854 #[tokio::test]
12855 async fn test_merge_segments_preserves_format_version(
12856 #[case] format_version: InvertedListFormatVersion,
12857 #[case] block_size: usize,
12858 ) -> Result<()> {
12859 let src_dir = TempObjDir::default();
12860 let dest_dir = TempObjDir::default();
12861 let src_store = Arc::new(LanceIndexStore::new(
12862 ObjectStore::local().into(),
12863 src_dir.clone(),
12864 Arc::new(LanceCache::no_cache()),
12865 ));
12866 let dest_store = Arc::new(LanceIndexStore::new(
12867 ObjectStore::local().into(),
12868 dest_dir.clone(),
12869 Arc::new(LanceCache::no_cache()),
12870 ));
12871 let params = InvertedIndexParams::default()
12872 .block_size(block_size)?
12873 .format_version(format_version);
12874
12875 let index =
12876 write_single_partition_index(src_store, params, TokenSetFormat::Fst, "hello", 100)
12877 .await?;
12878 assert_eq!(index.format_version(), format_version);
12879
12880 let created = InvertedIndex::merge_segments(
12881 &[index],
12882 empty_doc_stream(),
12883 dest_store.as_ref(),
12884 None,
12885 crate::progress::noop_progress(),
12886 )
12887 .await?;
12888 assert_eq!(created.index_version, format_version.index_version());
12889
12890 let merged = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
12891 assert_eq!(merged.format_version(), format_version);
12892 assert_eq!(merged.index_version(), format_version.index_version());
12893
12894 Ok(())
12895 }
12896
12897 #[tokio::test]
12898 async fn test_merge_segments_uses_memory_limit_for_old_partitions() -> Result<()> {
12899 let src_dir_1 = TempObjDir::default();
12900 let src_dir_2 = TempObjDir::default();
12901 let dest_dir = TempObjDir::default();
12902 let src_store_1 = Arc::new(LanceIndexStore::new(
12903 ObjectStore::local().into(),
12904 src_dir_1.clone(),
12905 Arc::new(LanceCache::no_cache()),
12906 ));
12907 let src_store_2 = Arc::new(LanceIndexStore::new(
12908 ObjectStore::local().into(),
12909 src_dir_2.clone(),
12910 Arc::new(LanceCache::no_cache()),
12911 ));
12912 let dest_store = Arc::new(LanceIndexStore::new(
12913 ObjectStore::local().into(),
12914 dest_dir.clone(),
12915 Arc::new(LanceCache::no_cache()),
12916 ));
12917
12918 let params = InvertedIndexParams::default().memory_limit_mb(0);
12919 let first = write_single_partition_index(
12920 src_store_1,
12921 params.clone(),
12922 TokenSetFormat::default(),
12923 "alpha",
12924 100,
12925 )
12926 .await?;
12927 let second = write_single_partition_index(
12928 src_store_2,
12929 params,
12930 TokenSetFormat::default(),
12931 "beta",
12932 200,
12933 )
12934 .await?;
12935
12936 let mut builder =
12937 InvertedIndexBuilder::new(InvertedIndexParams::default().memory_limit_mb(0))
12938 .with_token_set_format(TokenSetFormat::default());
12939 builder
12940 .update_from_segments(
12941 empty_doc_stream(),
12942 dest_store.as_ref(),
12943 &[first, second],
12944 None,
12945 )
12946 .await?;
12947
12948 let merged = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
12949 assert_eq!(merged.partitions.len(), 2);
12950 let mut partition_ids = merged
12951 .partitions
12952 .iter()
12953 .map(|partition| partition.id())
12954 .collect::<Vec<_>>();
12955 partition_ids.sort_unstable();
12956 assert_eq!(partition_ids, vec![0, 1]);
12957
12958 Ok(())
12959 }
12960
12961 #[tokio::test]
12962 async fn test_modern_index_without_deleted_col_has_empty_bitmap() {
12963 let tmpdir = TempObjDir::default();
12967 let store = Arc::new(LanceIndexStore::new(
12968 ObjectStore::local().into(),
12969 tmpdir.clone(),
12970 Arc::new(LanceCache::no_cache()),
12971 ));
12972
12973 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12974 builder.tokens.add("test".to_owned());
12975 builder.posting_lists.push(PostingListBuilder::new(false));
12976 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
12977 builder.docs.append(100, 1);
12978 builder.write(store.as_ref()).await.unwrap();
12979
12980 let metadata = std::collections::HashMap::from_iter(vec![
12983 (
12984 "partitions".to_owned(),
12985 serde_json::to_string(&vec![0u64]).unwrap(),
12986 ),
12987 (
12988 "params".to_owned(),
12989 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
12990 ),
12991 (
12992 TOKEN_SET_FORMAT_KEY.to_owned(),
12993 TokenSetFormat::default().to_string(),
12994 ),
12995 ]);
12996 let mut writer = store
12997 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
12998 .await
12999 .unwrap();
13000 writer.finish_with_metadata(metadata).await.unwrap();
13001
13002 let index = InvertedIndex::load(store, None, &LanceCache::no_cache())
13003 .await
13004 .unwrap();
13005 assert!(
13006 index.deleted_fragments().is_empty(),
13007 "index without deleted_fragments column should have empty bitmap"
13008 );
13009 }
13010
13011 #[tokio::test]
13012 async fn flat_bm25_search_stream_with_metrics_records_elapsed_compute() {
13013 use crate::scalar::inverted::tokenizer::document_tokenizer::TextTokenizer;
13014 use arrow_array::{StringArray, UInt64Array};
13015 use lance_tokenizer::{SimpleTokenizer, TextAnalyzer};
13016
13017 let schema = Arc::new(Schema::new(vec![
13019 ROW_ID_FIELD.clone(),
13020 Field::new("text", DataType::Utf8, false),
13021 ]));
13022 let batch = RecordBatch::try_new(
13023 schema.clone(),
13024 vec![
13025 Arc::new(UInt64Array::from(vec![0u64, 1, 2, 3])),
13026 Arc::new(StringArray::from(vec![
13027 "the quick brown fox",
13028 "lazy dog sleeps",
13029 "the brown fox jumps over",
13030 "completely unrelated text",
13031 ])),
13032 ],
13033 )
13034 .unwrap();
13035
13036 let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
13037 schema.clone(),
13038 stream::iter(vec![Ok(batch)]),
13039 ));
13040
13041 let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
13042 TextAnalyzer::builder(SimpleTokenizer::default()).build(),
13043 ));
13044
13045 let elapsed_compute = Time::default();
13046 let result_stream = flat_bm25_search_stream_with_metrics(
13047 input,
13048 "text".to_string(),
13049 "fox".to_string(),
13050 tokenizer,
13051 None,
13052 100,
13053 Some(elapsed_compute.clone()),
13054 )
13055 .await
13056 .unwrap();
13057
13058 let batches: Vec<_> = result_stream.try_collect().await.unwrap();
13059 assert!(!batches.is_empty(), "expected at least one scored batch");
13060
13061 assert!(
13065 elapsed_compute.value() > 0,
13066 "elapsed_compute should have been populated; got 0"
13067 );
13068 }
13069
13070 #[tokio::test]
13071 async fn flat_bm25_skips_zero_token_documents_from_corpus_stats() {
13072 let schema = Arc::new(Schema::new(vec![
13073 ROW_ID_FIELD.clone(),
13074 Field::new("text", DataType::Utf8, true),
13075 ]));
13076 let batch = RecordBatch::try_new(
13077 schema,
13078 vec![
13079 Arc::new(UInt64Array::from(vec![0_u64, 1, 2, 3, 4, 5])) as ArrayRef,
13080 Arc::new(StringArray::from(vec![
13081 Some(""),
13082 Some(" "),
13083 Some("the"),
13084 Some("overlength"),
13085 None,
13086 Some("hello"),
13087 ])) as ArrayRef,
13088 ],
13089 )
13090 .unwrap();
13091 let params = InvertedIndexParams::new("whitespace".to_string(), Language::English)
13092 .remove_stop_words(true)
13093 .stem(false)
13094 .max_token_length(Some(6));
13095 let query_tokens = Arc::new(Tokens::new(vec!["hello".to_string()], DocType::Text));
13096
13097 let counted_input = tokenize_and_count(
13098 stream::iter(vec![Ok(batch)]),
13099 params.build().unwrap(),
13100 query_tokens.clone(),
13101 1,
13102 None,
13103 )
13104 .await
13105 .unwrap();
13106
13107 assert_eq!(counted_input.num_rows(), 1);
13108 assert_eq!(
13109 counted_input[ROW_ID].as_primitive::<UInt64Type>().values(),
13110 &[5]
13111 );
13112 let scorer = initialize_scorer(None, query_tokens.as_ref(), &counted_input);
13113 let expected_scorer = MemBM25Scorer::new(1, 1, HashMap::from([("hello".to_string(), 1)]));
13114 assert_eq!(scorer.total_tokens, 1);
13115 assert_eq!(scorer.num_docs(), 1);
13116 assert_eq!(scorer.num_docs_containing_token("hello"), 1);
13117 assert_eq!(scorer.avg_doc_length(), expected_scorer.avg_doc_length());
13118 assert_eq!(
13119 scorer.query_weight("hello"),
13120 expected_scorer.query_weight("hello")
13121 );
13122 }
13123
13124 #[tokio::test]
13125 async fn flat_bm25_search_uses_full_document_length_for_normalization() {
13126 let schema = Arc::new(Schema::new(vec![
13127 ROW_ID_FIELD.clone(),
13128 Field::new("text", DataType::Utf8, false),
13129 ]));
13130 let batch = RecordBatch::try_new(
13131 schema.clone(),
13132 vec![
13133 Arc::new(UInt64Array::from(vec![0u64, 1])),
13134 Arc::new(StringArray::from(vec![
13135 "alpha",
13136 "alpha filler filler filler filler filler filler filler filler filler",
13137 ])),
13138 ],
13139 )
13140 .unwrap();
13141
13142 let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
13143 schema.clone(),
13144 stream::iter(vec![Ok(batch)]),
13145 ));
13146 let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
13147 TextAnalyzer::builder(SimpleTokenizer::default()).build(),
13148 ));
13149
13150 let result_stream = flat_bm25_search_stream_with_metrics(
13151 input,
13152 "text".to_string(),
13153 "alpha".to_string(),
13154 tokenizer,
13155 None,
13156 100,
13157 None,
13158 )
13159 .await
13160 .unwrap();
13161 let batches: Vec<_> = result_stream.try_collect().await.unwrap();
13162 let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
13163 let row_ids = scored[ROW_ID].as_primitive::<UInt64Type>();
13164 let scores = scored[SCORE_COL].as_primitive::<Float32Type>();
13165
13166 assert_eq!(row_ids.values(), &[0, 1]);
13167 assert!(
13168 scores.value(0) > scores.value(1),
13169 "same term frequency should score shorter document higher; short={}, long={}",
13170 scores.value(0),
13171 scores.value(1)
13172 );
13173 }
13174
13175 #[tokio::test]
13176 async fn flat_bm25_search_treats_string_lists_as_row_documents() {
13177 let mut docs_builder =
13178 GenericListBuilder::<i32, _>::new(GenericStringBuilder::<i32>::new());
13179 docs_builder.values().append_value("alpha");
13180 docs_builder.values().append_value("alpha beta");
13181 docs_builder.append(true);
13182 docs_builder.values().append_value("beta");
13183 docs_builder.append(true);
13184 docs_builder.append(true);
13185 docs_builder.values().append_null();
13186 docs_builder.append(true);
13187 docs_builder.append(false);
13188
13189 let docs = Arc::new(docs_builder.finish()) as ArrayRef;
13190 let schema = Arc::new(Schema::new(vec![
13191 ROW_ID_FIELD.clone(),
13192 Field::new("text", docs.data_type().clone(), true),
13193 ]));
13194 let batch = RecordBatch::try_new(
13195 schema.clone(),
13196 vec![
13197 Arc::new(UInt64Array::from(vec![0u64, 1, 2, 3, 4])) as ArrayRef,
13198 docs,
13199 ],
13200 )
13201 .unwrap();
13202
13203 let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
13204 schema.clone(),
13205 stream::iter(vec![Ok(batch)]),
13206 ));
13207 let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
13208 TextAnalyzer::builder(SimpleTokenizer::default()).build(),
13209 ));
13210
13211 let result_stream = flat_bm25_search_stream_with_metrics(
13212 input,
13213 "text".to_string(),
13214 "alpha".to_string(),
13215 tokenizer,
13216 None,
13217 100,
13218 None,
13219 )
13220 .await
13221 .unwrap();
13222 let batches: Vec<_> = result_stream.try_collect().await.unwrap();
13223 let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
13224 let row_ids = scored[ROW_ID].as_primitive::<UInt64Type>();
13225
13226 assert_eq!(row_ids.values(), &[0]);
13227 }
13228
13229 #[tokio::test]
13230 async fn flat_bm25_search_code_and_uses_position_groups() {
13231 let schema = Arc::new(Schema::new(vec![
13232 ROW_ID_FIELD.clone(),
13233 Field::new("code", DataType::Utf8, false),
13234 ]));
13235 let batch = RecordBatch::try_new(
13236 schema.clone(),
13237 vec![
13238 Arc::new(UInt64Array::from(vec![0u64, 1, 2, 3])),
13239 Arc::new(StringArray::from(vec![
13240 "get user name",
13241 "getUserName",
13242 "get user",
13243 "username",
13244 ])),
13245 ],
13246 )
13247 .unwrap();
13248
13249 let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
13250 schema.clone(),
13251 stream::iter(vec![Ok(batch)]),
13252 ));
13253 let tokenizer = InvertedIndexParams::code()
13254 .split_identifiers(true)
13255 .build()
13256 .unwrap();
13257
13258 let result_stream = flat_bm25_search_stream_with_metrics_and_operator(
13259 input,
13260 "code".to_string(),
13261 "getUserName".to_string(),
13262 tokenizer,
13263 None,
13264 100,
13265 Operator::And,
13266 None,
13267 )
13268 .await
13269 .unwrap();
13270
13271 let batches: Vec<_> = result_stream.try_collect().await.unwrap();
13272 let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
13273 let mut row_ids = scored[ROW_ID]
13274 .as_primitive::<UInt64Type>()
13275 .values()
13276 .to_vec();
13277 row_ids.sort_unstable();
13278
13279 assert_eq!(row_ids, vec![0, 1]);
13280 }
13281
13282 #[tokio::test]
13283 async fn flat_bm25_search_code_and_counts_repeated_subwords() {
13284 let schema = Arc::new(Schema::new(vec![
13285 ROW_ID_FIELD.clone(),
13286 Field::new("code", DataType::Utf8, false),
13287 ]));
13288 let batch = RecordBatch::try_new(
13289 schema.clone(),
13290 vec![
13291 Arc::new(UInt64Array::from(vec![0u64, 1])),
13292 Arc::new(StringArray::from(vec![
13293 "pub fn edge_flat_generic_return<T>() -> Result<T, EdgeFlatError> where T: TryFrom<String> { todo!() }",
13294 "pub fn edge_flat_generic_return<T>() -> Result<T> { todo!() }",
13295 ])),
13296 ],
13297 )
13298 .unwrap();
13299
13300 let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
13301 schema.clone(),
13302 stream::iter(vec![Ok(batch)]),
13303 ));
13304 let tokenizer = InvertedIndexParams::code().build().unwrap();
13305
13306 let result_stream = flat_bm25_search_stream_with_metrics_and_operator(
13307 input,
13308 "code".to_string(),
13309 "edge_flat_generic_return TryFrom EdgeFlatError Result".to_string(),
13310 tokenizer,
13311 None,
13312 100,
13313 Operator::And,
13314 None,
13315 )
13316 .await
13317 .unwrap();
13318
13319 let batches: Vec<_> = result_stream.try_collect().await.unwrap();
13320 let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
13321 let row_ids = scored[ROW_ID].as_primitive::<UInt64Type>().values();
13322
13323 assert_eq!(row_ids, &[0]);
13324 }
13325
13326 fn posting_entries(posting: &PostingList) -> Vec<(u64, u32)> {
13327 posting.iter().map(|(doc, freq, _)| (doc, freq)).collect()
13328 }
13329
13330 #[tokio::test]
13331 async fn test_modern_posting_validation_is_cached_per_token() {
13332 let tmpdir = TempObjDir::default();
13333 let store = Arc::new(LanceIndexStore::new(
13334 ObjectStore::local().into(),
13335 tmpdir.clone(),
13336 Arc::new(LanceCache::no_cache()),
13337 ));
13338
13339 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13340 builder.tokens.add("term".to_owned());
13341 let mut valid_builder = PostingListBuilder::new(false);
13342 valid_builder.add(0, PositionRecorder::Count(1));
13343 builder.posting_lists.push(valid_builder);
13344 builder.docs.append(1000, 1);
13345 builder.write(store.as_ref()).await.unwrap();
13346
13347 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
13348 let mut posting_reader = PostingListReader::try_new(reader, &LanceCache::no_cache())
13349 .await
13350 .unwrap();
13351 posting_reader.modern_num_docs = Some(1);
13352 let validation = &posting_reader
13353 .modern_doc_id_validations
13354 .as_ref()
13355 .expect("modern readers have per-token validation state")[0];
13356 assert!(validation.get().is_none());
13357 assert!(!posting_reader.modern_posting_is_validated(0).unwrap());
13358
13359 let mut corrupt_builder = PostingListBuilder::new(false);
13360 corrupt_builder.add(1, PositionRecorder::Count(1));
13361 let corrupt_batch = corrupt_builder.to_batch(vec![1.0]).unwrap();
13362 let corrupt_posting = PostingList::from_batch(&corrupt_batch, Some(1.0), Some(1)).unwrap();
13363 let error = posting_reader
13364 .ensure_modern_posting_validated(0, &corrupt_posting)
13365 .await
13366 .unwrap_err();
13367 assert!(matches!(error, Error::Index { .. }));
13368 assert!(error.to_string().contains("DocId 1"));
13369 assert!(error.to_string().contains("[0, 1)"));
13370 assert!(validation.get().is_none());
13371 assert!(!posting_reader.modern_posting_is_validated(0).unwrap());
13372
13373 let first = posting_reader
13374 .posting_list(0, false, &NoOpMetricsCollector)
13375 .await
13376 .unwrap();
13377 assert_eq!(posting_entries(&first), vec![(0, 1)]);
13378 assert!(validation.get().is_some());
13379 assert!(posting_reader.modern_posting_is_validated(0).unwrap());
13380
13381 let second = posting_reader
13382 .posting_list(0, false, &NoOpMetricsCollector)
13383 .await
13384 .unwrap();
13385 assert_eq!(posting_entries(&second), vec![(0, 1)]);
13386 assert!(validation.get().is_some());
13387 }
13388
13389 #[tokio::test]
13392 async fn test_posting_list_synthetic_grouping_reads_group_boundaries() {
13393 let tmpdir = TempObjDir::default();
13394 let store = Arc::new(LanceIndexStore::new(
13395 ObjectStore::local().into(),
13396 tmpdir.clone(),
13397 Arc::new(LanceCache::no_cache()),
13398 ));
13399
13400 let num_tokens = runtime_posting_group_tokens() as u32 + 4;
13401 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13402 for t in 0..num_tokens {
13403 builder.tokens.add(format!("t{t}"));
13404 let mut pl = PostingListBuilder::new(false);
13405 pl.add(t, PositionRecorder::Count(1));
13406 builder.posting_lists.push(pl);
13407 builder.docs.append(1000 + t as u64, 1);
13408 }
13409 builder.write(store.as_ref()).await.unwrap();
13410
13411 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
13412 let cache = LanceCache::no_cache();
13413 let mut posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
13414 posting_reader.modern_num_docs = Some(num_tokens as usize);
13415 assert!(
13416 matches!(
13417 &posting_reader.grouping,
13418 PostingGrouping::SyntheticFixed { .. }
13419 ),
13420 "v2 reader must synthesize runtime posting groups",
13421 );
13422
13423 let metrics = NoOpMetricsCollector;
13424 for token in 0..num_tokens {
13425 let posting = posting_reader
13426 .posting_list(token, false, &metrics)
13427 .await
13428 .unwrap();
13429 assert_eq!(
13430 posting_entries(&posting),
13431 vec![(token as u64, 1)],
13432 "synthetic grouping mismatch for token {token}",
13433 );
13434 assert_eq!(posting.len(), 1, "length mismatch for token {token}");
13435 }
13436 }
13437
13438 #[tokio::test]
13444 async fn test_prewarm_group_keys_match_read_path() {
13445 let tmpdir = TempObjDir::default();
13446 let store = Arc::new(LanceIndexStore::new(
13447 ObjectStore::local().into(),
13448 tmpdir.clone(),
13449 Arc::new(LanceCache::no_cache()),
13450 ));
13451
13452 let num_tokens = runtime_posting_group_tokens() as u32 + 4;
13453 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13454 for t in 0..num_tokens {
13455 builder.tokens.add(format!("t{t}"));
13456 let mut pl = PostingListBuilder::new(false);
13457 pl.add(t, PositionRecorder::Count(1));
13458 builder.posting_lists.push(pl);
13459 builder.docs.append(1000 + t as u64, 1);
13460 }
13461 builder.write(store.as_ref()).await.unwrap();
13462
13463 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
13464 let cache = LanceCache::with_capacity(1 << 20);
13467 let mut posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
13468 posting_reader.modern_num_docs = Some(num_tokens as usize);
13469 assert!(
13470 matches!(
13471 &posting_reader.grouping,
13472 PostingGrouping::SyntheticFixed { .. }
13473 ),
13474 "v2 reader should use runtime synthetic groups",
13475 );
13476
13477 posting_reader
13478 .prewarm_posting_lists(false, 2)
13479 .await
13480 .unwrap();
13481 assert!(posting_reader.modern_posting_validation_ready());
13482 assert!(
13483 posting_reader
13484 .modern_postings_validated
13485 .load(Ordering::Acquire)
13486 );
13487
13488 for token in 0..num_tokens {
13489 let (start, end) = posting_reader.group_range_for_token(token).unwrap();
13490 assert!(
13491 posting_reader
13492 .index_cache
13493 .get_with_key(&posting_list_group_cache_key(
13494 start,
13495 end,
13496 posting_reader.has_impacts,
13497 ))
13498 .await
13499 .is_some(),
13500 "prewarm did not populate group [{start}, {end}) that the read \
13501 path requests for token {token}",
13502 );
13503 }
13504
13505 let (_, last_end) = posting_reader
13506 .group_range_for_token(num_tokens - 1)
13507 .unwrap();
13508 assert_eq!(
13509 last_end, num_tokens,
13510 "the last group must end at the posting count ({num_tokens})",
13511 );
13512 }
13513
13514 #[tokio::test]
13517 async fn test_empty_partition_has_no_synthetic_groups() {
13518 let tmpdir = TempObjDir::default();
13519 let store = Arc::new(LanceIndexStore::new(
13520 ObjectStore::local().into(),
13521 tmpdir.clone(),
13522 Arc::new(LanceCache::no_cache()),
13523 ));
13524
13525 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13526 builder.write(store.as_ref()).await.unwrap();
13527
13528 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
13529 let posting_reader = PostingListReader::try_new(reader, &LanceCache::no_cache())
13530 .await
13531 .unwrap();
13532 assert!(
13533 matches!(&posting_reader.grouping, PostingGrouping::None),
13534 "reader for an empty partition must not create cache groups",
13535 );
13536 assert!(posting_reader.is_empty());
13537 }
13538
13539 #[tokio::test]
13542 async fn test_large_posting_reads_inside_synthetic_group() {
13543 let tmpdir = TempObjDir::default();
13544 let store = Arc::new(LanceIndexStore::new(
13545 ObjectStore::local().into(),
13546 tmpdir.clone(),
13547 Arc::new(LanceCache::no_cache()),
13548 ));
13549
13550 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13551 let big_docs = (BLOCK_SIZE * 3 + 5) as u32;
13552 builder.tokens.add("big".to_owned());
13553 let mut big = PostingListBuilder::new(false);
13554 for d in 0..big_docs {
13555 big.add(d, PositionRecorder::Count(1));
13556 }
13557 builder.posting_lists.push(big);
13558 for t in 1..5u32 {
13559 builder.tokens.add(format!("t{t}"));
13560 let mut pl = PostingListBuilder::new(false);
13561 pl.add(0, PositionRecorder::Count(1));
13562 builder.posting_lists.push(pl);
13563 }
13564 for d in 0..big_docs as u64 {
13565 builder.docs.append(1000 + d, 1);
13566 }
13567 builder.write(store.as_ref()).await.unwrap();
13568
13569 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
13570 let posting_reader = PostingListReader::try_new(reader, &LanceCache::no_cache())
13571 .await
13572 .unwrap();
13573 let expected_end = runtime_posting_group_tokens().min(5) as u32;
13574
13575 assert_eq!(
13576 posting_reader.group_range_for_token(0),
13577 Some((0, expected_end)),
13578 "runtime synthetic grouping should group by token count, not posting bytes",
13579 );
13580 let big = posting_reader
13581 .posting_list(0, false, &NoOpMetricsCollector)
13582 .await
13583 .unwrap();
13584 assert_eq!(big.len(), big_docs as usize);
13585 let tiny = posting_reader
13587 .posting_list(2, false, &NoOpMetricsCollector)
13588 .await
13589 .unwrap();
13590 assert_eq!(tiny.len(), 1);
13591 }
13592
13593 #[tokio::test]
13597 async fn test_prewarm_synthetic_grouping_populates_group_entries() {
13598 let tmpdir = TempObjDir::default();
13599 let store = Arc::new(LanceIndexStore::new(
13600 ObjectStore::local().into(),
13601 tmpdir.clone(),
13602 Arc::new(LanceCache::no_cache()),
13603 ));
13604
13605 let num_tokens = 3u32;
13606 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13607 for t in 0..num_tokens {
13608 builder.tokens.add(format!("t{t}"));
13609 let mut pl = PostingListBuilder::new(false);
13610 pl.add(t, PositionRecorder::Count(1));
13611 builder.posting_lists.push(pl);
13612 builder.docs.append(1000 + t as u64, 1);
13613 }
13614 builder.write(store.as_ref()).await.unwrap();
13615
13616 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
13617 let cache = LanceCache::with_capacity(1 << 20);
13618 let posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
13619 assert!(matches!(
13620 &posting_reader.grouping,
13621 PostingGrouping::SyntheticFixed { .. }
13622 ));
13623
13624 posting_reader
13625 .prewarm_posting_lists(false, 2)
13626 .await
13627 .unwrap();
13628
13629 for token_id in 0..num_tokens {
13630 let (start, end) = posting_reader.group_range_for_token(token_id).unwrap();
13631 let group = posting_reader
13632 .index_cache
13633 .get_with_key(&posting_list_group_cache_key(
13634 start,
13635 end,
13636 posting_reader.has_impacts,
13637 ))
13638 .await
13639 .unwrap_or_else(|| {
13640 panic!(
13641 "synthetic prewarm should populate group [{start}, {end}) for token {token_id}"
13642 )
13643 });
13644 assert!(
13645 group.is_packed(),
13646 "no-position synthetic prewarm should insert a packed group"
13647 );
13648 assert!(
13649 posting_reader
13650 .index_cache
13651 .get_with_key(&posting_list_cache_key(
13652 token_id,
13653 posting_reader.has_impacts,
13654 ))
13655 .await
13656 .is_none(),
13657 "synthetic prewarm should not populate per-token entry {token_id}",
13658 );
13659 }
13660 }
13661
13662 #[tokio::test]
13666 async fn test_grouped_bm25_search_correct_and_cache_stable() {
13667 let tmpdir = TempObjDir::default();
13668 let store = Arc::new(LanceIndexStore::new(
13669 ObjectStore::local().into(),
13670 tmpdir.clone(),
13671 Arc::new(LanceCache::no_cache()),
13672 ));
13673
13674 let num_rare = runtime_posting_group_tokens() as u32 + 2;
13678 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13679 for t in 0..num_rare {
13680 builder.tokens.add(format!("t{t}"));
13681 builder.posting_lists.push(PostingListBuilder::new(false));
13682 }
13683 let common_id = builder.tokens.add("common".to_owned());
13684 builder.posting_lists.push(PostingListBuilder::new(false));
13685 for d in 0..num_rare {
13686 builder.posting_lists[d as usize].add(d, PositionRecorder::Count(1));
13687 builder.posting_lists[common_id as usize].add(d, PositionRecorder::Count(1));
13688 builder.docs.append(1000 + d as u64, 2);
13689 }
13690 builder.write(store.as_ref()).await.unwrap();
13691
13692 let metadata = HashMap::from([
13693 (
13694 "partitions".to_owned(),
13695 serde_json::to_string(&vec![0u64]).unwrap(),
13696 ),
13697 (
13698 "params".to_owned(),
13699 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
13700 ),
13701 (
13702 TOKEN_SET_FORMAT_KEY.to_owned(),
13703 TokenSetFormat::default().to_string(),
13704 ),
13705 ]);
13706 let mut writer = store
13707 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
13708 .await
13709 .unwrap();
13710 writer.finish_with_metadata(metadata).await.unwrap();
13711
13712 let cache = Arc::new(LanceCache::with_capacity(1 << 20));
13713 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
13714 .await
13715 .unwrap();
13716
13717 let query = |term: &str| {
13719 let index = index.clone();
13720 let term = term.to_string();
13721 async move {
13722 index
13723 .bm25_search(
13724 Arc::new(Tokens::new(vec![term], DocType::Text)),
13725 Arc::new(FtsSearchParams::new().with_limit(Some(num_rare as usize))),
13726 Operator::Or,
13727 Arc::new(NoFilter),
13728 Arc::new(NoOpMetricsCollector),
13729 None,
13730 )
13731 .await
13732 .unwrap()
13733 }
13734 };
13735
13736 let rare_query_id = num_rare / 2;
13737 let (rare_rows, _) = query(&format!("t{rare_query_id}")).await;
13738 assert_eq!(
13739 rare_rows,
13740 vec![1000 + rare_query_id as u64],
13741 "rare token must map to its single doc",
13742 );
13743
13744 let (cold_rows, cold_scores) = query("common").await;
13746 let (warm_rows, warm_scores) = query("common").await;
13747 assert_eq!(cold_rows.len(), num_rare as usize);
13748 assert_eq!(cold_rows, warm_rows, "warm-cache rows must match cold");
13749 assert_eq!(
13750 cold_scores, warm_scores,
13751 "warm-cache scores must match cold"
13752 );
13753 }
13754
13755 #[tokio::test]
13756 async fn flat_bm25_search_stop_word_query_over_unindexed_rows_returns_empty() {
13757 let schema = Arc::new(Schema::new(vec![
13758 ROW_ID_FIELD.clone(),
13759 Field::new("text", DataType::Utf8, false),
13760 ]));
13761 let batch = RecordBatch::try_new(
13762 schema.clone(),
13763 vec![
13764 Arc::new(UInt64Array::from(vec![0u64, 1, 2])),
13765 Arc::new(StringArray::from(vec![
13766 "the quick brown fox",
13767 "a lazy dog",
13768 "for the win",
13769 ])),
13770 ],
13771 )
13772 .unwrap();
13773
13774 let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
13775 schema.clone(),
13776 stream::iter(vec![Ok(batch)]),
13777 ));
13778
13779 let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
13782 TextAnalyzer::builder(SimpleTokenizer::default())
13783 .filter(StopWordFilter::new(Language::English).unwrap())
13784 .build(),
13785 ));
13786
13787 let result_stream = flat_bm25_search_stream_with_metrics(
13788 input,
13789 "text".to_string(),
13790 "the".to_string(),
13791 tokenizer,
13792 None,
13793 100,
13794 None,
13795 )
13796 .await
13797 .unwrap();
13798
13799 let batches: Vec<_> = result_stream.try_collect().await.unwrap();
13800 let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
13801 assert_eq!(
13802 total_rows, 0,
13803 "a stop-word-only query has no searchable terms and must match nothing"
13804 );
13805 }
13806}