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 arrow::array::{FixedSizeListBuilder, Float32Builder, Int32Builder};
22use arrow::datatypes::{self, Float32Type, Int32Type, UInt64Type};
23use arrow::{
24 array::{
25 AsArray, LargeBinaryBuilder, ListBuilder, StringBuilder, UInt32Builder, UInt64Builder,
26 },
27 buffer::{Buffer, OffsetBuffer},
28};
29use arrow::{buffer::ScalarBuffer, datatypes::UInt32Type};
30use arrow_array::{
31 Array, ArrayRef, Float32Array, LargeBinaryArray, ListArray, OffsetSizeTrait, RecordBatch,
32 UInt32Array, UInt64Array,
33};
34use arrow_schema::{DataType, Field, Schema, SchemaRef};
35use async_trait::async_trait;
36use datafusion::execution::SendableRecordBatchStream;
37use datafusion::physical_plan::metrics::Time;
38use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
39use fst::{Automaton, IntoStreamer, Streamer};
40use futures::{FutureExt, Stream, StreamExt, TryStreamExt, stream};
41use itertools::{Either, Itertools};
42use lance_arrow::{RecordBatchExt, iter_str_array};
43use lance_core::cache::{CacheCodec, CacheKey, LanceCache, WeakLanceCache};
44use lance_core::deepsize::DeepSizeOf;
45use lance_core::error::{DataFusionResult, LanceOptionExt};
46use lance_core::utils::address::RowAddress;
47use lance_core::utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu};
48use lance_core::utils::tracing::{IO_TYPE_LOAD_SCALAR_PART, TRACE_IO_EVENTS};
49use lance_core::{Error, ROW_ID, ROW_ID_FIELD, Result};
50use lance_select::{RowAddrMask, RowAddrTreeMap};
51use roaring::RoaringBitmap;
52use std::sync::LazyLock;
53use tokio::{sync::OnceCell, task::spawn_blocking};
54use tracing::{info, instrument, warn};
55
56use super::encoding::{MAX_POSTING_BLOCK_SIZE, PositionBlockBuilder};
57use super::impact::{IMPACT_LEVEL1_BLOCKS, ImpactSkipData, ImpactSkipDataBuilder};
58use super::iter::PostingListIterator;
59use super::lazy_docset::LazyDocSet;
60use super::tokenizer::{LEGACY_BLOCK_SIZE, validate_block_size};
61use super::{InvertedIndexBuilder, InvertedIndexParams, wand::*};
62use super::{
63 builder::{
64 BLOCK_SIZE, ScoredDoc, doc_file_path,
65 inverted_list_schema_for_version_with_block_size_and_impacts, posting_file_path,
66 token_file_path,
67 },
68 iter::PlainPostingListIterator,
69 query::*,
70 scorer::{B, IndexBM25Scorer, K1, Scorer, idf},
71};
72use super::{
73 builder::{InnerBuilder, PositionRecorder},
74 iter::CompressedPostingListIterator,
75};
76use crate::pbold;
77use crate::progress::IndexBuildProgress;
78use crate::scalar::inverted::scorer::MemBM25Scorer;
79use crate::scalar::inverted::tokenizer::document_tokenizer::LanceTokenizer;
80use crate::scalar::{
81 AnyQuery, BuiltinIndexType, CreatedIndex, IndexReader, IndexStore, MetricsCollector,
82 OldIndexDataFilter, RowIdRemapper, ScalarIndex, ScalarIndexParams, SearchResult, TokenQuery,
83 UpdateCriteria,
84};
85use crate::{FtsPrewarmOptions, Index};
86use crate::{prefilter::PreFilter, scalar::inverted::iter::take_fst_keys};
87use std::str::FromStr;
88
89pub const INVERTED_INDEX_VERSION_V1: u32 = 1;
94pub const INVERTED_INDEX_VERSION_V2: u32 = 2;
95pub const INVERTED_INDEX_VERSION_V3: u32 = 3;
96pub const TOKENS_FILE: &str = "tokens.lance";
97pub const INVERT_LIST_FILE: &str = "invert.lance";
98pub const DOCS_FILE: &str = "docs.lance";
99pub const METADATA_FILE: &str = "metadata.lance";
100
101pub const TOKEN_COL: &str = "_token";
102pub const TOKEN_ID_COL: &str = "_token_id";
103pub const TOKEN_FST_BYTES_COL: &str = "_token_fst_bytes";
104pub const TOKEN_NEXT_ID_COL: &str = "_token_next_id";
105pub const TOKEN_TOTAL_LENGTH_COL: &str = "_token_total_length";
106pub const FREQUENCY_COL: &str = "_frequency";
107pub const POSITION_COL: &str = "_position";
108pub const COMPRESSED_POSITION_COL: &str = "_compressed_position";
109pub const POSITION_BLOCK_OFFSET_COL: &str = "_position_block_offset";
110pub const POSTING_COL: &str = "_posting";
111pub const IMPACT_COL: &str = "_impacts";
112pub const MAX_SCORE_COL: &str = "_max_score";
113pub const LENGTH_COL: &str = "_length";
114pub const BLOCK_MAX_SCORE_COL: &str = "_block_max_score";
115pub const NUM_TOKEN_COL: &str = "_num_tokens";
116pub const SCORE_COL: &str = "_score";
117pub const TOKEN_SET_FORMAT_KEY: &str = "token_set_format";
118pub const POSTING_TAIL_CODEC_KEY: &str = "posting_tail_codec";
119pub const FTS_FORMAT_VERSION_KEY: &str = "format_version";
120pub const POSITIONS_LAYOUT_KEY: &str = "positions_layout";
121pub const POSITIONS_CODEC_KEY: &str = "positions_codec";
122pub const POSTING_BLOCK_SIZE_KEY: &str = "posting_block_size";
123pub const POSTING_TAIL_CODEC_FIXED32_V1: &str = "fixed32_v1";
124pub const POSTING_TAIL_CODEC_VARINT_DELTA_V1: &str = "varint_delta_v1";
125pub const POSITIONS_LAYOUT_SHARED_STREAM_V2: &str = "shared_stream_v2";
126pub const POSITIONS_CODEC_VARINT_DOC_DELTA_V2: &str = "varint_doc_delta_v2";
127pub const POSITIONS_CODEC_PACKED_DELTA_V1: &str = "packed_delta_v1";
128pub const DELETED_FRAGMENTS_COL: &str = "deleted_fragments";
129
130pub const ESTIMATED_MAX_TOKENS_PER_ROW: usize = 4 * 1024;
132
133pub static SCORE_FIELD: LazyLock<Field> =
134 LazyLock::new(|| Field::new(SCORE_COL, DataType::Float32, true));
135pub static FTS_SCHEMA: LazyLock<SchemaRef> =
136 LazyLock::new(|| Arc::new(Schema::new(vec![ROW_ID_FIELD.clone(), SCORE_FIELD.clone()])));
137static ROW_ID_SCHEMA: LazyLock<SchemaRef> =
138 LazyLock::new(|| Arc::new(Schema::new(vec![ROW_ID_FIELD.clone()])));
139
140pub fn resolve_fts_format_version(
141 value: Option<&str>,
142) -> std::result::Result<InvertedListFormatVersion, Error> {
143 match value {
144 Some(value) => value.parse(),
145 None => Ok(default_fts_format_version()),
146 }
147}
148
149pub fn default_fts_format_version() -> InvertedListFormatVersion {
150 InvertedListFormatVersion::V2
151}
152
153pub fn current_fts_format_version() -> InvertedListFormatVersion {
154 default_fts_format_version()
155}
156
157pub fn max_supported_fts_format_version() -> InvertedListFormatVersion {
158 InvertedListFormatVersion::V3
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
162pub enum InvertedListFormatVersion {
163 V1,
164 #[default]
165 V2,
166 V3,
167}
168
169impl InvertedListFormatVersion {
170 pub fn from_posting_tail_codec(codec: PostingTailCodec) -> Self {
171 match codec {
172 PostingTailCodec::Fixed32 => Self::V1,
173 PostingTailCodec::VarintDelta => Self::V2,
174 }
175 }
176
177 pub fn from_posting_tail_codec_and_block_size(
178 codec: PostingTailCodec,
179 block_size: usize,
180 ) -> Result<Self> {
181 validate_block_size(block_size)?;
182 let format_version = match (codec, block_size) {
183 (PostingTailCodec::Fixed32, LEGACY_BLOCK_SIZE) => Self::V1,
184 (PostingTailCodec::VarintDelta, LEGACY_BLOCK_SIZE) => Self::V2,
185 (PostingTailCodec::VarintDelta, 256) => Self::V3,
186 (PostingTailCodec::Fixed32, 256) => {
187 return Err(Error::invalid_input(
188 "FTS format_version=3 requires the varint-delta posting tail codec".to_string(),
189 ));
190 }
191 _ => unreachable!("validate_block_size limits supported block sizes"),
192 };
193 validate_format_version_block_size(format_version, block_size)?;
194 Ok(format_version)
195 }
196
197 pub fn index_version(self) -> u32 {
198 match self {
199 Self::V1 => INVERTED_INDEX_VERSION_V1,
200 Self::V2 => INVERTED_INDEX_VERSION_V2,
201 Self::V3 => INVERTED_INDEX_VERSION_V3,
202 }
203 }
204
205 pub fn posting_tail_codec(self) -> PostingTailCodec {
206 match self {
207 Self::V1 => PostingTailCodec::Fixed32,
208 Self::V2 | Self::V3 => PostingTailCodec::VarintDelta,
209 }
210 }
211
212 pub fn position_codec(self) -> Option<PositionStreamCodec> {
213 match self {
214 Self::V1 => None,
215 Self::V2 | Self::V3 => Some(PositionStreamCodec::PackedDelta),
216 }
217 }
218
219 pub fn uses_shared_position_stream(self) -> bool {
220 matches!(self, Self::V2 | Self::V3)
221 }
222}
223
224impl FromStr for InvertedListFormatVersion {
225 type Err = Error;
226
227 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
228 match s.trim() {
229 "1" | "v1" | "V1" => Ok(Self::V1),
230 "2" | "v2" | "V2" => Ok(Self::V2),
231 "3" | "v3" | "V3" => Ok(Self::V3),
232 other => Err(Error::index(format!(
233 "unsupported FTS format version {}, expected 1, 2, or 3",
234 other
235 ))),
236 }
237 }
238}
239
240pub fn default_fts_format_version_for_block_size(
241 block_size: usize,
242) -> Result<InvertedListFormatVersion> {
243 validate_block_size(block_size)?;
244 match block_size {
245 LEGACY_BLOCK_SIZE => Ok(InvertedListFormatVersion::V2),
246 256 => Ok(InvertedListFormatVersion::V3),
247 _ => unreachable!("validate_block_size limits supported block sizes"),
248 }
249}
250
251pub fn validate_format_version_block_size(
252 format_version: InvertedListFormatVersion,
253 block_size: usize,
254) -> Result<()> {
255 validate_block_size(block_size)?;
256 match (format_version, block_size) {
257 (InvertedListFormatVersion::V1 | InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)
258 | (InvertedListFormatVersion::V3, _) => Ok(()),
259 (InvertedListFormatVersion::V1 | InvertedListFormatVersion::V2, 256) => {
260 Err(Error::invalid_input(format!(
261 "FTS format_version={} is incompatible with block_size=256; use format_version=3",
262 format_version.index_version()
263 )))
264 }
265 _ => unreachable!("validate_block_size limits supported block sizes"),
266 }
267}
268
269#[derive(Debug)]
270struct PartitionCandidates {
271 tokens_by_position: Vec<String>,
272 grouped_expansions: Vec<GroupedExpansionTerms>,
273 candidates: Vec<DocCandidate>,
274}
275
276impl PartitionCandidates {
277 fn empty() -> Self {
278 Self {
279 tokens_by_position: Vec::new(),
280 grouped_expansions: Vec::new(),
281 candidates: Vec::new(),
282 }
283 }
284}
285
286#[derive(Debug)]
287struct LoadedPostings {
288 postings: Vec<PostingIterator>,
289 grouped_expansions: Vec<GroupedExpansionTerms>,
290 impact_safe: bool,
291 exact_scoring_required: bool,
292}
293
294impl LoadedPostings {
295 fn empty() -> Self {
296 Self {
297 postings: Vec::new(),
298 grouped_expansions: Vec::new(),
299 impact_safe: false,
300 exact_scoring_required: false,
301 }
302 }
303}
304
305#[derive(Debug)]
306struct GroupedExpansionTerms {
307 position: u32,
308 terms: Arc<[GroupedTermScorer]>,
309}
310
311#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)]
312pub enum TokenSetFormat {
313 Arrow,
314 #[default]
315 Fst,
316}
317
318impl Display for TokenSetFormat {
319 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320 match self {
321 Self::Arrow => f.write_str("arrow"),
322 Self::Fst => f.write_str("fst"),
323 }
324 }
325}
326
327impl FromStr for TokenSetFormat {
328 type Err = Error;
329
330 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
331 match s.trim() {
332 "" => Ok(Self::Arrow),
333 "arrow" => Ok(Self::Arrow),
334 "fst" => Ok(Self::Fst),
335 other => Err(Error::index(format!(
336 "unsupported token set format {}",
337 other
338 ))),
339 }
340 }
341}
342
343impl DeepSizeOf for TokenSetFormat {
344 fn deep_size_of_children(&self, _: &mut lance_core::deepsize::Context) -> usize {
345 0
346 }
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
350pub enum PositionStreamCodec {
351 VarintDocDelta,
352 #[default]
353 PackedDelta,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
357pub enum PostingTailCodec {
358 Fixed32,
359 #[default]
360 VarintDelta,
361}
362
363impl PostingTailCodec {
364 pub fn as_str(self) -> &'static str {
365 match self {
366 Self::Fixed32 => POSTING_TAIL_CODEC_FIXED32_V1,
367 Self::VarintDelta => POSTING_TAIL_CODEC_VARINT_DELTA_V1,
368 }
369 }
370
371 fn from_metadata_value(value: &str) -> Result<Self> {
372 match value.trim() {
373 POSTING_TAIL_CODEC_FIXED32_V1 => Ok(Self::Fixed32),
374 POSTING_TAIL_CODEC_VARINT_DELTA_V1 => Ok(Self::VarintDelta),
375 other => Err(Error::index(format!(
376 "unsupported posting tail codec {}",
377 other
378 ))),
379 }
380 }
381}
382
383pub(super) fn parse_posting_tail_codec(
384 metadata: &HashMap<String, String>,
385) -> Result<PostingTailCodec> {
386 Ok(metadata
387 .get(POSTING_TAIL_CODEC_KEY)
388 .map(|codec| PostingTailCodec::from_metadata_value(codec))
389 .transpose()?
390 .unwrap_or(PostingTailCodec::Fixed32))
391}
392
393pub(super) fn parse_posting_block_size(metadata: &HashMap<String, String>) -> Result<usize> {
394 metadata
395 .get(POSTING_BLOCK_SIZE_KEY)
396 .map(|value| {
397 let block_size = value.parse::<usize>().map_err(|err| {
398 Error::index(format!(
399 "invalid {POSTING_BLOCK_SIZE_KEY} metadata value {value:?}: {err}"
400 ))
401 })?;
402 validate_block_size(block_size)
403 })
404 .transpose()
405 .map(|block_size| block_size.unwrap_or(LEGACY_BLOCK_SIZE))
406}
407
408impl PositionStreamCodec {
409 pub fn as_str(self) -> &'static str {
410 match self {
411 Self::VarintDocDelta => POSITIONS_CODEC_VARINT_DOC_DELTA_V2,
412 Self::PackedDelta => POSITIONS_CODEC_PACKED_DELTA_V1,
413 }
414 }
415
416 fn from_metadata_value(value: &str) -> Result<Self> {
417 match value.trim() {
418 POSITIONS_CODEC_VARINT_DOC_DELTA_V2 => Ok(Self::VarintDocDelta),
419 POSITIONS_CODEC_PACKED_DELTA_V1 => Ok(Self::PackedDelta),
420 other => Err(Error::index(format!(
421 "unsupported positions codec {}",
422 other
423 ))),
424 }
425 }
426}
427
428fn parse_shared_position_codec(metadata: &HashMap<String, String>) -> Result<PositionStreamCodec> {
429 if let Some(codec) = metadata.get(POSITIONS_CODEC_KEY) {
430 return PositionStreamCodec::from_metadata_value(codec);
431 }
432
433 match metadata
434 .get(POSITIONS_LAYOUT_KEY)
435 .map(|layout| layout.as_str())
436 {
437 Some(POSITIONS_LAYOUT_SHARED_STREAM_V2) => Ok(PositionStreamCodec::VarintDocDelta),
438 _ => Ok(PositionStreamCodec::VarintDocDelta),
439 }
440}
441
442pub(super) fn parse_format_version_from_metadata(
443 metadata: &HashMap<String, String>,
444) -> Result<InvertedListFormatVersion> {
445 if let Some(value) = metadata.get(FTS_FORMAT_VERSION_KEY) {
446 let format_version = InvertedListFormatVersion::from_str(value)?;
447 let block_size = parse_posting_block_size(metadata)?;
448 validate_format_version_block_size(format_version, block_size)?;
449 return Ok(format_version);
450 }
451 let block_size = parse_posting_block_size(metadata)?;
452 if block_size == 256 {
453 if metadata
454 .get(POSTING_TAIL_CODEC_KEY)
455 .map(|_| parse_posting_tail_codec(metadata))
456 .transpose()?
457 .is_some_and(|posting_tail_codec| posting_tail_codec != PostingTailCodec::VarintDelta)
458 {
459 return Err(Error::index(
460 "FTS block_size=256 requires the varint-delta posting tail codec".to_string(),
461 ));
462 }
463 return Ok(InvertedListFormatVersion::V3);
464 }
465 if metadata.contains_key(POSITIONS_CODEC_KEY) || metadata.contains_key(POSITIONS_LAYOUT_KEY) {
466 return Ok(InvertedListFormatVersion::V2);
467 }
468 if parse_posting_tail_codec(metadata)? == PostingTailCodec::VarintDelta {
469 Ok(InvertedListFormatVersion::V2)
470 } else {
471 Ok(InvertedListFormatVersion::V1)
472 }
473}
474
475#[derive(Clone)]
476pub struct InvertedIndex {
477 params: InvertedIndexParams,
478 store: Arc<dyn IndexStore>,
479 tokenizer: Box<dyn LanceTokenizer>,
480 token_set_format: TokenSetFormat,
481 format_version: InvertedListFormatVersion,
482 pub(crate) partitions: Vec<Arc<InvertedPartition>>,
483 corpus_stats: Arc<OnceCell<(u64, usize)>>,
484 deleted_fragments: RoaringBitmap,
487}
488
489impl Debug for InvertedIndex {
490 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
491 f.debug_struct("InvertedIndex")
492 .field("params", &self.params)
493 .field("token_set_format", &self.token_set_format)
494 .field("format_version", &self.format_version)
495 .field("partitions", &self.partitions)
496 .field("deleted_fragments", &self.deleted_fragments)
497 .finish()
498 }
499}
500
501impl DeepSizeOf for InvertedIndex {
502 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
503 self.partitions.deep_size_of_children(context)
504 }
505}
506
507async fn resolve_deferred_candidates(
511 docs: &LazyDocSet,
512 candidates: &mut [DocCandidate],
513) -> Result<()> {
514 let pending: Vec<u32> = candidates
515 .iter()
516 .filter_map(|c| match c.addr {
517 CandidateAddr::Pending(d) => Some(d),
518 CandidateAddr::RowId(_) => None,
519 })
520 .collect();
521 if pending.is_empty() {
522 return Ok(());
523 }
524 let mut iter = docs.resolve_row_ids(&pending).await?.into_iter();
525 for c in candidates {
526 if matches!(c.addr, CandidateAddr::Pending(_)) {
527 let r = iter.next().ok_or_else(|| {
528 Error::internal("resolve_row_ids returned fewer items than requested")
529 })?;
530 c.addr = CandidateAddr::RowId(r);
531 }
532 }
533 Ok(())
534}
535
536impl InvertedIndex {
537 fn format_version(&self) -> InvertedListFormatVersion {
538 self.format_version
539 }
540
541 fn index_version(&self) -> u32 {
542 match (self.token_set_format, self.format_version()) {
543 (
544 TokenSetFormat::Arrow,
545 InvertedListFormatVersion::V1 | InvertedListFormatVersion::V2,
546 ) => 0,
547 (_, format_version) => format_version.index_version(),
548 }
549 }
550
551 fn posting_tail_codec(&self) -> PostingTailCodec {
552 self.partitions
553 .first()
554 .map(|partition| partition.inverted_list.posting_tail_codec())
555 .unwrap_or_default()
556 }
557
558 fn to_builder(&self) -> InvertedIndexBuilder {
559 self.to_builder_with_offset(None)
560 }
561
562 fn to_builder_with_offset(&self, fragment_mask: Option<u64>) -> InvertedIndexBuilder {
563 if self.is_legacy() {
564 InvertedIndexBuilder::from_existing_index(
566 self.params.clone(),
567 None,
568 Vec::new(),
569 self.token_set_format,
570 fragment_mask,
571 self.deleted_fragments.clone(),
572 )
573 .with_posting_tail_codec(self.posting_tail_codec())
574 } else {
575 let partitions = match fragment_mask {
576 Some(fragment_mask) => self
577 .partitions
578 .iter()
579 .filter(|part| part.belongs_to_fragment(fragment_mask))
583 .map(|part| part.id())
584 .collect(),
585 None => self.partitions.iter().map(|part| part.id()).collect(),
586 };
587
588 InvertedIndexBuilder::from_existing_index(
589 self.params.clone(),
590 Some(self.store.clone()),
591 partitions,
592 self.token_set_format,
593 fragment_mask,
594 self.deleted_fragments.clone(),
595 )
596 .with_format_version(self.format_version())
597 }
598 }
599
600 pub fn tokenizer(&self) -> Box<dyn LanceTokenizer> {
601 self.tokenizer.clone()
602 }
603
604 pub fn params(&self) -> &InvertedIndexParams {
605 &self.params
606 }
607
608 pub fn partition_count(&self) -> usize {
610 self.partitions.len()
611 }
612 pub fn deleted_fragments(&self) -> &RoaringBitmap {
618 &self.deleted_fragments
619 }
620
621 pub async fn merge_segments(
622 segments: &[Arc<Self>],
623 new_data: SendableRecordBatchStream,
624 dest_store: &dyn IndexStore,
625 old_data_filter: Option<OldIndexDataFilter>,
626 progress: Arc<dyn IndexBuildProgress>,
627 ) -> Result<CreatedIndex> {
628 let Some(first) = segments.first() else {
629 return Err(Error::invalid_input(
630 "cannot merge inverted index without at least one source segment".to_string(),
631 ));
632 };
633
634 for segment in segments.iter().skip(1) {
635 if segment.params != first.params {
636 return Err(Error::index(
637 "cannot merge inverted index segments with different parameters".to_string(),
638 ));
639 }
640 if segment.token_set_format != first.token_set_format {
641 return Err(Error::index(
642 "cannot merge inverted index segments with different token set formats"
643 .to_string(),
644 ));
645 }
646 if segment.format_version() != first.format_version() {
647 return Err(Error::index(
648 "cannot merge inverted index segments with different format versions"
649 .to_string(),
650 ));
651 }
652 if segment.posting_tail_codec() != first.posting_tail_codec() {
653 return Err(Error::index(
654 "cannot merge inverted index segments with different posting tail codecs"
655 .to_string(),
656 ));
657 }
658 }
659
660 let mut builder = InvertedIndexBuilder::new(first.params.clone()).with_progress(progress);
661 builder = builder
662 .with_token_set_format(first.token_set_format)
663 .with_format_version(first.format_version());
664 let files = builder
665 .update_from_segments(new_data, dest_store, segments, old_data_filter)
666 .await?;
667
668 let details = pbold::InvertedIndexDetails::try_from(&first.params)?;
669
670 Ok(CreatedIndex {
671 index_details: prost_types::Any::from_msg(&details).unwrap(),
672 index_version: first.index_version(),
673 files,
674 })
675 }
676
677 pub async fn bm25_base_scorer(
684 &self,
685 query_tokens: &Tokens,
686 params: &FtsSearchParams,
687 ) -> Result<MemBM25Scorer> {
688 if matches!(params.fuzziness, Some(n) if n != 0) {
689 let expanded = self.expand_fuzzy_tokens(query_tokens, params)?;
690 self.bm25_scorer_for_final_tokens(&expanded).await
691 } else {
692 self.bm25_scorer_for_final_tokens(query_tokens).await
693 }
694 }
695
696 async fn bm25_scorer_for_final_tokens(&self, tokens: &Tokens) -> Result<MemBM25Scorer> {
701 let (total_tokens, num_docs) = self.aggregate_corpus_stats().await?;
702 let mut terms: Vec<String> = Vec::new();
703 let mut seen = HashSet::new();
704 for token in tokens {
705 if seen.insert(token.to_string()) {
706 terms.push(token.to_string());
707 }
708 }
709 let mut token_docs = HashMap::with_capacity(terms.len());
710 for term in &terms {
711 let df = self.df_for_term(term).await?;
712 token_docs.insert(term.clone(), df);
713 }
714 Ok(MemBM25Scorer::new(total_tokens, num_docs, token_docs))
715 }
716
717 pub async fn bm25_stats_for_terms(&self, terms: &[String]) -> Result<(u64, usize, Vec<usize>)> {
718 let (total_tokens, num_docs) = self.aggregate_corpus_stats().await?;
719 let token_docs =
720 futures::future::try_join_all(terms.iter().map(|term| self.df_for_term(term))).await?;
721 Ok((total_tokens, num_docs, token_docs))
722 }
723
724 async fn aggregate_corpus_stats(&self) -> Result<(u64, usize)> {
730 self.corpus_stats
731 .get_or_try_init(|| async {
732 let io_parallelism = self.store.io_parallelism();
733 let num_docs: usize = self.partitions.iter().map(|p| p.docs.len()).sum();
734 let futures = self
735 .partitions
736 .iter()
737 .map(|p| {
738 let docs = p.docs.clone();
739 async move { docs.total_tokens_num().await }
740 })
741 .collect::<Vec<_>>();
742 let totals: Vec<u64> = stream::iter(futures)
743 .buffer_unordered(io_parallelism)
744 .try_collect()
745 .await?;
746 Ok((totals.into_iter().sum(), num_docs))
747 })
748 .await
749 .copied()
750 }
751
752 async fn df_for_term(&self, term: &str) -> Result<usize> {
756 let io_parallelism = self.store.io_parallelism();
757 let futures = self
758 .partitions
759 .iter()
760 .map(|part| {
761 let part = part.clone();
762 async move {
763 match part.tokens.get(term) {
764 Some(token_id) => part.inverted_list.posting_len_for_token(token_id).await,
765 None => Ok(0),
766 }
767 }
768 })
769 .collect::<Vec<_>>();
770 let dfs: Vec<usize> = stream::iter(futures)
771 .buffer_unordered(io_parallelism)
772 .try_collect()
773 .await?;
774 Ok(dfs.into_iter().sum())
775 }
776
777 pub fn expand_fuzzy_tokens(&self, tokens: &Tokens, params: &FtsSearchParams) -> Result<Tokens> {
787 let mut expanded_tokens = Vec::new();
788 let mut expanded_positions = Vec::new();
789 let mut seen = HashSet::new();
790 for token_idx in 0..tokens.len() {
791 let remaining = params.max_expansions.saturating_sub(expanded_tokens.len());
792 if remaining == 0 {
793 break;
794 }
795 let token = tokens.get_token(token_idx);
796 let position = tokens.position(token_idx);
797 let mut candidates = BTreeSet::new();
802 let base_prefix_len = tokens.token_type().prefix_len(token) as u32;
803 for partition in &self.partitions {
804 partition.collect_fuzzy_candidates(
805 token,
806 base_prefix_len,
807 params,
808 remaining,
809 &mut candidates,
810 )?;
811 }
812 for candidate in candidates {
813 if expanded_tokens.len() >= params.max_expansions {
814 break;
815 }
816 if seen.insert((candidate.clone(), position)) {
817 expanded_tokens.push(candidate);
818 expanded_positions.push(position);
819 }
820 }
821 }
822 Ok(Tokens::with_positions(
823 expanded_tokens,
824 expanded_positions,
825 tokens.token_type().clone(),
826 ))
827 }
828
829 #[instrument(level = "debug", skip_all)]
834 pub async fn bm25_search(
835 &self,
836 tokens: Arc<Tokens>,
837 params: Arc<FtsSearchParams>,
838 operator: Operator,
839 prefilter: Arc<dyn PreFilter>,
840 metrics: Arc<dyn MetricsCollector>,
841 base_scorer: Option<&MemBM25Scorer>,
842 ) -> Result<(Vec<u64>, Vec<f32>)> {
843 let tokens = if matches!(params.fuzziness, Some(n) if n != 0) {
848 let expanded = Arc::new(self.expand_fuzzy_tokens(tokens.as_ref(), params.as_ref())?);
849 if operator == Operator::And || params.phrase_slop.is_some() {
850 let surviving = (0..expanded.len())
854 .map(|idx| expanded.position(idx))
855 .collect::<HashSet<_>>();
856 if (0..tokens.len()).any(|idx| !surviving.contains(&tokens.position(idx))) {
857 return Ok((Vec::new(), Vec::new()));
858 }
859 }
860 expanded
861 } else {
862 tokens
863 };
864
865 let local_scorer;
870 let scorer: &MemBM25Scorer = if let Some(base_scorer) = base_scorer {
871 base_scorer
872 } else {
873 local_scorer = self.bm25_scorer_for_final_tokens(tokens.as_ref()).await?;
874 &local_scorer
875 };
876 let impact_scorer = Arc::new(scorer.clone());
877
878 let limit = params.limit.unwrap_or(usize::MAX);
879 if limit == 0 {
880 return Ok((Vec::new(), Vec::new()));
881 }
882
883 fn push_scored_candidate(
884 candidates: &mut BinaryHeap<Reverse<ScoredDoc>>,
885 limit: usize,
886 addr: CandidateAddr,
887 score: f32,
888 ) -> Result<()> {
889 let row_id = match addr {
892 CandidateAddr::RowId(r) => r,
893 CandidateAddr::Pending(_) => {
894 return Err(Error::internal(
895 "bm25_search post-condition: deferred candidate left unresolved",
896 ));
897 }
898 };
899
900 if candidates.len() < limit {
901 candidates.push(Reverse(ScoredDoc::new(row_id, score)));
902 } else if candidates.peek().unwrap().0.score.0 < score {
903 candidates.pop();
904 candidates.push(Reverse(ScoredDoc::new(row_id, score)));
905 }
906 Ok(())
907 }
908
909 let mask = prefilter.mask();
910
911 let mut candidates = BinaryHeap::new();
912 let impact_shared_threshold = Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()));
917 let legacy_shared_threshold = Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()));
918 let parts = self
919 .partitions
920 .iter()
921 .map(|part| {
922 let part = part.clone();
923 let tokens = tokens.clone();
924 let params = params.clone();
925 let mask = mask.clone();
926 let metrics = metrics.clone();
927 let impact_scorer = impact_scorer.clone();
928 let impact_shared_threshold = impact_shared_threshold.clone();
929 let legacy_shared_threshold = legacy_shared_threshold.clone();
930 async move {
931 let loaded_postings = part
932 .load_posting_lists(
933 tokens.as_ref(),
934 params.as_ref(),
935 operator,
936 impact_scorer.as_ref(),
937 metrics.as_ref(),
938 )
939 .await?;
940 let LoadedPostings {
941 postings,
942 grouped_expansions,
943 impact_safe,
944 exact_scoring_required,
945 } = loaded_postings;
946 if postings.is_empty() {
947 return Result::Ok(PartitionCandidates::empty());
951 }
952 let docs_for_wand = part.docs.docs_for_wand(mask.as_ref()).await?;
953 let max_position = postings
954 .iter()
955 .map(|posting| posting.term_index() as usize)
956 .max()
957 .unwrap_or_default();
958 let mut tokens_by_position = vec![String::new(); max_position + 1];
959 for posting in &postings {
960 let idx = posting.term_index() as usize;
961 tokens_by_position[idx] = posting.token().to_owned();
962 }
963 let params = params.clone();
964 let mask = mask.clone();
965 let metrics = metrics.clone();
966 let part_for_wand = part.clone();
967 let use_global_scorer = impact_safe || exact_scoring_required;
968 let partition_threshold = if use_global_scorer {
969 impact_shared_threshold
970 } else {
971 legacy_shared_threshold
972 };
973 let wand_scorer = use_global_scorer.then(|| impact_scorer.clone());
974 let candidates = spawn_cpu(move || {
975 let candidates = part_for_wand.bm25_search(
976 docs_for_wand.as_ref(),
977 params.as_ref(),
978 operator,
979 mask,
980 postings,
981 wand_scorer,
982 metrics.as_ref(),
983 partition_threshold,
984 )?;
985 std::result::Result::<_, Error>::Ok(candidates)
986 })
987 .await?;
988 let mut partition_result = PartitionCandidates {
989 tokens_by_position,
990 grouped_expansions,
991 candidates,
992 };
993 resolve_deferred_candidates(&part.docs, &mut partition_result.candidates)
994 .await?;
995 Result::Ok(partition_result)
996 }
997 })
998 .collect::<Vec<_>>();
999 let mut parts = stream::iter(parts).buffer_unordered(get_num_compute_intensive_cpus());
1000 let mut idf_cache: HashMap<String, f32> = HashMap::new();
1001 while let Some(res) = parts.try_next().await? {
1002 if res.candidates.is_empty() {
1003 continue;
1004 }
1005 let PartitionCandidates {
1006 tokens_by_position,
1007 grouped_expansions,
1008 candidates: part_candidates,
1009 } = res;
1010 let mut idf_by_position = Vec::with_capacity(tokens_by_position.len());
1011 for token in &tokens_by_position {
1012 let idf_weight = match idf_cache.get(token) {
1013 Some(weight) => *weight,
1014 None => {
1015 let weight = scorer.query_weight(token);
1016 idf_cache.insert(token.clone(), weight);
1017 weight
1018 }
1019 };
1020 idf_by_position.push(idf_weight);
1021 }
1022
1023 if grouped_expansions.is_empty() {
1024 for DocCandidate {
1025 addr,
1026 freqs,
1027 doc_length,
1028 ..
1029 } in part_candidates
1030 {
1031 let mut score = 0.0;
1032 for (term_index, freq) in freqs.into_iter() {
1033 debug_assert!((term_index as usize) < idf_by_position.len());
1034 score += idf_by_position[term_index as usize]
1035 * scorer.doc_weight(freq, doc_length);
1036 }
1037 push_scored_candidate(&mut candidates, limit, addr, score)?;
1038 }
1039 } else {
1040 let grouped_positions = grouped_expansions
1041 .iter()
1042 .map(|group| group.position)
1043 .collect::<HashSet<_>>();
1044 for DocCandidate {
1045 addr,
1046 posting_doc_id,
1047 freqs,
1048 doc_length,
1049 } in part_candidates
1050 {
1051 let mut score = 0.0;
1052 for (term_index, freq) in freqs.into_iter() {
1053 if grouped_positions.contains(&term_index) {
1054 continue;
1055 }
1056 debug_assert!((term_index as usize) < idf_by_position.len());
1057 score += idf_by_position[term_index as usize]
1058 * scorer.doc_weight(freq, doc_length);
1059 }
1060 for group in &grouped_expansions {
1061 for term in group.terms.iter() {
1062 let Some(freq) = term.frequency(posting_doc_id) else {
1063 continue;
1064 };
1065 score += term.query_weight() * scorer.doc_weight(freq, doc_length);
1066 }
1067 }
1068 push_scored_candidate(&mut candidates, limit, addr, score)?;
1069 }
1070 }
1071 }
1072
1073 Ok(candidates
1074 .into_sorted_vec()
1075 .into_iter()
1076 .map(|Reverse(doc)| (doc.row_id, doc.score.0))
1077 .unzip())
1078 }
1079
1080 async fn load_legacy_index(
1081 store: Arc<dyn IndexStore>,
1082 frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1083 index_cache: &LanceCache,
1084 ) -> Result<Arc<Self>> {
1085 log::warn!("loading legacy FTS index");
1086 let tokens_fut = tokio::spawn({
1087 let store = store.clone();
1088 async move {
1089 let token_reader = store.open_index_file(TOKENS_FILE).await?;
1090 let tokenizer = token_reader
1091 .schema()
1092 .metadata
1093 .get("tokenizer")
1094 .map(|s| serde_json::from_str::<InvertedIndexParams>(s))
1095 .transpose()?
1096 .unwrap_or_default();
1097 let tokens = TokenSet::load(token_reader, TokenSetFormat::Arrow).await?;
1098 Result::Ok((tokenizer, tokens))
1099 }
1100 });
1101 let invert_list_fut = tokio::spawn({
1102 let store = store.clone();
1103 let index_cache_clone = index_cache.clone();
1104 async move {
1105 let invert_list_reader = store.open_index_file(INVERT_LIST_FILE).await?;
1106 let invert_list =
1107 PostingListReader::try_new(invert_list_reader, &index_cache_clone).await?;
1108 Result::Ok(Arc::new(invert_list))
1109 }
1110 });
1111 let docs_fut = tokio::spawn({
1112 let store = store.clone();
1113 async move {
1114 let docs_reader = store.open_index_file(DOCS_FILE).await?;
1115 let docs = DocSet::load(docs_reader, true, frag_reuse_index).await?;
1116 Result::Ok(docs)
1117 }
1118 });
1119
1120 let (tokenizer_config, tokens) = tokens_fut.await??;
1121 let inverted_list = invert_list_fut.await??;
1122 let docs = docs_fut.await??;
1123
1124 let tokenizer = tokenizer_config.build()?;
1125
1126 Ok(Arc::new(Self {
1127 params: tokenizer_config,
1128 store: store.clone(),
1129 tokenizer,
1130 token_set_format: TokenSetFormat::Arrow,
1131 format_version: InvertedListFormatVersion::V1,
1132 partitions: vec![Arc::new(InvertedPartition {
1133 id: 0,
1134 store,
1135 tokens,
1136 inverted_list,
1137 docs: Arc::new(LazyDocSet::from_loaded(docs)),
1138 token_set_format: TokenSetFormat::Arrow,
1139 })],
1140 corpus_stats: Arc::new(OnceCell::new()),
1141 deleted_fragments: RoaringBitmap::new(),
1142 }))
1143 }
1144
1145 pub fn is_legacy(&self) -> bool {
1146 self.partitions.len() == 1 && self.partitions[0].is_legacy()
1147 }
1148
1149 pub async fn load_params(store: &dyn IndexStore) -> Result<InvertedIndexParams> {
1152 match store.open_index_file(METADATA_FILE).await {
1153 Ok(reader) => {
1154 let params = reader
1155 .schema()
1156 .metadata
1157 .get("params")
1158 .ok_or(Error::index("params not found in metadata".to_owned()))?;
1159 Ok(serde_json::from_str::<InvertedIndexParams>(params)?)
1160 }
1161 Err(metadata_error) => {
1162 let Ok(reader) = store.open_index_file(TOKENS_FILE).await else {
1167 return Err(metadata_error);
1168 };
1169 Ok(reader
1170 .schema()
1171 .metadata
1172 .get("tokenizer")
1173 .map(|s| serde_json::from_str::<InvertedIndexParams>(s))
1174 .transpose()?
1175 .unwrap_or_default())
1176 }
1177 }
1178 }
1179
1180 pub async fn load(
1181 store: Arc<dyn IndexStore>,
1182 frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1183 index_cache: &LanceCache,
1184 ) -> Result<Arc<Self>>
1185 where
1186 Self: Sized,
1187 {
1188 match store.open_index_file(METADATA_FILE).await {
1193 Ok(reader) => {
1194 let params = reader
1195 .schema()
1196 .metadata
1197 .get("params")
1198 .ok_or(Error::index("params not found in metadata".to_owned()))?;
1199 let params = serde_json::from_str::<InvertedIndexParams>(params)?;
1200 let partitions = reader
1201 .schema()
1202 .metadata
1203 .get("partitions")
1204 .ok_or(Error::index("partitions not found in metadata".to_owned()))?;
1205 let partitions: Vec<u64> = serde_json::from_str(partitions)?;
1206 let token_set_format = reader
1207 .schema()
1208 .metadata
1209 .get(TOKEN_SET_FORMAT_KEY)
1210 .map(|name| TokenSetFormat::from_str(name))
1211 .transpose()?
1212 .unwrap_or(TokenSetFormat::Arrow);
1213 let format_version = parse_format_version_from_metadata(&reader.schema().metadata)?;
1214
1215 let deleted_fragments = if reader.num_rows() > 0 {
1217 let metadata_batch = reader.read_range(0..1, None).await?;
1218 if let Some(col) = metadata_batch.column_by_name(DELETED_FRAGMENTS_COL) {
1219 let arr = col.as_binary_opt::<i32>().expect_ok()?;
1220 RoaringBitmap::deserialize_from(arr.value(0))?
1221 } else {
1222 RoaringBitmap::new()
1223 }
1224 } else {
1225 RoaringBitmap::new()
1226 };
1227
1228 let format = token_set_format;
1229 let partitions = partitions.into_iter().enumerate().map(|(priority, id)| {
1230 let store = store.with_io_priority(priority as u64);
1231 let frag_reuse_index_clone = frag_reuse_index.clone();
1232 let index_cache_for_part =
1233 index_cache.with_key_prefix(format!("part-{}", id).as_str());
1234 let token_set_format = format;
1235 async move {
1236 Result::Ok(Arc::new(
1237 InvertedPartition::load(
1238 store,
1239 id,
1240 frag_reuse_index_clone,
1241 &index_cache_for_part,
1242 token_set_format,
1243 )
1244 .await?,
1245 ))
1246 }
1247 });
1248 let partitions = stream::iter(partitions)
1249 .buffer_unordered(store.io_parallelism())
1250 .try_collect::<Vec<_>>()
1251 .await?;
1252
1253 let tokenizer = params.build()?;
1254 Ok(Arc::new(Self {
1255 params,
1256 store,
1257 tokenizer,
1258 token_set_format,
1259 format_version,
1260 partitions,
1261 corpus_stats: Arc::new(OnceCell::new()),
1262 deleted_fragments,
1263 }))
1264 }
1265 Err(_) => {
1266 Self::load_legacy_index(store, frag_reuse_index, index_cache).await
1268 }
1269 }
1270 }
1271}
1272
1273#[async_trait]
1274impl Index for InvertedIndex {
1275 fn as_any(&self) -> &dyn std::any::Any {
1276 self
1277 }
1278
1279 fn as_index(self: Arc<Self>) -> Arc<dyn Index> {
1280 self
1281 }
1282
1283 fn statistics(&self) -> Result<serde_json::Value> {
1284 let num_tokens = self
1285 .partitions
1286 .iter()
1287 .map(|part| part.tokens.len())
1288 .sum::<usize>();
1289 let num_docs = self
1290 .partitions
1291 .iter()
1292 .map(|part| part.docs.len())
1293 .sum::<usize>();
1294 Ok(serde_json::json!({
1295 "params": self.params,
1296 "num_tokens": num_tokens,
1297 "num_docs": num_docs,
1298 }))
1299 }
1300
1301 async fn prewarm(&self) -> Result<()> {
1302 self.prewarm_with_options(&FtsPrewarmOptions::default())
1303 .await
1304 }
1305
1306 fn index_type(&self) -> crate::IndexType {
1307 crate::IndexType::Inverted
1308 }
1309
1310 async fn calculate_included_frags(&self) -> Result<RoaringBitmap> {
1311 unimplemented!()
1312 }
1313}
1314
1315const PREWARM_CHUNK_TARGET_BYTES: u64 = 128 << 20;
1319
1320const PREWARM_MAX_CHUNK_TOKENS: usize = 256 * 1024;
1322
1323const PREWARM_MIN_CHUNK_TOKENS: usize = 1;
1325
1326static LANCE_FTS_POSTING_GROUP_MAX_TOKENS: LazyLock<usize> = LazyLock::new(|| {
1330 std::env::var("LANCE_FTS_POSTING_GROUP_MAX_TOKENS")
1331 .unwrap_or_else(|_| "128".to_string())
1332 .parse()
1333 .expect("failed to parse LANCE_FTS_POSTING_GROUP_MAX_TOKENS")
1334});
1335
1336fn runtime_posting_group_tokens() -> usize {
1337 (*LANCE_FTS_POSTING_GROUP_MAX_TOKENS).max(1)
1338}
1339
1340#[derive(Debug, Clone, DeepSizeOf)]
1344enum PostingGrouping {
1345 None,
1347 SyntheticFixed { group_size: u32 },
1349}
1350
1351impl PostingGrouping {
1352 fn for_reader(is_legacy_layout: bool, token_count: usize) -> Self {
1353 if is_legacy_layout || token_count == 0 {
1354 return Self::None;
1355 }
1356
1357 let group_size = u32::try_from(runtime_posting_group_tokens())
1358 .unwrap_or(u32::MAX)
1359 .max(1);
1360 Self::SyntheticFixed { group_size }
1361 }
1362
1363 fn is_grouped(&self) -> bool {
1364 !matches!(self, Self::None)
1365 }
1366
1367 fn range_for_token(&self, token_id: u32, token_count: usize) -> Option<(u32, u32)> {
1368 match self {
1369 Self::None => None,
1370 Self::SyntheticFixed { group_size } => {
1371 let token_count = u32::try_from(token_count).unwrap_or(u32::MAX);
1372 let start = (token_id / *group_size) * *group_size;
1373 let end = start.saturating_add(*group_size).min(token_count);
1374 Some((start, end))
1375 }
1376 }
1377 }
1378
1379 fn aligned_chunk_end(&self, token_count: usize, tok_start: usize, desired_end: usize) -> usize {
1380 match self {
1381 Self::None => desired_end,
1382 Self::SyntheticFixed { group_size } => synthetic_group_aligned_chunk_end(
1383 usize::try_from(*group_size).unwrap_or(usize::MAX).max(1),
1384 token_count,
1385 tok_start,
1386 desired_end,
1387 ),
1388 }
1389 }
1390
1391 fn ranges_for_chunk(
1392 &self,
1393 tok_start: usize,
1394 tok_end: usize,
1395 token_count: usize,
1396 ) -> Vec<(u32, u32)> {
1397 match self {
1398 Self::None => Vec::new(),
1399 Self::SyntheticFixed { group_size } => synthetic_group_ranges_for_chunk(
1400 usize::try_from(*group_size).unwrap_or(usize::MAX).max(1),
1401 tok_start,
1402 tok_end,
1403 token_count,
1404 ),
1405 }
1406 }
1407}
1408
1409fn prewarm_chunk_tokens(token_count: usize, file_size_bytes: u64) -> usize {
1411 if token_count == 0 {
1412 return PREWARM_MIN_CHUNK_TOKENS;
1413 }
1414 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;
1416 by_bytes.clamp(PREWARM_MIN_CHUNK_TOKENS, PREWARM_MAX_CHUNK_TOKENS)
1417}
1418
1419fn synthetic_group_aligned_chunk_end(
1420 group_size: usize,
1421 token_count: usize,
1422 tok_start: usize,
1423 desired_end: usize,
1424) -> usize {
1425 if desired_end >= token_count {
1426 return token_count;
1427 }
1428
1429 let boundary = desired_end - (desired_end % group_size);
1430 if boundary > tok_start {
1431 boundary
1432 } else {
1433 tok_start.saturating_add(group_size).min(token_count)
1434 }
1435}
1436
1437fn synthetic_group_ranges_for_chunk(
1438 group_size: usize,
1439 tok_start: usize,
1440 tok_end: usize,
1441 token_count: usize,
1442) -> Vec<(u32, u32)> {
1443 let mut ranges = Vec::new();
1444 let mut start = tok_start - (tok_start % group_size);
1445 if start < tok_start {
1446 start = start.saturating_add(group_size).min(token_count);
1447 }
1448 while start < tok_end {
1449 let end = start.saturating_add(group_size).min(token_count);
1450 ranges.push((
1451 u32::try_from(start).unwrap_or(u32::MAX),
1452 u32::try_from(end).unwrap_or(u32::MAX),
1453 ));
1454 start = end;
1455 }
1456 ranges
1457}
1458
1459fn prewarm_chunk_ranges(
1460 grouping: &PostingGrouping,
1461 token_count: usize,
1462 chunk_tokens: usize,
1463) -> Vec<(usize, usize)> {
1464 let mut ranges = Vec::new();
1465 let mut tok_start = 0usize;
1466 while tok_start < token_count {
1467 let mut tok_end = (tok_start + chunk_tokens).min(token_count);
1468 if grouping.is_grouped() {
1470 tok_end = grouping.aligned_chunk_end(token_count, tok_start, tok_end);
1471 }
1472 ranges.push((tok_start, tok_end));
1473 tok_start = tok_end;
1474 }
1475 ranges
1476}
1477
1478impl InvertedIndex {
1479 pub async fn prewarm_with_options(&self, options: &FtsPrewarmOptions) -> Result<()> {
1480 let with_position = options.with_position;
1481 let chunk_concurrency = self.store.io_parallelism().max(1);
1482 let prewarm_started = Instant::now();
1483 info!(
1484 partition_count = self.partitions.len(),
1485 with_position, chunk_concurrency, "fts index prewarm started"
1486 );
1487 for part in &self.partitions {
1488 let partition_started = Instant::now();
1489 info!(
1490 partition_id = part.id(),
1491 token_count = part.tokens.len(),
1492 with_position,
1493 chunk_concurrency,
1494 "fts partition prewarm started"
1495 );
1496 if let Err(err) = part
1497 .inverted_list
1498 .prewarm_posting_lists(with_position, chunk_concurrency)
1499 .await
1500 {
1501 warn!(
1502 partition_id = part.id(),
1503 error = %err,
1504 elapsed_ms = partition_started.elapsed().as_millis() as u64,
1505 "fts partition posting list prewarm failed"
1506 );
1507 return Err(err);
1508 }
1509 info!(
1510 partition_id = part.id(),
1511 elapsed_ms = partition_started.elapsed().as_millis() as u64,
1512 "fts partition posting lists prewarmed"
1513 );
1514 let docs_started = Instant::now();
1519 if let Err(err) = part.docs.ensure_loaded().await {
1520 warn!(
1521 partition_id = part.id(),
1522 error = %err,
1523 elapsed_ms = docs_started.elapsed().as_millis() as u64,
1524 total_elapsed_ms = partition_started.elapsed().as_millis() as u64,
1525 "fts partition docset prewarm failed"
1526 );
1527 return Err(err);
1528 }
1529 info!(
1530 partition_id = part.id(),
1531 docset_elapsed_ms = docs_started.elapsed().as_millis() as u64,
1532 elapsed_ms = partition_started.elapsed().as_millis() as u64,
1533 "fts partition prewarm finished"
1534 );
1535 }
1536 info!(
1537 partition_count = self.partitions.len(),
1538 elapsed_ms = prewarm_started.elapsed().as_millis() as u64,
1539 "fts index prewarm finished"
1540 );
1541 Ok(())
1542 }
1543 async fn do_search(&self, text: &str) -> Result<RecordBatch> {
1545 let params = FtsSearchParams::new();
1546 let mut tokenizer = self.tokenizer.clone();
1547 let tokens = collect_query_tokens(text, &mut tokenizer);
1548
1549 let (doc_ids, _) = self
1550 .bm25_search(
1551 Arc::new(tokens),
1552 params.into(),
1553 Operator::And,
1554 Arc::new(NoFilter),
1555 Arc::new(NoOpMetricsCollector),
1556 None,
1557 )
1558 .boxed()
1559 .await?;
1560
1561 Ok(RecordBatch::try_new(
1562 ROW_ID_SCHEMA.clone(),
1563 vec![Arc::new(UInt64Array::from(doc_ids))],
1564 )?)
1565 }
1566}
1567
1568#[async_trait]
1569impl ScalarIndex for InvertedIndex {
1570 #[instrument(level = "debug", skip_all)]
1572 async fn search(
1573 &self,
1574 query: &dyn AnyQuery,
1575 _metrics: &dyn MetricsCollector,
1576 ) -> Result<SearchResult> {
1577 let query = query.as_any().downcast_ref::<TokenQuery>().unwrap();
1578
1579 match query {
1580 TokenQuery::TokensContains(text) => {
1581 let records = self.do_search(text).await?;
1582 let row_ids = records
1583 .column(0)
1584 .as_any()
1585 .downcast_ref::<UInt64Array>()
1586 .unwrap();
1587 let row_ids = row_ids.iter().flatten().collect_vec();
1588 Ok(SearchResult::at_most(RowAddrTreeMap::from_iter(row_ids)))
1589 }
1590 }
1591 }
1592
1593 fn can_remap(&self) -> bool {
1594 true
1595 }
1596
1597 async fn remap(
1598 &self,
1599 mapping: &RowAddrRemap,
1600 dest_store: &dyn IndexStore,
1601 ) -> Result<CreatedIndex> {
1602 let files = self
1603 .to_builder()
1604 .remap(mapping, self.store.clone(), dest_store)
1605 .await?;
1606
1607 let details = pbold::InvertedIndexDetails::try_from(&self.params)?;
1608
1609 Ok(CreatedIndex {
1610 index_details: prost_types::Any::from_msg(&details).unwrap(),
1611 index_version: self.index_version(),
1612 files,
1613 })
1614 }
1615
1616 async fn update(
1617 &self,
1618 new_data: SendableRecordBatchStream,
1619 dest_store: &dyn IndexStore,
1620 old_data_filter: Option<crate::scalar::OldIndexDataFilter>,
1621 ) -> Result<CreatedIndex> {
1622 let files = self
1623 .to_builder()
1624 .update(new_data, dest_store, old_data_filter)
1625 .await?;
1626
1627 let details = pbold::InvertedIndexDetails::try_from(&self.params)?;
1628
1629 Ok(CreatedIndex {
1630 index_details: prost_types::Any::from_msg(&details).unwrap(),
1631 index_version: self.index_version(),
1632 files,
1633 })
1634 }
1635
1636 fn update_criteria(&self) -> UpdateCriteria {
1637 let criteria = TrainingCriteria::new(TrainingOrdering::None).with_row_id();
1638 if self.is_legacy() {
1639 UpdateCriteria::requires_old_data(criteria)
1640 } else {
1641 UpdateCriteria::only_new_data(criteria)
1642 }
1643 }
1644
1645 fn derive_index_params(&self) -> Result<ScalarIndexParams> {
1646 let mut params = self.params.clone();
1647 if params.base_tokenizer.is_empty() {
1648 params.base_tokenizer = "simple".to_string();
1650 }
1651 params = params.format_version(self.format_version());
1652
1653 let params_json = params.to_training_json()?.to_string();
1654
1655 Ok(ScalarIndexParams {
1656 index_type: BuiltinIndexType::Inverted.as_str().to_string(),
1657 params: Some(params_json),
1658 })
1659 }
1660}
1661
1662#[derive(Debug, Clone, DeepSizeOf)]
1663pub struct InvertedPartition {
1664 id: u64,
1666 store: Arc<dyn IndexStore>,
1667 pub(crate) tokens: TokenSet,
1668 pub(crate) inverted_list: Arc<PostingListReader>,
1669 pub(crate) docs: Arc<LazyDocSet>,
1673 token_set_format: TokenSetFormat,
1674}
1675
1676impl InvertedPartition {
1677 pub fn belongs_to_fragment(&self, fragment_mask: u64) -> bool {
1688 (self.id() & fragment_mask) == fragment_mask
1689 }
1690
1691 pub fn id(&self) -> u64 {
1692 self.id
1693 }
1694
1695 pub fn store(&self) -> &dyn IndexStore {
1696 self.store.as_ref()
1697 }
1698
1699 pub fn is_legacy(&self) -> bool {
1700 self.inverted_list.is_legacy_layout()
1701 }
1702
1703 pub async fn load(
1704 store: Arc<dyn IndexStore>,
1705 id: u64,
1706 frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1707 index_cache: &LanceCache,
1708 token_set_format: TokenSetFormat,
1709 ) -> Result<Self> {
1710 let token_file = store.open_index_file(&token_file_path(id)).await?;
1711 let tokens = TokenSet::load(token_file, token_set_format).await?;
1712 let invert_list_file = store.open_index_file(&posting_file_path(id)).await?;
1713 let inverted_list = PostingListReader::try_new(invert_list_file, index_cache).await?;
1714 let docs_path = doc_file_path(id);
1721 let num_docs = store.open_index_file(&docs_path).await?.num_rows();
1722 let docs = Arc::new(LazyDocSet::new(
1723 store.clone(),
1724 docs_path,
1725 num_docs,
1726 false,
1727 frag_reuse_index,
1728 inverted_list.block_size() == MAX_POSTING_BLOCK_SIZE,
1730 ));
1731
1732 Ok(Self {
1733 id,
1734 store,
1735 tokens,
1736 inverted_list: Arc::new(inverted_list),
1737 docs,
1738 token_set_format,
1739 })
1740 }
1741
1742 fn map(&self, token: &str) -> Option<u32> {
1743 self.tokens.get(token)
1744 }
1745
1746 pub fn expand_fuzzy(&self, tokens: &Tokens, params: &FtsSearchParams) -> Result<Tokens> {
1747 let mut new_tokens = Vec::with_capacity(min(tokens.len(), params.max_expansions));
1748 let mut new_positions = Vec::with_capacity(new_tokens.capacity());
1749 let mut seen = HashSet::new();
1750 for token_idx in 0..tokens.len() {
1751 let remaining = params.max_expansions.saturating_sub(new_tokens.len());
1752 if remaining == 0 {
1753 break;
1754 }
1755 let token = tokens.get_token(token_idx);
1756 let position = tokens.position(token_idx);
1757 let base_prefix_len = tokens.token_type().prefix_len(token) as u32;
1758 let mut candidates = BTreeSet::new();
1759 self.collect_fuzzy_candidates(
1760 token,
1761 base_prefix_len,
1762 params,
1763 remaining,
1764 &mut candidates,
1765 )?;
1766 for candidate in candidates {
1767 if new_tokens.len() >= params.max_expansions {
1768 break;
1769 }
1770 if seen.insert((candidate.clone(), position)) {
1771 new_tokens.push(candidate);
1772 new_positions.push(position);
1773 }
1774 }
1775 }
1776 Ok(Tokens::with_positions(
1777 new_tokens,
1778 new_positions,
1779 tokens.token_type().clone(),
1780 ))
1781 }
1782
1783 fn collect_fuzzy_candidates(
1791 &self,
1792 token: &str,
1793 base_prefix_len: u32,
1794 params: &FtsSearchParams,
1795 limit: usize,
1796 candidates: &mut BTreeSet<String>,
1797 ) -> Result<()> {
1798 let fuzziness = match params.fuzziness {
1799 Some(fuzziness) => fuzziness,
1800 None => MatchQuery::auto_fuzziness(token),
1801 };
1802 let lev = fst::automaton::Levenshtein::new(token, fuzziness)
1803 .map_err(|e| Error::index(format!("failed to construct the fuzzy query: {}", e)))?;
1804
1805 if let TokenMap::Fst(ref map) = self.tokens.tokens {
1806 let mut expanded = Vec::new();
1807 match base_prefix_len + params.prefix_length {
1808 0 => take_fst_keys(map.search(lev), &mut expanded, limit),
1809 prefix_length => {
1810 let prefix = &token[..min(prefix_length as usize, token.len())];
1811 let prefix = fst::automaton::Str::new(prefix).starts_with();
1812 take_fst_keys(map.search(lev.intersection(prefix)), &mut expanded, limit)
1813 }
1814 }
1815 candidates.extend(expanded);
1816 Ok(())
1817 } else {
1818 Err(Error::index(
1819 "tokens is not fst, which is not expected".to_owned(),
1820 ))
1821 }
1822 }
1823
1824 #[inline]
1825 fn grouped_score_upper_bound(
1826 query_weight: f32,
1827 union_freq: u32,
1828 doc_length: u32,
1829 scorer: &MemBM25Scorer,
1830 ) -> f32 {
1831 query_weight * scorer.doc_weight(union_freq, doc_length)
1835 }
1836
1837 fn grouped_block_max_scores(
1838 doc_ids: &[u32],
1839 frequencies: &[u32],
1840 block_size: usize,
1841 docs: &DocSet,
1842 query_weight: f32,
1843 scorer: &MemBM25Scorer,
1844 ) -> Vec<f32> {
1845 doc_ids
1846 .chunks(block_size)
1847 .zip(frequencies.chunks(block_size))
1848 .map(|(doc_ids, frequencies)| {
1849 doc_ids
1850 .iter()
1851 .zip(frequencies)
1852 .map(|(doc_id, freq)| {
1853 Self::grouped_score_upper_bound(
1854 query_weight,
1855 *freq,
1856 docs.scoring_num_tokens(*doc_id),
1857 scorer,
1858 )
1859 })
1860 .fold(0.0, f32::max)
1861 })
1862 .collect()
1863 }
1864
1865 fn union_plain_posting_lists(
1866 postings: Vec<PostingList>,
1867 docs: &DocSet,
1868 query_weight: f32,
1869 scorer: &MemBM25Scorer,
1870 ) -> Result<PostingList> {
1871 let mut freqs_by_row_id = BTreeMap::new();
1872 for posting in postings {
1873 for (row_id, freq, _) in posting.iter() {
1874 let entry = freqs_by_row_id.entry(row_id).or_insert(0u32);
1875 *entry = entry.checked_add(freq).ok_or_else(|| {
1876 Error::index(format!("posting frequency overflow for row id {}", row_id))
1877 })?;
1878 }
1879 }
1880 let mut row_ids = Vec::with_capacity(freqs_by_row_id.len());
1881 let mut frequencies = Vec::with_capacity(freqs_by_row_id.len());
1882 let mut max_score = 0.0_f32;
1883 for (row_id, freq) in freqs_by_row_id {
1884 max_score = max_score.max(Self::grouped_score_upper_bound(
1885 query_weight,
1886 freq,
1887 docs.num_tokens_by_row_id(row_id),
1888 scorer,
1889 ));
1890 row_ids.push(row_id);
1891 frequencies.push(freq as f32);
1892 }
1893 Ok(PostingList::Plain(PlainPostingList::new(
1894 ScalarBuffer::from(row_ids),
1895 ScalarBuffer::from(frequencies),
1896 Some(max_score),
1897 None,
1898 )))
1899 }
1900
1901 fn union_plain_posting_lists_with_positions(
1902 postings: Vec<PostingList>,
1903 docs: &DocSet,
1904 query_weight: f32,
1905 scorer: &MemBM25Scorer,
1906 ) -> Result<PostingList> {
1907 let mut positions_by_row_id = BTreeMap::<u64, Vec<u32>>::new();
1908 for posting in postings {
1909 for (row_id, _, positions) in posting.iter() {
1910 let positions = positions.ok_or_else(|| {
1911 Error::index("cannot union grouped phrase terms without positions".to_string())
1912 })?;
1913 positions_by_row_id
1914 .entry(row_id)
1915 .or_default()
1916 .extend(positions);
1917 }
1918 }
1919 if positions_by_row_id.is_empty() {
1920 return Ok(PostingList::Plain(PlainPostingList::new(
1921 ScalarBuffer::from(Vec::<u64>::new()),
1922 ScalarBuffer::from(Vec::<f32>::new()),
1923 None,
1924 None,
1925 )));
1926 }
1927
1928 let mut row_ids = Vec::with_capacity(positions_by_row_id.len());
1929 let mut frequencies = Vec::with_capacity(positions_by_row_id.len());
1930 let mut positions_builder = ListBuilder::new(Int32Builder::new());
1931 let mut max_score = 0.0_f32;
1932 for (row_id, mut positions) in positions_by_row_id {
1933 positions.sort_unstable();
1934 let frequency = positions.len() as u32;
1935 max_score = max_score.max(Self::grouped_score_upper_bound(
1936 query_weight,
1937 frequency,
1938 docs.num_tokens_by_row_id(row_id),
1939 scorer,
1940 ));
1941 row_ids.push(row_id);
1942 frequencies.push(frequency as f32);
1943 for position in positions {
1944 positions_builder.values().append_value(position as i32);
1945 }
1946 positions_builder.append(true);
1947 }
1948
1949 Ok(PostingList::Plain(PlainPostingList::new(
1950 ScalarBuffer::from(row_ids),
1951 ScalarBuffer::from(frequencies),
1952 Some(max_score),
1953 Some(positions_builder.finish()),
1954 )))
1955 }
1956
1957 fn union_compressed_posting_lists(
1958 postings: Vec<PostingList>,
1959 docs: &DocSet,
1960 query_weight: f32,
1961 scorer: &MemBM25Scorer,
1962 ) -> Result<PostingList> {
1963 let block_size = postings
1964 .iter()
1965 .find_map(|posting| match posting {
1966 PostingList::Compressed(posting) => Some(posting.block_size),
1967 PostingList::Plain(_) => None,
1968 })
1969 .unwrap_or(LEGACY_BLOCK_SIZE);
1970 let mut freqs_by_doc_id = BTreeMap::new();
1971 for posting in postings {
1972 for (doc_id, freq, _) in posting.iter() {
1973 let doc_id = u32::try_from(doc_id).map_err(|_| {
1974 Error::index(format!(
1975 "compressed posting doc id {} exceeds u32::MAX",
1976 doc_id
1977 ))
1978 })?;
1979 let entry = freqs_by_doc_id.entry(doc_id).or_insert(0u32);
1980 *entry = entry.checked_add(freq).ok_or_else(|| {
1981 Error::index(format!("posting frequency overflow for doc id {}", doc_id))
1982 })?;
1983 }
1984 }
1985 if freqs_by_doc_id.is_empty() {
1986 return Ok(PostingList::Plain(PlainPostingList::new(
1987 ScalarBuffer::from(Vec::<u64>::new()),
1988 ScalarBuffer::from(Vec::<f32>::new()),
1989 None,
1990 None,
1991 )));
1992 }
1993
1994 let mut builder = PostingListBuilder::new_with_block_size(false, block_size);
1995 let mut doc_ids = Vec::with_capacity(freqs_by_doc_id.len());
1996 let mut frequencies = Vec::with_capacity(freqs_by_doc_id.len());
1997 for (doc_id, freq) in freqs_by_doc_id {
1998 builder.add(doc_id, PositionRecorder::Count(freq));
1999 doc_ids.push(doc_id);
2000 frequencies.push(freq);
2001 }
2002 let block_max_scores = Self::grouped_block_max_scores(
2003 &doc_ids,
2004 &frequencies,
2005 block_size,
2006 docs,
2007 query_weight,
2008 scorer,
2009 );
2010 let batch = builder.to_batch(block_max_scores)?;
2011 let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
2012 let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
2013 PostingList::from_batch(&batch, Some(max_score), Some(length))
2014 }
2015
2016 fn union_compressed_posting_lists_with_positions(
2017 postings: Vec<PostingList>,
2018 docs: &DocSet,
2019 query_weight: f32,
2020 scorer: &MemBM25Scorer,
2021 ) -> Result<PostingList> {
2022 let block_size = postings
2023 .iter()
2024 .find_map(|posting| match posting {
2025 PostingList::Compressed(posting) => Some(posting.block_size),
2026 PostingList::Plain(_) => None,
2027 })
2028 .unwrap_or(LEGACY_BLOCK_SIZE);
2029 let mut positions_by_doc_id = BTreeMap::<u32, Vec<u32>>::new();
2030 for posting in postings {
2031 for (doc_id, _, positions) in posting.iter() {
2032 let doc_id = u32::try_from(doc_id).map_err(|_| {
2033 Error::index(format!(
2034 "compressed posting doc id {} exceeds u32::MAX",
2035 doc_id
2036 ))
2037 })?;
2038 let positions = positions.ok_or_else(|| {
2039 Error::index("cannot union grouped phrase terms without positions".to_string())
2040 })?;
2041 positions_by_doc_id
2042 .entry(doc_id)
2043 .or_default()
2044 .extend(positions);
2045 }
2046 }
2047 if positions_by_doc_id.is_empty() {
2048 return Ok(PostingList::Plain(PlainPostingList::new(
2049 ScalarBuffer::from(Vec::<u64>::new()),
2050 ScalarBuffer::from(Vec::<f32>::new()),
2051 None,
2052 None,
2053 )));
2054 }
2055
2056 let mut builder = PostingListBuilder::new_with_block_size(true, block_size);
2057 let mut doc_ids = Vec::with_capacity(positions_by_doc_id.len());
2058 let mut frequencies = Vec::with_capacity(positions_by_doc_id.len());
2059 for (doc_id, mut positions) in positions_by_doc_id {
2060 positions.sort_unstable();
2061 let frequency = positions.len() as u32;
2062 builder.add(doc_id, PositionRecorder::Position(positions.into()));
2063 doc_ids.push(doc_id);
2064 frequencies.push(frequency);
2065 }
2066 let block_max_scores = Self::grouped_block_max_scores(
2067 &doc_ids,
2068 &frequencies,
2069 block_size,
2070 docs,
2071 query_weight,
2072 scorer,
2073 );
2074 let batch = builder.to_batch(block_max_scores)?;
2075 let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
2076 let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
2077 PostingList::from_batch(&batch, Some(max_score), Some(length))
2078 }
2079
2080 fn union_posting_lists(
2081 postings: Vec<PostingList>,
2082 docs: &DocSet,
2083 with_positions: bool,
2084 query_weight: f32,
2085 scorer: &MemBM25Scorer,
2086 ) -> Result<PostingList> {
2087 let has_plain = postings
2088 .iter()
2089 .any(|posting| matches!(posting, PostingList::Plain(_)));
2090 let has_compressed = postings
2091 .iter()
2092 .any(|posting| matches!(posting, PostingList::Compressed(_)));
2093 match (has_plain, has_compressed) {
2094 (true, true) => Err(Error::index(
2095 "cannot union mixed plain and compressed posting lists".to_owned(),
2096 )),
2097 (true, false) if with_positions => {
2098 Self::union_plain_posting_lists_with_positions(postings, docs, query_weight, scorer)
2099 }
2100 (true, false) => Self::union_plain_posting_lists(postings, docs, query_weight, scorer),
2101 (false, true) if with_positions => Self::union_compressed_posting_lists_with_positions(
2102 postings,
2103 docs,
2104 query_weight,
2105 scorer,
2106 ),
2107 (false, true) => {
2108 Self::union_compressed_posting_lists(postings, docs, query_weight, scorer)
2109 }
2110 (false, false) => Ok(PostingList::Plain(PlainPostingList::new(
2111 ScalarBuffer::from(Vec::<u64>::new()),
2112 ScalarBuffer::from(Vec::<f32>::new()),
2113 None,
2114 None,
2115 ))),
2116 }
2117 }
2118
2119 #[instrument(level = "debug", skip_all)]
2123 async fn load_posting_lists(
2124 &self,
2125 tokens: &Tokens,
2126 params: &FtsSearchParams,
2127 operator: Operator,
2128 impact_scorer: &MemBM25Scorer,
2129 metrics: &dyn MetricsCollector,
2130 ) -> Result<LoadedPostings> {
2131 let is_phrase_query = params.phrase_slop.is_some();
2132 let is_and_query = operator == Operator::And;
2133 let required_positions = (is_and_query || is_phrase_query).then(|| {
2134 (0..tokens.len())
2135 .map(|index| tokens.position(index))
2136 .collect::<HashSet<_>>()
2137 });
2138 let tokens = tokens.clone();
2143 let token_positions = (0..tokens.len())
2144 .map(|index| tokens.position(index))
2145 .collect::<Vec<_>>();
2146 let mut seen_positions = HashSet::with_capacity(token_positions.len());
2147 let exact_scoring_required = token_positions
2148 .iter()
2149 .any(|position| !seen_positions.insert(*position));
2150 let mut token_ids = Vec::with_capacity(tokens.len());
2151 let mut matched_positions = required_positions.as_ref().map(|_| HashSet::new());
2152 for (index, token) in tokens.into_iter().enumerate() {
2153 let token_id = self.map(&token);
2154 if let Some(token_id) = token_id {
2155 let position = token_positions[index];
2156 if let Some(matched_positions) = matched_positions.as_mut() {
2157 matched_positions.insert(position);
2158 }
2159 token_ids.push((token_id, token, position));
2160 }
2161 }
2162 if token_ids.is_empty() {
2163 return Ok(LoadedPostings::empty());
2164 }
2165 if let Some(required_positions) = required_positions.as_ref()
2166 && let Some(matched_positions) = matched_positions.as_ref()
2167 && !required_positions.is_subset(matched_positions)
2168 {
2169 return Ok(LoadedPostings::empty());
2170 }
2171
2172 token_ids.sort_unstable_by_key(|(token_id, _, position)| (*position, *token_id));
2173 token_ids.dedup_by(|lhs, rhs| lhs.0 == rhs.0 && lhs.2 == rhs.2);
2174
2175 let num_docs = self.docs.len();
2176 let loaded_postings = stream::iter(token_ids)
2177 .map(|(token_id, token, position)| async move {
2178 let posting = self
2179 .inverted_list
2180 .posting_list(token_id, is_phrase_query, metrics)
2181 .await?;
2182
2183 Result::Ok((token_id, token, position, posting))
2184 })
2185 .buffered(self.store.io_parallelism())
2186 .try_collect::<Vec<_>>()
2187 .await?;
2188
2189 let needs_union = loaded_postings
2190 .windows(2)
2191 .any(|window| window[0].2 == window[1].2);
2192 if (is_and_query || is_phrase_query)
2193 && !needs_union
2194 && loaded_postings
2195 .iter()
2196 .any(|(_, _, _, posting)| posting.is_empty())
2197 {
2198 return Ok(LoadedPostings::empty());
2199 }
2200
2201 if !needs_union {
2202 let impact_safe = loaded_postings
2203 .iter()
2204 .all(|(_, _, _, posting)| posting.has_impacts());
2205 return Ok(LoadedPostings {
2206 postings: loaded_postings
2207 .into_iter()
2208 .map(|(token_id, token, position, posting)| {
2209 let needs_scorer_upper_bound =
2210 exact_scoring_required && !posting.has_impacts();
2211 let query_weight = if impact_safe || exact_scoring_required {
2212 impact_scorer.query_weight(&token)
2213 } else {
2214 idf(posting.len(), num_docs)
2215 };
2216 let posting = PostingIterator::with_query_weight(
2217 token,
2218 token_id,
2219 position,
2220 query_weight,
2221 posting,
2222 num_docs,
2223 );
2224 if needs_scorer_upper_bound {
2225 posting.with_scorer_upper_bound()
2226 } else {
2227 posting
2228 }
2229 })
2230 .collect(),
2231 grouped_expansions: Vec::new(),
2232 impact_safe,
2233 exact_scoring_required,
2234 });
2235 }
2236
2237 let docs_for_union = if needs_union {
2238 Some(self.docs.ensure_num_tokens_loaded().await?)
2239 } else {
2240 None
2241 };
2242
2243 let mut grouped_postings = Vec::new();
2246 let mut grouped_expansions = Vec::new();
2247 let mut iter = loaded_postings.into_iter().peekable();
2248 while let Some((token_id, token, position, posting)) = iter.next() {
2249 let mut group = vec![(token_id, token, posting)];
2250 while matches!(iter.peek(), Some((_, _, next_position, _)) if *next_position == position)
2251 {
2252 let (token_id, token, _, posting) = iter.next().expect("peeked item must exist");
2253 group.push((token_id, token, posting));
2254 }
2255
2256 let (token_id, token, posting) = if group.len() == 1 {
2257 group.pop().expect("single-item group must exist")
2258 } else {
2259 let token_id = group[0].0;
2260 let token = group[0].1.clone();
2261 let terms = group
2262 .iter()
2263 .map(|(_, token, posting)| {
2264 GroupedTermScorer::new(impact_scorer.query_weight(token), posting)
2265 })
2266 .collect::<Vec<_>>();
2267 let terms = Arc::<[GroupedTermScorer]>::from(terms);
2268 let query_weight = terms.iter().map(GroupedTermScorer::query_weight).sum();
2269 grouped_expansions.push(GroupedExpansionTerms {
2270 position,
2271 terms: terms.clone(),
2272 });
2273 let postings = group
2274 .into_iter()
2275 .map(|(_, _, posting)| posting)
2276 .collect::<Vec<_>>();
2277 let docs = docs_for_union.as_deref().ok_or_else(|| {
2278 Error::index("union docs were not loaded for grouped query terms".to_string())
2279 })?;
2280 let posting = Self::union_posting_lists(
2281 postings,
2282 docs,
2283 is_phrase_query,
2284 query_weight,
2285 impact_scorer,
2286 )?;
2287 if posting.is_empty() && (is_and_query || is_phrase_query) {
2288 return Ok(LoadedPostings::empty());
2289 }
2290 grouped_postings.push(
2291 PostingIterator::with_query_weight(
2292 token,
2293 token_id,
2294 position,
2295 query_weight,
2296 posting,
2297 num_docs,
2298 )
2299 .with_grouped_terms(terms),
2300 );
2301 continue;
2302 };
2303 if posting.is_empty() {
2304 if is_and_query || is_phrase_query {
2305 return Ok(LoadedPostings::empty());
2306 }
2307 continue;
2308 }
2309
2310 let query_weight = impact_scorer.query_weight(&token);
2311 let needs_scorer_upper_bound = !posting.has_impacts();
2312 let posting = PostingIterator::with_query_weight(
2313 token,
2314 token_id,
2315 position,
2316 query_weight,
2317 posting,
2318 num_docs,
2319 );
2320 grouped_postings.push(if needs_scorer_upper_bound {
2321 posting.with_scorer_upper_bound()
2322 } else {
2323 posting
2324 });
2325 }
2326
2327 Ok(LoadedPostings {
2328 postings: grouped_postings,
2329 grouped_expansions,
2330 impact_safe: false,
2331 exact_scoring_required: true,
2332 })
2333 }
2334
2335 #[instrument(level = "debug", skip_all)]
2336 #[allow(clippy::too_many_arguments)]
2340 pub fn bm25_search(
2341 &self,
2342 docs: &DocSet,
2343 params: &FtsSearchParams,
2344 operator: Operator,
2345 mask: Arc<RowAddrMask>,
2346 postings: Vec<PostingIterator>,
2347 impact_scorer: Option<Arc<MemBM25Scorer>>,
2348 metrics: &dyn MetricsCollector,
2349 shared_threshold: Arc<AtomicU32>,
2350 ) -> Result<Vec<DocCandidate>> {
2351 if postings.is_empty() {
2352 return Ok(Vec::new());
2353 }
2354
2355 let hits = if let Some(scorer) = impact_scorer {
2359 let mut wand = Wand::new(operator, postings.into_iter(), docs, scorer)
2360 .with_shared_threshold(shared_threshold);
2361 wand.search(params, mask, metrics)?
2362 } else {
2363 let scorer = IndexBM25Scorer::new(std::iter::once(self));
2364 let mut wand = Wand::new(operator, postings.into_iter(), docs, scorer)
2365 .with_shared_threshold(shared_threshold);
2366 wand.search(params, mask, metrics)?
2367 };
2368 Ok(hits)
2369 }
2370
2371 pub async fn into_builder(self) -> Result<InnerBuilder> {
2372 let mut builder = InnerBuilder::new_with_posting_tail_codec_and_block_size(
2373 self.id,
2374 self.inverted_list.has_positions(),
2375 self.token_set_format,
2376 self.inverted_list.posting_tail_codec(),
2377 self.inverted_list.block_size(),
2378 );
2379 builder.tokens = self.tokens.into_mutable();
2380 let docs_arc = self.docs.ensure_loaded().await?;
2383 builder.docs = (*docs_arc).clone();
2384
2385 builder
2386 .posting_lists
2387 .reserve_exact(self.inverted_list.len());
2388 for posting_list in self
2389 .inverted_list
2390 .read_all(self.inverted_list.has_positions())
2391 .await?
2392 {
2393 let posting_list = posting_list?;
2394 builder
2395 .posting_lists
2396 .push(posting_list.into_builder(&builder.docs));
2397 }
2398 Ok(builder)
2399 }
2400}
2401
2402#[derive(Debug, Clone)]
2405pub enum TokenMap {
2406 HashMap(HashMap<String, u32>),
2407 Fst(fst::Map<Vec<u8>>),
2408}
2409
2410impl Default for TokenMap {
2411 fn default() -> Self {
2412 Self::HashMap(HashMap::new())
2413 }
2414}
2415
2416impl DeepSizeOf for TokenMap {
2417 fn deep_size_of_children(&self, ctx: &mut lance_core::deepsize::Context) -> usize {
2418 match self {
2419 Self::HashMap(map) => map.deep_size_of_children(ctx),
2420 Self::Fst(map) => map.as_fst().size(),
2421 }
2422 }
2423}
2424
2425impl TokenMap {
2426 pub fn len(&self) -> usize {
2427 match self {
2428 Self::HashMap(map) => map.len(),
2429 Self::Fst(map) => map.len(),
2430 }
2431 }
2432
2433 pub fn is_empty(&self) -> bool {
2434 self.len() == 0
2435 }
2436}
2437
2438#[derive(Debug, Clone, Default, DeepSizeOf)]
2440pub struct TokenSet {
2441 pub(crate) tokens: TokenMap,
2443 pub(crate) next_id: u32,
2444 total_length: usize,
2445}
2446
2447impl TokenSet {
2448 pub fn into_mut(self) -> Self {
2449 let tokens = match self.tokens {
2450 TokenMap::HashMap(map) => map,
2451 TokenMap::Fst(map) => {
2452 let mut new_map = HashMap::with_capacity(map.len());
2453 let mut stream = map.into_stream();
2454 while let Some((token, token_id)) = stream.next() {
2455 new_map.insert(String::from_utf8_lossy(token).into_owned(), token_id as u32);
2456 }
2457
2458 new_map
2459 }
2460 };
2461
2462 Self {
2463 tokens: TokenMap::HashMap(tokens),
2464 next_id: self.next_id,
2465 total_length: self.total_length,
2466 }
2467 }
2468
2469 pub fn len(&self) -> usize {
2470 self.tokens.len()
2471 }
2472
2473 pub fn is_empty(&self) -> bool {
2474 self.len() == 0
2475 }
2476
2477 pub fn to_batch(self, format: TokenSetFormat) -> Result<RecordBatch> {
2478 match format {
2479 TokenSetFormat::Arrow => self.into_arrow_batch(),
2480 TokenSetFormat::Fst => self.into_fst_batch(),
2481 }
2482 }
2483
2484 fn into_arrow_batch(self) -> Result<RecordBatch> {
2485 let mut token_builder = StringBuilder::with_capacity(self.tokens.len(), self.total_length);
2486 let mut token_id_builder = UInt32Builder::with_capacity(self.tokens.len());
2487
2488 match self.tokens {
2489 TokenMap::Fst(map) => {
2490 let mut stream = map.stream();
2491 while let Some((token, token_id)) = stream.next() {
2492 token_builder.append_value(String::from_utf8_lossy(token));
2493 token_id_builder.append_value(token_id as u32);
2494 }
2495 }
2496 TokenMap::HashMap(map) => {
2497 for (token, token_id) in map.into_iter().sorted_unstable() {
2498 token_builder.append_value(token);
2499 token_id_builder.append_value(token_id);
2500 }
2501 }
2502 }
2503
2504 let token_col = token_builder.finish();
2505 let token_id_col = token_id_builder.finish();
2506
2507 let schema = arrow_schema::Schema::new(vec![
2508 arrow_schema::Field::new(TOKEN_COL, DataType::Utf8, false),
2509 arrow_schema::Field::new(TOKEN_ID_COL, DataType::UInt32, false),
2510 ]);
2511
2512 let batch = RecordBatch::try_new(
2513 Arc::new(schema),
2514 vec![
2515 Arc::new(token_col) as ArrayRef,
2516 Arc::new(token_id_col) as ArrayRef,
2517 ],
2518 )?;
2519 Ok(batch)
2520 }
2521
2522 fn into_fst_batch(mut self) -> Result<RecordBatch> {
2523 let fst_map = match std::mem::take(&mut self.tokens) {
2524 TokenMap::Fst(map) => map,
2525 TokenMap::HashMap(map) => Self::build_fst_from_map(map)?,
2526 };
2527 let bytes = fst_map.into_fst().into_inner();
2528
2529 let mut fst_builder = LargeBinaryBuilder::with_capacity(1, bytes.len());
2530 fst_builder.append_value(bytes);
2531 let fst_col = fst_builder.finish();
2532
2533 let mut next_id_builder = UInt32Builder::with_capacity(1);
2534 next_id_builder.append_value(self.next_id);
2535 let next_id_col = next_id_builder.finish();
2536
2537 let mut total_length_builder = UInt64Builder::with_capacity(1);
2538 total_length_builder.append_value(self.total_length as u64);
2539 let total_length_col = total_length_builder.finish();
2540
2541 let schema = arrow_schema::Schema::new(vec![
2542 arrow_schema::Field::new(TOKEN_FST_BYTES_COL, DataType::LargeBinary, false),
2543 arrow_schema::Field::new(TOKEN_NEXT_ID_COL, DataType::UInt32, false),
2544 arrow_schema::Field::new(TOKEN_TOTAL_LENGTH_COL, DataType::UInt64, false),
2545 ]);
2546
2547 let batch = RecordBatch::try_new(
2548 Arc::new(schema),
2549 vec![
2550 Arc::new(fst_col) as ArrayRef,
2551 Arc::new(next_id_col) as ArrayRef,
2552 Arc::new(total_length_col) as ArrayRef,
2553 ],
2554 )?;
2555 Ok(batch)
2556 }
2557
2558 fn build_fst_from_map(map: HashMap<String, u32>) -> Result<fst::Map<Vec<u8>>> {
2559 let mut entries: Vec<_> = map.into_iter().collect();
2560 entries.sort_unstable_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs));
2561 let mut builder = fst::MapBuilder::memory();
2562 for (token, token_id) in entries {
2563 builder
2564 .insert(&token, token_id as u64)
2565 .map_err(|e| Error::index(format!("failed to insert token {}: {}", token, e)))?;
2566 }
2567 Ok(builder.into_map())
2568 }
2569
2570 pub async fn load(reader: Arc<dyn IndexReader>, format: TokenSetFormat) -> Result<Self> {
2571 match format {
2572 TokenSetFormat::Arrow => Self::load_arrow(reader).await,
2573 TokenSetFormat::Fst => Self::load_fst(reader).await,
2574 }
2575 }
2576
2577 async fn load_arrow(reader: Arc<dyn IndexReader>) -> Result<Self> {
2578 let batch = reader.read_range(0..reader.num_rows(), None).await?;
2579
2580 let (tokens, next_id, total_length) = spawn_blocking(move || {
2581 let mut next_id = 0;
2582 let mut total_length = 0;
2583 let mut tokens = fst::MapBuilder::memory();
2584
2585 let token_col = batch[TOKEN_COL].as_string::<i32>();
2586 let token_id_col = batch[TOKEN_ID_COL].as_primitive::<datatypes::UInt32Type>();
2587
2588 for (token, &token_id) in token_col.iter().zip(token_id_col.values().iter()) {
2589 let token =
2590 token.ok_or(Error::index("found null token in token set".to_owned()))?;
2591 next_id = next_id.max(token_id + 1);
2592 total_length += token.len();
2593 tokens.insert(token, token_id as u64).map_err(|e| {
2594 Error::index(format!("failed to insert token {}: {}", token, e))
2595 })?;
2596 }
2597
2598 Ok::<_, Error>((tokens.into_map(), next_id, total_length))
2599 })
2600 .await
2601 .map_err(|err| Error::execution(format!("failed to spawn blocking task: {}", err)))??;
2602
2603 Ok(Self {
2604 tokens: TokenMap::Fst(tokens),
2605 next_id,
2606 total_length,
2607 })
2608 }
2609
2610 async fn load_fst(reader: Arc<dyn IndexReader>) -> Result<Self> {
2611 let batch = reader.read_range(0..reader.num_rows(), None).await?;
2612 if batch.num_rows() == 0 {
2613 return Err(Error::index("token set batch is empty".to_owned()));
2614 }
2615
2616 let fst_col = batch[TOKEN_FST_BYTES_COL].as_binary::<i64>();
2617 let bytes = fst_col.value(0);
2618 let map = fst::Map::new(bytes.to_vec())
2619 .map_err(|e| Error::index(format!("failed to load fst tokens: {}", e)))?;
2620
2621 let total_length_col =
2622 batch[TOKEN_TOTAL_LENGTH_COL].as_primitive::<datatypes::UInt64Type>();
2623
2624 let next_id = map.len() as u32;
2628
2629 let total_length = total_length_col
2630 .values()
2631 .first()
2632 .copied()
2633 .ok_or(Error::index(
2634 "token total length column is empty".to_owned(),
2635 ))?;
2636
2637 Ok(Self {
2638 tokens: TokenMap::Fst(map),
2639 next_id,
2640 total_length: usize::try_from(total_length).map_err(|_| {
2641 Error::index(format!(
2642 "token total length {} overflows usize",
2643 total_length
2644 ))
2645 })?,
2646 })
2647 }
2648
2649 pub fn add(&mut self, token: String) -> u32 {
2650 let next_id = self.next_id();
2651 let len = token.len();
2652 let token_id = match self.tokens {
2653 TokenMap::HashMap(ref mut map) => *map.entry(token).or_insert(next_id),
2654 _ => unreachable!("tokens must be HashMap while indexing"),
2655 };
2656
2657 if token_id == next_id {
2659 self.next_id += 1;
2660 self.total_length += len;
2661 }
2662
2663 token_id
2664 }
2665
2666 pub(crate) fn get_or_add(&mut self, token: &str) -> u32 {
2667 let next_id = self.next_id;
2668 match self.tokens {
2669 TokenMap::HashMap(ref mut map) => {
2670 if let Some(&token_id) = map.get(token) {
2671 return token_id;
2672 }
2673
2674 map.insert(token.to_owned(), next_id);
2675 }
2676 _ => unreachable!("tokens must be HashMap while indexing"),
2677 }
2678
2679 self.next_id += 1;
2680 self.total_length += token.len();
2681 next_id
2682 }
2683
2684 pub(crate) fn into_mutable(self) -> Self {
2685 let Self {
2686 tokens,
2687 next_id,
2688 total_length,
2689 } = self;
2690 match tokens {
2691 TokenMap::HashMap(_) => Self {
2692 tokens,
2693 next_id,
2694 total_length,
2695 },
2696 TokenMap::Fst(map) => {
2697 let mut mutable = HashMap::new();
2698 let mut stream = map.stream();
2699 while let Some((token, token_id)) = stream.next() {
2700 mutable.insert(String::from_utf8_lossy(token).into_owned(), token_id as u32);
2701 }
2702 Self {
2703 tokens: TokenMap::HashMap(mutable),
2704 next_id,
2705 total_length,
2706 }
2707 }
2708 }
2709 }
2710
2711 pub fn get(&self, token: &str) -> Option<u32> {
2712 match self.tokens {
2713 TokenMap::HashMap(ref map) => map.get(token).copied(),
2714 TokenMap::Fst(ref map) => map.get(token).map(|id| id as u32),
2715 }
2716 }
2717
2718 pub fn remap(&mut self, removed_token_ids: &[u32]) {
2720 if removed_token_ids.is_empty() {
2721 return;
2722 }
2723
2724 let mut map = match std::mem::take(&mut self.tokens) {
2725 TokenMap::HashMap(map) => map,
2726 TokenMap::Fst(map) => {
2727 let mut new_map = HashMap::with_capacity(map.len());
2728 let mut stream = map.into_stream();
2729 while let Some((token, token_id)) = stream.next() {
2730 new_map.insert(String::from_utf8_lossy(token).into_owned(), token_id as u32);
2731 }
2732
2733 new_map
2734 }
2735 };
2736
2737 let mut retained_length = 0;
2738 map.retain(
2739 |token, token_id| match removed_token_ids.binary_search(token_id) {
2740 Ok(_) => false,
2741 Err(index) => {
2742 *token_id -= index as u32;
2743 retained_length += token.len();
2744 true
2745 }
2746 },
2747 );
2748
2749 self.tokens = TokenMap::HashMap(map);
2750
2751 self.next_id = self.tokens.len() as u32;
2756 self.total_length = retained_length;
2757 }
2758
2759 pub fn next_id(&self) -> u32 {
2760 self.next_id
2761 }
2762
2763 pub(crate) fn memory_size(&self) -> usize {
2764 match &self.tokens {
2765 TokenMap::HashMap(map) => {
2766 self.total_length
2767 + map.capacity()
2768 * (std::mem::size_of::<String>()
2769 + std::mem::size_of::<u32>()
2770 + std::mem::size_of::<usize>())
2771 }
2772 TokenMap::Fst(map) => map.as_fst().size(),
2773 }
2774 }
2775}
2776
2777pub struct PostingListReader {
2778 reader: Arc<dyn IndexReader>,
2779
2780 metadata: PostingMetadata,
2784
2785 has_position: bool,
2786 has_impacts: bool,
2787 posting_tail_codec: PostingTailCodec,
2788 block_size: usize,
2789 positions_layout: PositionsLayout,
2790
2791 grouping: PostingGrouping,
2795
2796 index_cache: WeakLanceCache,
2797}
2798
2799enum PostingMetadata {
2805 LegacyV1 {
2808 offsets: Vec<usize>,
2809 max_scores: Option<Vec<f32>>,
2810 },
2811 V2 {
2816 metadata: OnceCell<LoadedPostingMetadata>,
2817 },
2818}
2819
2820#[derive(Debug, Clone)]
2821struct LoadedPostingMetadata {
2822 max_scores: Vec<f32>,
2823 lengths: Vec<u32>,
2824}
2825
2826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2827enum PositionsLayout {
2828 None,
2829 LegacyPerDoc,
2830 SharedStream(PositionStreamCodec),
2831}
2832
2833impl std::fmt::Debug for PostingListReader {
2834 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2835 let mut s = f.debug_struct("InvertedListReader");
2836 match &self.metadata {
2837 PostingMetadata::LegacyV1 {
2838 offsets,
2839 max_scores,
2840 } => {
2841 s.field("layout", &"legacy_v1")
2842 .field("offsets", offsets)
2843 .field("max_scores", max_scores);
2844 }
2845 PostingMetadata::V2 { metadata } => {
2846 s.field("layout", &"v2")
2847 .field("metadata_loaded", &metadata.initialized());
2848 }
2849 }
2850 s.finish()
2851 }
2852}
2853
2854impl DeepSizeOf for PostingListReader {
2855 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
2856 let metadata_size = match &self.metadata {
2857 PostingMetadata::LegacyV1 {
2858 offsets,
2859 max_scores,
2860 } => offsets.deep_size_of_children(context) + max_scores.deep_size_of_children(context),
2861 PostingMetadata::V2 { metadata } => metadata
2862 .get()
2863 .map(|loaded| {
2864 loaded.max_scores.deep_size_of_children(context)
2865 + loaded.lengths.deep_size_of_children(context)
2866 })
2867 .unwrap_or(0),
2868 };
2869 metadata_size + self.grouping.deep_size_of_children(context)
2870 }
2871}
2872
2873impl PostingListReader {
2874 pub(crate) async fn try_new(
2875 reader: Arc<dyn IndexReader>,
2876 index_cache: &LanceCache,
2877 ) -> Result<Self> {
2878 let positions_layout = if reader.schema().field(COMPRESSED_POSITION_COL).is_some() {
2879 PositionsLayout::SharedStream(parse_shared_position_codec(&reader.schema().metadata)?)
2880 } else if reader.schema().field(POSITION_COL).is_some() {
2881 PositionsLayout::LegacyPerDoc
2882 } else {
2883 PositionsLayout::None
2884 };
2885 let posting_tail_codec = parse_posting_tail_codec(&reader.schema().metadata)?;
2886 let block_size = parse_posting_block_size(&reader.schema().metadata)?;
2887 let has_position = positions_layout != PositionsLayout::None;
2888 let has_impacts = reader.schema().field(IMPACT_COL).is_some();
2889 let metadata = if reader.schema().field(POSTING_COL).is_none() {
2890 let (offsets, max_scores) = Self::load_metadata(reader.schema())?;
2891 PostingMetadata::LegacyV1 {
2892 offsets,
2893 max_scores,
2894 }
2895 } else {
2896 PostingMetadata::V2 {
2897 metadata: OnceCell::new(),
2898 }
2899 };
2900
2901 let is_legacy_layout = matches!(&metadata, PostingMetadata::LegacyV1 { .. });
2902 let grouping = PostingGrouping::for_reader(is_legacy_layout, reader.num_rows());
2903
2904 Ok(Self {
2905 reader,
2906 metadata,
2907 has_position,
2908 has_impacts,
2909 posting_tail_codec,
2910 block_size,
2911 positions_layout,
2912 grouping,
2913 index_cache: WeakLanceCache::from(index_cache),
2914 })
2915 }
2916
2917 fn load_metadata(
2920 schema: &lance_core::datatypes::Schema,
2921 ) -> Result<(Vec<usize>, Option<Vec<f32>>)> {
2922 let offsets = schema
2923 .metadata
2924 .get("offsets")
2925 .ok_or(Error::index("offsets not found in metadata".to_owned()))?;
2926 let offsets = serde_json::from_str(offsets)?;
2927
2928 let max_scores = schema
2929 .metadata
2930 .get("max_scores")
2931 .map(|max_scores| serde_json::from_str(max_scores))
2932 .transpose()?;
2933 Ok((offsets, max_scores))
2934 }
2935
2936 pub fn len(&self) -> usize {
2938 match &self.metadata {
2939 PostingMetadata::LegacyV1 { offsets, .. } => offsets.len(),
2940 PostingMetadata::V2 { .. } => self.reader.num_rows(),
2941 }
2942 }
2943
2944 pub fn is_empty(&self) -> bool {
2945 self.len() == 0
2946 }
2947
2948 pub(crate) fn has_positions(&self) -> bool {
2949 self.has_position
2950 }
2951
2952 pub(crate) fn posting_tail_codec(&self) -> PostingTailCodec {
2953 self.posting_tail_codec
2954 }
2955
2956 pub(crate) fn block_size(&self) -> usize {
2957 self.block_size
2958 }
2959
2960 fn is_legacy_layout(&self) -> bool {
2961 matches!(self.metadata, PostingMetadata::LegacyV1 { .. })
2962 }
2963
2964 pub(crate) fn posting_len(&self, token_id: u32) -> usize {
2969 let token_id = token_id as usize;
2970 match &self.metadata {
2971 PostingMetadata::LegacyV1 { offsets, .. } => {
2972 let next_offset = offsets
2973 .get(token_id + 1)
2974 .copied()
2975 .unwrap_or(self.reader.num_rows());
2976 next_offset - offsets[token_id]
2977 }
2978 PostingMetadata::V2 { metadata } => {
2979 let metadata = metadata
2980 .get()
2981 .expect("v2 posting metadata must be bulk-loaded before sync posting_len; call ensure_metadata_loaded first");
2982 metadata.lengths[token_id] as usize
2983 }
2984 }
2985 }
2986
2987 pub(crate) async fn posting_len_for_token(&self, token_id: u32) -> Result<usize> {
2993 match &self.metadata {
2994 PostingMetadata::LegacyV1 { .. } => Ok(self.posting_len(token_id)),
2995 PostingMetadata::V2 { metadata } => {
2996 if let Some(metadata) = metadata.get() {
2997 return Ok(metadata.lengths[token_id as usize] as usize);
2998 }
2999 let (_, length) = self.posting_metadata_for_token(token_id).await?;
3000 length
3001 .map(|len| len as usize)
3002 .ok_or_else(|| Error::index("posting length metadata missing".to_string()))
3003 }
3004 }
3005 }
3006
3007 pub(crate) async fn posting_metadata_for_token(
3014 &self,
3015 token_id: u32,
3016 ) -> Result<(Option<f32>, Option<u32>)> {
3017 match &self.metadata {
3018 PostingMetadata::LegacyV1 { max_scores, .. } => {
3019 Ok((max_scores.as_ref().map(|m| m[token_id as usize]), None))
3020 }
3021 PostingMetadata::V2 { metadata } => {
3022 if let Some(loaded) = metadata.get() {
3023 return Ok((
3024 Some(loaded.max_scores[token_id as usize]),
3025 Some(loaded.lengths[token_id as usize]),
3026 ));
3027 }
3028 let metadata = self
3029 .index_cache
3030 .get_or_insert_with_key(PostingMetadataKey { token_id }, || async move {
3031 let token_id = token_id as usize;
3032 let batch = self
3033 .reader
3034 .read_range(token_id..token_id + 1, Some(&[MAX_SCORE_COL, LENGTH_COL]))
3035 .await?;
3036 let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
3037 let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
3038 Ok(PostingMetadataValue { max_score, length })
3039 })
3040 .await?;
3041 Ok((Some(metadata.max_score), Some(metadata.length)))
3042 }
3043 }
3044 }
3045
3046 pub(crate) async fn ensure_metadata_loaded(&self) -> Result<()> {
3050 let PostingMetadata::V2 { metadata } = &self.metadata else {
3051 return Ok(());
3052 };
3053 metadata
3054 .get_or_try_init(|| async {
3055 let batch = self
3056 .reader
3057 .read_range(
3058 0..self.reader.num_rows(),
3059 Some(&[MAX_SCORE_COL, LENGTH_COL]),
3060 )
3061 .await?;
3062 let max_scores = batch[MAX_SCORE_COL]
3063 .as_primitive::<Float32Type>()
3064 .values()
3065 .to_vec();
3066 let lengths = batch[LENGTH_COL]
3067 .as_primitive::<UInt32Type>()
3068 .values()
3069 .to_vec();
3070 Ok::<LoadedPostingMetadata, Error>(LoadedPostingMetadata {
3071 max_scores,
3072 lengths,
3073 })
3074 })
3075 .await?;
3076 Ok(())
3077 }
3078
3079 pub(crate) async fn posting_batch(
3080 &self,
3081 token_id: u32,
3082 with_position: bool,
3083 ) -> Result<RecordBatch> {
3084 if self.is_legacy_layout() {
3085 self.posting_batch_legacy(token_id, with_position).await
3086 } else {
3087 let token_id = token_id as usize;
3088 let mut columns = if with_position {
3089 match self.positions_layout {
3090 PositionsLayout::SharedStream(_) => {
3091 vec![
3092 POSTING_COL,
3093 COMPRESSED_POSITION_COL,
3094 POSITION_BLOCK_OFFSET_COL,
3095 ]
3096 }
3097 PositionsLayout::LegacyPerDoc => vec![POSTING_COL, POSITION_COL],
3098 PositionsLayout::None => vec![POSTING_COL],
3099 }
3100 } else {
3101 vec![POSTING_COL]
3102 };
3103 if self.has_impacts {
3104 columns.push(IMPACT_COL);
3105 }
3106 let batch = self
3107 .reader
3108 .read_range(token_id..token_id + 1, Some(&columns))
3109 .await?;
3110 Ok(batch)
3111 }
3112 }
3113
3114 async fn posting_batch_legacy(
3115 &self,
3116 token_id: u32,
3117 with_position: bool,
3118 ) -> Result<RecordBatch> {
3119 let mut columns = vec![ROW_ID, FREQUENCY_COL];
3120 if with_position {
3121 columns.push(POSITION_COL);
3122 }
3123
3124 let length = self.posting_len(token_id);
3125 let PostingMetadata::LegacyV1 { offsets, .. } = &self.metadata else {
3126 unreachable!("posting_batch_legacy is only reachable on legacy v1 layout");
3127 };
3128 let token_id = token_id as usize;
3129 let offset = offsets[token_id];
3130 let batch = self
3131 .reader
3132 .read_range(offset..offset + length, Some(&columns))
3133 .await?;
3134 Ok(batch)
3135 }
3136
3137 #[instrument(level = "debug", skip(self, metrics))]
3138 pub(crate) async fn posting_list(
3139 &self,
3140 token_id: u32,
3141 is_phrase_query: bool,
3142 metrics: &dyn MetricsCollector,
3143 ) -> Result<PostingList> {
3144 let mut posting = match self.group_range_for_token(token_id) {
3145 Some((start, end)) => {
3148 let group = self
3149 .index_cache
3150 .get_or_insert_with_key(
3151 posting_list_group_cache_key(start, end, self.has_impacts),
3152 || async move {
3153 metrics.record_part_load();
3154 info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_SCALAR_PART, index_type="inverted", part_id=start);
3155 self.load_posting_list_group(start, end).await
3156 },
3157 )
3158 .await?;
3159 let (max_score, length) = if group.needs_external_metadata() {
3160 self.posting_metadata_for_token(token_id).await?
3161 } else {
3162 (None, None)
3163 };
3164 let slot = (token_id - start) as usize;
3165 group
3166 .posting_list(slot, max_score, length)?
3167 .ok_or_else(|| {
3168 Error::index(format!(
3169 "token {token_id} maps to slot {slot} outside posting group [{start}, {end})"
3170 ))
3171 })?
3172 }
3173 None => self
3176 .index_cache
3177 .get_or_insert_with_key(
3178 posting_list_cache_key(token_id, self.has_impacts),
3179 || async move {
3180 metrics.record_part_load();
3181 info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_SCALAR_PART, index_type="inverted", part_id=token_id);
3182 let (batch, (max_score, length)) = futures::try_join!(
3187 self.posting_batch(token_id, false),
3188 self.posting_metadata_for_token(token_id),
3189 )?;
3190 self.posting_list_from_batch(&batch, max_score, length)
3191 },
3192 )
3193 .await?
3194 .as_ref()
3195 .clone(),
3196 };
3197
3198 if is_phrase_query && !posting.has_position() {
3199 let positions = self.read_positions(token_id).await?;
3201 posting.set_positions(positions);
3202 }
3203
3204 Ok(posting)
3205 }
3206
3207 fn group_range_for_token(&self, token_id: u32) -> Option<(u32, u32)> {
3212 self.grouping.range_for_token(token_id, self.len())
3213 }
3214
3215 async fn load_posting_list_group(&self, start: u32, end: u32) -> Result<PostingListGroup> {
3219 let mut columns = vec![POSTING_COL, MAX_SCORE_COL, LENGTH_COL];
3220 if self.has_impacts {
3221 columns.push(IMPACT_COL);
3222 }
3223 let batch = self
3224 .reader
3225 .read_range(start as usize..end as usize, Some(&columns))
3226 .await?;
3227 PostingListGroup::new_packed_with_block_size(
3228 batch.shrink_to_fit()?,
3229 self.posting_tail_codec,
3230 self.block_size,
3231 )
3232 }
3233
3234 fn posting_list_from_batch_parts(
3235 batch: &RecordBatch,
3236 max_score: Option<f32>,
3237 length: Option<u32>,
3238 posting_tail_codec: PostingTailCodec,
3239 block_size: usize,
3240 positions_layout: PositionsLayout,
3241 ) -> Result<PostingList> {
3242 let posting_list = PostingList::from_batch_with_tail_codec_and_positions_layout(
3243 batch,
3244 max_score,
3245 length,
3246 posting_tail_codec,
3247 block_size,
3248 positions_layout,
3249 )?;
3250 Ok(posting_list)
3251 }
3252
3253 pub(crate) fn posting_list_from_batch(
3254 &self,
3255 batch: &RecordBatch,
3256 max_score: Option<f32>,
3257 length: Option<u32>,
3258 ) -> Result<PostingList> {
3259 Self::posting_list_from_batch_parts(
3260 batch,
3261 max_score,
3262 length,
3263 self.posting_tail_codec,
3264 self.block_size,
3265 self.positions_layout,
3266 )
3267 }
3268
3269 fn build_prewarm_posting_lists_chunk(
3273 chunk_batch: RecordBatch,
3274 chunk: PrewarmChunk<'_>,
3275 ctx: &PrewarmBuildCtx<'_>,
3276 ) -> Result<Vec<(u32, PostingList)>> {
3277 let mut posting_lists = Vec::with_capacity(chunk.token_count);
3278 for local in 0..chunk.token_count {
3279 let global = chunk.tok_start + local;
3280 let row_batch = if let Some(chunk_offsets) = chunk.offsets {
3281 let base = chunk_offsets[0];
3284 let start = chunk_offsets[local] - base;
3285 let end = if local + 1 < chunk_offsets.len() {
3286 chunk_offsets[local + 1] - base
3287 } else {
3288 chunk.end_row - base
3289 };
3290 chunk_batch.slice(start, end - start)
3291 } else {
3292 chunk_batch.slice(local, 1)
3294 };
3295 let row_batch = row_batch.shrink_to_fit()?;
3296 let posting_list = Self::posting_list_from_batch_parts(
3297 &row_batch,
3298 ctx.max_scores.map(|scores| scores[global]),
3299 ctx.lengths.map(|lengths| lengths[global]),
3300 ctx.posting_tail_codec,
3301 ctx.block_size,
3302 ctx.positions_layout,
3303 )?;
3304 posting_lists.push((global as u32, posting_list));
3305 }
3306
3307 Ok(posting_lists)
3308 }
3309
3310 async fn read_chunk_batch(
3313 &self,
3314 tok_start: usize,
3315 tok_end: usize,
3316 with_position: bool,
3317 ) -> Result<RecordBatch> {
3318 let columns = self.posting_columns(with_position);
3319 let row_range = match &self.metadata {
3320 PostingMetadata::LegacyV1 { offsets, .. } => {
3321 let start = offsets[tok_start];
3322 let end = offsets
3323 .get(tok_end)
3324 .copied()
3325 .unwrap_or_else(|| self.reader.num_rows());
3326 start..end
3327 }
3328 PostingMetadata::V2 { .. } => tok_start..tok_end,
3329 };
3330 let batch = self.reader.read_range(row_range, Some(&columns)).await?;
3331 Ok(batch)
3332 }
3333
3334 async fn prewarm_posting_lists(
3335 &self,
3336 with_position: bool,
3337 chunk_concurrency: usize,
3338 ) -> Result<()> {
3339 self.prewarm_posting_lists_chunked(with_position, None, chunk_concurrency)
3340 .await?;
3341 Ok(())
3342 }
3343
3344 async fn prewarm_posting_lists_chunked(
3348 &self,
3349 with_position: bool,
3350 chunk_tokens_override: Option<usize>,
3351 chunk_concurrency: usize,
3352 ) -> Result<usize> {
3353 if with_position && !self.has_positions() {
3354 return Err(Error::invalid_input(
3355 "cannot prewarm positions for an inverted index that was built without positions; recreate the index with with_position=true".to_owned(),
3356 ));
3357 }
3358
3359 self.ensure_metadata_loaded().await?;
3362
3363 let grouping = self.grouping.clone();
3367 let use_packed_groups = grouping.is_grouped() && !with_position;
3368 let state = (!use_packed_groups).then(|| self.chunk_build_state());
3372 let token_count = self.len();
3373 let posting_data_size_bytes = self.posting_data_size_bytes();
3374 let chunk_tokens = chunk_tokens_override
3375 .unwrap_or_else(|| prewarm_chunk_tokens(token_count, posting_data_size_bytes))
3376 .max(1);
3377 let chunk_ranges = prewarm_chunk_ranges(&grouping, token_count, chunk_tokens);
3378 let chunk_count = chunk_ranges.len();
3379 let chunk_concurrency = chunk_concurrency.max(1);
3380
3381 let read_build_start = Instant::now();
3382 stream::iter(chunk_ranges)
3383 .map(|(tok_start, tok_end)| {
3384 let state = state.as_ref();
3385 let grouping = &grouping;
3386 async move {
3387 if use_packed_groups {
3388 let groups = self
3389 .build_packed_chunk_groups(tok_start, tok_end, token_count, grouping)
3390 .await?;
3391 for (start, end, group) in groups {
3392 self.index_cache
3393 .insert_with_key(
3394 &posting_list_group_cache_key(start, end, self.has_impacts),
3395 Arc::new(group),
3396 )
3397 .await;
3398 }
3399 } else {
3400 let state = state.expect(
3401 "materialized prewarm must initialize posting-list build state",
3402 );
3403 let posting_lists = self
3404 .build_chunk_postings(tok_start, tok_end, with_position, state)
3405 .await?;
3406 self.publish_chunk_postings(
3407 posting_lists,
3408 grouping,
3409 tok_start,
3410 tok_end,
3411 token_count,
3412 with_position,
3413 )
3414 .await;
3415 }
3416 Result::Ok(())
3417 }
3418 })
3419 .buffer_unordered(chunk_concurrency)
3420 .try_collect::<()>()
3421 .await?;
3422 let read_build_elapsed = read_build_start.elapsed();
3423
3424 info!(
3425 legacy_layout = self.is_legacy_layout(),
3426 with_position,
3427 token_count,
3428 chunk_count,
3429 chunk_tokens,
3430 chunk_concurrency,
3431 posting_data_size_bytes,
3432 read_build_ms = read_build_elapsed.as_secs_f64() * 1000.0,
3433 "posting list prewarm timing"
3434 );
3435
3436 Ok(chunk_count)
3437 }
3438
3439 fn chunk_build_state(&self) -> ChunkBuildState {
3442 let (offsets, max_scores, lengths) = match &self.metadata {
3443 PostingMetadata::LegacyV1 {
3444 offsets,
3445 max_scores,
3446 } => (Some(offsets.clone()), max_scores.clone(), None),
3447 PostingMetadata::V2 { metadata } => (
3448 None,
3449 metadata.get().map(|loaded| loaded.max_scores.clone()),
3450 metadata.get().map(|loaded| loaded.lengths.clone()),
3451 ),
3452 };
3453 ChunkBuildState {
3454 offsets: offsets.map(Arc::new),
3455 max_scores: max_scores.map(Arc::new),
3456 lengths: lengths.map(Arc::new),
3457 posting_tail_codec: self.posting_tail_codec,
3458 block_size: self.block_size,
3459 positions_layout: self.positions_layout,
3460 }
3461 }
3462
3463 async fn build_chunk_postings(
3467 &self,
3468 tok_start: usize,
3469 tok_end: usize,
3470 with_position: bool,
3471 state: &ChunkBuildState,
3472 ) -> Result<Vec<(u32, PostingList)>> {
3473 let chunk_token_count = tok_end - tok_start;
3474 let chunk_batch = self
3475 .read_chunk_batch(tok_start, tok_end, with_position)
3476 .await?;
3477
3478 let (chunk_offsets, chunk_end_row) = match state.offsets.as_ref() {
3479 Some(offsets) => {
3480 let end_row = offsets
3481 .get(tok_end)
3482 .copied()
3483 .unwrap_or_else(|| self.reader.num_rows());
3484 (Some(offsets[tok_start..tok_end].to_vec()), end_row)
3485 }
3486 None => (None, tok_end),
3488 };
3489 let max_scores = state.max_scores.clone();
3490 let lengths = state.lengths.clone();
3491 let posting_tail_codec = state.posting_tail_codec;
3492 let block_size = state.block_size;
3493 let positions_layout = state.positions_layout;
3494 let posting_lists = spawn_blocking(move || {
3495 let ctx = PrewarmBuildCtx {
3496 max_scores: max_scores.as_deref().map(|v| v.as_slice()),
3497 lengths: lengths.as_deref().map(|v| v.as_slice()),
3498 posting_tail_codec,
3499 block_size,
3500 positions_layout,
3501 };
3502 let chunk = PrewarmChunk {
3503 tok_start,
3504 token_count: chunk_token_count,
3505 offsets: chunk_offsets.as_deref(),
3506 end_row: chunk_end_row,
3507 };
3508 Self::build_prewarm_posting_lists_chunk(chunk_batch, chunk, &ctx)
3509 })
3510 .await
3511 .map_err(|err| {
3512 Error::internal(format!(
3513 "Failed to build prewarm posting lists in blocking task: {err}"
3514 ))
3515 })??;
3516 debug_assert_eq!(posting_lists.len(), chunk_token_count);
3519 debug_assert!(
3520 posting_lists
3521 .iter()
3522 .enumerate()
3523 .all(|(i, (token_id, _))| *token_id as usize == tok_start + i)
3524 );
3525 Ok(posting_lists)
3526 }
3527
3528 async fn build_packed_chunk_groups(
3532 &self,
3533 tok_start: usize,
3534 tok_end: usize,
3535 token_count: usize,
3536 grouping: &PostingGrouping,
3537 ) -> Result<Vec<(u32, u32, PostingListGroup)>> {
3538 debug_assert!(grouping.is_grouped());
3539 debug_assert!(!self.is_legacy_layout());
3540
3541 let chunk_batch = self.read_chunk_batch(tok_start, tok_end, false).await?;
3542 let ranges = grouping.ranges_for_chunk(tok_start, tok_end, token_count);
3543 let posting_tail_codec = self.posting_tail_codec;
3544 let block_size = self.block_size;
3545
3546 spawn_blocking(move || {
3547 let mut groups = Vec::with_capacity(ranges.len());
3548 for (start, end) in ranges {
3549 let start_usize = start as usize;
3550 let end_usize = end as usize;
3551 let local_start = start_usize - tok_start;
3552 let group_len = end_usize - start_usize;
3553 let group_batch = chunk_batch.slice(local_start, group_len).shrink_to_fit()?;
3554 groups.push((
3555 start,
3556 end,
3557 PostingListGroup::new_packed_with_block_size(
3558 group_batch,
3559 posting_tail_codec,
3560 block_size,
3561 )?,
3562 ));
3563 }
3564 Result::Ok(groups)
3565 })
3566 .await
3567 .map_err(|err| {
3568 Error::internal(format!(
3569 "Failed to build packed prewarm posting groups in blocking task: {err}"
3570 ))
3571 })?
3572 }
3573
3574 async fn publish_chunk_postings(
3579 &self,
3580 posting_lists: Vec<(u32, PostingList)>,
3581 grouping: &PostingGrouping,
3582 tok_start: usize,
3583 tok_end: usize,
3584 token_count: usize,
3585 with_position: bool,
3586 ) {
3587 match grouping {
3588 PostingGrouping::None => {
3589 for (token_id, mut posting_list) in posting_lists {
3590 self.cache_positions(&mut posting_list, token_id, with_position)
3591 .await;
3592 self.index_cache
3593 .insert_with_key(
3594 &posting_list_cache_key(token_id, self.has_impacts),
3595 Arc::new(posting_list),
3596 )
3597 .await;
3598 }
3599 }
3600 PostingGrouping::SyntheticFixed { .. } => {
3601 let mut chunk_postings = Vec::with_capacity(posting_lists.len());
3602 for (token_id, mut posting_list) in posting_lists {
3603 self.cache_positions(&mut posting_list, token_id, with_position)
3604 .await;
3605 chunk_postings.push(posting_list);
3606 }
3607 for (start, end) in grouping.ranges_for_chunk(tok_start, tok_end, token_count) {
3612 let start_usize = start as usize;
3613 let lo = start_usize - tok_start;
3614 let hi = end as usize - tok_start;
3615 let group = PostingListGroup::new(chunk_postings[lo..hi].to_vec());
3616 self.index_cache
3617 .insert_with_key(
3618 &posting_list_group_cache_key(start, end, self.has_impacts),
3619 Arc::new(group),
3620 )
3621 .await;
3622 }
3623 }
3624 }
3625 }
3626
3627 async fn cache_positions(
3630 &self,
3631 posting_list: &mut PostingList,
3632 token_id: u32,
3633 with_position: bool,
3634 ) {
3635 if with_position && let Some(positions) = posting_list.take_positions() {
3636 self.index_cache
3637 .insert_with_key(&PositionKey { token_id }, Arc::new(Positions(positions)))
3638 .await;
3639 }
3640 }
3641
3642 pub(crate) fn posting_data_size_bytes(&self) -> u64 {
3646 if let Some(size) = self.reader.file_size_bytes() {
3647 return size;
3648 }
3649 const ESTIMATED_BYTES_PER_ROW: u64 = 16;
3652 (self.reader.num_rows() as u64).saturating_mul(ESTIMATED_BYTES_PER_ROW)
3653 }
3654
3655 pub(crate) async fn read_batch(&self, with_position: bool) -> Result<RecordBatch> {
3656 let columns = self.posting_columns(with_position);
3657 let batch = self
3658 .reader
3659 .read_range(0..self.reader.num_rows(), Some(&columns))
3660 .await?;
3661 Ok(batch)
3662 }
3663
3664 pub(crate) async fn read_all(
3665 &self,
3666 with_position: bool,
3667 ) -> Result<impl Iterator<Item = Result<PostingList>> + '_> {
3668 self.ensure_metadata_loaded().await?;
3672 let batch = self.read_batch(with_position).await?;
3673 Ok((0..self.len()).map(move |i| {
3674 let token_id = i as u32;
3675 let range = self.posting_list_range(token_id);
3676 let batch = batch.slice(i, range.end - range.start);
3677 let (max_score, length) = self.bulk_metadata_for_token(token_id);
3678 self.posting_list_from_batch(&batch, max_score, length)
3679 }))
3680 }
3681
3682 fn bulk_metadata_for_token(&self, token_id: u32) -> (Option<f32>, Option<u32>) {
3687 match &self.metadata {
3688 PostingMetadata::LegacyV1 { max_scores, .. } => {
3689 (max_scores.as_ref().map(|m| m[token_id as usize]), None)
3690 }
3691 PostingMetadata::V2 { metadata } => {
3692 let loaded = metadata.get().expect(
3693 "v2 metadata must be bulk-loaded before bulk_metadata_for_token; call ensure_metadata_loaded first",
3694 );
3695 (
3696 Some(loaded.max_scores[token_id as usize]),
3697 Some(loaded.lengths[token_id as usize]),
3698 )
3699 }
3700 }
3701 }
3702
3703 async fn read_positions(&self, token_id: u32) -> Result<CompressedPositionStorage> {
3704 let positions = self.index_cache.get_or_insert_with_key(PositionKey { token_id }, || async move {
3705 let positions = match self.positions_layout {
3706 PositionsLayout::None => {
3707 return Err(Error::invalid_input(
3708 "position is not found but required for phrase queries, try recreating the index with position".to_owned(),
3709 ));
3710 }
3711 PositionsLayout::LegacyPerDoc => {
3712 let batch = self
3713 .reader
3714 .read_range(self.posting_list_range(token_id), Some(&[POSITION_COL]))
3715 .await
3716 .map_err(|e| match e {
3717 Error::Schema { .. } => Error::invalid_input("position is not found but required for phrase queries, try recreating the index with position".to_owned()),
3718 e => e,
3719 })?;
3720 CompressedPositionStorage::LegacyPerDoc(
3721 batch[POSITION_COL].as_list::<i32>().value(0).as_list::<i32>().clone(),
3722 )
3723 }
3724 PositionsLayout::SharedStream(codec) => {
3725 let batch = self
3726 .reader
3727 .read_range(
3728 self.posting_list_range(token_id),
3729 Some(&[COMPRESSED_POSITION_COL, POSITION_BLOCK_OFFSET_COL]),
3730 )
3731 .await
3732 .map_err(|e| match e {
3733 Error::Schema { .. } => Error::invalid_input("position is not found but required for phrase queries, try recreating the index with position".to_owned()),
3734 e => e,
3735 })?;
3736 let bytes = bytes::Bytes::from(
3737 batch[COMPRESSED_POSITION_COL]
3738 .as_binary::<i64>()
3739 .value(0)
3740 .to_vec(),
3741 );
3742 let block_offsets = batch[POSITION_BLOCK_OFFSET_COL]
3743 .as_list::<i32>()
3744 .value(0)
3745 .as_primitive::<UInt32Type>()
3746 .values()
3747 .to_vec();
3748 CompressedPositionStorage::SharedStream(SharedPositionStream::new(
3749 codec,
3750 block_offsets,
3751 bytes,
3752 ))
3753 }
3754 };
3755 Result::Ok(Positions(positions))
3756 }).await?;
3757 Ok(positions.0.clone())
3758 }
3759
3760 fn posting_list_range(&self, token_id: u32) -> Range<usize> {
3761 match &self.metadata {
3762 PostingMetadata::LegacyV1 { offsets, .. } => {
3763 let offset = offsets[token_id as usize];
3764 let posting_len = self.posting_len(token_id);
3765 offset..offset + posting_len
3766 }
3767 PostingMetadata::V2 { .. } => {
3768 let token_id = token_id as usize;
3769 token_id..token_id + 1
3770 }
3771 }
3772 }
3773
3774 fn posting_columns(&self, with_position: bool) -> Vec<&'static str> {
3775 let mut base_columns = if self.is_legacy_layout() {
3776 vec![ROW_ID, FREQUENCY_COL]
3777 } else {
3778 vec![POSTING_COL]
3779 };
3780 if with_position {
3781 match self.positions_layout {
3782 PositionsLayout::None => {}
3783 PositionsLayout::LegacyPerDoc => base_columns.push(POSITION_COL),
3784 PositionsLayout::SharedStream(_) => {
3785 base_columns.push(COMPRESSED_POSITION_COL);
3786 base_columns.push(POSITION_BLOCK_OFFSET_COL);
3787 }
3788 }
3789 }
3790 if self.has_impacts {
3791 base_columns.push(IMPACT_COL);
3792 }
3793 base_columns
3794 }
3795}
3796
3797struct ChunkBuildState {
3800 offsets: Option<Arc<Vec<usize>>>,
3801 max_scores: Option<Arc<Vec<f32>>>,
3802 lengths: Option<Arc<Vec<u32>>>,
3803 posting_tail_codec: PostingTailCodec,
3804 block_size: usize,
3805 positions_layout: PositionsLayout,
3806}
3807
3808struct PrewarmBuildCtx<'a> {
3812 max_scores: Option<&'a [f32]>,
3813 lengths: Option<&'a [u32]>,
3814 posting_tail_codec: PostingTailCodec,
3815 block_size: usize,
3816 positions_layout: PositionsLayout,
3817}
3818
3819struct PrewarmChunk<'a> {
3823 tok_start: usize,
3824 token_count: usize,
3825 offsets: Option<&'a [usize]>,
3827 end_row: usize,
3829}
3830
3831#[derive(Clone)]
3834pub struct Positions(pub(super) CompressedPositionStorage);
3835
3836fn sliced_cache_bytes(array: &dyn Array) -> usize {
3842 let validity_bytes = array
3843 .nulls()
3844 .map(|nulls| nulls.len().div_ceil(8))
3845 .unwrap_or(0);
3846 match array.data_type() {
3847 DataType::LargeBinary => {
3848 let array = array.as_binary::<i64>();
3849 let data_bytes = if array.is_empty() {
3850 0
3851 } else {
3852 let offsets = array.value_offsets();
3853 (offsets[array.len()] - offsets[0]) as usize
3854 };
3855 data_bytes + (array.len() + 1) * std::mem::size_of::<i64>() + validity_bytes
3856 }
3857 DataType::List(_) => {
3858 let array = array.as_list::<i32>();
3859 let (child_start, child_end) = if array.is_empty() {
3860 (0, 0)
3861 } else {
3862 let offsets = array.value_offsets();
3863 (offsets[0] as usize, offsets[array.len()] as usize)
3864 };
3865 let offset_bytes = (array.len() + 1) * std::mem::size_of::<i32>();
3866 let child = array.values().slice(child_start, child_end - child_start);
3867 offset_bytes + validity_bytes + sliced_cache_bytes(child.as_ref())
3868 }
3869 other => match other.primitive_width() {
3873 Some(width) => array.len() * width + validity_bytes,
3874 None => array.get_buffer_memory_size(),
3875 },
3876 }
3877}
3878
3879impl DeepSizeOf for Positions {
3880 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
3881 self.0.deep_size_of_children(context)
3882 }
3883}
3884
3885#[derive(Debug, Clone)]
3887pub struct PostingListKey {
3888 pub token_id: u32,
3889}
3890
3891impl CacheKey for PostingListKey {
3892 type ValueType = PostingList;
3893
3894 fn key(&self) -> std::borrow::Cow<'_, str> {
3895 format!("postings-{}", self.token_id).into()
3896 }
3897
3898 fn type_name() -> &'static str {
3899 "PostingList"
3900 }
3901
3902 fn codec() -> Option<CacheCodec> {
3903 Some(CacheCodec::from_impl::<PostingList>())
3904 }
3905}
3906
3907#[derive(Debug, Clone)]
3912pub struct PostingListGroupKey {
3913 pub start: u32,
3914 pub end: u32,
3915}
3916
3917impl CacheKey for PostingListGroupKey {
3918 type ValueType = PostingListGroup;
3919
3920 fn key(&self) -> std::borrow::Cow<'_, str> {
3921 format!("postings-{}-{}", self.start, self.end).into()
3922 }
3923
3924 fn type_name() -> &'static str {
3925 "PostingListGroup"
3926 }
3927
3928 fn codec() -> Option<CacheCodec> {
3929 Some(CacheCodec::from_impl::<PostingListGroup>())
3930 }
3931}
3932
3933#[derive(Debug, Clone)]
3936struct ImpactAwareCacheKey<K> {
3937 inner: K,
3938 has_impacts: bool,
3939}
3940
3941impl<K: CacheKey> CacheKey for ImpactAwareCacheKey<K> {
3942 type ValueType = K::ValueType;
3943
3944 fn key(&self) -> std::borrow::Cow<'_, str> {
3945 if self.has_impacts {
3946 format!("{}-impacts", self.inner.key()).into()
3947 } else {
3948 self.inner.key()
3949 }
3950 }
3951
3952 fn type_name() -> &'static str {
3953 K::type_name()
3954 }
3955
3956 fn codec() -> Option<CacheCodec> {
3957 K::codec()
3958 }
3959}
3960
3961fn posting_list_cache_key(token_id: u32, has_impacts: bool) -> ImpactAwareCacheKey<PostingListKey> {
3962 ImpactAwareCacheKey {
3963 inner: PostingListKey { token_id },
3964 has_impacts,
3965 }
3966}
3967
3968fn posting_list_group_cache_key(
3969 start: u32,
3970 end: u32,
3971 has_impacts: bool,
3972) -> ImpactAwareCacheKey<PostingListGroupKey> {
3973 ImpactAwareCacheKey {
3974 inner: PostingListGroupKey { start, end },
3975 has_impacts,
3976 }
3977}
3978
3979#[derive(Debug, Clone, DeepSizeOf)]
3980struct PostingMetadataValue {
3981 max_score: f32,
3982 length: u32,
3983}
3984
3985#[derive(Debug, Clone)]
3986struct PostingMetadataKey {
3987 token_id: u32,
3988}
3989
3990impl CacheKey for PostingMetadataKey {
3991 type ValueType = PostingMetadataValue;
3992
3993 fn key(&self) -> std::borrow::Cow<'_, str> {
3994 format!("posting-metadata-{}", self.token_id).into()
3995 }
3996
3997 fn type_name() -> &'static str {
3998 "PostingMetadata"
3999 }
4000}
4001
4002#[derive(Debug, Clone)]
4003pub struct PositionKey {
4004 pub token_id: u32,
4005}
4006
4007impl CacheKey for PositionKey {
4008 type ValueType = Positions;
4009
4010 fn key(&self) -> std::borrow::Cow<'_, str> {
4011 format!("positions-{}", self.token_id).into()
4012 }
4013
4014 fn type_name() -> &'static str {
4015 "Position"
4016 }
4017
4018 fn codec() -> Option<CacheCodec> {
4019 Some(CacheCodec::from_impl::<Positions>())
4020 }
4021}
4022
4023#[derive(Debug, Clone, PartialEq)]
4024pub enum CompressedPositionStorage {
4025 LegacyPerDoc(ListArray),
4026 SharedStream(SharedPositionStream),
4027}
4028
4029impl DeepSizeOf for CompressedPositionStorage {
4030 fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
4031 match self {
4032 Self::LegacyPerDoc(positions) => sliced_cache_bytes(positions),
4033 Self::SharedStream(stream) => stream.size(),
4034 }
4035 }
4036}
4037
4038#[derive(Debug, Clone, PartialEq, Eq, Default)]
4039pub struct SharedPositionStream {
4040 codec: PositionStreamCodec,
4041 block_offsets: Arc<[u32]>,
4042 bytes: bytes::Bytes,
4045}
4046
4047impl SharedPositionStream {
4048 pub fn new(codec: PositionStreamCodec, block_offsets: Vec<u32>, bytes: bytes::Bytes) -> Self {
4049 Self {
4050 codec,
4051 block_offsets: Arc::from(block_offsets.into_boxed_slice()),
4052 bytes,
4053 }
4054 }
4055
4056 pub fn codec(&self) -> PositionStreamCodec {
4057 self.codec
4058 }
4059
4060 pub fn block_count(&self) -> usize {
4061 self.block_offsets.len()
4062 }
4063
4064 pub fn block_range(&self, index: usize) -> Range<usize> {
4065 let start = self.block_offsets[index] as usize;
4066 let end = self
4067 .block_offsets
4068 .get(index + 1)
4069 .map(|offset| *offset as usize)
4070 .unwrap_or(self.bytes.len());
4071 start..end
4072 }
4073
4074 pub fn block(&self, index: usize) -> &[u8] {
4075 let range = self.block_range(index);
4076 &self.bytes[range]
4077 }
4078
4079 pub fn bytes(&self) -> &[u8] {
4080 &self.bytes
4081 }
4082
4083 pub fn block_offsets(&self) -> &[u32] {
4084 self.block_offsets.as_ref()
4085 }
4086
4087 pub fn size(&self) -> usize {
4088 self.block_offsets.len() * std::mem::size_of::<u32>() + self.bytes.len()
4089 }
4090}
4091
4092#[derive(Debug, Clone)]
4100pub struct PostingListGroup {
4101 pub(super) storage: PostingListGroupStorage,
4102}
4103
4104#[derive(Debug, Clone)]
4105pub(super) enum PostingListGroupStorage {
4106 Packed(PackedPostingListGroup),
4107 Materialized(Vec<PostingList>),
4108}
4109
4110#[derive(Debug, Clone)]
4111pub(super) struct PackedPostingListGroup {
4112 pub(super) batch: RecordBatch,
4113 pub(super) posting_tail_codec: PostingTailCodec,
4114 pub(super) block_size: usize,
4115 first_docs_states: Arc<[OnceLock<Box<[u32]>>]>,
4116 first_docs_state_capacity_bytes: usize,
4117 impact_states: Option<Arc<[OnceLock<Box<ImpactSkipData>>]>>,
4118 impact_state_capacity_bytes: usize,
4119}
4120
4121impl DeepSizeOf for PostingListGroup {
4122 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
4123 match &self.storage {
4124 PostingListGroupStorage::Packed(group) => group
4125 .batch
4126 .columns()
4127 .iter()
4128 .map(|column| sliced_cache_bytes(column.as_ref()))
4129 .sum::<usize>()
4130 .saturating_add(group.first_docs_state_capacity_bytes)
4131 .saturating_add(group.impact_state_capacity_bytes),
4132 PostingListGroupStorage::Materialized(posting_lists) => {
4133 posting_lists.deep_size_of_children(context)
4134 }
4135 }
4136 }
4137}
4138
4139impl PostingListGroup {
4140 pub(super) fn new(posting_lists: Vec<PostingList>) -> Self {
4141 Self {
4142 storage: PostingListGroupStorage::Materialized(posting_lists),
4143 }
4144 }
4145
4146 pub(super) fn new_packed(
4147 batch: RecordBatch,
4148 posting_tail_codec: PostingTailCodec,
4149 ) -> Result<Self> {
4150 let block_size = parse_posting_block_size(batch.schema_ref().metadata())?;
4151 Self::new_packed_with_block_size(batch, posting_tail_codec, block_size)
4152 }
4153
4154 fn new_packed_with_block_size(
4155 batch: RecordBatch,
4156 posting_tail_codec: PostingTailCodec,
4157 block_size: usize,
4158 ) -> Result<Self> {
4159 validate_block_size(block_size)?;
4160 if let Some(encoded_block_size) = batch.schema_ref().metadata().get(POSTING_BLOCK_SIZE_KEY)
4161 {
4162 let encoded_block_size = encoded_block_size.parse::<usize>().map_err(|err| {
4163 Error::index(format!(
4164 "invalid {POSTING_BLOCK_SIZE_KEY} metadata value {encoded_block_size:?}: {err}"
4165 ))
4166 })?;
4167 if encoded_block_size != block_size {
4168 return Err(Error::index(format!(
4169 "packed posting group {POSTING_BLOCK_SIZE_KEY}={encoded_block_size} does not match block_size={block_size}"
4170 )));
4171 }
4172 }
4173
4174 let mut schema = batch.schema().as_ref().clone();
4179 schema
4180 .metadata
4181 .insert(POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string());
4182 let batch = batch.with_schema(Arc::new(schema))?;
4183 let postings = batch
4184 .column_by_name(POSTING_COL)
4185 .and_then(|column| column.as_list_opt::<i32>())
4186 .ok_or_else(|| {
4187 Error::index(format!(
4188 "packed posting group column {POSTING_COL} must be List<LargeBinary>"
4189 ))
4190 })?;
4191 if postings.values().data_type() != &DataType::LargeBinary {
4192 return Err(Error::index(format!(
4193 "packed posting group column {POSTING_COL} must contain LargeBinary values, got {}",
4194 postings.values().data_type()
4195 )));
4196 }
4197 if postings.null_count() != 0 {
4198 return Err(Error::index(
4199 "packed posting group column must not contain nulls".to_string(),
4200 ));
4201 }
4202 let total_posting_blocks = (0..batch.num_rows())
4203 .map(|slot| postings.value_length(slot) as usize)
4204 .sum::<usize>();
4205 let first_docs_states: Arc<[OnceLock<Box<[u32]>>]> = (0..batch.num_rows())
4206 .map(|_| OnceLock::new())
4207 .collect::<Vec<_>>()
4208 .into();
4209 let first_docs_state_capacity_bytes = first_docs_states
4213 .len()
4214 .saturating_mul(std::mem::size_of::<OnceLock<Box<[u32]>>>())
4215 .saturating_add(total_posting_blocks.saturating_mul(std::mem::size_of::<u32>()));
4216 let (impact_states, impact_state_capacity_bytes) = if let Some(impacts) =
4217 batch.column_by_name(IMPACT_COL)
4218 {
4219 let impacts = impacts.as_list_opt::<i32>().ok_or_else(|| {
4220 Error::index(format!(
4221 "packed posting group column {IMPACT_COL} must be List<LargeBinary>"
4222 ))
4223 })?;
4224 if impacts.values().data_type() != &DataType::LargeBinary {
4225 return Err(Error::index(format!(
4226 "packed posting group column {IMPACT_COL} must contain LargeBinary values, got {}",
4227 impacts.values().data_type()
4228 )));
4229 }
4230 if impacts.null_count() != 0 {
4231 return Err(Error::index(format!(
4232 "packed posting group column {IMPACT_COL} must not contain nulls"
4233 )));
4234 }
4235 let mut derived_cache_bytes = 0usize;
4236 for slot in 0..batch.num_rows() {
4237 let posting_blocks = postings.value_length(slot) as usize;
4238 let impact_entries = impacts.value_length(slot) as usize;
4239 let expected_impact_entries =
4240 posting_blocks.saturating_add(posting_blocks.div_ceil(IMPACT_LEVEL1_BLOCKS));
4241 if impact_entries != expected_impact_entries {
4242 return Err(Error::index(format!(
4243 "packed posting group impact slot {slot} has {impact_entries} entries, expected {expected_impact_entries} for {posting_blocks} posting blocks"
4244 )));
4245 }
4246 derived_cache_bytes = derived_cache_bytes.saturating_add(
4247 ImpactSkipData::derived_cache_bytes_for_entries(impact_entries),
4248 );
4249 }
4250
4251 let states: Arc<[OnceLock<Box<ImpactSkipData>>]> = (0..batch.num_rows())
4252 .map(|_| OnceLock::new())
4253 .collect::<Vec<_>>()
4254 .into();
4255 let per_slot_bytes = std::mem::size_of::<OnceLock<Box<ImpactSkipData>>>()
4259 .saturating_add(std::mem::size_of::<ImpactSkipData>());
4260 let capacity_bytes = states
4261 .len()
4262 .saturating_mul(per_slot_bytes)
4263 .saturating_add(derived_cache_bytes);
4264 (Some(states), capacity_bytes)
4265 } else {
4266 (None, 0)
4267 };
4268 match (
4269 batch.column_by_name(MAX_SCORE_COL),
4270 batch.column_by_name(LENGTH_COL),
4271 ) {
4272 (None, None) => {}
4273 (Some(max_scores), Some(lengths)) => {
4274 let max_scores = max_scores
4275 .as_primitive_opt::<Float32Type>()
4276 .ok_or_else(|| {
4277 Error::index(format!(
4278 "packed posting group column {MAX_SCORE_COL} must be Float32"
4279 ))
4280 })?;
4281 let lengths = lengths.as_primitive_opt::<UInt32Type>().ok_or_else(|| {
4282 Error::index(format!(
4283 "packed posting group column {LENGTH_COL} must be UInt32"
4284 ))
4285 })?;
4286 if max_scores.null_count() != 0 || lengths.null_count() != 0 {
4287 return Err(Error::index(
4288 "packed posting group metadata columns must not contain nulls".to_string(),
4289 ));
4290 }
4291 }
4292 _ => {
4293 return Err(Error::index(format!(
4294 "packed posting group must contain both {MAX_SCORE_COL} and {LENGTH_COL}, or neither"
4295 )));
4296 }
4297 }
4298
4299 Ok(Self {
4300 storage: PostingListGroupStorage::Packed(PackedPostingListGroup {
4301 batch,
4302 posting_tail_codec,
4303 block_size,
4304 first_docs_states,
4305 first_docs_state_capacity_bytes,
4306 impact_states,
4307 impact_state_capacity_bytes,
4308 }),
4309 })
4310 }
4311
4312 pub(super) fn len(&self) -> usize {
4313 match &self.storage {
4314 PostingListGroupStorage::Packed(group) => group.batch.num_rows(),
4315 PostingListGroupStorage::Materialized(posting_lists) => posting_lists.len(),
4316 }
4317 }
4318
4319 #[cfg(test)]
4320 pub(super) fn is_packed(&self) -> bool {
4321 matches!(&self.storage, PostingListGroupStorage::Packed(_))
4322 }
4323
4324 fn needs_external_metadata(&self) -> bool {
4325 match &self.storage {
4326 PostingListGroupStorage::Packed(group) => {
4327 group.batch.column_by_name(MAX_SCORE_COL).is_none()
4328 }
4329 PostingListGroupStorage::Materialized(_) => false,
4330 }
4331 }
4332
4333 pub(super) fn posting_list(
4337 &self,
4338 slot: usize,
4339 max_score: Option<f32>,
4340 length: Option<u32>,
4341 ) -> Result<Option<PostingList>> {
4342 match &self.storage {
4343 PostingListGroupStorage::Materialized(posting_lists) => {
4344 Ok(posting_lists.get(slot).cloned())
4345 }
4346 PostingListGroupStorage::Packed(group) => {
4347 if slot >= group.batch.num_rows() {
4348 return Ok(None);
4349 }
4350 let postings = group
4351 .batch
4352 .column_by_name(POSTING_COL)
4353 .and_then(|column| column.as_list_opt::<i32>())
4354 .ok_or_else(|| {
4355 Error::index(format!(
4356 "packed posting group column {POSTING_COL} must be List<LargeBinary>"
4357 ))
4358 })?;
4359 let blocks = postings.value(slot);
4360 let blocks = blocks.as_binary_opt::<i64>().ok_or_else(|| {
4361 Error::index(format!(
4362 "packed posting group slot {slot} is not LargeBinary"
4363 ))
4364 })?;
4365 let max_score = match group.batch.column_by_name(MAX_SCORE_COL) {
4366 Some(column) => column
4367 .as_primitive_opt::<Float32Type>()
4368 .expect("packed group metadata was validated at construction")
4369 .value(slot),
4370 None => max_score.ok_or_else(|| {
4371 Error::index("packed posting group requires max-score metadata".to_string())
4372 })?,
4373 };
4374 let length = match group.batch.column_by_name(LENGTH_COL) {
4375 Some(column) => column
4376 .as_primitive_opt::<UInt32Type>()
4377 .expect("packed group metadata was validated at construction")
4378 .value(slot),
4379 None => length.ok_or_else(|| {
4380 Error::index("packed posting group requires length metadata".to_string())
4381 })?,
4382 };
4383 let impacts = match (
4384 group.impact_states.as_ref(),
4385 group.batch.column_by_name(IMPACT_COL),
4386 ) {
4387 (Some(states), Some(column)) => {
4388 let state = states.get(slot).ok_or_else(|| {
4389 Error::index(format!(
4390 "packed posting group impact state missing slot {slot}"
4391 ))
4392 })?;
4393 let impact_lists = column.as_list_opt::<i32>().ok_or_else(|| {
4394 Error::index(format!(
4395 "packed posting group column {IMPACT_COL} must be List<LargeBinary>"
4396 ))
4397 })?;
4398 let entries = impact_lists.value(slot);
4399 let entries = entries.as_binary_opt::<i64>().ok_or_else(|| {
4400 Error::index(format!(
4401 "packed posting group impact slot {slot} is not LargeBinary"
4402 ))
4403 })?;
4404 let impacts =
4405 state.get_or_init(|| {
4406 Box::new(ImpactSkipData::new(entries.clone(), blocks.len()).expect(
4407 "packed impact entry count was validated at construction",
4408 ))
4409 });
4410 Some(impacts.as_ref().clone())
4411 }
4412 (None, None) => None,
4413 _ => {
4414 return Err(Error::internal(
4415 "packed posting group impact column/state mismatch".to_string(),
4416 ));
4417 }
4418 };
4419 Ok(Some(PostingList::Compressed(
4420 CompressedPostingList::new(
4421 blocks.clone(),
4422 max_score,
4423 length,
4424 group.posting_tail_codec,
4425 group.block_size,
4426 None,
4427 impacts,
4428 )
4429 .with_packed_first_docs(group.first_docs_states.clone(), slot),
4430 )))
4431 }
4432 }
4433 }
4434}
4435
4436#[derive(Debug, Clone, DeepSizeOf)]
4437#[allow(clippy::large_enum_variant)]
4438pub enum PostingList {
4439 Plain(PlainPostingList),
4440 Compressed(CompressedPostingList),
4441}
4442
4443impl PostingList {
4444 pub fn from_batch(
4445 batch: &RecordBatch,
4446 max_score: Option<f32>,
4447 length: Option<u32>,
4448 ) -> Result<Self> {
4449 let posting_tail_codec = parse_posting_tail_codec(batch.schema_ref().metadata())?;
4450 let block_size = parse_posting_block_size(batch.schema_ref().metadata())?;
4451 Self::from_batch_with_tail_codec(batch, max_score, length, posting_tail_codec, block_size)
4452 }
4453
4454 pub fn from_batch_with_tail_codec(
4455 batch: &RecordBatch,
4456 max_score: Option<f32>,
4457 length: Option<u32>,
4458 posting_tail_codec: PostingTailCodec,
4459 block_size: usize,
4460 ) -> Result<Self> {
4461 let positions_layout = if batch.column_by_name(COMPRESSED_POSITION_COL).is_some() {
4462 PositionsLayout::SharedStream(parse_shared_position_codec(
4463 batch.schema_ref().metadata(),
4464 )?)
4465 } else if batch.column_by_name(POSITION_COL).is_some() {
4466 PositionsLayout::LegacyPerDoc
4467 } else {
4468 PositionsLayout::None
4469 };
4470 Self::from_batch_with_tail_codec_and_positions_layout(
4471 batch,
4472 max_score,
4473 length,
4474 posting_tail_codec,
4475 block_size,
4476 positions_layout,
4477 )
4478 }
4479
4480 fn from_batch_with_tail_codec_and_positions_layout(
4481 batch: &RecordBatch,
4482 max_score: Option<f32>,
4483 length: Option<u32>,
4484 posting_tail_codec: PostingTailCodec,
4485 block_size: usize,
4486 positions_layout: PositionsLayout,
4487 ) -> Result<Self> {
4488 match batch.column_by_name(POSTING_COL) {
4489 Some(_) => {
4490 debug_assert!(max_score.is_some() && length.is_some());
4491 let shared_position_codec = match positions_layout {
4492 PositionsLayout::SharedStream(codec) => Some(codec),
4493 _ => None,
4494 };
4495 let posting = CompressedPostingList::from_batch(
4496 batch,
4497 max_score.unwrap(),
4498 length.unwrap(),
4499 posting_tail_codec,
4500 block_size,
4501 shared_position_codec,
4502 )?;
4503 Ok(Self::Compressed(posting))
4504 }
4505 None => {
4506 let posting = PlainPostingList::from_batch(batch, max_score);
4507 Ok(Self::Plain(posting))
4508 }
4509 }
4510 }
4511
4512 pub fn iter(&self) -> PostingListIterator<'_> {
4513 PostingListIterator::new(self)
4514 }
4515
4516 pub fn has_position(&self) -> bool {
4517 match self {
4518 Self::Plain(posting) => posting.positions.is_some(),
4519 Self::Compressed(posting) => posting.positions.is_some(),
4520 }
4521 }
4522
4523 pub fn has_impacts(&self) -> bool {
4524 match self {
4525 Self::Plain(_) => false,
4526 Self::Compressed(posting) => posting.impacts.is_some(),
4527 }
4528 }
4529
4530 pub fn set_positions(&mut self, positions: CompressedPositionStorage) {
4531 match self {
4532 Self::Plain(posting) => match positions {
4533 CompressedPositionStorage::LegacyPerDoc(positions) => {
4534 posting.positions = Some(positions)
4535 }
4536 CompressedPositionStorage::SharedStream(_) => {
4537 unreachable!("shared position stream is not supported for plain postings")
4538 }
4539 },
4540 Self::Compressed(posting) => {
4541 posting.positions = Some(positions);
4542 }
4543 }
4544 }
4545
4546 pub fn take_positions(&mut self) -> Option<CompressedPositionStorage> {
4547 match self {
4548 Self::Plain(posting) => posting
4549 .positions
4550 .take()
4551 .map(CompressedPositionStorage::LegacyPerDoc),
4552 Self::Compressed(posting) => posting.positions.take(),
4553 }
4554 }
4555
4556 pub fn max_score(&self) -> Option<f32> {
4557 match self {
4558 Self::Plain(posting) => posting.max_score,
4559 Self::Compressed(posting) => Some(posting.max_score),
4560 }
4561 }
4562
4563 pub fn len(&self) -> usize {
4564 match self {
4565 Self::Plain(posting) => posting.len(),
4566 Self::Compressed(posting) => posting.length as usize,
4567 }
4568 }
4569
4570 pub fn is_empty(&self) -> bool {
4571 self.len() == 0
4572 }
4573
4574 pub fn into_builder(self, docs: &DocSet) -> PostingListBuilder {
4575 let posting_tail_codec = match &self {
4576 Self::Plain(_) => PostingTailCodec::Fixed32,
4577 Self::Compressed(posting) => posting.posting_tail_codec,
4578 };
4579 let block_size = match &self {
4580 Self::Plain(_) => LEGACY_BLOCK_SIZE,
4581 Self::Compressed(posting) => posting.block_size,
4582 };
4583 let mut builder = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
4584 self.has_position(),
4585 posting_tail_codec,
4586 block_size,
4587 );
4588 match self {
4589 Self::Plain(posting) => {
4591 struct Item {
4595 doc_id: u32,
4596 positions: PositionRecorder,
4597 }
4598 let doc_ids = docs
4599 .row_ids
4600 .iter()
4601 .enumerate()
4602 .map(|(doc_id, row_id)| (*row_id, doc_id as u32))
4603 .collect::<HashMap<_, _>>();
4604 let mut items = Vec::with_capacity(posting.len());
4605 for (row_id, freq, positions) in posting.iter() {
4606 let freq = freq as u32;
4607 let positions = match positions {
4608 Some(positions) => {
4609 PositionRecorder::Position(positions.collect::<Vec<_>>().into())
4610 }
4611 None => PositionRecorder::Count(freq),
4612 };
4613 items.push(Item {
4614 doc_id: doc_ids[&row_id],
4615 positions,
4616 });
4617 }
4618 items.sort_unstable_by_key(|item| item.doc_id);
4619 for item in items {
4620 builder.add(item.doc_id, item.positions);
4621 }
4622 }
4623 Self::Compressed(posting) => {
4624 posting.iter().for_each(|(doc_id, freq, positions)| {
4625 let positions = match positions {
4626 Some(positions) => {
4627 PositionRecorder::Position(positions.collect::<Vec<_>>().into())
4628 }
4629 None => PositionRecorder::Count(freq),
4630 };
4631 builder.add(doc_id, positions);
4632 });
4633 }
4634 }
4635 builder
4636 }
4637}
4638
4639#[derive(Debug, PartialEq, Clone)]
4640pub struct PlainPostingList {
4641 pub row_ids: ScalarBuffer<u64>,
4642 pub frequencies: ScalarBuffer<f32>,
4643 pub max_score: Option<f32>,
4644 pub positions: Option<ListArray>, }
4646
4647impl DeepSizeOf for PlainPostingList {
4648 fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
4649 self.row_ids.len() * std::mem::size_of::<u64>()
4650 + self.frequencies.len() * std::mem::size_of::<f32>()
4651 + self
4652 .positions
4653 .as_ref()
4654 .map(|positions| sliced_cache_bytes(positions))
4655 .unwrap_or(0)
4656 }
4657}
4658
4659impl PlainPostingList {
4660 pub fn new(
4661 row_ids: ScalarBuffer<u64>,
4662 frequencies: ScalarBuffer<f32>,
4663 max_score: Option<f32>,
4664 positions: Option<ListArray>,
4665 ) -> Self {
4666 Self {
4667 row_ids,
4668 frequencies,
4669 max_score,
4670 positions,
4671 }
4672 }
4673
4674 pub fn from_batch(batch: &RecordBatch, max_score: Option<f32>) -> Self {
4675 let row_ids = batch[ROW_ID].as_primitive::<UInt64Type>().values().clone();
4676 let frequencies = batch[FREQUENCY_COL]
4677 .as_primitive::<Float32Type>()
4678 .values()
4679 .clone();
4680 let positions = batch
4681 .column_by_name(POSITION_COL)
4682 .map(|col| col.as_list::<i32>().clone());
4683
4684 Self::new(row_ids, frequencies, max_score, positions)
4685 }
4686
4687 pub fn len(&self) -> usize {
4688 self.row_ids.len()
4689 }
4690
4691 pub fn is_empty(&self) -> bool {
4692 self.len() == 0
4693 }
4694
4695 pub fn iter(&self) -> PlainPostingListIterator<'_> {
4696 Box::new(
4697 self.row_ids
4698 .iter()
4699 .zip(self.frequencies.iter())
4700 .enumerate()
4701 .map(|(idx, (doc_id, freq))| {
4702 (
4703 *doc_id,
4704 *freq,
4705 self.positions.as_ref().map(|p| {
4706 let start = p.value_offsets()[idx] as usize;
4707 let end = p.value_offsets()[idx + 1] as usize;
4708 Box::new(
4709 p.values().as_primitive::<Int32Type>().values()[start..end]
4710 .iter()
4711 .map(|pos| *pos as u32),
4712 ) as _
4713 }),
4714 )
4715 }),
4716 )
4717 }
4718
4719 #[inline]
4720 pub fn doc(&self, i: usize) -> LocatedDocInfo {
4721 LocatedDocInfo::new(self.row_ids[i], self.frequencies[i])
4722 }
4723
4724 pub fn positions(&self, index: usize) -> Option<Arc<dyn Array>> {
4725 self.positions
4726 .as_ref()
4727 .map(|positions| positions.value(index))
4728 }
4729
4730 pub fn max_score(&self) -> Option<f32> {
4731 self.max_score
4732 }
4733
4734 pub fn row_id(&self, i: usize) -> u64 {
4735 self.row_ids[i]
4736 }
4737}
4738
4739#[derive(Debug, Clone)]
4740enum FirstDocsState {
4741 Standalone(Arc<OnceLock<Box<[u32]>>>),
4742 Packed {
4743 states: Arc<[OnceLock<Box<[u32]>>]>,
4744 slot: usize,
4745 },
4746}
4747
4748impl FirstDocsState {
4749 fn standalone() -> Self {
4750 Self::Standalone(Arc::new(OnceLock::new()))
4751 }
4752
4753 fn state(&self) -> &OnceLock<Box<[u32]>> {
4754 match self {
4755 Self::Standalone(state) => state,
4756 Self::Packed { states, slot } => &states[*slot],
4757 }
4758 }
4759
4760 fn get_or_init(&self, initialize: impl FnOnce() -> Box<[u32]>) -> &[u32] {
4761 self.state().get_or_init(initialize)
4762 }
4763
4764 fn capacity_bytes(
4765 &self,
4766 block_count: usize,
4767 context: &mut lance_core::deepsize::Context,
4768 ) -> usize {
4769 if context.mark_seen(self.state() as *const _ as usize) {
4770 std::mem::size_of::<OnceLock<Box<[u32]>>>()
4771 .saturating_add(block_count.saturating_mul(std::mem::size_of::<u32>()))
4772 } else {
4773 0
4774 }
4775 }
4776
4777 #[cfg(test)]
4778 fn shares_state_with(&self, other: &Self) -> bool {
4779 std::ptr::eq(self.state(), other.state())
4780 }
4781}
4782
4783#[derive(Debug, Clone)]
4784pub struct CompressedPostingList {
4785 pub max_score: f32,
4786 pub length: u32,
4787 pub blocks: LargeBinaryArray,
4791 pub posting_tail_codec: PostingTailCodec,
4792 pub block_size: usize,
4793 pub positions: Option<CompressedPositionStorage>,
4794 pub(crate) impacts: Option<ImpactSkipData>,
4795 first_docs: FirstDocsState,
4798}
4799
4800impl PartialEq for CompressedPostingList {
4801 fn eq(&self, other: &Self) -> bool {
4802 self.max_score == other.max_score
4803 && self.length == other.length
4804 && self.blocks == other.blocks
4805 && self.posting_tail_codec == other.posting_tail_codec
4806 && self.block_size == other.block_size
4807 && self.positions == other.positions
4808 && self.impacts == other.impacts
4809 }
4810}
4811
4812impl DeepSizeOf for CompressedPostingList {
4813 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
4814 sliced_cache_bytes(&self.blocks)
4815 + self
4816 .positions
4817 .as_ref()
4818 .map(|positions| positions.deep_size_of_children(context))
4819 .unwrap_or(0)
4820 + self
4821 .impacts
4822 .as_ref()
4823 .map(|impacts| {
4824 sliced_cache_bytes(impacts.entries())
4825 .saturating_add(impacts.derived_cache_bytes())
4826 })
4827 .unwrap_or(0)
4828 + self.first_docs.capacity_bytes(self.blocks.len(), context)
4829 }
4830}
4831
4832impl CompressedPostingList {
4833 pub(crate) fn new(
4834 blocks: LargeBinaryArray,
4835 max_score: f32,
4836 length: u32,
4837 posting_tail_codec: PostingTailCodec,
4838 block_size: usize,
4839 positions: Option<CompressedPositionStorage>,
4840 impacts: Option<ImpactSkipData>,
4841 ) -> Self {
4842 debug_assert!(block_size.is_power_of_two());
4843 Self {
4844 max_score,
4845 length,
4846 blocks,
4847 posting_tail_codec,
4848 block_size,
4849 positions,
4850 impacts,
4851 first_docs: FirstDocsState::standalone(),
4852 }
4853 }
4854
4855 fn with_packed_first_docs(mut self, states: Arc<[OnceLock<Box<[u32]>>]>, slot: usize) -> Self {
4856 debug_assert!(slot < states.len());
4857 self.first_docs = FirstDocsState::Packed { states, slot };
4858 self
4859 }
4860
4861 #[inline]
4865 pub(crate) fn block_shift(&self) -> u32 {
4866 self.block_size.trailing_zeros()
4867 }
4868
4869 #[inline]
4870 pub(crate) fn block_mask(&self) -> usize {
4871 self.block_size - 1
4872 }
4873
4874 pub fn from_batch(
4875 batch: &RecordBatch,
4876 max_score: f32,
4877 length: u32,
4878 posting_tail_codec: PostingTailCodec,
4879 block_size: usize,
4880 shared_position_codec: Option<PositionStreamCodec>,
4881 ) -> Result<Self> {
4882 debug_assert_eq!(batch.num_rows(), 1);
4883 let blocks = batch[POSTING_COL]
4884 .as_list::<i32>()
4885 .value(0)
4886 .as_binary::<i64>()
4887 .clone();
4888 let positions = if let Some(col) = batch.column_by_name(COMPRESSED_POSITION_COL) {
4889 let bytes = bytes::Bytes::from(col.as_binary::<i64>().value(0).to_vec());
4890 let block_offsets = batch[POSITION_BLOCK_OFFSET_COL]
4891 .as_list::<i32>()
4892 .value(0)
4893 .as_primitive::<UInt32Type>()
4894 .values()
4895 .to_vec();
4896 let codec = shared_position_codec.unwrap_or_else(|| {
4897 parse_shared_position_codec(batch.schema_ref().metadata())
4898 .expect("shared position stream codec metadata should be valid")
4899 });
4900 Some(CompressedPositionStorage::SharedStream(
4901 SharedPositionStream::new(codec, block_offsets, bytes),
4902 ))
4903 } else {
4904 batch.column_by_name(POSITION_COL).map(|col| {
4905 CompressedPositionStorage::LegacyPerDoc(
4906 col.as_list::<i32>().value(0).as_list::<i32>().clone(),
4907 )
4908 })
4909 };
4910 let impacts = batch
4911 .column_by_name(IMPACT_COL)
4912 .map(|col| {
4913 let entries = col.as_list::<i32>().value(0).as_binary::<i64>().clone();
4914 ImpactSkipData::new(entries, blocks.len())
4915 })
4916 .transpose()?;
4917
4918 Ok(Self {
4919 max_score,
4920 length,
4921 blocks,
4922 posting_tail_codec,
4923 block_size,
4924 positions,
4925 impacts,
4926 first_docs: FirstDocsState::standalone(),
4927 })
4928 }
4929
4930 pub fn iter(&self) -> CompressedPostingListIterator {
4931 CompressedPostingListIterator::new(
4932 self.length as usize,
4933 self.blocks.clone(),
4934 self.posting_tail_codec,
4935 self.positions.clone(),
4936 self.block_size,
4937 )
4938 }
4939
4940 pub fn block_max_score(&self, block_idx: usize) -> f32 {
4941 if super::encoding::posting_block_score_prefix_len(self.block_size) == 0 {
4946 return self.max_score;
4947 }
4948 let block = self.blocks.value(block_idx);
4949 block[0..4].try_into().map(f32::from_le_bytes).unwrap()
4950 }
4951
4952 #[inline]
4953 pub fn block_least_doc_id(&self, block_idx: usize) -> u32 {
4954 self.block_first_docs()[block_idx]
4955 }
4956
4957 pub(crate) fn block_first_docs(&self) -> &[u32] {
4962 self.first_docs.get_or_init(|| {
4963 (0..self.blocks.len())
4964 .map(|block_idx| {
4965 let block = self.blocks.value(block_idx);
4966 let remainder = self.length as usize % self.block_size;
4967 if block_idx + 1 == self.blocks.len() && remainder > 0 {
4968 return super::encoding::read_posting_tail_first_doc(
4969 block,
4970 self.posting_tail_codec,
4971 self.block_size,
4972 );
4973 }
4974 let prefix = super::encoding::posting_block_score_prefix_len(self.block_size);
4975 block[prefix..prefix + 4]
4976 .try_into()
4977 .map(u32::from_le_bytes)
4978 .unwrap()
4979 })
4980 .collect::<Vec<_>>()
4981 .into_boxed_slice()
4982 })
4983 }
4984
4985 #[cfg(test)]
4986 fn shares_first_docs_with(&self, other: &Self) -> bool {
4987 self.first_docs.shares_state_with(&other.first_docs)
4988 }
4989}
4990
4991#[derive(Debug, Clone, PartialEq, Eq, Default)]
4992struct EncodedBlocks {
4993 offsets: Vec<u32>,
4994 bytes: Vec<u8>,
4995}
4996
4997impl EncodedBlocks {
4998 fn len(&self) -> usize {
4999 self.offsets.len()
5000 }
5001
5002 fn size(&self) -> usize {
5003 self.offsets.capacity() * std::mem::size_of::<u32>() + self.bytes.capacity()
5004 }
5005
5006 fn push_full_block(&mut self, doc_ids: &[u32], frequencies: &[u32]) -> Result<usize> {
5007 let start = self.bytes.len();
5008 self.offsets.push(start as u32);
5009 super::encoding::encode_full_posting_block_into(doc_ids, frequencies, &mut self.bytes)?;
5010 Ok(self.bytes.len() - start)
5011 }
5012
5013 fn block(&self, index: usize) -> &[u8] {
5014 let (start, end) = self.block_range(index);
5015 &self.bytes[start..end]
5016 }
5017
5018 fn block_range(&self, index: usize) -> (usize, usize) {
5019 let start = self.offsets[index] as usize;
5020 let end = self
5021 .offsets
5022 .get(index + 1)
5023 .map(|offset| *offset as usize)
5024 .unwrap_or(self.bytes.len());
5025 (start, end)
5026 }
5027
5028 fn set_block_score(&mut self, index: usize, score: f32) {
5029 let (start, _) = self.block_range(index);
5030 self.bytes[start..start + 4].copy_from_slice(&score.to_le_bytes());
5031 }
5032
5033 fn append_remainder_block_with_codec(
5034 &mut self,
5035 doc_ids: &[u32],
5036 frequencies: &[u32],
5037 codec: PostingTailCodec,
5038 block_size: usize,
5039 ) -> Result<()> {
5040 self.offsets.push(self.bytes.len() as u32);
5041 super::encoding::encode_remainder_posting_block_into(
5042 doc_ids,
5043 frequencies,
5044 codec,
5045 block_size,
5046 &mut self.bytes,
5047 )
5048 }
5049
5050 fn into_array(mut self) -> LargeBinaryArray {
5051 let mut offsets = Vec::with_capacity(self.offsets.len() + 1);
5052 offsets.extend(self.offsets.into_iter().map(i64::from));
5053 offsets.push(self.bytes.len() as i64);
5054 LargeBinaryArray::new(
5055 OffsetBuffer::new(ScalarBuffer::from(offsets)),
5056 Buffer::from_vec(std::mem::take(&mut self.bytes)),
5057 None,
5058 )
5059 }
5060
5061 fn iter(&self) -> impl Iterator<Item = &[u8]> {
5062 (0..self.len()).map(|index| self.block(index))
5063 }
5064}
5065
5066#[derive(Debug, Clone, PartialEq, Eq, Default)]
5067struct EncodedPositionBlocks {
5068 offsets: Vec<u32>,
5069 bytes: Vec<u8>,
5070}
5071
5072impl EncodedPositionBlocks {
5073 fn size(&self) -> usize {
5074 self.offsets.capacity() * std::mem::size_of::<u32>() + self.bytes.capacity()
5075 }
5076
5077 fn block(&self, index: usize) -> &[u8] {
5078 let start = self.offsets[index] as usize;
5079 let end = self
5080 .offsets
5081 .get(index + 1)
5082 .map(|offset| *offset as usize)
5083 .unwrap_or(self.bytes.len());
5084 &self.bytes[start..end]
5085 }
5086
5087 fn push_encoded_block(&mut self, block: &[u8]) -> usize {
5088 let start = self.bytes.len();
5089 self.offsets.push(start as u32);
5090 self.bytes.extend_from_slice(block);
5091 self.bytes.len() - start
5092 }
5093
5094 fn into_stream(self) -> SharedPositionStream {
5095 SharedPositionStream::new(
5096 PositionStreamCodec::PackedDelta,
5097 self.offsets,
5098 bytes::Bytes::from(self.bytes),
5099 )
5100 }
5101}
5102
5103#[derive(Debug)]
5104pub struct PostingListBuilder {
5105 with_positions: bool,
5106 posting_tail_codec: PostingTailCodec,
5107 encoded_blocks: Option<Box<EncodedBlocks>>,
5108 encoded_position_blocks: Option<Box<EncodedPositionBlocks>>,
5109 tail_entries: Vec<RawDocInfo>,
5110 tail_positions: PositionBlockBuilder,
5111 open_doc_id: Option<u32>,
5112 open_doc_frequency: u32,
5113 open_doc_last_position: Option<u32>,
5114 block_size: usize,
5115 memory_size_bytes: u32,
5116 len: u32,
5117}
5118
5119pub(super) struct PostingListBatchBuilder {
5120 schema: SchemaRef,
5121 postings: ListBuilder<LargeBinaryBuilder>,
5122 impacts: Option<ListBuilder<LargeBinaryBuilder>>,
5123 max_scores: Float32Builder,
5124 lengths: UInt32Builder,
5125 positions: BatchPositionsBuilder,
5126 len: usize,
5127}
5128
5129enum BatchPositionsBuilder {
5130 None,
5131 Legacy(ListBuilder<ListBuilder<LargeBinaryBuilder>>),
5132 Shared {
5133 bytes: LargeBinaryBuilder,
5134 block_offsets: ListBuilder<UInt32Builder>,
5135 },
5136}
5137
5138struct PostingListParts<'a> {
5139 with_positions: bool,
5140 posting_tail_codec: PostingTailCodec,
5141 block_size: usize,
5142 length: usize,
5143 encoded_blocks: EncodedBlocks,
5144 encoded_position_blocks: EncodedPositionBlocks,
5145 tail_entries: &'a [RawDocInfo],
5146 tail_position_block: Option<Vec<u8>>,
5147}
5148
5149impl PostingListBatchBuilder {
5150 pub fn new(
5151 schema: SchemaRef,
5152 with_positions: bool,
5153 format_version: InvertedListFormatVersion,
5154 capacity: usize,
5155 ) -> Self {
5156 let positions = if !with_positions {
5157 BatchPositionsBuilder::None
5158 } else if format_version.uses_shared_position_stream() {
5159 BatchPositionsBuilder::Shared {
5160 bytes: LargeBinaryBuilder::with_capacity(capacity, 0),
5161 block_offsets: ListBuilder::with_capacity(UInt32Builder::new(), capacity),
5162 }
5163 } else {
5164 BatchPositionsBuilder::Legacy(ListBuilder::with_capacity(
5165 ListBuilder::new(LargeBinaryBuilder::new()),
5166 capacity,
5167 ))
5168 };
5169 let impacts = schema
5170 .field_with_name(IMPACT_COL)
5171 .ok()
5172 .map(|_| ListBuilder::with_capacity(LargeBinaryBuilder::new(), capacity));
5173 Self {
5174 schema,
5175 postings: ListBuilder::with_capacity(LargeBinaryBuilder::new(), capacity),
5176 impacts,
5177 max_scores: Float32Builder::with_capacity(capacity),
5178 lengths: UInt32Builder::with_capacity(capacity),
5179 positions,
5180 len: 0,
5181 }
5182 }
5183
5184 pub fn len(&self) -> usize {
5185 self.len
5186 }
5187
5188 pub fn is_empty(&self) -> bool {
5189 self.len == 0
5190 }
5191
5192 fn append(
5193 &mut self,
5194 compressed: LargeBinaryArray,
5195 impacts: Option<&ImpactSkipData>,
5196 max_score: f32,
5197 length: u32,
5198 positions: Option<&CompressedPositionStorage>,
5199 ) -> Result<()> {
5200 {
5201 let values = self.postings.values();
5202 for index in 0..compressed.len() {
5203 values.append_value(compressed.value(index));
5204 }
5205 }
5206 self.postings.append(true);
5207 if let Some(impacts_builder) = &mut self.impacts {
5208 let impacts = impacts.ok_or_else(|| {
5209 Error::index(format!(
5210 "impacts builder missing impact data for posting length {}",
5211 length
5212 ))
5213 })?;
5214 let values = impacts_builder.values();
5215 for index in 0..impacts.entries().len() {
5216 values.append_value(impacts.entries().value(index));
5217 }
5218 impacts_builder.append(true);
5219 }
5220 self.max_scores.append_value(max_score);
5221 self.lengths.append_value(length);
5222
5223 match &mut self.positions {
5224 BatchPositionsBuilder::None => {}
5225 BatchPositionsBuilder::Shared {
5226 bytes,
5227 block_offsets,
5228 } => {
5229 let positions = positions.ok_or_else(|| {
5230 Error::index(format!(
5231 "positions builder missing position data for posting length {}",
5232 length
5233 ))
5234 })?;
5235 let CompressedPositionStorage::SharedStream(positions) = positions else {
5236 return Err(Error::index(
5237 "shared positions builder received legacy positions".to_owned(),
5238 ));
5239 };
5240 bytes.append_value(positions.bytes());
5241 let offsets_builder = block_offsets.values();
5242 for &offset in positions.block_offsets() {
5243 offsets_builder.append_value(offset);
5244 }
5245 block_offsets.append(true);
5246 }
5247 BatchPositionsBuilder::Legacy(position_lists) => {
5248 let positions = positions.ok_or_else(|| {
5249 Error::index(format!(
5250 "positions builder missing position data for posting length {}",
5251 length
5252 ))
5253 })?;
5254 let CompressedPositionStorage::LegacyPerDoc(positions) = positions else {
5255 return Err(Error::index(
5256 "legacy positions builder received shared position stream".to_owned(),
5257 ));
5258 };
5259 let docs_builder = position_lists.values();
5260 for doc_idx in 0..positions.len() {
5261 let doc_positions = positions.value(doc_idx);
5262 let compressed_positions = doc_positions.as_binary::<i64>();
5263 for block_idx in 0..compressed_positions.len() {
5264 docs_builder
5265 .values()
5266 .append_value(compressed_positions.value(block_idx));
5267 }
5268 docs_builder.append(true);
5269 }
5270 position_lists.append(true);
5271 }
5272 }
5273
5274 self.len += 1;
5275 Ok(())
5276 }
5277
5278 pub fn finish(&mut self) -> Result<RecordBatch> {
5279 let mut columns = vec![
5280 Arc::new(self.postings.finish()) as ArrayRef,
5281 Arc::new(self.max_scores.finish()) as ArrayRef,
5282 Arc::new(self.lengths.finish()) as ArrayRef,
5283 ];
5284 if let Some(impacts) = &mut self.impacts {
5285 columns.push(Arc::new(impacts.finish()) as ArrayRef);
5286 }
5287 match &mut self.positions {
5288 BatchPositionsBuilder::None => {}
5289 BatchPositionsBuilder::Legacy(position_lists) => {
5290 columns.push(Arc::new(position_lists.finish()) as ArrayRef);
5291 }
5292 BatchPositionsBuilder::Shared {
5293 bytes,
5294 block_offsets,
5295 } => {
5296 columns.push(Arc::new(bytes.finish()) as ArrayRef);
5297 columns.push(Arc::new(block_offsets.finish()) as ArrayRef);
5298 }
5299 }
5300 self.len = 0;
5301 RecordBatch::try_new(self.schema.clone(), columns).map_err(Error::from)
5302 }
5303}
5304
5305impl PostingListBuilder {
5306 pub fn size(&self) -> u64 {
5307 self.memory_size_bytes as u64
5308 }
5309
5310 pub fn has_positions(&self) -> bool {
5311 self.with_positions
5312 }
5313
5314 pub fn new(with_position: bool) -> Self {
5315 Self::new_with_posting_tail_codec_and_block_size(
5316 with_position,
5317 current_fts_format_version().posting_tail_codec(),
5318 LEGACY_BLOCK_SIZE,
5319 )
5320 }
5321
5322 pub fn new_with_posting_tail_codec(
5323 with_position: bool,
5324 posting_tail_codec: PostingTailCodec,
5325 ) -> Self {
5326 Self::new_with_posting_tail_codec_and_block_size(
5327 with_position,
5328 posting_tail_codec,
5329 LEGACY_BLOCK_SIZE,
5330 )
5331 }
5332
5333 pub fn new_with_block_size(with_position: bool, block_size: usize) -> Self {
5334 Self::new_with_posting_tail_codec_and_block_size(
5335 with_position,
5336 current_fts_format_version().posting_tail_codec(),
5337 block_size,
5338 )
5339 }
5340
5341 pub fn new_with_posting_tail_codec_and_block_size(
5342 with_position: bool,
5343 posting_tail_codec: PostingTailCodec,
5344 block_size: usize,
5345 ) -> Self {
5346 validate_block_size(block_size).expect("invalid posting list block size");
5347 Self {
5348 with_positions: with_position,
5349 posting_tail_codec,
5350 encoded_blocks: None,
5351 encoded_position_blocks: None,
5352 tail_entries: Vec::new(),
5353 tail_positions: PositionBlockBuilder::default(),
5354 open_doc_id: None,
5355 open_doc_frequency: 0,
5356 open_doc_last_position: None,
5357 block_size,
5358 len: 0,
5359 memory_size_bytes: 0,
5360 }
5361 }
5362
5363 pub fn len(&self) -> usize {
5364 self.len as usize
5365 }
5366
5367 pub fn is_empty(&self) -> bool {
5368 self.len == 0
5369 }
5370
5371 pub fn iter(&self) -> std::vec::IntoIter<(u32, u32, Option<Vec<u32>>)> {
5372 self.collect_entries().into_iter()
5373 }
5374
5375 pub fn for_each_entry<E>(
5376 &self,
5377 mut visit: impl FnMut(u32, u32, Option<Vec<u32>>) -> std::result::Result<(), E>,
5378 ) -> std::result::Result<(), E> {
5379 let mut doc_ids = Vec::with_capacity(self.block_size);
5380 let mut frequencies = Vec::with_capacity(self.block_size);
5381 let mut decoded_positions = Vec::new();
5382 let mut position_block_index = 0usize;
5383
5384 if let Some(encoded_blocks) = self.encoded_blocks.as_deref() {
5385 for block in encoded_blocks.iter() {
5386 doc_ids.clear();
5387 frequencies.clear();
5388 super::encoding::decode_full_posting_block(
5389 block,
5390 &mut doc_ids,
5391 &mut frequencies,
5392 self.block_size,
5393 );
5394 decoded_positions.clear();
5395 if self.with_positions {
5396 let position_blocks = self
5397 .encoded_position_blocks
5398 .as_deref()
5399 .expect("positions must exist for posting list");
5400 super::encoding::decode_position_stream_block(
5401 position_blocks.block(position_block_index),
5402 &frequencies,
5403 PositionStreamCodec::PackedDelta,
5404 &mut decoded_positions,
5405 )
5406 .expect("position stream decoding should succeed");
5407 position_block_index += 1;
5408 }
5409 let mut offset = 0usize;
5410 for (doc_id, frequency) in doc_ids.iter().copied().zip(frequencies.iter().copied())
5411 {
5412 let positions = self.with_positions.then(|| {
5413 let end = offset + frequency as usize;
5414 let doc_positions = decoded_positions[offset..end].to_vec();
5415 offset = end;
5416 doc_positions
5417 });
5418 visit(doc_id, frequency, positions)?;
5419 }
5420 }
5421 }
5422
5423 let mut decoded_tail_positions = Vec::new();
5424 if self.with_positions && !self.tail_entries.is_empty() {
5425 let tail_frequencies = self
5426 .tail_entries
5427 .iter()
5428 .map(|entry| entry.frequency)
5429 .collect::<Vec<_>>();
5430 self.tail_positions
5431 .decode_into(tail_frequencies.as_slice(), &mut decoded_tail_positions)
5432 .expect("tail position stream decoding should succeed");
5433 }
5434 let mut tail_offset = 0usize;
5435 for entry in &self.tail_entries {
5436 let positions = self.with_positions.then(|| {
5437 let end = tail_offset + entry.frequency as usize;
5438 let doc_positions = decoded_tail_positions[tail_offset..end].to_vec();
5439 tail_offset = end;
5440 doc_positions
5441 });
5442 visit(entry.doc_id, entry.frequency, positions)?;
5443 }
5444
5445 Ok(())
5446 }
5447
5448 pub fn add(&mut self, doc_id: u32, term_positions: PositionRecorder) {
5449 debug_assert!(
5450 self.open_doc_id.is_none(),
5451 "cannot add closed doc while a positions doc is still open"
5452 );
5453 let tail_entries_capacity_before = self.tail_entries.capacity();
5454 self.tail_entries
5455 .push(RawDocInfo::new(doc_id, term_positions.len()));
5456 let tail_entries_capacity_after = self.tail_entries.capacity();
5457 if tail_entries_capacity_after > tail_entries_capacity_before {
5458 self.add_memory_bytes(
5459 (tail_entries_capacity_after - tail_entries_capacity_before)
5460 * std::mem::size_of::<RawDocInfo>(),
5461 );
5462 }
5463 if let PositionRecorder::Position(positions_in_doc) = term_positions {
5464 debug_assert!(self.with_positions);
5465 let old_size = self.tail_positions.size();
5466 self.tail_positions
5467 .append_doc_positions(positions_in_doc.as_slice())
5468 .expect("position stream encoding should succeed");
5469 self.adjust_tail_positions_size(old_size);
5470 }
5471 self.len += 1;
5472
5473 if self.tail_entries.len() == self.block_size {
5474 self.flush_tail_block()
5475 .expect("posting list block compression should succeed");
5476 }
5477 }
5478
5479 pub fn add_occurrence(&mut self, doc_id: u32, position: u32) -> Result<bool> {
5480 if !self.with_positions {
5481 return Err(Error::index(
5482 "cannot append streamed positions to a posting list without positions".to_owned(),
5483 ));
5484 }
5485
5486 match self.open_doc_id {
5487 Some(open_doc_id) if open_doc_id == doc_id => {
5488 let old_size = self.tail_positions.size();
5489 self.tail_positions
5490 .append_position(position, self.open_doc_last_position)?;
5491 self.adjust_tail_positions_size(old_size);
5492 self.open_doc_frequency += 1;
5493 self.open_doc_last_position = Some(position);
5494 Ok(false)
5495 }
5496 Some(open_doc_id) => Err(Error::index(format!(
5497 "posting list received doc {} before finishing open doc {}",
5498 doc_id, open_doc_id
5499 ))),
5500 None => {
5501 let old_size = self.tail_positions.size();
5502 self.tail_positions.append_position(position, None)?;
5503 self.adjust_tail_positions_size(old_size);
5504 self.open_doc_id = Some(doc_id);
5505 self.open_doc_frequency = 1;
5506 self.open_doc_last_position = Some(position);
5507 self.len += 1;
5508 Ok(true)
5509 }
5510 }
5511 }
5512
5513 pub fn finish_open_doc(&mut self, doc_id: u32) -> Result<()> {
5514 if !self.with_positions {
5515 return Ok(());
5516 }
5517 match self.open_doc_id {
5518 Some(open_doc_id) if open_doc_id == doc_id => {
5519 let tail_entries_capacity_before = self.tail_entries.capacity();
5520 self.tail_entries
5521 .push(RawDocInfo::new(doc_id, self.open_doc_frequency));
5522 let tail_entries_capacity_after = self.tail_entries.capacity();
5523 if tail_entries_capacity_after > tail_entries_capacity_before {
5524 self.add_memory_bytes(
5525 (tail_entries_capacity_after - tail_entries_capacity_before)
5526 * std::mem::size_of::<RawDocInfo>(),
5527 );
5528 }
5529 self.open_doc_id = None;
5530 self.open_doc_frequency = 0;
5531 self.open_doc_last_position = None;
5532 if self.tail_entries.len() == self.block_size {
5533 self.flush_tail_block()?;
5534 }
5535 Ok(())
5536 }
5537 Some(open_doc_id) => Err(Error::index(format!(
5538 "attempted to finish doc {} while doc {} is still open",
5539 doc_id, open_doc_id
5540 ))),
5541 None => Ok(()),
5542 }
5543 }
5544
5545 fn collect_entries(&self) -> Vec<(u32, u32, Option<Vec<u32>>)> {
5546 let mut entries = Vec::with_capacity(self.len());
5547 self.for_each_entry(|doc_id, frequency, positions| {
5548 entries.push((doc_id, frequency, positions));
5549 Ok::<(), ()>(())
5550 })
5551 .expect("collecting posting list entries should not fail");
5552 entries
5553 }
5554
5555 fn encoded_blocks_mut(&mut self) -> &mut EncodedBlocks {
5556 if self.encoded_blocks.is_none() {
5557 self.encoded_blocks = Some(Box::default());
5558 self.add_memory_bytes(std::mem::size_of::<EncodedBlocks>());
5559 }
5560 self.encoded_blocks
5561 .as_deref_mut()
5562 .expect("encoded blocks must exist")
5563 }
5564
5565 fn encoded_position_blocks_mut(&mut self) -> &mut EncodedPositionBlocks {
5566 if self.encoded_position_blocks.is_none() {
5567 self.encoded_position_blocks = Some(Box::default());
5568 self.add_memory_bytes(std::mem::size_of::<EncodedPositionBlocks>());
5569 }
5570 self.encoded_position_blocks
5571 .as_deref_mut()
5572 .expect("encoded position blocks must exist")
5573 }
5574
5575 fn flush_tail_block(&mut self) -> Result<()> {
5576 if self.tail_entries.is_empty() {
5577 return Ok(());
5578 }
5579 debug_assert!(
5580 self.open_doc_id.is_none(),
5581 "cannot flush a posting block while a document is still open"
5582 );
5583 debug_assert_eq!(self.tail_entries.len(), self.block_size);
5584 let doc_ids = self
5585 .tail_entries
5586 .iter()
5587 .map(|entry| entry.doc_id)
5588 .collect::<Vec<_>>();
5589 let frequencies = self
5590 .tail_entries
5591 .iter()
5592 .map(|entry| entry.frequency)
5593 .collect::<Vec<_>>();
5594 let encoded_blocks_size_before = self
5595 .encoded_blocks
5596 .as_ref()
5597 .map(|encoded_blocks| encoded_blocks.size())
5598 .unwrap_or(0usize);
5599 self.encoded_blocks_mut()
5600 .push_full_block(&doc_ids, &frequencies)?;
5601 let encoded_blocks_size_after = self
5602 .encoded_blocks
5603 .as_ref()
5604 .map(|encoded_blocks| encoded_blocks.size())
5605 .unwrap_or(0usize);
5606 if encoded_blocks_size_after > encoded_blocks_size_before {
5607 self.add_memory_bytes(encoded_blocks_size_after - encoded_blocks_size_before);
5608 }
5609 if self.with_positions {
5610 let encoded_positions_size_before = self
5611 .encoded_position_blocks
5612 .as_ref()
5613 .map(|encoded| encoded.size())
5614 .unwrap_or(0usize);
5615 let released_tail_positions_bytes = self.tail_positions.size();
5616 let tail_position_block = std::mem::take(&mut self.tail_positions).finish();
5617 self.encoded_position_blocks_mut()
5618 .push_encoded_block(tail_position_block.as_slice());
5619 let encoded_positions_size_after = self
5620 .encoded_position_blocks
5621 .as_ref()
5622 .map(|encoded| encoded.size())
5623 .unwrap_or(0usize);
5624 if released_tail_positions_bytes > 0 {
5625 self.subtract_memory_bytes(released_tail_positions_bytes);
5626 }
5627 if encoded_positions_size_after > encoded_positions_size_before {
5628 self.add_memory_bytes(encoded_positions_size_after - encoded_positions_size_before);
5629 }
5630 }
5631 self.tail_entries.clear();
5632 Ok(())
5633 }
5634
5635 fn adjust_tail_positions_size(&mut self, old_size: usize) {
5636 let new_size = self.tail_positions.size();
5637 if new_size > old_size {
5638 self.add_memory_bytes(new_size - old_size);
5639 } else if old_size > new_size {
5640 self.subtract_memory_bytes(old_size - new_size);
5641 }
5642 }
5643
5644 fn add_memory_bytes(&mut self, bytes: usize) {
5645 self.memory_size_bytes = self
5646 .memory_size_bytes
5647 .checked_add(
5648 u32::try_from(bytes).expect("posting list memory size delta overflowed u32"),
5649 )
5650 .expect("posting list memory size overflowed u32");
5651 }
5652
5653 fn subtract_memory_bytes(&mut self, bytes: usize) {
5654 self.memory_size_bytes = self
5655 .memory_size_bytes
5656 .checked_sub(
5657 u32::try_from(bytes).expect("posting list memory size delta overflowed u32"),
5658 )
5659 .expect("posting list memory size underflowed u32");
5660 }
5661
5662 fn build_position_columns(
5663 positions: Option<CompressedPositionStorage>,
5664 ) -> Result<Vec<ArrayRef>> {
5665 let Some(positions) = positions else {
5666 return Ok(Vec::new());
5667 };
5668 match positions {
5669 CompressedPositionStorage::LegacyPerDoc(positions) => {
5670 Ok(vec![Arc::new(ListArray::try_new(
5671 Arc::new(Field::new("item", positions.data_type().clone(), true)),
5672 OffsetBuffer::new(ScalarBuffer::from(vec![0_i32, positions.len() as i32])),
5673 Arc::new(positions) as ArrayRef,
5674 None,
5675 )?) as ArrayRef])
5676 }
5677 CompressedPositionStorage::SharedStream(positions) => {
5678 let mut columns = Vec::with_capacity(2);
5679 columns.push(
5680 Arc::new(LargeBinaryArray::from(vec![Some(positions.bytes())])) as ArrayRef,
5681 );
5682
5683 let mut offsets_builder = ListBuilder::new(UInt32Builder::new());
5684 for &offset in positions.block_offsets() {
5685 offsets_builder.values().append_value(offset);
5686 }
5687 offsets_builder.append(true);
5688 columns.push(Arc::new(offsets_builder.finish()) as ArrayRef);
5689 Ok(columns)
5690 }
5691 }
5692 }
5693
5694 fn build_batch(
5695 self,
5696 compressed: LargeBinaryArray,
5697 impacts: Option<ImpactSkipData>,
5698 max_score: f32,
5699 schema: SchemaRef,
5700 positions: Option<CompressedPositionStorage>,
5701 ) -> Result<RecordBatch> {
5702 let length = self.len();
5703 let offsets = OffsetBuffer::new(ScalarBuffer::from(vec![0, compressed.len() as i32]));
5704 let mut columns = vec![
5705 Arc::new(ListArray::try_new(
5706 Arc::new(Field::new("item", datatypes::DataType::LargeBinary, true)),
5707 offsets,
5708 Arc::new(compressed),
5709 None,
5710 )?) as ArrayRef,
5711 Arc::new(Float32Array::from_iter_values(std::iter::once(max_score))) as ArrayRef,
5712 Arc::new(UInt32Array::from_iter_values(std::iter::once(
5713 length as u32,
5714 ))) as ArrayRef,
5715 ];
5716 if schema.field_with_name(IMPACT_COL).is_ok() {
5717 let impacts = impacts.ok_or_else(|| {
5718 Error::index(format!(
5719 "impact column requested without impact data for posting length {}",
5720 length
5721 ))
5722 })?;
5723 let impact_offsets =
5724 OffsetBuffer::new(ScalarBuffer::from(vec![0, impacts.entries().len() as i32]));
5725 columns.push(Arc::new(ListArray::try_new(
5726 Arc::new(Field::new("item", datatypes::DataType::LargeBinary, true)),
5727 impact_offsets,
5728 Arc::new(impacts.entries().clone()),
5729 None,
5730 )?) as ArrayRef);
5731 }
5732 columns.extend(Self::build_position_columns(positions)?);
5733
5734 let batch = RecordBatch::try_new(schema, columns)?;
5735 Ok(batch)
5736 }
5737
5738 fn build_legacy_positions(&self) -> Result<ListArray> {
5739 let mut positions_builder = ListBuilder::new(LargeBinaryBuilder::new());
5740 self.for_each_entry(|_doc_id, frequency, positions| {
5741 let positions = positions.ok_or_else(|| {
5742 Error::index(format!(
5743 "legacy position writer missing positions for frequency {}",
5744 frequency
5745 ))
5746 })?;
5747 let compressed = super::encoding::compress_positions(positions.as_slice())?;
5748 for block_idx in 0..compressed.len() {
5749 positions_builder
5750 .values()
5751 .append_value(compressed.value(block_idx));
5752 }
5753 positions_builder.append(true);
5754 Ok::<(), Error>(())
5755 })?;
5756 Ok(positions_builder.finish())
5757 }
5758
5759 pub(super) fn append_to_batch_with_docs(
5760 self,
5761 docs: &DocSet,
5762 batch_builder: &mut PostingListBatchBuilder,
5763 format_version: InvertedListFormatVersion,
5764 ) -> Result<()> {
5765 let legacy_positions =
5766 if self.with_positions && !format_version.uses_shared_position_stream() {
5767 Some(self.build_legacy_positions()?)
5768 } else {
5769 None
5770 };
5771 let Self {
5772 with_positions,
5773 posting_tail_codec,
5774 encoded_blocks,
5775 encoded_position_blocks,
5776 tail_entries,
5777 tail_positions,
5778 open_doc_id,
5779 open_doc_frequency,
5780 open_doc_last_position,
5781 block_size,
5782 len,
5783 ..
5784 } = self;
5785 debug_assert!(open_doc_id.is_none());
5786 debug_assert_eq!(open_doc_frequency, 0);
5787 debug_assert!(open_doc_last_position.is_none());
5788 let parts = PostingListParts {
5789 with_positions,
5790 posting_tail_codec,
5791 block_size,
5792 length: len as usize,
5793 encoded_blocks: encoded_blocks
5794 .map(|encoded_blocks| *encoded_blocks)
5795 .unwrap_or_default(),
5796 encoded_position_blocks: encoded_position_blocks
5797 .map(|encoded_positions| *encoded_positions)
5798 .unwrap_or_default(),
5799 tail_entries: tail_entries.as_slice(),
5800 tail_position_block: with_positions.then(|| tail_positions.finish()),
5801 };
5802 let (compressed, shared_positions, max_score, impacts) =
5803 Self::build_compressed_with_scores_from_parts(parts, docs)?;
5804 let positions = match legacy_positions {
5805 Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)),
5806 None => shared_positions.map(CompressedPositionStorage::SharedStream),
5807 };
5808 batch_builder.append(
5809 compressed,
5810 Some(&impacts),
5811 max_score,
5812 len,
5813 positions.as_ref(),
5814 )
5815 }
5816
5817 fn extend_tail_components(
5818 tail_entries: &[RawDocInfo],
5819 doc_ids: &mut Vec<u32>,
5820 frequencies: &mut Vec<u32>,
5821 ) {
5822 doc_ids.clear();
5823 frequencies.clear();
5824 doc_ids.extend(tail_entries.iter().map(|entry| entry.doc_id));
5825 frequencies.extend(tail_entries.iter().map(|entry| entry.frequency));
5826 }
5827
5828 fn build_compressed_with_scores_from_parts(
5829 parts: PostingListParts<'_>,
5830 docs: &DocSet,
5831 ) -> Result<(
5832 LargeBinaryArray,
5833 Option<SharedPositionStream>,
5834 f32,
5835 ImpactSkipData,
5836 )> {
5837 let PostingListParts {
5838 with_positions,
5839 posting_tail_codec,
5840 length,
5841 block_size,
5842 mut encoded_blocks,
5843 mut encoded_position_blocks,
5844 tail_entries,
5845 tail_position_block,
5846 } = parts;
5847 let avgdl = docs.average_length();
5848 let idf_scale = idf(length, docs.len()) * (K1 + 1.0);
5849 let mut max_score = f32::MIN;
5850 let mut doc_ids = Vec::with_capacity(block_size);
5851 let mut frequencies = Vec::with_capacity(block_size);
5852 let mut impact_block = Vec::with_capacity(block_size);
5853 let mut impact_builder =
5854 ImpactSkipDataBuilder::with_capacity(length.div_ceil(block_size), block_size);
5855
5856 for index in 0..encoded_blocks.len() {
5857 let block = encoded_blocks.block(index);
5858 doc_ids.clear();
5859 frequencies.clear();
5860 super::encoding::decode_full_posting_block(
5861 block,
5862 &mut doc_ids,
5863 &mut frequencies,
5864 block_size,
5865 );
5866 let block_score = compute_block_score_and_impact_block(
5867 docs,
5868 avgdl,
5869 idf_scale,
5870 doc_ids.iter().copied(),
5871 frequencies.iter().copied(),
5872 &mut impact_block,
5873 );
5874 impact_builder.append_block(impact_block.as_slice())?;
5875 max_score = max_score.max(block_score);
5876 if super::encoding::posting_block_score_prefix_len(block_size) > 0 {
5877 encoded_blocks.set_block_score(index, block_score);
5878 }
5879 }
5880
5881 if !tail_entries.is_empty() {
5882 Self::extend_tail_components(tail_entries, &mut doc_ids, &mut frequencies);
5883 let block_score = compute_block_score_and_impact_block(
5884 docs,
5885 avgdl,
5886 idf_scale,
5887 doc_ids.iter().copied(),
5888 frequencies.iter().copied(),
5889 &mut impact_block,
5890 );
5891 impact_builder.append_block(impact_block.as_slice())?;
5892 max_score = max_score.max(block_score);
5893 encoded_blocks.append_remainder_block_with_codec(
5894 doc_ids.as_slice(),
5895 frequencies.as_slice(),
5896 posting_tail_codec,
5897 block_size,
5898 )?;
5899 if super::encoding::posting_block_score_prefix_len(block_size) > 0 {
5900 encoded_blocks.set_block_score(encoded_blocks.len() - 1, block_score);
5901 }
5902 if with_positions {
5903 encoded_position_blocks.push_encoded_block(
5904 tail_position_block
5905 .as_deref()
5906 .expect("tail position block must exist for postings with positions"),
5907 );
5908 }
5909 }
5910
5911 let impacts = impact_builder.finish()?;
5912 Ok((
5913 encoded_blocks.into_array(),
5914 with_positions.then(|| encoded_position_blocks.into_stream()),
5915 max_score,
5916 impacts,
5917 ))
5918 }
5919
5920 #[allow(clippy::too_many_arguments)]
5921 fn build_compressed_with_block_scores_from_parts(
5922 with_positions: bool,
5923 posting_tail_codec: PostingTailCodec,
5924 block_size: usize,
5925 mut encoded_blocks: EncodedBlocks,
5926 mut encoded_position_blocks: EncodedPositionBlocks,
5927 tail_entries: &[RawDocInfo],
5928 tail_position_block: Option<Vec<u8>>,
5929 mut block_max_scores: impl Iterator<Item = f32>,
5930 ) -> Result<(LargeBinaryArray, Option<SharedPositionStream>, f32)> {
5931 let has_score_prefix = super::encoding::posting_block_score_prefix_len(block_size) > 0;
5932 let mut max_score = f32::MIN;
5933 let mut doc_ids = Vec::with_capacity(BLOCK_SIZE);
5934 let mut frequencies = Vec::with_capacity(BLOCK_SIZE);
5935
5936 for index in 0..encoded_blocks.len() {
5937 let block_score = block_max_scores
5938 .next()
5939 .ok_or_else(|| Error::index("missing block max score".to_owned()))?;
5940 max_score = max_score.max(block_score);
5941 if has_score_prefix {
5942 encoded_blocks.set_block_score(index, block_score);
5943 }
5944 }
5945
5946 if !tail_entries.is_empty() {
5947 let block_score = block_max_scores
5948 .next()
5949 .ok_or_else(|| Error::index("missing tail block max score".to_owned()))?;
5950 max_score = max_score.max(block_score);
5951 Self::extend_tail_components(tail_entries, &mut doc_ids, &mut frequencies);
5952 encoded_blocks.append_remainder_block_with_codec(
5953 doc_ids.as_slice(),
5954 frequencies.as_slice(),
5955 posting_tail_codec,
5956 block_size,
5957 )?;
5958 if has_score_prefix {
5959 encoded_blocks.set_block_score(encoded_blocks.len() - 1, block_score);
5960 }
5961 if with_positions {
5962 encoded_position_blocks.push_encoded_block(
5963 tail_position_block
5964 .as_deref()
5965 .expect("tail position block must exist for postings with positions"),
5966 );
5967 }
5968 }
5969
5970 Ok((
5971 encoded_blocks.into_array(),
5972 with_positions.then(|| encoded_position_blocks.into_stream()),
5973 max_score,
5974 ))
5975 }
5976
5977 pub fn to_batch(self, block_max_scores: Vec<f32>) -> Result<RecordBatch> {
5978 let format_version = InvertedListFormatVersion::from_posting_tail_codec_and_block_size(
5979 self.posting_tail_codec,
5980 self.block_size,
5981 )?;
5982 let schema = inverted_list_schema_for_version_with_block_size_and_impacts(
5983 self.has_positions(),
5984 format_version,
5985 self.block_size,
5986 false,
5987 );
5988 let legacy_positions =
5989 if self.with_positions && !format_version.uses_shared_position_stream() {
5990 Some(self.build_legacy_positions()?)
5991 } else {
5992 None
5993 };
5994 let Self {
5995 with_positions,
5996 posting_tail_codec,
5997 encoded_blocks,
5998 encoded_position_blocks,
5999 tail_entries,
6000 tail_positions,
6001 open_doc_id,
6002 open_doc_frequency,
6003 open_doc_last_position,
6004 block_size,
6005 len,
6006 ..
6007 } = self;
6008 debug_assert!(open_doc_id.is_none());
6009 debug_assert_eq!(open_doc_frequency, 0);
6010 debug_assert!(open_doc_last_position.is_none());
6011 let (compressed, shared_positions, max_score) =
6012 Self::build_compressed_with_block_scores_from_parts(
6013 with_positions,
6014 posting_tail_codec,
6015 block_size,
6016 encoded_blocks
6017 .map(|encoded_blocks| *encoded_blocks)
6018 .unwrap_or_default(),
6019 encoded_position_blocks
6020 .map(|encoded_positions| *encoded_positions)
6021 .unwrap_or_default(),
6022 tail_entries.as_slice(),
6023 with_positions.then(|| tail_positions.finish()),
6024 block_max_scores.into_iter(),
6025 )?;
6026 let builder = Self {
6027 with_positions,
6028 posting_tail_codec,
6029 encoded_blocks: None,
6030 encoded_position_blocks: None,
6031 tail_entries: Vec::new(),
6032 tail_positions: PositionBlockBuilder::default(),
6033 open_doc_id: None,
6034 open_doc_frequency: 0,
6035 open_doc_last_position: None,
6036 block_size,
6037 memory_size_bytes: 0,
6038 len,
6039 };
6040 let positions = match legacy_positions {
6041 Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)),
6042 None => shared_positions.map(CompressedPositionStorage::SharedStream),
6043 };
6044 builder.build_batch(compressed, None, max_score, schema, positions)
6045 }
6046
6047 pub fn to_batch_with_docs(self, docs: &DocSet, schema: SchemaRef) -> Result<RecordBatch> {
6048 let format_version = parse_format_version_from_metadata(schema.metadata())?;
6049 let legacy_positions =
6050 if self.with_positions && !format_version.uses_shared_position_stream() {
6051 Some(self.build_legacy_positions()?)
6052 } else {
6053 None
6054 };
6055 let Self {
6056 with_positions,
6057 posting_tail_codec,
6058 encoded_blocks,
6059 encoded_position_blocks,
6060 tail_entries,
6061 tail_positions,
6062 open_doc_id,
6063 open_doc_frequency,
6064 open_doc_last_position,
6065 block_size,
6066 len,
6067 ..
6068 } = self;
6069 debug_assert!(open_doc_id.is_none());
6070 debug_assert_eq!(open_doc_frequency, 0);
6071 debug_assert!(open_doc_last_position.is_none());
6072 let parts = PostingListParts {
6073 with_positions,
6074 posting_tail_codec,
6075 block_size,
6076 length: len as usize,
6077 encoded_blocks: encoded_blocks
6078 .map(|encoded_blocks| *encoded_blocks)
6079 .unwrap_or_default(),
6080 encoded_position_blocks: encoded_position_blocks
6081 .map(|encoded_positions| *encoded_positions)
6082 .unwrap_or_default(),
6083 tail_entries: tail_entries.as_slice(),
6084 tail_position_block: with_positions.then(|| tail_positions.finish()),
6085 };
6086 let (compressed, shared_positions, max_score, impacts) =
6087 Self::build_compressed_with_scores_from_parts(parts, docs)?;
6088 let builder = Self {
6089 with_positions,
6090 posting_tail_codec,
6091 encoded_blocks: None,
6092 encoded_position_blocks: None,
6093 tail_entries: Vec::new(),
6094 tail_positions: PositionBlockBuilder::default(),
6095 open_doc_id: None,
6096 open_doc_frequency: 0,
6097 open_doc_last_position: None,
6098 block_size,
6099 memory_size_bytes: 0,
6100 len,
6101 };
6102 let positions = match legacy_positions {
6103 Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)),
6104 None => shared_positions.map(CompressedPositionStorage::SharedStream),
6105 };
6106 builder.build_batch(compressed, Some(impacts), max_score, schema, positions)
6107 }
6108
6109 pub fn remap(&mut self, removed: &[u32]) {
6110 let mut cursor = 0;
6111 let mut new_builder = Self::new_with_posting_tail_codec_and_block_size(
6112 self.has_positions(),
6113 self.posting_tail_codec,
6114 self.block_size,
6115 );
6116 for (doc_id, freq, positions) in self.iter() {
6117 while cursor < removed.len() && removed[cursor] < doc_id {
6118 cursor += 1;
6119 }
6120 if cursor < removed.len() && removed[cursor] == doc_id {
6121 continue;
6122 }
6123 let positions = match positions {
6124 Some(positions) => PositionRecorder::Position(positions.into()),
6125 None => PositionRecorder::Count(freq),
6126 };
6127 new_builder.add(doc_id - cursor as u32, positions);
6128 }
6129
6130 *self = new_builder;
6131 }
6132}
6133
6134fn compute_block_score_and_impact_block(
6135 docs: &DocSet,
6136 avgdl: f32,
6137 idf_scale: f32,
6138 doc_ids: impl Iterator<Item = u32>,
6139 frequencies: impl Iterator<Item = u32>,
6140 impact_block: &mut Vec<(u32, u32, u32)>,
6141) -> f32 {
6142 impact_block.clear();
6143 let mut block_max_score = f32::MIN;
6144 for (doc_id, freq) in doc_ids.zip(frequencies) {
6145 let doc_len = docs.num_tokens(doc_id);
6146 let doc_norm = K1 * (1.0 - B + B * doc_len as f32 / avgdl);
6147 let freq_f32 = freq as f32;
6148 let score = freq_f32 / (freq_f32 + doc_norm);
6149 block_max_score = block_max_score.max(score);
6150 impact_block.push((doc_id, freq, doc_len));
6151 }
6152 block_max_score * idf_scale
6153}
6154
6155#[derive(Debug, Clone, DeepSizeOf, Copy)]
6156pub enum DocInfo {
6157 Located(LocatedDocInfo),
6158 Raw(RawDocInfo),
6159}
6160
6161impl DocInfo {
6162 pub fn doc_id(&self) -> u64 {
6163 match self {
6164 Self::Raw(info) => info.doc_id as u64,
6165 Self::Located(info) => info.row_id,
6166 }
6167 }
6168
6169 pub fn frequency(&self) -> u32 {
6170 match self {
6171 Self::Raw(info) => info.frequency,
6172 Self::Located(info) => info.frequency as u32,
6173 }
6174 }
6175}
6176
6177impl Eq for DocInfo {}
6178
6179impl PartialEq for DocInfo {
6180 fn eq(&self, other: &Self) -> bool {
6181 self.doc_id() == other.doc_id()
6182 }
6183}
6184
6185impl PartialOrd for DocInfo {
6186 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
6187 Some(self.cmp(other))
6188 }
6189}
6190
6191impl Ord for DocInfo {
6192 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
6193 self.doc_id().cmp(&other.doc_id())
6194 }
6195}
6196
6197#[derive(Debug, Clone, Default, DeepSizeOf, Copy)]
6198pub struct LocatedDocInfo {
6199 pub row_id: u64,
6200 pub frequency: f32,
6201}
6202
6203impl LocatedDocInfo {
6204 pub fn new(row_id: u64, frequency: f32) -> Self {
6205 Self { row_id, frequency }
6206 }
6207}
6208
6209impl Eq for LocatedDocInfo {}
6210
6211impl PartialEq for LocatedDocInfo {
6212 fn eq(&self, other: &Self) -> bool {
6213 self.row_id == other.row_id
6214 }
6215}
6216
6217impl PartialOrd for LocatedDocInfo {
6218 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
6219 Some(self.cmp(other))
6220 }
6221}
6222
6223impl Ord for LocatedDocInfo {
6224 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
6225 self.row_id.cmp(&other.row_id)
6226 }
6227}
6228
6229#[derive(Debug, Clone, Default, DeepSizeOf, Copy)]
6230pub struct RawDocInfo {
6231 pub doc_id: u32,
6232 pub frequency: u32,
6233}
6234
6235impl RawDocInfo {
6236 pub fn new(doc_id: u32, frequency: u32) -> Self {
6237 Self { doc_id, frequency }
6238 }
6239}
6240
6241impl Eq for RawDocInfo {}
6242
6243impl PartialEq for RawDocInfo {
6244 fn eq(&self, other: &Self) -> bool {
6245 self.doc_id == other.doc_id
6246 }
6247}
6248
6249impl PartialOrd for RawDocInfo {
6250 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
6251 Some(self.cmp(other))
6252 }
6253}
6254
6255impl Ord for RawDocInfo {
6256 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
6257 self.doc_id.cmp(&other.doc_id)
6258 }
6259}
6260
6261pub(super) fn quantize_doc_length(value: u32) -> u8 {
6267 let num_bits = 32 - value.leading_zeros();
6268 if num_bits < 4 {
6269 value as u8
6270 } else {
6271 let shift = num_bits - 4;
6272 (((value >> shift) as u8) & 0x07) | (((shift + 1) as u8) << 3)
6273 }
6274}
6275
6276#[inline]
6277pub(super) fn dequantize_doc_length(code: u8) -> u32 {
6278 DEQUANTIZED_DOC_LENGTHS[code as usize]
6279}
6280
6281pub(super) static DEQUANTIZED_DOC_LENGTHS: [u32; 256] = build_dequantized_doc_lengths();
6282
6283const fn build_dequantized_doc_lengths() -> [u32; 256] {
6284 let mut table = [0u32; 256];
6285 let mut code = 0usize;
6286 while code < 256 {
6287 let bits = (code & 0x07) as u64;
6288 let shift = (code >> 3) as i64 - 1;
6289 let decoded = if shift < 0 {
6290 bits
6291 } else {
6292 (bits | 0x08) << shift
6293 };
6294 table[code] = if decoded > u32::MAX as u64 {
6297 u32::MAX
6298 } else {
6299 decoded as u32
6300 };
6301 code += 1;
6302 }
6303 table
6304}
6305
6306#[derive(Debug, Clone)]
6307enum NumTokens {
6308 Owned(Vec<u32>),
6309 Shared(ScalarBuffer<u32>),
6310}
6311
6312impl Default for NumTokens {
6313 fn default() -> Self {
6314 Self::Owned(Vec::new())
6315 }
6316}
6317
6318impl std::ops::Deref for NumTokens {
6319 type Target = [u32];
6320
6321 fn deref(&self) -> &Self::Target {
6322 match self {
6323 Self::Owned(values) => values,
6324 Self::Shared(values) => values,
6325 }
6326 }
6327}
6328
6329impl DeepSizeOf for NumTokens {
6330 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
6331 match self {
6332 Self::Owned(values) => values.deep_size_of_children(context),
6333 Self::Shared(values) => values.deep_size_of_children(context),
6334 }
6335 }
6336}
6337
6338impl NumTokens {
6339 fn with_capacity(capacity: usize) -> Self {
6340 Self::Owned(Vec::with_capacity(capacity))
6341 }
6342
6343 fn into_owned(self) -> Vec<u32> {
6344 match self {
6345 Self::Owned(values) => values,
6346 Self::Shared(values) => values.to_vec(),
6347 }
6348 }
6349
6350 fn push(&mut self, value: u32) {
6351 match self {
6352 Self::Owned(values) => values.push(value),
6353 Self::Shared(values) => {
6354 let mut owned = values.to_vec();
6355 owned.push(value);
6356 *self = Self::Owned(owned);
6357 }
6358 }
6359 }
6360
6361 fn memory_size(&self) -> usize {
6362 match self {
6363 Self::Owned(values) => values.capacity() * std::mem::size_of::<u32>(),
6364 Self::Shared(values) => values.inner().capacity(),
6365 }
6366 }
6367}
6368
6369#[derive(Debug, Clone, Default)]
6372pub struct DocSet {
6373 row_ids: Vec<u64>,
6374 num_tokens: NumTokens,
6375 inv: Vec<(u64, u32)>,
6377
6378 total_tokens: u64,
6379
6380 scoring_quantized: bool,
6385 norms: Arc<std::sync::OnceLock<Box<[u8]>>>,
6386}
6387
6388impl DeepSizeOf for DocSet {
6389 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
6390 self.row_ids.deep_size_of_children(context)
6391 + self.num_tokens.deep_size_of_children(context)
6392 + self.inv.deep_size_of_children(context)
6393 + self
6394 .norms
6395 .get()
6396 .map(|slab| std::mem::size_of_val(slab.as_ref()))
6397 .unwrap_or(0)
6398 }
6399}
6400
6401impl DocSet {
6402 #[inline]
6403 pub fn len(&self) -> usize {
6404 self.num_tokens.len()
6409 }
6410
6411 pub fn is_empty(&self) -> bool {
6412 self.len() == 0
6413 }
6414
6415 #[inline]
6421 pub fn has_row_ids(&self) -> bool {
6422 !self.row_ids.is_empty()
6423 }
6424
6425 pub fn iter(&self) -> impl Iterator<Item = (&u64, &u32)> {
6426 self.row_ids.iter().zip(self.num_tokens.iter())
6427 }
6428
6429 pub fn row_id(&self, doc_id: u32) -> u64 {
6430 self.row_ids[doc_id as usize]
6431 }
6432
6433 pub fn doc_ids(&self, row_id: u64) -> impl Iterator<Item = u64> + '_ {
6441 if self.inv.is_empty() {
6442 let found = self.row_ids.binary_search(&row_id).is_ok();
6444 Either::Left(found.then_some(row_id).into_iter())
6445 } else {
6446 let lo = self.inv.partition_point(|entry| entry.0 < row_id);
6449 let hi = self.inv.partition_point(|entry| entry.0 <= row_id);
6450 Either::Right(self.inv[lo..hi].iter().map(|entry| entry.1 as u64))
6451 }
6452 }
6453 pub fn total_tokens_num(&self) -> u64 {
6454 self.total_tokens
6455 }
6456
6457 #[inline]
6458 pub fn average_length(&self) -> f32 {
6459 self.total_tokens as f32 / self.len() as f32
6460 }
6461
6462 pub fn calculate_block_max_scores<'a>(
6463 &self,
6464 doc_ids: impl Iterator<Item = &'a u32>,
6465 freqs: impl Iterator<Item = &'a u32>,
6466 ) -> Vec<f32> {
6467 self.calculate_block_max_scores_with_block_size(doc_ids, freqs, LEGACY_BLOCK_SIZE)
6468 }
6469
6470 pub fn calculate_block_max_scores_with_block_size<'a>(
6471 &self,
6472 doc_ids: impl Iterator<Item = &'a u32>,
6473 freqs: impl Iterator<Item = &'a u32>,
6474 block_size: usize,
6475 ) -> Vec<f32> {
6476 validate_block_size(block_size).expect("invalid posting list block size");
6477 let avgdl = self.average_length();
6478 let length = doc_ids.size_hint().0;
6479 let num_blocks = length.div_ceil(block_size);
6480 let mut block_max_scores = Vec::with_capacity(num_blocks);
6481 let idf_scale = idf(length, self.len()) * (K1 + 1.0);
6482 let mut max_score = f32::MIN;
6483 for (i, (doc_id, freq)) in doc_ids.zip(freqs).enumerate() {
6484 let doc_norm = K1 * (1.0 - B + B * self.num_tokens(*doc_id) as f32 / avgdl);
6485 let freq = *freq as f32;
6486 let score = freq / (freq + doc_norm);
6487 if score > max_score {
6488 max_score = score;
6489 }
6490 if (i + 1) % block_size == 0 {
6491 max_score *= idf_scale;
6492 block_max_scores.push(max_score);
6493 max_score = f32::MIN;
6494 }
6495 }
6496 if !length.is_multiple_of(block_size) {
6497 max_score *= idf_scale;
6498 block_max_scores.push(max_score);
6499 }
6500 block_max_scores
6501 }
6502
6503 pub fn to_batch(&self) -> Result<RecordBatch> {
6504 let row_id_col = UInt64Array::from_iter_values(self.row_ids.iter().cloned());
6505 let num_tokens_col = UInt32Array::from_iter_values(self.num_tokens.iter().cloned());
6506
6507 let schema = arrow_schema::Schema::new(vec![
6508 arrow_schema::Field::new(ROW_ID, DataType::UInt64, false),
6509 arrow_schema::Field::new(NUM_TOKEN_COL, DataType::UInt32, false),
6510 ]);
6511
6512 let batch = RecordBatch::try_new(
6513 Arc::new(schema),
6514 vec![
6515 Arc::new(row_id_col) as ArrayRef,
6516 Arc::new(num_tokens_col) as ArrayRef,
6517 ],
6518 )?;
6519 Ok(batch)
6520 }
6521
6522 pub async fn load(
6523 reader: Arc<dyn IndexReader>,
6524 is_legacy: bool,
6525 frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
6526 ) -> Result<Self> {
6527 let batch = reader.read_range(0..reader.num_rows(), None).await?;
6528 let row_id_col = batch[ROW_ID].as_primitive::<datatypes::UInt64Type>();
6529 let num_tokens_col = batch[NUM_TOKEN_COL].as_primitive::<datatypes::UInt32Type>();
6530 Self::from_columns(row_id_col, num_tokens_col, is_legacy, frag_reuse_index)
6531 }
6532
6533 pub fn from_num_tokens_only(num_tokens_col: &arrow_array::UInt32Array) -> Self {
6539 let total_tokens = num_tokens_col.values().iter().map(|&n| n as u64).sum();
6540 Self::from_cached_num_tokens(num_tokens_col, total_tokens)
6541 }
6542
6543 pub(crate) fn from_cached_num_tokens(
6547 num_tokens_col: &arrow_array::UInt32Array,
6548 total_tokens: u64,
6549 ) -> Self {
6550 Self {
6551 row_ids: Vec::new(),
6552 num_tokens: NumTokens::Shared(num_tokens_col.values().clone()),
6553 inv: Vec::new(),
6554 total_tokens,
6555 scoring_quantized: false,
6556 norms: Arc::new(std::sync::OnceLock::new()),
6557 }
6558 }
6559
6560 pub fn from_columns(
6565 row_id_col: &UInt64Array,
6566 num_tokens_col: &arrow_array::UInt32Array,
6567 is_legacy: bool,
6568 frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
6569 ) -> Result<Self> {
6570 if is_legacy {
6572 let (row_ids, num_tokens): (Vec<_>, Vec<_>) = row_id_col
6573 .values()
6574 .iter()
6575 .filter_map(|id| {
6576 if let Some(frag_reuse_index_ref) = frag_reuse_index.as_ref() {
6577 frag_reuse_index_ref.remap_row_id(*id)
6578 } else {
6579 Some(*id)
6580 }
6581 })
6582 .zip(num_tokens_col.values().iter())
6583 .sorted_unstable_by_key(|x| x.0)
6584 .unzip();
6585
6586 let total_tokens = num_tokens.iter().map(|&x| x as u64).sum();
6587 return Ok(Self {
6588 row_ids,
6589 num_tokens: NumTokens::Owned(num_tokens),
6590 inv: Vec::new(),
6591 total_tokens,
6592 scoring_quantized: false,
6593 norms: Arc::new(std::sync::OnceLock::new()),
6594 });
6595 }
6596
6597 if let Some(frag_reuse_index_ref) = frag_reuse_index.as_ref() {
6611 let mut row_ids = Vec::with_capacity(row_id_col.len());
6612 let num_tokens = num_tokens_col.values().to_vec();
6613 let mut inv = Vec::with_capacity(row_id_col.len());
6614 for (doc_id, row_id) in row_id_col.values().iter().enumerate() {
6615 match frag_reuse_index_ref.remap_row_id(*row_id) {
6616 Some(new_row_id) => {
6617 row_ids.push(new_row_id);
6618 inv.push((new_row_id, doc_id as u32));
6619 }
6620 None => {
6621 row_ids.push(RowAddress::TOMBSTONE_ROW);
6624 }
6625 }
6626 }
6627 inv.sort_unstable_by_key(|entry| entry.0);
6628
6629 let total_tokens = num_tokens.iter().map(|&x| x as u64).sum();
6630 return Ok(Self {
6631 row_ids,
6632 num_tokens: NumTokens::Owned(num_tokens),
6633 inv,
6634 total_tokens,
6635 scoring_quantized: false,
6636 norms: Arc::new(std::sync::OnceLock::new()),
6637 });
6638 }
6639
6640 let row_ids = row_id_col.values().to_vec();
6641 let num_tokens = num_tokens_col.values().to_vec();
6642 let mut inv: Vec<(u64, u32)> = row_ids
6643 .iter()
6644 .enumerate()
6645 .map(|(doc_id, row_id)| (*row_id, doc_id as u32))
6646 .collect();
6647 if !row_ids.is_sorted() {
6648 inv.sort_unstable_by_key(|entry| entry.0);
6649 }
6650 let total_tokens = num_tokens.iter().map(|&x| x as u64).sum();
6651 Ok(Self {
6652 row_ids,
6653 num_tokens: NumTokens::Owned(num_tokens),
6654 inv,
6655 total_tokens,
6656 scoring_quantized: false,
6657 norms: Arc::new(std::sync::OnceLock::new()),
6658 })
6659 }
6660
6661 pub fn remap(&mut self, mapping: &RowAddrRemap) -> Vec<u32> {
6664 let mut removed = Vec::new();
6665 let len = self.len();
6666 let row_ids = std::mem::replace(&mut self.row_ids, Vec::with_capacity(len));
6667 let num_tokens =
6668 std::mem::replace(&mut self.num_tokens, NumTokens::with_capacity(len)).into_owned();
6669 self.invalidate_norms();
6670 self.total_tokens = 0;
6671 for (doc_id, (row_id, num_token)) in std::iter::zip(row_ids, num_tokens).enumerate() {
6672 match mapping.get(row_id) {
6673 Some(Some(new_row_id)) => {
6674 self.row_ids.push(new_row_id);
6675 self.num_tokens.push(num_token);
6676 self.total_tokens += num_token as u64;
6677 }
6678 Some(None) => {
6679 removed.push(doc_id as u32);
6680 }
6681 None => {
6682 self.row_ids.push(row_id);
6683 self.num_tokens.push(num_token);
6684 self.total_tokens += num_token as u64;
6685 }
6686 }
6687 }
6688 removed
6689 }
6690
6691 #[inline]
6692 pub fn num_tokens(&self, doc_id: u32) -> u32 {
6693 self.num_tokens[doc_id as usize]
6694 }
6695
6696 pub fn set_quantized_scoring(&mut self, quantized: bool) {
6698 self.scoring_quantized = quantized;
6699 }
6700
6701 pub fn scoring_norms(&self) -> Option<&[u8]> {
6704 if !self.scoring_quantized {
6705 return None;
6706 }
6707 Some(
6708 self.norms
6709 .get_or_init(|| {
6710 self.num_tokens
6711 .iter()
6712 .map(|&n| quantize_doc_length(n))
6713 .collect()
6714 })
6715 .as_ref(),
6716 )
6717 }
6718
6719 #[inline]
6722 pub fn scoring_num_tokens(&self, doc_id: u32) -> u32 {
6723 match self.scoring_norms() {
6724 Some(norms) => dequantize_doc_length(norms[doc_id as usize]),
6725 None => self.num_tokens[doc_id as usize],
6726 }
6727 }
6728
6729 #[inline]
6732 pub fn num_tokens_by_row_id(&self, row_id: u64) -> u32 {
6733 self.row_ids
6734 .binary_search(&row_id)
6735 .map(|idx| self.num_tokens[idx])
6736 .unwrap_or(0)
6737 }
6738
6739 pub fn append(&mut self, row_id: u64, num_tokens: u32) -> u32 {
6742 self.row_ids.push(row_id);
6743 self.num_tokens.push(num_tokens);
6744 self.total_tokens += num_tokens as u64;
6745 self.invalidate_norms();
6746 self.row_ids.len() as u32 - 1
6747 }
6748
6749 fn invalidate_norms(&mut self) {
6752 if self.norms.get().is_some() {
6753 self.norms = Arc::new(std::sync::OnceLock::new());
6754 }
6755 }
6756
6757 pub(crate) fn memory_size(&self) -> usize {
6758 self.row_ids.capacity() * std::mem::size_of::<u64>()
6759 + self.num_tokens.memory_size()
6760 + self.inv.capacity() * std::mem::size_of::<(u64, u32)>()
6761 }
6762}
6763
6764pub fn flat_full_text_search(
6765 batches: &[&RecordBatch],
6766 doc_col: &str,
6767 query: &str,
6768 tokenizer: Option<Box<dyn LanceTokenizer>>,
6769) -> Result<Vec<u64>> {
6770 if batches.is_empty() {
6771 return Ok(vec![]);
6772 }
6773
6774 if is_phrase_query(query) {
6775 return Err(Error::invalid_input(
6776 "phrase query is not supported for flat full text search, try using FTS index",
6777 ));
6778 }
6779
6780 match batches[0][doc_col].data_type() {
6781 DataType::Utf8 => do_flat_full_text_search::<i32>(batches, doc_col, query, tokenizer),
6782 DataType::LargeUtf8 => do_flat_full_text_search::<i64>(batches, doc_col, query, tokenizer),
6783 DataType::List(_) => {
6784 do_flat_full_text_search_list::<i32>(batches, doc_col, query, tokenizer)
6785 }
6786 DataType::LargeList(_) => {
6787 do_flat_full_text_search_list::<i64>(batches, doc_col, query, tokenizer)
6788 }
6789 data_type => Err(Error::invalid_input(format!(
6790 "unsupported data type {} for inverted index",
6791 data_type
6792 ))),
6793 }
6794}
6795
6796fn do_flat_full_text_search<Offset: OffsetSizeTrait>(
6797 batches: &[&RecordBatch],
6798 doc_col: &str,
6799 query: &str,
6800 tokenizer: Option<Box<dyn LanceTokenizer>>,
6801) -> Result<Vec<u64>> {
6802 let mut results = Vec::new();
6803 let mut tokenizer =
6804 tokenizer.unwrap_or_else(|| InvertedIndexParams::default().build().unwrap());
6805 let query_tokens = collect_query_tokens(query, &mut tokenizer);
6806
6807 for batch in batches {
6808 let row_id_array = batch[ROW_ID].as_primitive::<UInt64Type>();
6809 let doc_array = batch[doc_col].as_string::<Offset>();
6810 for i in 0..row_id_array.len() {
6811 let doc = doc_array.value(i);
6812 if has_query_token(doc, &mut tokenizer, &query_tokens) {
6813 results.push(row_id_array.value(i));
6814 assert!(doc.contains(query));
6817 }
6818 }
6819 }
6820
6821 Ok(results)
6822}
6823
6824fn do_flat_full_text_search_list<ListOffset: OffsetSizeTrait>(
6825 batches: &[&RecordBatch],
6826 doc_col: &str,
6827 query: &str,
6828 tokenizer: Option<Box<dyn LanceTokenizer>>,
6829) -> Result<Vec<u64>> {
6830 let mut results = Vec::new();
6831 let mut tokenizer =
6832 tokenizer.unwrap_or_else(|| InvertedIndexParams::default().build().unwrap());
6833 let query_tokens = collect_query_tokens(query, &mut tokenizer);
6834
6835 for batch in batches {
6836 let row_id_array = batch[ROW_ID].as_primitive::<UInt64Type>();
6837 let doc_array = batch[doc_col].as_list::<ListOffset>();
6838 match doc_array.value_type() {
6839 DataType::Utf8 | DataType::LargeUtf8 => {}
6840 data_type => {
6841 return Err(Error::invalid_input(format!(
6842 "unsupported list item data type {} for inverted index",
6843 data_type
6844 )));
6845 }
6846 }
6847 for i in 0..row_id_array.len() {
6848 if doc_array.is_null(i) {
6849 continue;
6850 }
6851 let elements = doc_array.value(i);
6852 if iter_str_array(elements.as_ref())
6853 .flatten()
6854 .any(|element| has_query_token(element, &mut tokenizer, &query_tokens))
6855 {
6856 results.push(row_id_array.value(i));
6857 }
6858 }
6859 }
6860
6861 Ok(results)
6862}
6863
6864const FLAT_ROW_ID_COL_IDX: usize = 0;
6865const FLAT_ALL_TOKENS_COL_IDX: usize = 1;
6866const FLAT_QUERY_TOKEN_COUNTS_COL_IDX: usize = 2;
6867
6868const BYTES_ACCUMULATED_WARNING_THRESHOLD: u64 = 1024 * 1024 * 1024; async fn tokenize_and_count(
6885 input: impl Stream<Item = DataFusionResult<RecordBatch>> + Send,
6886 tokenizer: Box<dyn LanceTokenizer>,
6887 query_tokens: Arc<Tokens>,
6888 doc_col_idx: usize,
6889 elapsed_compute: Option<Time>,
6890) -> DataFusionResult<RecordBatch> {
6891 let output_schema = Arc::new(Schema::new(vec![
6892 ROW_ID_FIELD.clone(),
6893 Field::new("all_tokens", DataType::UInt64, false),
6894 Field::new(
6895 "query_token_counts",
6896 DataType::FixedSizeList(
6897 Arc::new(Field::new("item", DataType::UInt64, true)),
6898 query_tokens.len() as i32,
6899 ),
6900 false,
6901 ),
6902 ]));
6903 let output_schema_clone = output_schema.clone();
6904 let query_token_indices = Arc::new(query_token_indices(query_tokens.as_ref()));
6905 let bytes_accumulated = Arc::new(AtomicU64::new(0));
6906 let bytes_warning_emitted = Arc::new(AtomicBool::new(false));
6907
6908 let batches = input
6909 .map(move |batch| {
6910 let mut tokenizer = tokenizer.box_clone();
6911 let output_schema = output_schema.clone();
6912 let query_tokens = query_tokens.clone();
6913 let query_token_indices = query_token_indices.clone();
6914 let bytes_accumulated = bytes_accumulated.clone();
6915 let bytes_warning_emitted = bytes_warning_emitted.clone();
6916 let elapsed_compute = elapsed_compute.clone();
6917 spawn_cpu(move || {
6918 let start = std::time::Instant::now();
6922 let batch = batch?;
6923 let row_id_array = batch[ROW_ID].as_primitive::<UInt64Type>();
6924 let mut row_ids = UInt64Builder::with_capacity(batch.num_rows());
6925 let mut all_token_counts = UInt64Builder::with_capacity(batch.num_rows());
6926 let mut query_token_counts = FixedSizeListBuilder::with_capacity(
6927 UInt64Builder::with_capacity(batch.num_rows() * query_tokens.len()),
6928 query_tokens.len() as i32,
6929 batch.num_rows(),
6930 );
6931 let mut temp_query_token_counts = Vec::with_capacity(query_tokens.len());
6932 let mut count_text = |doc: &str, temp_query_token_counts: &mut Vec<u64>| -> u64 {
6933 let mut stream = tokenizer.token_stream_for_doc(doc);
6934 let mut all_tokens = 0;
6935 while let Some(token) = stream.next() {
6936 all_tokens += 1;
6937 if let Some(token_indices) = query_token_indices.get(&token.text) {
6938 for token_index in token_indices {
6939 temp_query_token_counts[*token_index] += 1;
6940 }
6941 }
6942 }
6943 all_tokens
6944 };
6945 let mut append_counts =
6946 |row_id: u64, all_tokens: u64, temp_query_token_counts: &[u64]| {
6947 row_ids.append_value(row_id);
6948 all_token_counts.append_value(all_tokens);
6949 for count in temp_query_token_counts.iter().copied() {
6950 query_token_counts.values().append_value(count);
6951 }
6952 query_token_counts.append(true);
6953 };
6954 match batch.column(doc_col_idx).data_type() {
6955 DataType::Utf8 | DataType::LargeUtf8 => {
6956 let doc_iter = iter_str_array(batch.column(doc_col_idx));
6957 for (doc, row_id) in doc_iter.zip(row_id_array.values().iter()) {
6958 temp_query_token_counts.clear();
6959 temp_query_token_counts
6960 .extend(std::iter::repeat_n(0, query_tokens.len()));
6961
6962 let Some(doc) = doc else {
6963 continue;
6964 };
6965
6966 let all_tokens = count_text(doc, &mut temp_query_token_counts);
6967 if all_tokens > 0 {
6968 append_counts(*row_id, all_tokens, &temp_query_token_counts);
6969 }
6970 }
6971 }
6972 DataType::List(_) => {
6973 tokenize_and_count_list::<i32>(
6974 batch.column(doc_col_idx),
6975 row_id_array,
6976 &mut count_text,
6977 &mut append_counts,
6978 &mut temp_query_token_counts,
6979 query_tokens.len(),
6980 )?;
6981 }
6982 DataType::LargeList(_) => {
6983 tokenize_and_count_list::<i64>(
6984 batch.column(doc_col_idx),
6985 row_id_array,
6986 &mut count_text,
6987 &mut append_counts,
6988 &mut temp_query_token_counts,
6989 query_tokens.len(),
6990 )?;
6991 }
6992 data_type => {
6993 return DataFusionResult::Err(datafusion_common::DataFusionError::Execution(
6994 format!("unsupported data type {} for flat full text search", data_type),
6995 ));
6996 }
6997 }
6998 let row_ids = row_ids.finish();
6999 let all_token_counts = all_token_counts.finish();
7000 let query_token_counts = query_token_counts.finish();
7001 let result_batch = RecordBatch::try_new(
7002 output_schema,
7003 vec![
7004 Arc::new(row_ids) as ArrayRef,
7005 Arc::new(all_token_counts) as ArrayRef,
7006 Arc::new(query_token_counts) as ArrayRef,
7007 ],
7008 )?;
7009 let bytes_accumulated = bytes_accumulated.fetch_add(result_batch.get_array_memory_size() as u64, Ordering::Relaxed);
7010 if bytes_accumulated > BYTES_ACCUMULATED_WARNING_THRESHOLD && !bytes_warning_emitted.swap(true, Ordering::Relaxed) {
7011 tracing::warn!("Flat full text search is accumulating a large number of bytes. Consider using an FTS index instead.");
7012 }
7013
7014 if let Some(t) = &elapsed_compute {
7015 t.add_duration(start.elapsed());
7016 }
7017 DataFusionResult::Ok(result_batch)
7018 })
7019 })
7020 .buffered(get_num_compute_intensive_cpus())
7021 .try_collect::<Vec<_>>()
7022 .await?;
7023
7024 Ok(arrow::compute::concat_batches(
7025 &output_schema_clone,
7026 &batches,
7027 )?)
7028}
7029
7030fn tokenize_and_count_list<ListOffset: OffsetSizeTrait>(
7031 doc_col: &ArrayRef,
7032 row_id_array: &arrow_array::PrimitiveArray<UInt64Type>,
7033 count_text: &mut impl FnMut(&str, &mut Vec<u64>) -> u64,
7034 append_counts: &mut impl FnMut(u64, u64, &[u64]),
7035 temp_query_token_counts: &mut Vec<u64>,
7036 query_tokens_len: usize,
7037) -> DataFusionResult<()> {
7038 let doc_array = doc_col.as_list::<ListOffset>();
7039 match doc_array.value_type() {
7040 DataType::Utf8 | DataType::LargeUtf8 => {}
7041 data_type => {
7042 return Err(datafusion_common::DataFusionError::Execution(format!(
7043 "unsupported list item data type {} for flat full text search",
7044 data_type
7045 )));
7046 }
7047 }
7048
7049 for i in 0..row_id_array.len() {
7050 if doc_array.is_null(i) {
7051 continue;
7052 }
7053
7054 temp_query_token_counts.clear();
7055 temp_query_token_counts.extend(std::iter::repeat_n(0, query_tokens_len));
7056
7057 let elements = doc_array.value(i);
7058 let mut all_tokens = 0;
7059 for element in iter_str_array(elements.as_ref()).flatten() {
7060 all_tokens += count_text(element, temp_query_token_counts);
7061 }
7062
7063 if all_tokens > 0 {
7064 append_counts(row_id_array.value(i), all_tokens, temp_query_token_counts);
7065 }
7066 }
7067
7068 Ok(())
7069}
7070
7071fn query_token_indices(query_tokens: &Tokens) -> HashMap<String, Vec<usize>> {
7072 let mut indices = HashMap::new();
7073 for idx in 0..query_tokens.len() {
7074 indices
7075 .entry(query_tokens.get_token(idx).to_string())
7076 .or_insert_with(Vec::new)
7077 .push(idx);
7078 }
7079 indices
7080}
7081
7082fn initialize_scorer(
7087 base_scorer: Option<&MemBM25Scorer>,
7088 query_tokens: &Tokens,
7089 counted_input: &RecordBatch,
7090) -> MemBM25Scorer {
7091 let mut total_tokens = 0;
7092 let mut num_docs = 0;
7093 let mut all_token_counts = vec![0; query_tokens.len()];
7094
7095 if let Some(base_scorer) = base_scorer {
7096 total_tokens += base_scorer.total_tokens;
7097 num_docs += base_scorer.num_docs;
7098 for (token_index, token) in query_tokens.into_iter().enumerate() {
7099 all_token_counts[token_index] = base_scorer.num_docs_containing_token(token) as u64;
7100 }
7101 }
7102
7103 num_docs += counted_input.num_rows();
7104 total_tokens += arrow::compute::sum(
7105 counted_input
7106 .column(FLAT_ALL_TOKENS_COL_IDX)
7107 .as_primitive::<UInt64Type>(),
7108 )
7109 .unwrap_or_default();
7110
7111 let mut input_token_counters = counted_input
7112 .column(FLAT_QUERY_TOKEN_COUNTS_COL_IDX)
7113 .as_fixed_size_list()
7114 .values()
7115 .as_primitive::<UInt64Type>()
7116 .values()
7117 .iter()
7118 .copied();
7119
7120 for _ in 0..counted_input.num_rows() {
7121 for token_count in all_token_counts.iter_mut() {
7122 if input_token_counters.next().unwrap_or_default() > 0 {
7123 *token_count += 1;
7124 }
7125 }
7126 }
7127
7128 let token_counts_map = all_token_counts
7129 .into_iter()
7130 .enumerate()
7131 .map(|(token_index, count)| {
7132 (
7133 query_tokens.get_token(token_index).to_string(),
7134 count as usize,
7135 )
7136 })
7137 .collect::<HashMap<String, usize>>();
7138 MemBM25Scorer::new(total_tokens, num_docs, token_counts_map)
7139}
7140
7141fn flat_bm25_score(
7142 query_tokens: &Tokens,
7143 counted_input: &RecordBatch,
7144 scorer: &MemBM25Scorer,
7145 operator: Operator,
7146) -> Result<RecordBatch> {
7147 let mut row_ids_builder = UInt64Builder::with_capacity(counted_input.num_rows());
7148 let mut scores_builder = Float32Builder::with_capacity(counted_input.num_rows());
7149 let query_groups = query_position_groups(query_tokens);
7150
7151 let mut row_ids_iter = counted_input
7152 .column(FLAT_ROW_ID_COL_IDX)
7153 .as_primitive::<UInt64Type>()
7154 .values()
7155 .iter()
7156 .copied();
7157 let mut all_token_counts_iter = counted_input
7158 .column(FLAT_ALL_TOKENS_COL_IDX)
7159 .as_primitive::<UInt64Type>()
7160 .values()
7161 .iter()
7162 .copied();
7163 let mut query_token_counts_iter = counted_input
7164 .column(FLAT_QUERY_TOKEN_COUNTS_COL_IDX)
7165 .as_fixed_size_list()
7166 .values()
7167 .as_primitive::<UInt64Type>()
7168 .values()
7169 .iter()
7170 .copied();
7171 for _ in 0..counted_input.num_rows() {
7172 let num_tokens_in_doc = all_token_counts_iter.next().expect_ok()?;
7173 let row_id = row_ids_iter.next().expect_ok()?;
7174 let mut query_token_counts = Vec::with_capacity(query_tokens.len());
7175 for _ in query_tokens {
7176 query_token_counts.push(query_token_counts_iter.next().expect_ok()?);
7177 }
7178 if num_tokens_in_doc == 0 {
7179 continue;
7180 }
7181 if operator == Operator::And
7182 && !query_groups
7183 .iter()
7184 .all(|group| group.iter().any(|idx| query_token_counts[*idx] > 0))
7185 {
7186 continue;
7187 }
7188 let doc_norm = K1 * (1.0 - B + B * num_tokens_in_doc as f32 / scorer.avg_doc_length());
7189 let mut score = 0.0;
7190 for (token, freq) in query_tokens.into_iter().zip(query_token_counts) {
7191 let freq = freq as f32;
7192 let idf = idf(scorer.num_docs_containing_token(token), scorer.num_docs());
7193 score += idf * (freq * (K1 + 1.0) / (freq + doc_norm));
7194 }
7195 if score > 0.0 {
7196 row_ids_builder.append_value(row_id);
7197 scores_builder.append_value(score);
7198 }
7199 }
7200
7201 let row_ids = row_ids_builder.finish();
7202 let scores = scores_builder.finish();
7203 let batch = RecordBatch::try_new(
7204 FTS_SCHEMA.clone(),
7205 vec![Arc::new(row_ids) as ArrayRef, Arc::new(scores) as ArrayRef],
7206 )?;
7207 Ok(batch)
7208}
7209
7210fn query_position_groups(query_tokens: &Tokens) -> Vec<Vec<usize>> {
7211 let mut groups = Vec::new();
7212 let mut current_position = None;
7213 for idx in 0..query_tokens.len() {
7214 let position = query_tokens.position(idx);
7215 if current_position != Some(position) {
7216 current_position = Some(position);
7217 groups.push(Vec::new());
7218 }
7219 groups
7220 .last_mut()
7221 .expect("a group should exist after pushing for position")
7222 .push(idx);
7223 }
7224 groups
7225}
7226
7227#[deprecated(
7228 note = "use `flat_bm25_search_stream_with_metrics` to record CPU compute \
7229 time on a metric handle; pass `None` for the old behavior"
7230)]
7231pub async fn flat_bm25_search_stream(
7232 input: SendableRecordBatchStream,
7233 doc_col: String,
7234 query: String,
7235 tokenizer: Box<dyn LanceTokenizer>,
7236 base_scorer: Option<MemBM25Scorer>,
7237 target_batch_size: usize,
7238) -> DataFusionResult<SendableRecordBatchStream> {
7239 flat_bm25_search_stream_with_metrics(
7240 input,
7241 doc_col,
7242 query,
7243 tokenizer,
7244 base_scorer,
7245 target_batch_size,
7246 None,
7247 )
7248 .await
7249}
7250
7251pub async fn flat_bm25_search_stream_with_metrics(
7257 input: SendableRecordBatchStream,
7258 doc_col: String,
7259 query: String,
7260 tokenizer: Box<dyn LanceTokenizer>,
7261 base_scorer: Option<MemBM25Scorer>,
7262 target_batch_size: usize,
7263 elapsed_compute: Option<Time>,
7264) -> DataFusionResult<SendableRecordBatchStream> {
7265 flat_bm25_search_stream_with_metrics_and_operator(
7266 input,
7267 doc_col,
7268 query,
7269 tokenizer,
7270 base_scorer,
7271 target_batch_size,
7272 Operator::Or,
7273 elapsed_compute,
7274 )
7275 .await
7276}
7277
7278#[allow(clippy::too_many_arguments)]
7307pub async fn flat_bm25_search_stream_with_metrics_and_operator(
7308 input: SendableRecordBatchStream,
7309 doc_col: String,
7310 query: String,
7311 tokenizer: Box<dyn LanceTokenizer>,
7312 base_scorer: Option<MemBM25Scorer>,
7313 target_batch_size: usize,
7314 operator: Operator,
7315 elapsed_compute: Option<Time>,
7316) -> DataFusionResult<SendableRecordBatchStream> {
7317 let mut tokenizer = tokenizer;
7318
7319 let pre_await_start = std::time::Instant::now();
7321 let query_tokens = Arc::new(collect_query_tokens(&query, &mut tokenizer));
7322
7323 if query_tokens.is_empty() {
7328 return Ok(Box::pin(RecordBatchStreamAdapter::new(
7329 FTS_SCHEMA.clone(),
7330 stream::empty::<DataFusionResult<RecordBatch>>(),
7331 )));
7332 }
7333
7334 let input_schema = input.schema();
7335 let doc_col_idx = input_schema.index_of(&doc_col)?;
7336
7337 const ACCUMULATE_BYTES: usize = 256 * 1024;
7339 const SLICE_BYTES: usize = 512 * 1024;
7341
7342 let chunked = lance_arrow::stream::rechunk_stream_by_size(
7345 input,
7346 input_schema,
7347 ACCUMULATE_BYTES,
7348 SLICE_BYTES,
7349 );
7350 if let Some(t) = &elapsed_compute {
7351 t.add_duration(pre_await_start.elapsed());
7352 }
7353
7354 let counted_input = tokenize_and_count(
7359 chunked,
7360 tokenizer,
7361 query_tokens.clone(),
7362 doc_col_idx,
7363 elapsed_compute.clone(),
7364 )
7365 .await?;
7366
7367 let post_await_start = std::time::Instant::now();
7370 let scorer = initialize_scorer(base_scorer.as_ref(), query_tokens.as_ref(), &counted_input);
7371 let scores = flat_bm25_score(query_tokens.as_ref(), &counted_input, &scorer, operator)?;
7372
7373 let num_out_batches = scores.num_rows().div_ceil(target_batch_size);
7375 let mut batches = Vec::with_capacity(num_out_batches);
7376 for i in 0..num_out_batches {
7377 let start = i * target_batch_size;
7378 let len = (scores.num_rows() - start).min(target_batch_size);
7379 batches.push(Ok(scores.slice(start, len)));
7380 }
7381 if let Some(t) = &elapsed_compute {
7382 t.add_duration(post_await_start.elapsed());
7383 }
7384 Ok(Box::pin(RecordBatchStreamAdapter::new(
7385 FTS_SCHEMA.clone(),
7386 stream::iter(batches),
7387 )))
7388}
7389
7390pub fn is_phrase_query(query: &str) -> bool {
7391 query.starts_with('\"') && query.ends_with('\"')
7392}
7393
7394#[cfg(test)]
7395mod tests {
7396 use crate::scalar::inverted::document_tokenizer::DocType;
7397 use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
7398 use futures::stream;
7399 use lance_core::cache::LanceCache;
7400 use lance_core::utils::tempfile::TempObjDir;
7401 use lance_io::object_store::ObjectStore;
7402
7403 use crate::metrics::{LocalMetricsCollector, NoOpMetricsCollector};
7404 use crate::prefilter::NoFilter;
7405 use crate::scalar::ScalarIndex;
7406 use crate::scalar::inverted::builder::{
7407 InnerBuilder, InvertedIndexBuilder, PositionRecorder, doc_file_path, inverted_list_schema,
7408 inverted_list_schema_for_version_with_block_size,
7409 inverted_list_schema_for_version_with_block_size_and_impacts, posting_file_path,
7410 token_file_path,
7411 };
7412 use crate::scalar::inverted::encoding::{
7413 compress_positions, compress_posting_list_with_tail_codec,
7414 decompress_posting_list_with_tail_codec, encode_position_stream_block_into,
7415 };
7416 use crate::scalar::inverted::query::{FtsSearchParams, Operator};
7417 use crate::scalar::lance_format::LanceIndexStore;
7418 use arrow::array::{
7419 AsArray, GenericListBuilder, GenericStringBuilder, Int32Builder, LargeBinaryBuilder,
7420 ListBuilder, UInt32Builder,
7421 };
7422 use arrow::datatypes::{Float32Type, UInt32Type};
7423 use arrow_array::{ArrayRef, Float32Array, RecordBatch, StringArray, UInt32Array, UInt64Array};
7424 use arrow_schema::{DataType, Field, Schema};
7425 use std::collections::HashMap;
7426 use std::sync::Arc;
7427 use std::sync::atomic::{AtomicU32, Ordering};
7428
7429 use crate::scalar::inverted::tokenizer::document_tokenizer::TextTokenizer;
7430 use lance_tokenizer::{Language, SimpleTokenizer, StopWordFilter, TextAnalyzer};
7431
7432 use super::*;
7433
7434 #[derive(Debug)]
7435 struct MetadataAccessDeniedStore {
7436 inner: Arc<dyn IndexStore>,
7437 }
7438
7439 impl DeepSizeOf for MetadataAccessDeniedStore {
7440 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
7441 self.inner.deep_size_of_children(context)
7442 }
7443 }
7444
7445 #[async_trait]
7446 impl IndexStore for MetadataAccessDeniedStore {
7447 fn as_any(&self) -> &dyn std::any::Any {
7448 self
7449 }
7450
7451 fn clone_arc(&self) -> Arc<dyn IndexStore> {
7452 Arc::new(Self {
7453 inner: self.inner.clone(),
7454 })
7455 }
7456
7457 fn io_parallelism(&self) -> usize {
7458 self.inner.io_parallelism()
7459 }
7460
7461 async fn new_index_file(
7462 &self,
7463 name: &str,
7464 schema: Arc<Schema>,
7465 ) -> Result<Box<dyn crate::scalar::IndexWriter>> {
7466 self.inner.new_index_file(name, schema).await
7467 }
7468
7469 async fn open_index_file(&self, name: &str) -> Result<Arc<dyn IndexReader>> {
7470 if name == METADATA_FILE {
7471 Err(Error::io("metadata access denied"))
7472 } else {
7473 self.inner.open_index_file(name).await
7474 }
7475 }
7476
7477 fn with_io_priority(&self, io_priority: u64) -> Arc<dyn IndexStore> {
7478 Arc::new(Self {
7479 inner: self.inner.with_io_priority(io_priority),
7480 })
7481 }
7482
7483 async fn copy_index_file(
7484 &self,
7485 name: &str,
7486 dest_store: &dyn IndexStore,
7487 ) -> Result<crate::scalar::IndexFile> {
7488 self.inner.copy_index_file(name, dest_store).await
7489 }
7490
7491 async fn rename_index_file(
7492 &self,
7493 name: &str,
7494 new_name: &str,
7495 ) -> Result<crate::scalar::IndexFile> {
7496 self.inner.rename_index_file(name, new_name).await
7497 }
7498
7499 async fn delete_index_file(&self, name: &str) -> Result<()> {
7500 self.inner.delete_index_file(name).await
7501 }
7502
7503 async fn list_files_with_sizes(&self) -> Result<Vec<crate::scalar::IndexFile>> {
7504 self.inner.list_files_with_sizes().await
7505 }
7506 }
7507
7508 #[tokio::test]
7509 async fn params_legacy_fallback_probes_tokens_after_metadata_access_denied() {
7510 let tmpdir = TempObjDir::default();
7511 let inner: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
7512 ObjectStore::local().into(),
7513 tmpdir.clone(),
7514 Arc::new(LanceCache::no_cache()),
7515 ));
7516 let expected = InvertedIndexParams::default();
7517 let metadata = HashMap::from([(
7518 "tokenizer".to_owned(),
7519 serde_json::to_string(&expected).unwrap(),
7520 )]);
7521 let mut writer = inner
7522 .new_index_file(TOKENS_FILE, Arc::new(Schema::empty()))
7523 .await
7524 .unwrap();
7525 writer.finish_with_metadata(metadata).await.unwrap();
7526 let store = MetadataAccessDeniedStore { inner };
7527
7528 let actual = InvertedIndex::load_params(&store).await.unwrap();
7529 assert_eq!(
7530 serde_json::to_value(actual).unwrap(),
7531 serde_json::to_value(expected).unwrap()
7532 );
7533 }
7534
7535 #[tokio::test]
7536 async fn params_legacy_probe_preserves_metadata_error_when_tokens_are_missing() {
7537 let tmpdir = TempObjDir::default();
7538 let inner: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
7539 ObjectStore::local().into(),
7540 tmpdir.clone(),
7541 Arc::new(LanceCache::no_cache()),
7542 ));
7543 let store = MetadataAccessDeniedStore { inner };
7544
7545 let error = InvertedIndex::load_params(&store).await.unwrap_err();
7546 assert!(matches!(error, Error::IO { .. }));
7547 assert!(error.to_string().contains("metadata access denied"));
7548 }
7549
7550 #[tokio::test]
7551 async fn params_metadata_ignores_unknown_fields() {
7552 let tmpdir = TempObjDir::default();
7553 let store = Arc::new(LanceIndexStore::new(
7554 ObjectStore::local().into(),
7555 tmpdir.clone(),
7556 Arc::new(LanceCache::no_cache()),
7557 ));
7558 let expected = InvertedIndexParams::default();
7559 let mut params = serde_json::to_value(&expected).unwrap();
7560 let params = params.as_object_mut().unwrap();
7561 params.insert("skip_merge".to_owned(), true.into());
7562 params.insert(
7563 "future_parameter".to_owned(),
7564 serde_json::json!({ "enabled": true }),
7565 );
7566 let metadata =
7567 HashMap::from([("params".to_owned(), serde_json::to_string(params).unwrap())]);
7568 let mut writer = store
7569 .new_index_file(METADATA_FILE, Arc::new(Schema::empty()))
7570 .await
7571 .unwrap();
7572 writer.finish_with_metadata(metadata).await.unwrap();
7573
7574 let actual = InvertedIndex::load_params(store.as_ref()).await.unwrap();
7575 assert_eq!(
7576 serde_json::to_value(actual).unwrap(),
7577 serde_json::to_value(expected).unwrap()
7578 );
7579 }
7580
7581 async fn write_single_partition_index(
7582 store: Arc<LanceIndexStore>,
7583 params: InvertedIndexParams,
7584 token_set_format: TokenSetFormat,
7585 token: &str,
7586 row_id: u64,
7587 ) -> Result<Arc<InvertedIndex>> {
7588 let block_size = params.posting_block_size();
7589 let format_version = params.resolved_format_version();
7590 let mut partition = InnerBuilder::new_with_format_version_and_block_size(
7591 0,
7592 false,
7593 token_set_format,
7594 format_version,
7595 block_size,
7596 );
7597 partition.tokens.add(token.to_owned());
7598 let mut posting_list = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
7599 false,
7600 format_version.posting_tail_codec(),
7601 block_size,
7602 );
7603 posting_list.add(0, PositionRecorder::Count(1));
7604 partition.posting_lists.push(posting_list);
7605 partition.docs.append(row_id, 1);
7606 partition.write(store.as_ref()).await?;
7607
7608 let metadata = HashMap::from([
7609 (
7610 "partitions".to_owned(),
7611 serde_json::to_string(&vec![0_u64]).unwrap(),
7612 ),
7613 ("params".to_owned(), serde_json::to_string(¶ms).unwrap()),
7614 (
7615 TOKEN_SET_FORMAT_KEY.to_owned(),
7616 token_set_format.to_string(),
7617 ),
7618 (
7619 POSTING_TAIL_CODEC_KEY.to_owned(),
7620 format_version.posting_tail_codec().as_str().to_owned(),
7621 ),
7622 (
7623 FTS_FORMAT_VERSION_KEY.to_owned(),
7624 format_version.index_version().to_string(),
7625 ),
7626 (POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string()),
7627 ]);
7628 let mut writer = store
7629 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
7630 .await?;
7631 writer.finish_with_metadata(metadata).await?;
7632
7633 InvertedIndex::load(store, None, &LanceCache::no_cache()).await
7634 }
7635
7636 fn empty_doc_stream() -> SendableRecordBatchStream {
7637 let schema = Arc::new(Schema::new(vec![
7638 Field::new("doc", DataType::Utf8, true),
7639 Field::new(ROW_ID, DataType::UInt64, false),
7640 ]));
7641 Box::pin(RecordBatchStreamAdapter::new(
7642 schema,
7643 stream::iter(Vec::<datafusion::error::Result<RecordBatch>>::new()),
7644 ))
7645 }
7646
7647 #[test]
7648 fn test_posting_block_size_schema_metadata() {
7649 assert_eq!(parse_posting_block_size(&HashMap::new()).unwrap(), 128);
7650
7651 let metadata = HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), "512".to_owned())]);
7652 let err = parse_posting_block_size(&metadata).unwrap_err();
7653 assert!(err.to_string().contains("block_size"));
7654
7655 let metadata = HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), "129".to_owned())]);
7656 let err = parse_posting_block_size(&metadata).unwrap_err();
7657 assert!(err.to_string().contains("block_size"));
7658 }
7659
7660 #[test]
7661 fn test_num_tokens_only_reuses_sliced_arrow_storage() {
7662 let docs = {
7663 let source = UInt32Array::from(vec![999, 7, 16, 1024, 888]);
7664 let sliced = source.slice(1, 3);
7665 let mut docs = DocSet::from_num_tokens_only(&sliced);
7666
7667 let NumTokens::Shared(values) = &docs.num_tokens else {
7668 panic!("num-tokens-only DocSet must retain shared Arrow storage");
7669 };
7670 assert!(values.ptr_eq(sliced.values()));
7671 assert_eq!(values.as_ref(), &[7, 16, 1024]);
7672 assert_eq!(docs.total_tokens_num(), 1047);
7673 docs.set_quantized_scoring(true);
7674 assert_eq!(docs.scoring_norms().unwrap().len(), 3);
7675 assert_eq!(
7676 docs.scoring_num_tokens(0),
7677 dequantize_doc_length(quantize_doc_length(7))
7678 );
7679 assert_eq!(
7680 docs.scoring_num_tokens(2),
7681 dequantize_doc_length(quantize_doc_length(1024))
7682 );
7683 docs
7684 };
7685
7686 assert_eq!(docs.len(), 3);
7687 assert_eq!(docs.num_tokens(0), 7);
7688 assert_eq!(docs.num_tokens(2), 1024);
7689 }
7690
7691 #[test]
7692 fn test_cached_num_tokens_uses_supplied_total_and_full_stays_owned() {
7693 const CACHED_TOTAL_MARKER: u64 = 123_456;
7694
7695 let num_tokens = UInt32Array::from(vec![3, 5, 8]);
7696 let docs = DocSet::from_cached_num_tokens(&num_tokens, CACHED_TOTAL_MARKER);
7697 assert_eq!(docs.total_tokens_num(), CACHED_TOTAL_MARKER);
7698 assert!(matches!(&docs.num_tokens, NumTokens::Shared(_)));
7699
7700 let row_ids = UInt64Array::from(vec![10, 20, 30]);
7701 let full = DocSet::from_columns(&row_ids, &num_tokens, false, None).unwrap();
7702 assert!(matches!(&full.num_tokens, NumTokens::Owned(_)));
7703 assert_eq!(full.total_tokens_num(), 16);
7704 assert_eq!(full.row_id(1), 20);
7705 }
7706
7707 #[test]
7708 fn test_posting_builder_writes_impacts_for_supported_block_sizes() {
7709 for block_size in [128, 256] {
7710 let format_version = default_fts_format_version_for_block_size(block_size).unwrap();
7711 let num_docs = block_size * 33 + 1;
7712 let mut docs = DocSet::default();
7713 let mut posting = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
7714 false,
7715 format_version.posting_tail_codec(),
7716 block_size,
7717 );
7718 for doc_id in 0..num_docs {
7719 docs.append(doc_id as u64, (doc_id % 5 + 1) as u32);
7720 posting.add(
7721 doc_id as u32,
7722 PositionRecorder::Count((doc_id % 3 + 1) as u32),
7723 );
7724 }
7725 let schema =
7726 inverted_list_schema_for_version_with_block_size(false, format_version, block_size);
7727 let batch = posting.to_batch_with_docs(&docs, schema).unwrap();
7728 assert!(batch.column_by_name(IMPACT_COL).is_some());
7729 let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
7730 let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
7731 let posting = PostingList::from_batch(&batch, Some(max_score), Some(length)).unwrap();
7732 let PostingList::Compressed(posting) = posting else {
7733 panic!("expected compressed posting list");
7734 };
7735 let impacts = posting.impacts.expect("posting should include impacts");
7736 assert_eq!(impacts.level0_len(), posting.blocks.len());
7737 assert_eq!(impacts.level1_len(), posting.blocks.len().div_ceil(32));
7738 assert_eq!(
7739 impacts.entries().len(),
7740 impacts.level0_len() + impacts.level1_len()
7741 );
7742 }
7743 }
7744
7745 #[test]
7746 fn test_posting_builder_without_impact_column_roundtrips_without_impacts() {
7747 let mut posting = PostingListBuilder::new(false);
7748 for doc_id in 0..BLOCK_SIZE + 3 {
7749 posting.add(doc_id as u32, PositionRecorder::Count(1));
7750 }
7751 let batch = posting.to_batch(vec![1.0, 1.0]).unwrap();
7752 assert!(batch.column_by_name(IMPACT_COL).is_none());
7753 let posting =
7754 PostingList::from_batch(&batch, Some(1.0), Some((BLOCK_SIZE + 3) as u32)).unwrap();
7755 assert!(!posting.has_impacts());
7756 }
7757
7758 #[tokio::test]
7759 async fn test_build_search_uses_configured_posting_block_size() {
7760 let tmpdir = TempObjDir::default();
7761 let store = Arc::new(LanceIndexStore::new(
7762 ObjectStore::local().into(),
7763 tmpdir.clone(),
7764 Arc::new(LanceCache::no_cache()),
7765 ));
7766
7767 let params = InvertedIndexParams::default().block_size(256).unwrap();
7768 let format_version = params.resolved_format_version();
7769 let block_size = params.posting_block_size();
7770 let num_docs = block_size + 7;
7771
7772 let mut builder = InnerBuilder::new_with_format_version_and_block_size(
7773 0,
7774 false,
7775 TokenSetFormat::default(),
7776 format_version,
7777 block_size,
7778 );
7779 builder.tokens.add("needle".to_owned());
7780 let mut posting_list = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
7781 false,
7782 format_version.posting_tail_codec(),
7783 block_size,
7784 );
7785 for doc_id in 0..num_docs {
7786 posting_list.add(doc_id as u32, PositionRecorder::Count(1));
7787 builder.docs.append(1_000 + doc_id as u64, 1);
7788 }
7789 builder.posting_lists.push(posting_list);
7790 builder.write(store.as_ref()).await.unwrap();
7791 write_test_metadata(&store, vec![0], params).await;
7792
7793 let cache = Arc::new(LanceCache::with_capacity(4096));
7794 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
7795 .await
7796 .unwrap();
7797 assert_eq!(index.partitions[0].inverted_list.block_size(), block_size);
7798
7799 let posting = index.partitions[0]
7800 .inverted_list
7801 .posting_list(0, false, &NoOpMetricsCollector)
7802 .await
7803 .unwrap();
7804 let PostingList::Compressed(posting) = posting else {
7805 panic!("expected compressed posting list");
7806 };
7807 assert_eq!(posting.block_size, block_size);
7808 assert_eq!(posting.blocks.len(), num_docs.div_ceil(block_size));
7809 let impacts = posting
7810 .impacts
7811 .as_ref()
7812 .expect("newly written posting list should include impacts");
7813 assert_eq!(impacts.level0_len(), posting.blocks.len());
7814 assert_eq!(impacts.level1_len(), posting.blocks.len().div_ceil(32));
7815 assert_eq!(
7816 impacts.entries().len(),
7817 impacts.level0_len() + impacts.level1_len()
7818 );
7819
7820 let tokens = Arc::new(Tokens::new(vec!["needle".to_owned()], DocType::Text));
7821 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
7822 let prefilter = Arc::new(NoFilter);
7823 let metrics = Arc::new(NoOpMetricsCollector);
7824 let (row_ids, scores) = index
7825 .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
7826 .await
7827 .unwrap();
7828
7829 assert_eq!(row_ids.len(), 10);
7830 assert_eq!(scores.len(), 10);
7831 assert!(row_ids.iter().all(|row_id| *row_id >= 1_000));
7832 }
7833
7834 #[tokio::test]
7835 async fn test_posting_builder_remap() {
7836 let posting_tail_codec = PostingTailCodec::Fixed32;
7837 let mut builder =
7838 PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
7839 let n = BLOCK_SIZE + 3;
7840 for i in 0..n {
7841 builder.add(i as u32, PositionRecorder::Count(1));
7842 }
7843 let removed = vec![5, 7];
7844 builder.remap(&removed);
7845
7846 let mut expected =
7847 PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
7848 for i in 0..n - removed.len() {
7849 expected.add(i as u32, PositionRecorder::Count(1));
7850 }
7851 let expected_entries = expected.iter().collect::<Vec<_>>();
7852 let actual_entries = builder.iter().collect::<Vec<_>>();
7853 assert_eq!(actual_entries, expected_entries);
7854
7855 let batch = builder.to_batch(vec![1.0, 2.0]).unwrap();
7858 let (doc_ids, freqs) = decompress_posting_list_with_tail_codec(
7859 (n - removed.len()) as u32,
7860 batch[POSTING_COL]
7861 .as_list::<i32>()
7862 .value(0)
7863 .as_binary::<i64>(),
7864 posting_tail_codec,
7865 )
7866 .unwrap();
7867 assert!(
7868 doc_ids
7869 .iter()
7870 .zip(expected_entries.iter().map(|(doc_id, _, _)| doc_id))
7871 .all(|(a, b)| a == b)
7872 );
7873 assert!(
7874 freqs
7875 .iter()
7876 .zip(expected_entries.iter().map(|(_, freq, _)| freq))
7877 .all(|(a, b)| a == b)
7878 );
7879 }
7880
7881 #[test]
7882 fn test_posting_builder_size_tracking_matches_structure() {
7883 fn tracked_memory_size(builder: &PostingListBuilder) -> u64 {
7884 let encoded_blocks_size = builder
7885 .encoded_blocks
7886 .iter()
7887 .map(|encoded_blocks| std::mem::size_of::<EncodedBlocks>() + encoded_blocks.size())
7888 .sum::<usize>();
7889 let encoded_positions_size = builder
7890 .encoded_position_blocks
7891 .as_ref()
7892 .map(|positions| std::mem::size_of::<EncodedPositionBlocks>() + positions.size())
7893 .unwrap_or(0usize);
7894 (encoded_blocks_size
7895 + builder.tail_entries.capacity() * std::mem::size_of::<RawDocInfo>()
7896 + builder.tail_positions.size()
7897 + encoded_positions_size) as u64
7898 }
7899
7900 let mut builder = PostingListBuilder::new(true);
7901 for doc_id in 0..(BLOCK_SIZE + 5) as u32 {
7902 builder.add(
7903 doc_id,
7904 PositionRecorder::Position(smallvec::smallvec![1, 3, 5]),
7905 );
7906 }
7907
7908 assert_eq!(builder.size(), tracked_memory_size(&builder));
7909 }
7910
7911 #[test]
7912 fn test_posting_builder_flush_releases_tail_position_capacity() {
7913 let mut builder = PostingListBuilder::new(true);
7914 let positions = smallvec::SmallVec::<[u32; 2]>::from_vec((0..1024).collect());
7915 for doc_id in 0..BLOCK_SIZE as u32 {
7916 builder.add(doc_id, PositionRecorder::Position(positions.clone()));
7917 }
7918
7919 assert_eq!(builder.tail_positions.size(), 0);
7920 assert_eq!(builder.size(), {
7921 let encoded_blocks_size = builder
7922 .encoded_blocks
7923 .iter()
7924 .map(|encoded_blocks| std::mem::size_of::<EncodedBlocks>() + encoded_blocks.size())
7925 .sum::<usize>();
7926 let encoded_positions_size = builder
7927 .encoded_position_blocks
7928 .as_ref()
7929 .map(|positions| std::mem::size_of::<EncodedPositionBlocks>() + positions.size())
7930 .unwrap_or(0usize);
7931 (encoded_blocks_size
7932 + builder.tail_entries.capacity() * std::mem::size_of::<RawDocInfo>()
7933 + builder.tail_positions.size()
7934 + encoded_positions_size) as u64
7935 });
7936 }
7937
7938 #[test]
7939 fn test_posting_builder_streamed_positions_roundtrip() {
7940 let mut builder = PostingListBuilder::new(true);
7941 assert!(builder.add_occurrence(0, 1).unwrap());
7942 assert!(!builder.add_occurrence(0, 4).unwrap());
7943 assert!(!builder.add_occurrence(0, 9).unwrap());
7944 builder.finish_open_doc(0).unwrap();
7945
7946 assert!(builder.add_occurrence(2, 3).unwrap());
7947 builder.finish_open_doc(2).unwrap();
7948
7949 let entries = builder.iter().collect::<Vec<_>>();
7950 assert_eq!(
7951 entries,
7952 vec![
7953 (0_u32, 3_u32, Some(vec![1_u32, 4_u32, 9_u32])),
7954 (2_u32, 1_u32, Some(vec![3_u32])),
7955 ]
7956 );
7957 }
7958
7959 #[test]
7960 fn test_shared_position_stream_clone_shares_block_offsets() {
7961 let stream = SharedPositionStream::new(
7962 PositionStreamCodec::PackedDelta,
7963 vec![0_u32, 4, 11],
7964 bytes::Bytes::from_static(b"shared position bytes"),
7965 );
7966 let original_offsets = stream.block_offsets().as_ptr();
7967
7968 let cloned = stream.clone();
7969
7970 assert_eq!(cloned.block_offsets(), stream.block_offsets());
7971 assert_eq!(cloned.block_offsets().as_ptr(), original_offsets);
7972 }
7973
7974 #[test]
7975 fn test_posting_builder_roundtrip_shared_positions() {
7976 let entries = vec![
7977 (0_u32, vec![1_u32, 5]),
7978 (2, vec![0, 4, 9]),
7979 (4, vec![7]),
7980 (8, vec![3, 10]),
7981 (13, vec![2, 11, 30]),
7982 ];
7983 let mut builder =
7984 PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::VarintDelta);
7985 for (doc_id, positions) in &entries {
7986 builder.add(
7987 *doc_id,
7988 PositionRecorder::Position(positions.clone().into()),
7989 );
7990 }
7991
7992 let batch = builder.to_batch(vec![1.0]).unwrap();
7993 assert!(batch.column_by_name(COMPRESSED_POSITION_COL).is_some());
7994 assert!(batch.column_by_name(POSITION_COL).is_none());
7995 assert_eq!(
7996 batch.schema_ref().metadata().get(POSTING_TAIL_CODEC_KEY),
7997 Some(&PostingTailCodec::VarintDelta.as_str().to_owned())
7998 );
7999 assert_eq!(
8000 batch.schema_ref().metadata().get(POSITIONS_LAYOUT_KEY),
8001 Some(&POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned())
8002 );
8003 assert_eq!(
8004 batch.schema_ref().metadata().get(POSITIONS_CODEC_KEY),
8005 Some(&PositionStreamCodec::PackedDelta.as_str().to_owned())
8006 );
8007
8008 let posting =
8009 PostingList::from_batch(&batch, Some(1.0), Some(entries.len() as u32)).unwrap();
8010 let actual = posting
8011 .iter()
8012 .map(|(doc_id, freq, positions)| {
8013 (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
8014 })
8015 .collect::<Vec<_>>();
8016 let expected = entries
8017 .iter()
8018 .map(|(doc_id, positions)| (*doc_id, positions.len() as u32, positions.clone()))
8019 .collect::<Vec<_>>();
8020 assert_eq!(actual, expected);
8021 }
8022
8023 #[test]
8024 fn test_posting_builder_roundtrip_legacy_positions() {
8025 let entries = vec![(0_u32, vec![1_u32, 5]), (2, vec![0, 4, 9]), (4, vec![7])];
8026 let mut builder =
8027 PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::Fixed32);
8028 for (doc_id, positions) in &entries {
8029 builder.add(
8030 *doc_id,
8031 PositionRecorder::Position(positions.clone().into()),
8032 );
8033 }
8034
8035 let batch = builder.to_batch(vec![1.0]).unwrap();
8036 assert!(batch.column_by_name(POSITION_COL).is_some());
8037 assert!(batch.column_by_name(COMPRESSED_POSITION_COL).is_none());
8038 assert_eq!(
8039 batch.schema_ref().metadata().get(POSTING_TAIL_CODEC_KEY),
8040 None
8041 );
8042 assert_eq!(
8043 batch.schema_ref().metadata().get(POSITIONS_LAYOUT_KEY),
8044 None
8045 );
8046 assert_eq!(batch.schema_ref().metadata().get(POSITIONS_CODEC_KEY), None);
8047
8048 let posting =
8049 PostingList::from_batch(&batch, Some(1.0), Some(entries.len() as u32)).unwrap();
8050 let actual = posting
8051 .iter()
8052 .map(|(doc_id, freq, positions)| {
8053 (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
8054 })
8055 .collect::<Vec<_>>();
8056 let expected = entries
8057 .iter()
8058 .map(|(doc_id, positions)| (*doc_id, positions.len() as u32, positions.clone()))
8059 .collect::<Vec<_>>();
8060 assert_eq!(actual, expected);
8061 }
8062
8063 #[test]
8064 fn test_resolve_fts_format_version_defaults_to_v2() {
8065 assert_eq!(
8066 resolve_fts_format_version(None).unwrap(),
8067 InvertedListFormatVersion::V2
8068 );
8069 assert_eq!(
8070 resolve_fts_format_version(Some("2")).unwrap(),
8071 InvertedListFormatVersion::V2
8072 );
8073 assert_eq!(
8074 resolve_fts_format_version(Some("3")).unwrap(),
8075 InvertedListFormatVersion::V3
8076 );
8077 assert!(resolve_fts_format_version(Some("4")).is_err());
8078 }
8079
8080 #[test]
8081 fn test_block_size_256_metadata_resolves_to_v3() {
8082 let metadata = HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), "256".to_owned())]);
8083 assert_eq!(
8084 parse_format_version_from_metadata(&metadata).unwrap(),
8085 InvertedListFormatVersion::V3
8086 );
8087 }
8088
8089 #[test]
8090 fn test_legacy_compressed_positions_still_readable() {
8091 let doc_ids = [1_u32, 3_u32];
8092 let frequencies = [2_u32, 3_u32];
8093 let posting = compress_posting_list_with_tail_codec(
8094 doc_ids.len(),
8095 doc_ids.iter(),
8096 frequencies.iter(),
8097 std::iter::once(1.0_f32),
8098 PostingTailCodec::Fixed32,
8099 )
8100 .unwrap();
8101
8102 let mut posting_builder = ListBuilder::new(LargeBinaryBuilder::new());
8103 for idx in 0..posting.len() {
8104 posting_builder.values().append_value(posting.value(idx));
8105 }
8106 posting_builder.append(true);
8107
8108 let mut positions_builder = ListBuilder::new(ListBuilder::new(LargeBinaryBuilder::new()));
8109 for positions in [vec![1_u32, 5_u32], vec![0_u32, 4_u32, 9_u32]] {
8110 let compressed = compress_positions(&positions).unwrap();
8111 let doc_builder = positions_builder.values();
8112 for idx in 0..compressed.len() {
8113 doc_builder.values().append_value(compressed.value(idx));
8114 }
8115 doc_builder.append(true);
8116 }
8117 positions_builder.append(true);
8118
8119 let schema = Arc::new(Schema::new(vec![
8120 Field::new(
8121 POSTING_COL,
8122 DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))),
8123 false,
8124 ),
8125 Field::new(MAX_SCORE_COL, DataType::Float32, false),
8126 Field::new(LENGTH_COL, DataType::UInt32, false),
8127 Field::new(
8128 POSITION_COL,
8129 DataType::List(Arc::new(Field::new(
8130 "item",
8131 DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))),
8132 true,
8133 ))),
8134 false,
8135 ),
8136 ]));
8137 let batch = RecordBatch::try_new(
8138 schema,
8139 vec![
8140 Arc::new(posting_builder.finish()) as ArrayRef,
8141 Arc::new(Float32Array::from(vec![1.0])) as ArrayRef,
8142 Arc::new(UInt32Array::from(vec![doc_ids.len() as u32])) as ArrayRef,
8143 Arc::new(positions_builder.finish()) as ArrayRef,
8144 ],
8145 )
8146 .unwrap();
8147
8148 let posting =
8149 PostingList::from_batch(&batch, Some(1.0), Some(doc_ids.len() as u32)).unwrap();
8150 let actual = posting
8151 .iter()
8152 .map(|(doc_id, freq, positions)| {
8153 (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
8154 })
8155 .collect::<Vec<_>>();
8156 assert_eq!(actual, vec![(1, 2, vec![1, 5]), (3, 3, vec![0, 4, 9]),]);
8157 }
8158
8159 #[test]
8160 fn test_shared_stream_v2_without_codec_still_readable() {
8161 let doc_ids = [1_u32, 3_u32];
8162 let frequencies = [2_u32, 3_u32];
8163 let posting = compress_posting_list_with_tail_codec(
8164 doc_ids.len(),
8165 doc_ids.iter(),
8166 frequencies.iter(),
8167 std::iter::once(1.0_f32),
8168 PostingTailCodec::Fixed32,
8169 )
8170 .unwrap();
8171
8172 let mut posting_builder = ListBuilder::new(LargeBinaryBuilder::new());
8173 for idx in 0..posting.len() {
8174 posting_builder.values().append_value(posting.value(idx));
8175 }
8176 posting_builder.append(true);
8177
8178 let positions = vec![1_u32, 5_u32, 0_u32, 4_u32, 9_u32];
8179 let mut encoded_positions = Vec::new();
8180 encode_position_stream_block_into(
8181 &positions,
8182 &frequencies,
8183 PositionStreamCodec::VarintDocDelta,
8184 &mut encoded_positions,
8185 )
8186 .unwrap();
8187
8188 let mut position_offsets = ListBuilder::new(UInt32Builder::new());
8189 position_offsets.values().append_value(0);
8190 position_offsets.append(true);
8191
8192 let schema = Arc::new(Schema::new_with_metadata(
8193 vec![
8194 Field::new(
8195 POSTING_COL,
8196 DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))),
8197 false,
8198 ),
8199 Field::new(MAX_SCORE_COL, DataType::Float32, false),
8200 Field::new(LENGTH_COL, DataType::UInt32, false),
8201 Field::new(COMPRESSED_POSITION_COL, DataType::LargeBinary, false),
8202 Field::new(
8203 POSITION_BLOCK_OFFSET_COL,
8204 DataType::List(Arc::new(Field::new("item", DataType::UInt32, true))),
8205 false,
8206 ),
8207 ],
8208 HashMap::from([(
8209 POSITIONS_LAYOUT_KEY.to_owned(),
8210 POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
8211 )]),
8212 ));
8213 let batch = RecordBatch::try_new(
8214 schema,
8215 vec![
8216 Arc::new(posting_builder.finish()) as ArrayRef,
8217 Arc::new(Float32Array::from(vec![1.0])) as ArrayRef,
8218 Arc::new(UInt32Array::from(vec![doc_ids.len() as u32])) as ArrayRef,
8219 Arc::new(arrow_array::LargeBinaryArray::from(vec![Some(
8220 encoded_positions.as_slice(),
8221 )])) as ArrayRef,
8222 Arc::new(position_offsets.finish()) as ArrayRef,
8223 ],
8224 )
8225 .unwrap();
8226
8227 let posting =
8228 PostingList::from_batch(&batch, Some(1.0), Some(doc_ids.len() as u32)).unwrap();
8229 let actual = posting
8230 .iter()
8231 .map(|(doc_id, freq, positions)| {
8232 (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
8233 })
8234 .collect::<Vec<_>>();
8235 assert_eq!(actual, vec![(1, 2, vec![1, 5]), (3, 3, vec![0, 4, 9]),]);
8236 }
8237
8238 #[test]
8239 fn test_shared_position_stream_is_smaller_for_sparse_positions() {
8240 let mut builder =
8241 PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::VarintDelta);
8242 let mut legacy_positions = Vec::with_capacity(BLOCK_SIZE * 4);
8243 for doc_id in 0..(BLOCK_SIZE * 4) as u32 {
8244 let mut positions = vec![doc_id * 3 + 1];
8245 if doc_id % 8 == 0 {
8246 positions.push(doc_id * 3 + 2);
8247 }
8248 builder.add(doc_id, PositionRecorder::Position(positions.clone().into()));
8249 legacy_positions.push(positions);
8250 }
8251
8252 let batch = builder.to_batch(vec![1.0; 4]).unwrap();
8253 let shared_positions_size = batch[COMPRESSED_POSITION_COL].get_buffer_memory_size()
8254 + batch[POSITION_BLOCK_OFFSET_COL].get_buffer_memory_size();
8255
8256 let mut positions_builder = ListBuilder::new(ListBuilder::new(LargeBinaryBuilder::new()));
8257 for positions in legacy_positions {
8258 let compressed = compress_positions(&positions).unwrap();
8259 let doc_builder = positions_builder.values();
8260 for idx in 0..compressed.len() {
8261 doc_builder.values().append_value(compressed.value(idx));
8262 }
8263 doc_builder.append(true);
8264 }
8265 positions_builder.append(true);
8266 let legacy_positions_size = positions_builder.finish().get_buffer_memory_size();
8267
8268 assert!(
8269 shared_positions_size < legacy_positions_size,
8270 "expected shared position stream to be smaller than legacy per-doc storage, shared={shared_positions_size}, legacy={legacy_positions_size}",
8271 );
8272 }
8273
8274 #[test]
8275 fn test_posting_list_batch_matches_docset_scoring() {
8276 let mut docs = DocSet::default();
8277 let num_docs = BLOCK_SIZE + 3;
8278 for doc_id in 0..num_docs as u32 {
8279 docs.append(doc_id as u64, doc_id % 7 + 1);
8280 }
8281
8282 let doc_ids = (0..num_docs as u32).collect::<Vec<_>>();
8283 let freqs = doc_ids
8284 .iter()
8285 .map(|doc_id| doc_id % 5 + 1)
8286 .collect::<Vec<_>>();
8287
8288 let mut builder_scores = PostingListBuilder::new(false);
8289 let mut builder_docs = PostingListBuilder::new(false);
8290 for (&doc_id, &freq) in doc_ids.iter().zip(freqs.iter()) {
8291 builder_scores.add(doc_id, PositionRecorder::Count(freq));
8292 builder_docs.add(doc_id, PositionRecorder::Count(freq));
8293 }
8294
8295 let block_max_scores = docs.calculate_block_max_scores(doc_ids.iter(), freqs.iter());
8296 let batch_scores = builder_scores.to_batch(block_max_scores).unwrap();
8297 let batch_docs = builder_docs
8298 .to_batch_with_docs(&docs, inverted_list_schema(false))
8299 .unwrap();
8300
8301 let scores_posting = batch_scores[POSTING_COL].as_list::<i32>().value(0);
8302 let scores_posting = scores_posting.as_binary::<i64>();
8303 let docs_posting = batch_docs[POSTING_COL].as_list::<i32>().value(0);
8304 let docs_posting = docs_posting.as_binary::<i64>();
8305 assert_eq!(scores_posting, docs_posting);
8306
8307 let score_left = batch_scores[MAX_SCORE_COL]
8308 .as_primitive::<Float32Type>()
8309 .value(0);
8310 let score_right = batch_docs[MAX_SCORE_COL]
8311 .as_primitive::<Float32Type>()
8312 .value(0);
8313 assert!((score_left - score_right).abs() < 1e-6);
8314
8315 let len_left = batch_scores[LENGTH_COL]
8316 .as_primitive::<UInt32Type>()
8317 .value(0);
8318 let len_right = batch_docs[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
8319 assert_eq!(len_left, len_right);
8320 }
8321
8322 #[tokio::test]
8323 async fn test_remap_to_empty_posting_list() {
8324 let tmpdir = TempObjDir::default();
8325 let store = Arc::new(LanceIndexStore::new(
8326 ObjectStore::local().into(),
8327 tmpdir.clone(),
8328 Arc::new(LanceCache::no_cache()),
8329 ));
8330
8331 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
8332
8333 builder.tokens.add("lance".to_owned());
8338 builder.tokens.add("lake".to_owned());
8339 builder.posting_lists.push(PostingListBuilder::new(false));
8340 builder.posting_lists.push(PostingListBuilder::new(false));
8341 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
8342 builder.posting_lists[1].add(1, PositionRecorder::Count(2));
8343 builder.posting_lists[1].add(2, PositionRecorder::Count(3));
8344 builder.docs.append(0, 1);
8345 builder.docs.append(1, 1);
8346 builder.docs.append(2, 1);
8347 builder.write(store.as_ref()).await.unwrap();
8348
8349 let index = InvertedPartition::load(
8350 store.clone(),
8351 0,
8352 None,
8353 &LanceCache::no_cache(),
8354 TokenSetFormat::default(),
8355 )
8356 .await
8357 .unwrap();
8358 let mut builder = index.into_builder().await.unwrap();
8359
8360 let mapping = HashMap::from([(0, None), (2, Some(3))]);
8361 builder.remap(&RowAddrRemap::direct(mapping)).await.unwrap();
8362
8363 assert_eq!(builder.tokens.len(), 1);
8365 assert_eq!(builder.tokens.get("lake"), Some(0));
8366 assert_eq!(builder.posting_lists.len(), 1);
8367 assert_eq!(builder.posting_lists[0].len(), 2);
8368 assert_eq!(builder.docs.len(), 2);
8369 assert_eq!(builder.docs.row_id(0), 1);
8370 assert_eq!(builder.docs.row_id(1), 3);
8371
8372 builder.write(store.as_ref()).await.unwrap();
8373
8374 let mapping = HashMap::from([(1, None), (3, None)]);
8376 builder.remap(&RowAddrRemap::direct(mapping)).await.unwrap();
8377
8378 assert_eq!(builder.tokens.len(), 0);
8379 assert_eq!(builder.posting_lists.len(), 0);
8380 assert_eq!(builder.docs.len(), 0);
8381
8382 builder.write(store.as_ref()).await.unwrap();
8383 }
8384
8385 #[tokio::test]
8386 async fn test_posting_cache_conflict_across_partitions() {
8387 let tmpdir = TempObjDir::default();
8388 let store = Arc::new(LanceIndexStore::new(
8389 ObjectStore::local().into(),
8390 tmpdir.clone(),
8391 Arc::new(LanceCache::no_cache()),
8392 ));
8393
8394 let mut builder1 = InnerBuilder::new(0, false, TokenSetFormat::default());
8396 builder1.tokens.add("test".to_owned());
8397 builder1.posting_lists.push(PostingListBuilder::new(false));
8398 builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
8399 builder1.docs.append(100, 1); builder1.write(store.as_ref()).await.unwrap();
8401
8402 let mut builder2 = InnerBuilder::new(1, false, TokenSetFormat::default());
8404 builder2.tokens.add("test".to_owned()); builder2.posting_lists.push(PostingListBuilder::new(false));
8406 builder2.posting_lists[0].add(0, PositionRecorder::Count(2));
8407 builder2.posting_lists[0].add(1, PositionRecorder::Count(1));
8408 builder2.posting_lists[0].add(2, PositionRecorder::Count(3));
8409 builder2.posting_lists[0].add(3, PositionRecorder::Count(1));
8410 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();
8415
8416 let metadata = std::collections::HashMap::from_iter(vec![
8418 (
8419 "partitions".to_owned(),
8420 serde_json::to_string(&vec![0u64, 1u64]).unwrap(),
8421 ),
8422 (
8423 "params".to_owned(),
8424 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
8425 ),
8426 (
8427 TOKEN_SET_FORMAT_KEY.to_owned(),
8428 TokenSetFormat::default().to_string(),
8429 ),
8430 ]);
8431 let mut writer = store
8432 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
8433 .await
8434 .unwrap();
8435 writer.finish_with_metadata(metadata).await.unwrap();
8436
8437 let cache = Arc::new(LanceCache::with_capacity(4096));
8439 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
8440 .await
8441 .unwrap();
8442
8443 assert_eq!(index.partitions.len(), 2);
8445 assert_eq!(index.partitions[0].tokens.len(), 1);
8446 assert_eq!(index.partitions[1].tokens.len(), 1);
8447
8448 let pl_0_0 = index.partitions[0]
8454 .inverted_list
8455 .posting_len_for_token(0)
8456 .await
8457 .unwrap();
8458 let pl_1_0 = index.partitions[1]
8459 .inverted_list
8460 .posting_len_for_token(0)
8461 .await
8462 .unwrap();
8463 if index.partitions[0].id() == 0 {
8464 assert_eq!(pl_0_0, 1);
8465 assert_eq!(pl_1_0, 4);
8466 assert_eq!(index.partitions[0].docs.len(), 1);
8467 assert_eq!(index.partitions[1].docs.len(), 4);
8468 } else {
8469 assert_eq!(pl_0_0, 4);
8470 assert_eq!(pl_1_0, 1);
8471 assert_eq!(index.partitions[0].docs.len(), 4);
8472 assert_eq!(index.partitions[1].docs.len(), 1);
8473 }
8474
8475 index.prewarm().await.unwrap();
8477
8478 let tokens = Arc::new(Tokens::new(vec!["test".to_string()], DocType::Text));
8479 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
8480 let prefilter = Arc::new(NoFilter);
8481 let metrics = Arc::new(NoOpMetricsCollector);
8482
8483 let (row_ids, scores) = index
8484 .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
8485 .await
8486 .unwrap();
8487
8488 assert_eq!(row_ids.len(), 5, "row_ids: {:?}", row_ids);
8491 assert!(!row_ids.is_empty(), "Should find at least some documents");
8492 assert_eq!(row_ids.len(), scores.len());
8493
8494 for &score in &scores {
8496 assert!(score > 0.0, "All scores should be positive");
8497 }
8498
8499 assert!(
8501 row_ids.contains(&100),
8502 "Should contain row_id from partition 0"
8503 );
8504 assert!(
8505 row_ids.iter().any(|&id| id >= 200),
8506 "Should contain row_id from partition 1"
8507 );
8508 }
8509
8510 #[tokio::test]
8511 async fn test_modern_prewarm_packs_group_with_shared_posting_buffer() {
8512 let tmpdir = TempObjDir::default();
8513 let store = Arc::new(LanceIndexStore::new(
8514 ObjectStore::local().into(),
8515 tmpdir.clone(),
8516 Arc::new(LanceCache::no_cache()),
8517 ));
8518
8519 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
8520 builder.tokens.add("alpha".to_owned());
8521 builder.tokens.add("beta".to_owned());
8522 builder.posting_lists.push(PostingListBuilder::new(false));
8523 builder.posting_lists.push(PostingListBuilder::new(false));
8524 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
8525 builder.posting_lists[0].add(1, PositionRecorder::Count(2));
8526 builder.posting_lists[1].add(2, PositionRecorder::Count(3));
8527 builder.posting_lists[1].add(3, PositionRecorder::Count(4));
8528 builder.docs.append(100, 1);
8529 builder.docs.append(101, 2);
8530 builder.docs.append(102, 3);
8531 builder.docs.append(103, 4);
8532 builder.write(store.as_ref()).await.unwrap();
8533
8534 let metadata = std::collections::HashMap::from_iter(vec![
8535 (
8536 "partitions".to_owned(),
8537 serde_json::to_string(&vec![0u64]).unwrap(),
8538 ),
8539 (
8540 "params".to_owned(),
8541 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
8542 ),
8543 (
8544 TOKEN_SET_FORMAT_KEY.to_owned(),
8545 TokenSetFormat::default().to_string(),
8546 ),
8547 ]);
8548 let mut writer = store
8549 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
8550 .await
8551 .unwrap();
8552 writer.finish_with_metadata(metadata).await.unwrap();
8553
8554 let cache = Arc::new(LanceCache::with_capacity(4096));
8555 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
8556 .await
8557 .unwrap();
8558 let inverted_list = &index.partitions[0].inverted_list;
8559 assert!(
8560 !inverted_list.is_legacy_layout(),
8561 "test should use modern posting layout"
8562 );
8563 assert!(
8564 inverted_list.has_impacts,
8565 "modern posting fixture should include impact skip data"
8566 );
8567
8568 inverted_list.prewarm_posting_lists(false, 2).await.unwrap();
8569
8570 let (start, end) = inverted_list.group_range_for_token(0).unwrap();
8573 let group = inverted_list
8574 .index_cache
8575 .get_with_key(&posting_list_group_cache_key(
8576 start,
8577 end,
8578 inverted_list.has_impacts,
8579 ))
8580 .await
8581 .unwrap();
8582
8583 assert!(
8584 group.is_packed(),
8585 "no-position prewarm should pack v2 groups"
8586 );
8587 assert!(
8588 group.needs_external_metadata(),
8589 "prewarmed packed groups must not duplicate reader score/length metadata"
8590 );
8591 let (alpha_score, alpha_len) = inverted_list.bulk_metadata_for_token(0);
8592 let PostingList::Compressed(alpha) = group
8593 .posting_list(0, alpha_score, alpha_len)
8594 .unwrap()
8595 .unwrap()
8596 else {
8597 panic!("expected compressed posting list for token 0");
8598 };
8599 let PostingList::Compressed(alpha_again) = group
8600 .posting_list(0, alpha_score, alpha_len)
8601 .unwrap()
8602 .unwrap()
8603 else {
8604 panic!("expected compressed posting list for repeated token 0 access");
8605 };
8606 let (beta_score, beta_len) = inverted_list.bulk_metadata_for_token(1);
8607 let PostingList::Compressed(beta) = group
8608 .posting_list(1, beta_score, beta_len)
8609 .unwrap()
8610 .unwrap()
8611 else {
8612 panic!("expected compressed posting list for token 1");
8613 };
8614
8615 assert!(
8616 alpha.impacts.is_some() && beta.impacts.is_some(),
8617 "packed prewarm must preserve impact skip data"
8618 );
8619 assert!(
8620 alpha
8621 .impacts
8622 .as_ref()
8623 .unwrap()
8624 .shares_derived_state_with(alpha_again.impacts.as_ref().unwrap()),
8625 "repeated packed slot access must share decoded impact state"
8626 );
8627 assert!(
8628 alpha.shares_first_docs_with(&alpha_again),
8629 "repeated packed slot access must share decoded block heads"
8630 );
8631 assert_eq!(
8632 alpha.block_first_docs().as_ptr(),
8633 alpha_again.block_first_docs().as_ptr(),
8634 "packed block heads should be decoded only once per slot"
8635 );
8636 assert_eq!(
8637 alpha.blocks.values().as_ptr(),
8638 beta.blocks.values().as_ptr(),
8639 "packed posting views should share the group's values buffer"
8640 );
8641 }
8642
8643 #[tokio::test]
8644 async fn test_packed_prewarm_groups_do_not_retain_the_full_chunk() {
8645 let tmpdir = TempObjDir::default();
8646 let store = Arc::new(LanceIndexStore::new(
8647 ObjectStore::local().into(),
8648 tmpdir.clone(),
8649 Arc::new(LanceCache::no_cache()),
8650 ));
8651
8652 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
8653 for token_id in 0..4u32 {
8654 builder.tokens.add(format!("t{token_id}"));
8655 let mut posting = PostingListBuilder::new(false);
8656 posting.add(token_id, PositionRecorder::Count(1));
8657 builder.posting_lists.push(posting);
8658 builder.docs.append(1000 + token_id as u64, 1);
8659 }
8660 builder.write(store.as_ref()).await.unwrap();
8661
8662 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
8663 let cache = LanceCache::with_capacity(1 << 20);
8664 let mut posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
8665 posting_reader.grouping = PostingGrouping::SyntheticFixed { group_size: 2 };
8666
8667 assert_eq!(
8668 posting_reader
8669 .prewarm_posting_lists_chunked(false, Some(4), 1)
8670 .await
8671 .unwrap(),
8672 1,
8673 "the test must read both groups in one prewarm chunk"
8674 );
8675
8676 let first_group = posting_reader
8677 .index_cache
8678 .get_with_key(&posting_list_group_cache_key(
8679 0,
8680 2,
8681 posting_reader.has_impacts,
8682 ))
8683 .await
8684 .unwrap();
8685 let second_group = posting_reader
8686 .index_cache
8687 .get_with_key(&posting_list_group_cache_key(
8688 2,
8689 4,
8690 posting_reader.has_impacts,
8691 ))
8692 .await
8693 .unwrap();
8694 let (first_score, first_len) = posting_reader.bulk_metadata_for_token(0);
8695 let PostingList::Compressed(first) = first_group
8696 .posting_list(0, first_score, first_len)
8697 .unwrap()
8698 .unwrap()
8699 else {
8700 panic!("expected compressed posting list in first group");
8701 };
8702 let (neighbor_score, neighbor_len) = posting_reader.bulk_metadata_for_token(1);
8703 let PostingList::Compressed(first_neighbor) = first_group
8704 .posting_list(1, neighbor_score, neighbor_len)
8705 .unwrap()
8706 .unwrap()
8707 else {
8708 panic!("expected compressed posting list in first group");
8709 };
8710 let (second_score, second_len) = posting_reader.bulk_metadata_for_token(2);
8711 let PostingList::Compressed(second) = second_group
8712 .posting_list(0, second_score, second_len)
8713 .unwrap()
8714 .unwrap()
8715 else {
8716 panic!("expected compressed posting list in second group");
8717 };
8718
8719 assert_eq!(
8720 first.blocks.values().as_ptr(),
8721 first_neighbor.blocks.values().as_ptr(),
8722 "postings in one group should share the group's values buffer"
8723 );
8724 assert_ne!(
8725 first.blocks.values().as_ptr(),
8726 second.blocks.values().as_ptr(),
8727 "each group must own a compact buffer instead of retaining the full chunk"
8728 );
8729 }
8730
8731 #[test]
8732 fn test_prewarm_chunk_ranges_preserve_group_boundaries() {
8733 let grouping = PostingGrouping::SyntheticFixed { group_size: 4 };
8734 assert_eq!(
8735 prewarm_chunk_ranges(&grouping, 13, 5),
8736 vec![(0, 4), (4, 8), (8, 13)],
8737 "grouped chunks may contain multiple groups but must never split one"
8738 );
8739 assert_eq!(
8740 prewarm_chunk_ranges(&PostingGrouping::None, 13, 5),
8741 vec![(0, 5), (5, 10), (10, 13)],
8742 "ungrouped chunk ranges should use plain token ranges"
8743 );
8744 }
8745
8746 #[test]
8747 fn test_synthetic_grouping_preserves_fixed_boundaries() {
8748 let grouping = PostingGrouping::SyntheticFixed { group_size: 4 };
8749 assert_eq!(
8750 grouping.range_for_token(5, 10),
8751 Some((4, 8)),
8752 "synthetic token groups should be fixed-size ranges"
8753 );
8754 assert_eq!(
8755 grouping.range_for_token(9, 10),
8756 Some((8, 10)),
8757 "the final synthetic group should end at token_count"
8758 );
8759 assert_eq!(
8760 prewarm_chunk_ranges(&grouping, 10, 6),
8761 vec![(0, 4), (4, 10)],
8762 "prewarm chunks may contain multiple synthetic groups but must not split one"
8763 );
8764 assert_eq!(
8765 grouping.ranges_for_chunk(4, 10, 10),
8766 vec![(4, 8), (8, 10)],
8767 "publish selection should enumerate synthetic groups in a chunk"
8768 );
8769 }
8770
8771 #[rstest::rstest]
8777 #[case::v1(InvertedListFormatVersion::V1, LEGACY_BLOCK_SIZE)]
8778 #[case::v2(InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)]
8779 #[case::v3(InvertedListFormatVersion::V3, 256)]
8780 #[tokio::test]
8781 async fn test_prewarm_streams_in_chunks_preserves_content(
8782 #[case] format_version: InvertedListFormatVersion,
8783 #[case] block_size: usize,
8784 ) {
8785 let tmpdir = TempObjDir::default();
8786 let store = Arc::new(LanceIndexStore::new(
8787 ObjectStore::local().into(),
8788 tmpdir.clone(),
8789 Arc::new(LanceCache::no_cache()),
8790 ));
8791
8792 let num_tokens = runtime_posting_group_tokens() as u32 + 4;
8795 const DOCS_PER_TOKEN: u32 = 3;
8796 let posting_tail_codec = format_version.posting_tail_codec();
8797 let mut builder = InnerBuilder::new_with_format_version_and_block_size(
8798 0,
8799 false,
8800 TokenSetFormat::default(),
8801 format_version,
8802 block_size,
8803 );
8804 let mut expected: Vec<Vec<(u32, u32)>> = Vec::new();
8806 let mut doc_id = 0u64;
8807 for t in 0..num_tokens {
8808 builder.tokens.add(format!("tok_{t:03}"));
8809 let mut posting = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
8810 false,
8811 posting_tail_codec,
8812 block_size,
8813 );
8814 let mut docs = Vec::new();
8815 for _ in 0..DOCS_PER_TOKEN {
8816 posting.add(doc_id as u32, PositionRecorder::Count(1));
8817 builder.docs.append(doc_id, 1);
8818 docs.push((doc_id as u32, 1));
8819 doc_id += 1;
8820 }
8821 expected.push(docs);
8822 builder.posting_lists.push(posting);
8823 }
8824 builder.write(store.as_ref()).await.unwrap();
8825
8826 let params = InvertedIndexParams::default()
8827 .block_size(block_size)
8828 .unwrap();
8829 let metadata = std::collections::HashMap::from_iter(vec![
8830 (
8831 "partitions".to_owned(),
8832 serde_json::to_string(&vec![0u64]).unwrap(),
8833 ),
8834 ("params".to_owned(), serde_json::to_string(¶ms).unwrap()),
8835 (
8836 TOKEN_SET_FORMAT_KEY.to_owned(),
8837 TokenSetFormat::default().to_string(),
8838 ),
8839 (
8840 POSTING_TAIL_CODEC_KEY.to_owned(),
8841 posting_tail_codec.as_str().to_owned(),
8842 ),
8843 (
8844 FTS_FORMAT_VERSION_KEY.to_owned(),
8845 format_version.index_version().to_string(),
8846 ),
8847 (POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string()),
8848 ]);
8849 let mut writer = store
8850 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
8851 .await
8852 .unwrap();
8853 writer.finish_with_metadata(metadata).await.unwrap();
8854
8855 let cache = Arc::new(LanceCache::with_capacity(1 << 20));
8856 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
8857 .await
8858 .unwrap();
8859 let inverted_list = &index.partitions[0].inverted_list;
8860 assert_eq!(inverted_list.len(), num_tokens as usize);
8861 assert_eq!(inverted_list.block_size(), block_size);
8862
8863 const CHUNK_TOKENS: usize = 6;
8867 let chunk_count = inverted_list
8868 .prewarm_posting_lists_chunked(false, Some(CHUNK_TOKENS), 2)
8869 .await
8870 .unwrap();
8871
8872 assert!(
8876 chunk_count > 1,
8877 "single partition must be streamed in more than one chunk, got {chunk_count}"
8878 );
8879
8880 if block_size == 256 {
8881 let (start, end) = inverted_list.group_range_for_token(0).unwrap();
8882 let group = inverted_list
8883 .index_cache
8884 .get_with_key(&posting_list_group_cache_key(
8885 start,
8886 end,
8887 inverted_list.has_impacts,
8888 ))
8889 .await
8890 .expect("256-document blocks should populate the packed group cache");
8891 assert!(group.is_packed());
8892 let (max_score, length) = inverted_list.bulk_metadata_for_token(0);
8893 let PostingList::Compressed(posting) =
8894 group.posting_list(0, max_score, length).unwrap().unwrap()
8895 else {
8896 panic!("expected compressed posting list");
8897 };
8898 assert_eq!(posting.block_size, 256);
8899 assert!(
8900 posting.impacts.is_some(),
8901 "packed prewarm must preserve impact skip data"
8902 );
8903 }
8904
8905 for token_id in 0..num_tokens {
8908 let actual = inverted_list
8909 .posting_list(token_id, false, &NoOpMetricsCollector)
8910 .await
8911 .unwrap()
8912 .iter()
8913 .map(|(doc_id, freq, _positions)| (doc_id as u32, freq))
8914 .collect::<Vec<_>>();
8915 assert_eq!(
8916 actual, expected[token_id as usize],
8917 "token {token_id} posting list mismatch after chunked prewarm"
8918 );
8919 }
8920 }
8921
8922 #[tokio::test]
8926 async fn test_prewarm_streams_in_chunks_with_positions() {
8927 let tmpdir = TempObjDir::default();
8928 let store = Arc::new(LanceIndexStore::new(
8929 ObjectStore::local().into(),
8930 tmpdir.clone(),
8931 Arc::new(LanceCache::no_cache()),
8932 ));
8933
8934 let format_version = InvertedListFormatVersion::V2;
8935 let posting_tail_codec = format_version.posting_tail_codec();
8936 let num_tokens = runtime_posting_group_tokens() as u32 + 4;
8937 const DOCS_PER_TOKEN: u32 = 3;
8938 let mut builder = InnerBuilder::new_with_format_version(
8939 0,
8940 true,
8941 TokenSetFormat::default(),
8942 format_version,
8943 );
8944 let mut expected: Vec<Vec<(u32, u32, Vec<u32>)>> = Vec::new();
8946 let mut doc_id = 0u64;
8947 for t in 0..num_tokens {
8948 builder.tokens.add(format!("tok_{t:03}"));
8949 let mut posting =
8950 PostingListBuilder::new_with_posting_tail_codec(true, posting_tail_codec);
8951 let mut docs = Vec::new();
8952 for _ in 0..DOCS_PER_TOKEN {
8953 let positions = vec![t % 3, t % 3 + 2, t % 3 + 5];
8954 posting.add(
8955 doc_id as u32,
8956 PositionRecorder::Position(positions.clone().into()),
8957 );
8958 builder.docs.append(doc_id, positions.len() as u32);
8959 docs.push((doc_id as u32, positions.len() as u32, positions));
8960 doc_id += 1;
8961 }
8962 expected.push(docs);
8963 builder.posting_lists.push(posting);
8964 }
8965 builder.write(store.as_ref()).await.unwrap();
8966
8967 let metadata = std::collections::HashMap::from_iter(vec![
8968 (
8969 "partitions".to_owned(),
8970 serde_json::to_string(&vec![0u64]).unwrap(),
8971 ),
8972 (
8973 "params".to_owned(),
8974 serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
8975 ),
8976 (
8977 TOKEN_SET_FORMAT_KEY.to_owned(),
8978 TokenSetFormat::default().to_string(),
8979 ),
8980 (
8981 POSTING_TAIL_CODEC_KEY.to_owned(),
8982 posting_tail_codec.as_str().to_owned(),
8983 ),
8984 (
8985 POSITIONS_LAYOUT_KEY.to_owned(),
8986 POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
8987 ),
8988 (
8989 POSITIONS_CODEC_KEY.to_owned(),
8990 PositionStreamCodec::PackedDelta.as_str().to_owned(),
8991 ),
8992 ]);
8993 let mut writer = store
8994 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
8995 .await
8996 .unwrap();
8997 writer.finish_with_metadata(metadata).await.unwrap();
8998
8999 let cache = Arc::new(LanceCache::with_capacity(1 << 20));
9000 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9001 .await
9002 .unwrap();
9003 let inverted_list = &index.partitions[0].inverted_list;
9004
9005 const CHUNK_TOKENS: usize = 5;
9006 let chunk_count = inverted_list
9007 .prewarm_posting_lists_chunked(true, Some(CHUNK_TOKENS), 2)
9008 .await
9009 .unwrap();
9010 assert!(
9011 chunk_count > 1,
9012 "partition must be streamed in more than one chunk, got {chunk_count}"
9013 );
9014
9015 for token_id in 0..num_tokens {
9016 let (start, end) = inverted_list.group_range_for_token(token_id).unwrap();
9018 let group = inverted_list
9019 .index_cache
9020 .get_with_key(&posting_list_group_cache_key(
9021 start,
9022 end,
9023 inverted_list.has_impacts,
9024 ))
9025 .await
9026 .unwrap();
9027 let slot = (token_id - start) as usize;
9028 assert!(
9029 !group.is_packed(),
9030 "with-position prewarm should retain the materialized fallback"
9031 );
9032 assert!(
9033 !group
9034 .posting_list(slot, None, None)
9035 .unwrap()
9036 .unwrap()
9037 .has_position(),
9038 "token {token_id} posting cache entry must be positions-free after prewarm"
9039 );
9040
9041 let actual = inverted_list
9044 .posting_list(token_id, true, &NoOpMetricsCollector)
9045 .await
9046 .unwrap()
9047 .iter()
9048 .map(|(doc_id, freq, positions)| {
9049 (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
9050 })
9051 .collect::<Vec<_>>();
9052 assert_eq!(
9053 actual, expected[token_id as usize],
9054 "token {token_id} posting list / positions mismatch after chunked prewarm"
9055 );
9056 }
9057 }
9058
9059 #[derive(Debug, Default)]
9063 struct PostingMetadataCounter {
9064 rows_read: std::sync::atomic::AtomicUsize,
9065 metadata_rows_read: std::sync::atomic::AtomicUsize,
9066 read_range_calls: std::sync::atomic::AtomicUsize,
9067 }
9068
9069 impl PostingMetadataCounter {
9070 fn rows_read(&self) -> usize {
9071 self.rows_read.load(std::sync::atomic::Ordering::Relaxed)
9072 }
9073 fn metadata_rows_read(&self) -> usize {
9074 self.metadata_rows_read
9075 .load(std::sync::atomic::Ordering::Relaxed)
9076 }
9077 fn read_range_calls(&self) -> usize {
9078 self.read_range_calls
9079 .load(std::sync::atomic::Ordering::Relaxed)
9080 }
9081 }
9082
9083 struct CountingPostingReader {
9084 inner: Arc<dyn IndexReader>,
9085 counter: Arc<PostingMetadataCounter>,
9086 }
9087
9088 #[async_trait]
9089 impl IndexReader for CountingPostingReader {
9090 async fn read_record_batch(&self, n: u64, batch_size: u64) -> Result<RecordBatch> {
9091 self.inner.read_record_batch(n, batch_size).await
9092 }
9093 async fn read_global_buffer(&self, index: u32) -> Result<bytes::Bytes> {
9094 self.inner.read_global_buffer(index).await
9095 }
9096 async fn read_range(
9097 &self,
9098 range: std::ops::Range<usize>,
9099 projection: Option<&[&str]>,
9100 ) -> Result<RecordBatch> {
9101 let n = range.end - range.start;
9102 self.counter
9103 .read_range_calls
9104 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9105 self.counter
9106 .rows_read
9107 .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
9108 let touches_metadata = projection
9109 .map(|cols| cols.contains(&MAX_SCORE_COL) || cols.contains(&LENGTH_COL))
9110 .unwrap_or(false);
9111 if touches_metadata {
9112 self.counter
9113 .metadata_rows_read
9114 .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
9115 }
9116 self.inner.read_range(range, projection).await
9117 }
9118 async fn num_batches(&self, batch_size: u64) -> u32 {
9119 self.inner.num_batches(batch_size).await
9120 }
9121 fn num_rows(&self) -> usize {
9122 self.inner.num_rows()
9123 }
9124 fn schema(&self) -> &lance_core::datatypes::Schema {
9125 self.inner.schema()
9126 }
9127 }
9128
9129 #[derive(Debug)]
9130 struct CountingStore {
9131 inner: Arc<dyn IndexStore>,
9132 posting_file: String,
9133 counter: Arc<PostingMetadataCounter>,
9134 }
9135
9136 impl DeepSizeOf for CountingStore {
9137 fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
9138 self.inner.deep_size_of_children(context)
9139 }
9140 }
9141
9142 #[async_trait]
9143 impl IndexStore for CountingStore {
9144 fn as_any(&self) -> &dyn std::any::Any {
9145 self
9146 }
9147 fn clone_arc(&self) -> Arc<dyn IndexStore> {
9148 Arc::new(Self {
9149 inner: self.inner.clone(),
9150 posting_file: self.posting_file.clone(),
9151 counter: self.counter.clone(),
9152 })
9153 }
9154 fn io_parallelism(&self) -> usize {
9155 self.inner.io_parallelism()
9156 }
9157 fn with_io_priority(&self, io_priority: u64) -> Arc<dyn IndexStore> {
9158 Arc::new(Self {
9159 inner: self.inner.with_io_priority(io_priority),
9160 posting_file: self.posting_file.clone(),
9161 counter: self.counter.clone(),
9162 })
9163 }
9164 async fn new_index_file(
9165 &self,
9166 name: &str,
9167 schema: Arc<arrow_schema::Schema>,
9168 ) -> Result<Box<dyn crate::scalar::IndexWriter>> {
9169 self.inner.new_index_file(name, schema).await
9170 }
9171 async fn open_index_file(&self, name: &str) -> Result<Arc<dyn IndexReader>> {
9172 let reader = self.inner.open_index_file(name).await?;
9173 if name == self.posting_file {
9174 Ok(Arc::new(CountingPostingReader {
9175 inner: reader,
9176 counter: self.counter.clone(),
9177 }))
9178 } else {
9179 Ok(reader)
9180 }
9181 }
9182 async fn copy_index_file(
9183 &self,
9184 name: &str,
9185 dest_store: &dyn IndexStore,
9186 ) -> Result<crate::scalar::IndexFile> {
9187 self.inner.copy_index_file(name, dest_store).await
9188 }
9189 async fn copy_index_file_to(
9190 &self,
9191 name: &str,
9192 new_name: &str,
9193 dest_store: &dyn IndexStore,
9194 ) -> Result<crate::scalar::IndexFile> {
9195 self.inner
9196 .copy_index_file_to(name, new_name, dest_store)
9197 .await
9198 }
9199 async fn rename_index_file(
9200 &self,
9201 name: &str,
9202 new_name: &str,
9203 ) -> Result<crate::scalar::IndexFile> {
9204 self.inner.rename_index_file(name, new_name).await
9205 }
9206 async fn delete_index_file(&self, name: &str) -> Result<()> {
9207 self.inner.delete_index_file(name).await
9208 }
9209 async fn list_files_with_sizes(&self) -> Result<Vec<crate::scalar::IndexFile>> {
9210 self.inner.list_files_with_sizes().await
9211 }
9212 }
9213
9214 async fn load_counted_v2_index(
9219 num_tokens: usize,
9220 cache: LanceCache,
9221 ) -> (Arc<InvertedIndex>, Arc<PostingMetadataCounter>, TempObjDir) {
9222 let tmpdir = TempObjDir::default();
9223 let inner_store = Arc::new(LanceIndexStore::new(
9224 ObjectStore::local().into(),
9225 tmpdir.clone(),
9226 Arc::new(LanceCache::no_cache()),
9227 ));
9228
9229 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
9230 for i in 0..num_tokens {
9231 builder.tokens.add(format!("t{}", i));
9232 let mut pl = PostingListBuilder::new(false);
9233 pl.add(i as u32, PositionRecorder::Count(1));
9234 builder.posting_lists.push(pl);
9235 builder.docs.append(i as u64, 1);
9236 }
9237 builder.write(inner_store.as_ref()).await.unwrap();
9238
9239 let metadata = HashMap::from([
9240 (
9241 "partitions".to_owned(),
9242 serde_json::to_string(&vec![0u64]).unwrap(),
9243 ),
9244 (
9245 "params".to_owned(),
9246 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
9247 ),
9248 (
9249 TOKEN_SET_FORMAT_KEY.to_owned(),
9250 TokenSetFormat::default().to_string(),
9251 ),
9252 ]);
9253 let mut writer = inner_store
9254 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9255 .await
9256 .unwrap();
9257 writer.finish_with_metadata(metadata).await.unwrap();
9258
9259 let counter = Arc::new(PostingMetadataCounter::default());
9260 let counting_store: Arc<dyn IndexStore> = Arc::new(CountingStore {
9261 inner: inner_store,
9262 posting_file: posting_file_path(0),
9263 counter: counter.clone(),
9264 });
9265 let index = InvertedIndex::load(counting_store, None, &cache)
9266 .await
9267 .unwrap();
9268 (index, counter, tmpdir)
9269 }
9270
9271 #[rstest::rstest]
9288 #[case::tokens_10(10)]
9289 #[case::tokens_100(100)]
9290 #[case::tokens_1000(1000)]
9291 #[tokio::test]
9292 async fn test_bm25_stats_for_terms_is_lazy(#[case] num_tokens: usize) {
9293 let (index, counter, _tmpdir) =
9294 load_counted_v2_index(num_tokens, LanceCache::no_cache()).await;
9295 assert!(
9296 !index.partitions[0].inverted_list.is_legacy_layout(),
9297 "this test only proves the lazy path for v2 indexes",
9298 );
9299
9300 assert_eq!(
9304 counter.read_range_calls(),
9305 0,
9306 "InvertedIndex::load must not read the posting file (was {} calls)",
9307 counter.read_range_calls(),
9308 );
9309 assert_eq!(counter.rows_read(), 0);
9310
9311 let (total_tokens, num_docs, dfs) = index
9312 .bm25_stats_for_terms(&["t0".to_string()])
9313 .await
9314 .unwrap();
9315 assert_eq!(total_tokens, num_tokens as u64);
9316 assert_eq!(num_docs, num_tokens);
9317 assert_eq!(dfs, vec![1]);
9318
9319 assert_eq!(
9323 counter.metadata_rows_read(),
9324 1,
9325 "stats path should read exactly 1 metadata row per (term, partition); \
9326 got {} (read_range_calls={}, rows_read={}, num_tokens={})",
9327 counter.metadata_rows_read(),
9328 counter.read_range_calls(),
9329 counter.rows_read(),
9330 num_tokens,
9331 );
9332 }
9333
9334 #[tokio::test]
9335 async fn test_bm25_stats_for_terms_reuses_posting_metadata_cache() {
9336 let cache = LanceCache::with_capacity(1024 * 1024);
9337 let (index, counter, _tmpdir) = load_counted_v2_index(100, cache.clone()).await;
9338
9339 let terms = ["t0".to_string()];
9340 let first = index.bm25_stats_for_terms(&terms).await.unwrap();
9341 assert_eq!(first, (100, 100, vec![1]));
9342 assert_eq!(counter.metadata_rows_read(), 1);
9343
9344 let second = index.bm25_stats_for_terms(&terms).await.unwrap();
9345 assert_eq!(second, first);
9346 assert_eq!(
9347 counter.metadata_rows_read(),
9348 1,
9349 "repeated stats for the same token should reuse cached posting metadata",
9350 );
9351 }
9352
9353 #[tokio::test]
9354 async fn test_aggregate_corpus_stats_reuses_cached_value() {
9355 let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
9356 assert!(index.corpus_stats.get().is_none());
9357
9358 let first = index.aggregate_corpus_stats().await.unwrap();
9359 assert_eq!(first, (100, 100));
9360 assert_eq!(index.corpus_stats.get().copied(), Some(first));
9361
9362 let second = index.aggregate_corpus_stats().await.unwrap();
9363 assert_eq!(second, first);
9364 }
9365
9366 #[tokio::test]
9367 async fn test_stats_then_num_tokens_view_reuses_shared_storage() {
9368 let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
9369 let partition = index.partitions[0].clone();
9370
9371 assert_eq!(index.aggregate_corpus_stats().await.unwrap(), (100, 100));
9372 assert_eq!(partition.docs.total_tokens_cached(), Some(100));
9373
9374 let views =
9375 futures::future::join_all((0..8).map(|_| partition.docs.ensure_num_tokens_loaded()))
9376 .await
9377 .into_iter()
9378 .collect::<Result<Vec<_>>>()
9379 .unwrap();
9380 let first = &views[0];
9381 assert!(views.iter().all(|view| Arc::ptr_eq(first, view)));
9382 assert!(!first.has_row_ids());
9383 assert!(matches!(&first.num_tokens, NumTokens::Shared(_)));
9384 assert_eq!(first.total_tokens_num(), 100);
9385
9386 let all_rows = RowAddrMask::all_rows();
9387 let wand_view = partition.docs.docs_for_wand(&all_rows).await.unwrap();
9388 assert!(Arc::ptr_eq(first, &wand_view));
9389
9390 let filtered = RowAddrMask::allow_nothing();
9391 let full = partition.docs.docs_for_wand(&filtered).await.unwrap();
9392 assert!(full.has_row_ids());
9393 assert!(matches!(&full.num_tokens, NumTokens::Owned(_)));
9394 assert_eq!(full.total_tokens_num(), 100);
9395 assert_eq!(
9396 partition.docs.resolve_row_ids(&[0, 99]).await.unwrap(),
9397 [0, 99]
9398 );
9399 }
9400
9401 #[tokio::test]
9402 async fn test_concurrent_total_and_num_tokens_view_initialization() {
9403 let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
9404 let docs = index.partitions[0].docs.clone();
9405
9406 let totals = futures::future::join_all((0..8).map(|_| docs.total_tokens_num()));
9407 let views = futures::future::join_all((0..8).map(|_| docs.ensure_num_tokens_loaded()));
9408 let (totals, views) = tokio::join!(totals, views);
9409
9410 let totals = totals.into_iter().collect::<Result<Vec<_>>>().unwrap();
9411 assert_eq!(totals, vec![100; 8]);
9412 let views = views.into_iter().collect::<Result<Vec<_>>>().unwrap();
9413 let first = &views[0];
9414 assert!(views.iter().all(|view| Arc::ptr_eq(first, view)));
9415 assert!(matches!(&first.num_tokens, NumTokens::Shared(_)));
9416 assert_eq!(docs.total_tokens_cached(), Some(100));
9417 }
9418
9419 #[tokio::test]
9420 async fn test_grouped_posting_lists_read_one_group_per_neighborhood() {
9421 let runtime_group_size = runtime_posting_group_tokens().max(1);
9427 let queried_token_count = runtime_group_size.min(4);
9428 let queried_tokens = (0..queried_token_count as u32).collect::<Vec<_>>();
9429 let num_tokens = runtime_group_size
9430 .saturating_mul(2)
9431 .max(queried_token_count + 1)
9432 .min(1024);
9433 let (index, counter, _tmpdir) =
9434 load_counted_v2_index(num_tokens, LanceCache::no_cache()).await;
9435 let inverted_list = index.partitions[0].inverted_list.clone();
9436 assert!(
9437 !inverted_list.is_legacy_layout(),
9438 "this test only proves the lazy path for v2 indexes",
9439 );
9440 assert!(
9441 matches!(
9442 &inverted_list.grouping,
9443 PostingGrouping::SyntheticFixed { .. }
9444 ),
9445 "freshly written v2 index should use runtime synthetic groups",
9446 );
9447
9448 let metrics = Arc::new(NoOpMetricsCollector);
9452 for &token_id in &queried_tokens {
9453 inverted_list
9454 .posting_list(token_id, false, metrics.as_ref())
9455 .await
9456 .unwrap();
9457 }
9458
9459 let (start, end) = inverted_list.group_range_for_token(0).unwrap();
9460 let group_len = (end - start) as usize;
9461 assert!(
9462 (queried_tokens.len()..=num_tokens).contains(&group_len),
9463 "group [{start}, {end}) should cover the queried neighborhood and \
9464 stay bounded by the {num_tokens}-token table",
9465 );
9466 assert_eq!(
9467 counter.read_range_calls(),
9468 queried_tokens.len(),
9469 "each cold token should read exactly its own group, no bulk read",
9470 );
9471 assert_eq!(
9472 counter.metadata_rows_read(),
9473 queried_tokens.len() * group_len,
9474 "each query reads one group's metadata rows ({group_len}), not the \
9475 full {num_tokens}-row table",
9476 );
9477 }
9478
9479 async fn load_v2_index_with_grouped_postings(
9483 num_tokens: usize,
9484 docs_per_token: usize,
9485 ) -> (Arc<InvertedIndex>, Arc<LanceCache>) {
9486 let tmpdir = TempObjDir::default();
9487 let store = Arc::new(LanceIndexStore::new(
9488 ObjectStore::local().into(),
9489 tmpdir.clone(),
9490 Arc::new(LanceCache::no_cache()),
9491 ));
9492
9493 let num_docs = num_tokens * docs_per_token;
9494 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
9495 for token_id in 0..num_tokens {
9496 builder.tokens.add(format!("t{token_id}"));
9497 let mut pl = PostingListBuilder::new(false);
9498 for d in 0..docs_per_token {
9499 let doc_id = (token_id * docs_per_token + d) as u32;
9500 pl.add(doc_id, PositionRecorder::Count(1));
9501 }
9502 builder.posting_lists.push(pl);
9503 }
9504 for doc in 0..num_docs {
9505 builder.docs.append(doc as u64, 1);
9506 }
9507 builder.write(store.as_ref()).await.unwrap();
9508
9509 let metadata = HashMap::from([
9510 (
9511 "partitions".to_owned(),
9512 serde_json::to_string(&vec![0u64]).unwrap(),
9513 ),
9514 (
9515 "params".to_owned(),
9516 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
9517 ),
9518 (
9519 TOKEN_SET_FORMAT_KEY.to_owned(),
9520 TokenSetFormat::default().to_string(),
9521 ),
9522 ]);
9523 let mut writer = store
9524 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9525 .await
9526 .unwrap();
9527 writer.finish_with_metadata(metadata).await.unwrap();
9528
9529 let cache = Arc::new(LanceCache::with_capacity(1 << 30));
9532 let index = InvertedIndex::load(store, None, cache.as_ref())
9533 .await
9534 .unwrap();
9535 (index, cache)
9536 }
9537
9538 #[tokio::test]
9541 async fn test_packed_group_deep_size_is_smaller_than_materialized_graph() {
9542 let (index, _cache) = load_v2_index_with_grouped_postings(512, 1).await;
9543 let inverted_list = index.partitions[0].inverted_list.clone();
9544 assert!(!inverted_list.is_legacy_layout(), "expected v2 layout");
9545 assert!(
9546 matches!(
9547 &inverted_list.grouping,
9548 PostingGrouping::SyntheticFixed { .. }
9549 ),
9550 "expected grouped posting lists"
9551 );
9552
9553 inverted_list
9555 .posting_list(0, false, &NoOpMetricsCollector)
9556 .await
9557 .unwrap();
9558 let (start, end) = inverted_list.group_range_for_token(0).unwrap();
9559 let group = inverted_list
9560 .index_cache
9561 .get_with_key(&posting_list_group_cache_key(
9562 start,
9563 end,
9564 inverted_list.has_impacts,
9565 ))
9566 .await
9567 .unwrap();
9568 assert!(group.is_packed(), "cold v2 group should use packed storage");
9569 inverted_list.ensure_metadata_loaded().await.unwrap();
9570
9571 let mut distinct_buffers = std::collections::HashSet::new();
9572 let mut materialized = Vec::with_capacity(group.len());
9573 for slot in 0..group.len() {
9574 let (max_score, length) = inverted_list.bulk_metadata_for_token(start + slot as u32);
9575 let posting = group
9576 .posting_list(slot, max_score, length)
9577 .unwrap()
9578 .unwrap();
9579 let PostingList::Compressed(compressed) = posting else {
9580 panic!("expected compressed posting lists");
9581 };
9582 distinct_buffers.insert(compressed.blocks.values().as_ptr());
9583 materialized.push(PostingList::Compressed(compressed));
9584 }
9585 let posting_count = materialized.len();
9586
9587 assert!(
9588 posting_count > 1,
9589 "default grouping should pack multiple tiny postings into one group"
9590 );
9591 assert_eq!(
9592 distinct_buffers.len(),
9593 1,
9594 "read-path postings in a group should share one backing buffer"
9595 );
9596 let packed_size = group.deep_size_of();
9597 let materialized_size = PostingListGroup::new(materialized).deep_size_of();
9598 assert!(
9599 packed_size * 4 < materialized_size * 3,
9600 "packed group deep_size_of {packed_size}B should be at least 25% smaller than the \
9601 {materialized_size}B materialized graph for {posting_count} postings"
9602 );
9603 }
9604
9605 fn sliced_int32_list(
9620 num_sublists: usize,
9621 ints_per_sublist: usize,
9622 off: usize,
9623 len: usize,
9624 ) -> ListArray {
9625 let mut builder = ListBuilder::new(Int32Builder::new());
9626 for s in 0..num_sublists {
9627 for i in 0..ints_per_sublist {
9628 builder
9629 .values()
9630 .append_value((s * ints_per_sublist + i) as i32);
9631 }
9632 builder.append(true);
9633 }
9634 builder.finish().slice(off, len)
9635 }
9636
9637 #[test]
9638 fn test_compressed_posting_deep_size_counts_only_referenced_blocks_slice() {
9639 const ELEM_BYTES: usize = 256;
9640 const TOTAL_ELEMS: usize = 64;
9641 const SLICE_OFF: usize = 10;
9642 const SLICE_LEN: usize = 2;
9643
9644 let mut builder = LargeBinaryBuilder::new();
9645 for _ in 0..TOTAL_ELEMS {
9646 builder.append_value(vec![7u8; ELEM_BYTES]);
9647 }
9648 let full = builder.finish();
9649 let blocks = full.slice(SLICE_OFF, SLICE_LEN);
9650
9651 let posting = CompressedPostingList::new(
9652 blocks,
9653 1.0,
9654 SLICE_LEN as u32,
9655 PostingTailCodec::Fixed32,
9656 LEGACY_BLOCK_SIZE,
9657 None,
9658 None,
9659 );
9660
9661 let full_backing = full.get_buffer_memory_size();
9662 let slice_bytes = SLICE_LEN * ELEM_BYTES;
9663 let reported = posting.deep_size_of();
9664
9665 assert!(
9666 reported < full_backing / 4,
9667 "deep_size_of {reported}B must not count the {full_backing}B shared buffer"
9668 );
9669 assert!(
9670 reported <= slice_bytes * 2,
9671 "deep_size_of {reported}B should track the ~{slice_bytes}B referenced slice"
9672 );
9673 }
9674
9675 #[test]
9676 fn test_plain_posting_deep_size_counts_only_referenced_positions_slice() {
9677 const SUBLISTS: usize = 64;
9678 const INTS: usize = 64;
9679 const SLICE_LEN: usize = 2;
9680
9681 let positions = sliced_int32_list(SUBLISTS, INTS, 10, SLICE_LEN);
9682 let row_ids = ScalarBuffer::from(vec![0u64, 1]);
9683 let frequencies = ScalarBuffer::from(vec![1.0f32, 1.0]);
9684 let posting =
9685 PlainPostingList::new(row_ids, frequencies, Some(1.0), Some(positions.clone()));
9686
9687 let full_backing = positions.values().get_buffer_memory_size();
9688 let slice_bytes = SLICE_LEN * INTS * std::mem::size_of::<i32>();
9689 let reported = posting.deep_size_of();
9690
9691 assert!(
9692 reported < full_backing / 4,
9693 "deep_size_of {reported}B must not count the {full_backing}B shared positions buffer"
9694 );
9695 assert!(
9696 reported <= slice_bytes * 2 + 64,
9697 "deep_size_of {reported}B should track the ~{slice_bytes}B referenced slice"
9698 );
9699 }
9700
9701 #[test]
9702 fn test_legacy_per_doc_positions_deep_size_counts_only_referenced_slice() {
9703 const SUBLISTS: usize = 64;
9704 const INTS: usize = 64;
9705 const SLICE_LEN: usize = 2;
9706
9707 let positions = sliced_int32_list(SUBLISTS, INTS, 10, SLICE_LEN);
9708 let full_backing = positions.values().get_buffer_memory_size();
9709 let slice_bytes = SLICE_LEN * INTS * std::mem::size_of::<i32>();
9710
9711 let storage = CompressedPositionStorage::LegacyPerDoc(positions);
9712 let reported = storage.deep_size_of();
9713 assert!(
9714 reported < full_backing / 4,
9715 "CompressedPositionStorage deep_size_of {reported}B must not count the \
9716 {full_backing}B shared buffer"
9717 );
9718 assert!(
9719 reported <= slice_bytes * 2 + 64,
9720 "deep_size_of {reported}B should track the ~{slice_bytes}B referenced slice"
9721 );
9722
9723 let wrapped = Positions(storage).deep_size_of();
9725 assert!(
9726 wrapped < full_backing / 4,
9727 "Positions deep_size_of {wrapped}B must not count the {full_backing}B shared buffer"
9728 );
9729 }
9730
9731 #[tokio::test]
9732 async fn test_prewarm_with_positions_populates_separate_position_cache() {
9733 let tmpdir = TempObjDir::default();
9734 let store = Arc::new(LanceIndexStore::new(
9735 ObjectStore::local().into(),
9736 tmpdir.clone(),
9737 Arc::new(LanceCache::no_cache()),
9738 ));
9739
9740 let mut builder = InnerBuilder::new_with_format_version(
9741 0,
9742 true,
9743 TokenSetFormat::default(),
9744 InvertedListFormatVersion::V1,
9745 );
9746 builder.tokens.add("hello".to_owned());
9747 builder.tokens.add("world".to_owned());
9748 builder
9749 .posting_lists
9750 .push(PostingListBuilder::new_with_posting_tail_codec(
9751 true,
9752 PostingTailCodec::Fixed32,
9753 ));
9754 builder
9755 .posting_lists
9756 .push(PostingListBuilder::new_with_posting_tail_codec(
9757 true,
9758 PostingTailCodec::Fixed32,
9759 ));
9760 builder.posting_lists[0].add(0, PositionRecorder::Position(vec![0].into()));
9761 builder.posting_lists[1].add(0, PositionRecorder::Position(vec![1].into()));
9762 builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into()));
9763 builder.posting_lists[1].add(1, PositionRecorder::Position(vec![2].into()));
9764 builder.docs.append(100, 2);
9765 builder.docs.append(101, 2);
9766 builder.write(store.as_ref()).await.unwrap();
9767
9768 let metadata = std::collections::HashMap::from_iter(vec![
9769 (
9770 "partitions".to_owned(),
9771 serde_json::to_string(&vec![0_u64]).unwrap(),
9772 ),
9773 (
9774 "params".to_owned(),
9775 serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
9776 ),
9777 (
9778 TOKEN_SET_FORMAT_KEY.to_owned(),
9779 TokenSetFormat::default().to_string(),
9780 ),
9781 ]);
9782 let mut writer = store
9783 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9784 .await
9785 .unwrap();
9786 writer.finish_with_metadata(metadata).await.unwrap();
9787
9788 let cache = Arc::new(LanceCache::with_capacity(4096));
9789 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9790 .await
9791 .unwrap();
9792
9793 index
9794 .prewarm_with_options(&FtsPrewarmOptions::new().with_position(true))
9795 .await
9796 .unwrap();
9797
9798 let inverted_list = &index.partitions[0].inverted_list;
9799 let (start, end) = inverted_list.group_range_for_token(0).unwrap();
9803 let group = inverted_list
9804 .index_cache
9805 .get_with_key(&posting_list_group_cache_key(
9806 start,
9807 end,
9808 inverted_list.has_impacts,
9809 ))
9810 .await
9811 .unwrap();
9812 assert!(
9813 !group.is_packed(),
9814 "with-position prewarm should retain the materialized fallback"
9815 );
9816 assert!(
9817 !group
9818 .posting_list(0, None, None)
9819 .unwrap()
9820 .unwrap()
9821 .has_position(),
9822 "posting cache should remain positions-free after prewarm"
9823 );
9824
9825 let positions = inverted_list
9826 .index_cache
9827 .get_with_key(&PositionKey { token_id: 0 })
9828 .await
9829 .unwrap();
9830 assert!(
9831 matches!(
9832 positions.as_ref().0,
9833 CompressedPositionStorage::LegacyPerDoc(_)
9834 ),
9835 "positions should be stored in the dedicated position cache"
9836 );
9837 }
9838
9839 #[tokio::test]
9840 async fn test_prewarm_with_v2_positions_preserves_shared_stream_codec() {
9841 let tmpdir = TempObjDir::default();
9842 let store = Arc::new(LanceIndexStore::new(
9843 ObjectStore::local().into(),
9844 tmpdir.clone(),
9845 Arc::new(LanceCache::no_cache()),
9846 ));
9847
9848 let format_version = InvertedListFormatVersion::V2;
9849 let posting_tail_codec = format_version.posting_tail_codec();
9850 let mut builder = InnerBuilder::new_with_format_version(
9851 0,
9852 true,
9853 TokenSetFormat::default(),
9854 format_version,
9855 );
9856 builder.tokens.add("body".to_owned());
9857
9858 let mut posting_list =
9859 PostingListBuilder::new_with_posting_tail_codec(true, posting_tail_codec);
9860 let expected = (0..(BLOCK_SIZE + 5) as u32)
9861 .map(|doc_id| {
9862 let positions = vec![doc_id % 3, doc_id % 3 + 2, doc_id % 3 + 5];
9863 posting_list.add(doc_id, PositionRecorder::Position(positions.clone().into()));
9864 builder.docs.append(30_000 + doc_id as u64, 20 + doc_id % 7);
9865 (doc_id, positions.len() as u32, positions)
9866 })
9867 .collect::<Vec<_>>();
9868 builder.posting_lists.push(posting_list);
9869 builder.write(store.as_ref()).await.unwrap();
9870
9871 let metadata = HashMap::from([
9872 (
9873 "partitions".to_owned(),
9874 serde_json::to_string(&vec![0_u64]).unwrap(),
9875 ),
9876 (
9877 "params".to_owned(),
9878 serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
9879 ),
9880 (
9881 TOKEN_SET_FORMAT_KEY.to_owned(),
9882 TokenSetFormat::default().to_string(),
9883 ),
9884 (
9885 POSTING_TAIL_CODEC_KEY.to_owned(),
9886 posting_tail_codec.as_str().to_owned(),
9887 ),
9888 (
9889 POSITIONS_LAYOUT_KEY.to_owned(),
9890 POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
9891 ),
9892 (
9893 POSITIONS_CODEC_KEY.to_owned(),
9894 PositionStreamCodec::PackedDelta.as_str().to_owned(),
9895 ),
9896 ]);
9897 let mut writer = store
9898 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9899 .await
9900 .unwrap();
9901 writer.finish_with_metadata(metadata).await.unwrap();
9902
9903 let cache = Arc::new(LanceCache::with_capacity(4096));
9904 let index = InvertedIndex::load(store, None, cache.as_ref())
9905 .await
9906 .unwrap();
9907 index
9908 .prewarm_with_options(&FtsPrewarmOptions::new().with_position(true))
9909 .await
9910 .unwrap();
9911
9912 let actual = index.partitions[0]
9913 .inverted_list
9914 .posting_list(0, true, &NoOpMetricsCollector)
9915 .await
9916 .unwrap()
9917 .iter()
9918 .map(|(doc_id, freq, positions)| {
9919 (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
9920 })
9921 .collect::<Vec<_>>();
9922
9923 assert_eq!(actual, expected);
9924 }
9925
9926 #[test]
9927 fn test_block_max_scores_capacity_matches_block_count() {
9928 let mut docs = DocSet::default();
9929 let num_docs = BLOCK_SIZE * 3 + 7;
9930 let doc_ids = (0..num_docs as u32).collect::<Vec<_>>();
9931 for doc_id in &doc_ids {
9932 docs.append(*doc_id as u64, 1);
9933 }
9934
9935 let freqs = vec![1_u32; doc_ids.len()];
9936 let block_max_scores = docs.calculate_block_max_scores(doc_ids.iter(), freqs.iter());
9937 let expected_blocks = doc_ids.len().div_ceil(BLOCK_SIZE);
9938
9939 assert_eq!(block_max_scores.len(), expected_blocks);
9940 assert_eq!(block_max_scores.capacity(), expected_blocks);
9941 }
9942
9943 #[tokio::test]
9944 async fn test_bm25_search_uses_global_idf() {
9945 let tmpdir = TempObjDir::default();
9946 let store = Arc::new(LanceIndexStore::new(
9947 ObjectStore::local().into(),
9948 tmpdir.clone(),
9949 Arc::new(LanceCache::no_cache()),
9950 ));
9951
9952 let mut builder0 = InnerBuilder::new(0, false, TokenSetFormat::default());
9954 builder0.tokens.add("alpha".to_owned());
9955 builder0.tokens.add("beta".to_owned());
9956 builder0.posting_lists.push(PostingListBuilder::new(false));
9957 builder0.posting_lists.push(PostingListBuilder::new(false));
9958 builder0.posting_lists[0].add(0, PositionRecorder::Count(1));
9959 builder0.posting_lists[1].add(1, PositionRecorder::Count(1));
9960 builder0.posting_lists[1].add(2, PositionRecorder::Count(1));
9961 builder0.docs.append(100, 1);
9962 builder0.docs.append(101, 1);
9963 builder0.docs.append(102, 1);
9964 builder0.write(store.as_ref()).await.unwrap();
9965
9966 let mut builder1 = InnerBuilder::new(1, false, TokenSetFormat::default());
9968 builder1.tokens.add("alpha".to_owned());
9969 builder1.posting_lists.push(PostingListBuilder::new(false));
9970 builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
9971 builder1.docs.append(200, 1);
9972 builder1.write(store.as_ref()).await.unwrap();
9973
9974 let metadata = std::collections::HashMap::from_iter(vec![
9975 (
9976 "partitions".to_owned(),
9977 serde_json::to_string(&vec![0u64, 1u64]).unwrap(),
9978 ),
9979 (
9980 "params".to_owned(),
9981 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
9982 ),
9983 (
9984 TOKEN_SET_FORMAT_KEY.to_owned(),
9985 TokenSetFormat::default().to_string(),
9986 ),
9987 ]);
9988 let mut writer = store
9989 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9990 .await
9991 .unwrap();
9992 writer.finish_with_metadata(metadata).await.unwrap();
9993
9994 let cache = Arc::new(LanceCache::with_capacity(4096));
9995 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9996 .await
9997 .unwrap();
9998
9999 let tokens = Arc::new(Tokens::new(vec!["alpha".to_string()], DocType::Text));
10000 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
10001 let prefilter = Arc::new(NoFilter);
10002 let metrics = Arc::new(NoOpMetricsCollector);
10003
10004 let (row_ids, scores) = index
10005 .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
10006 .await
10007 .unwrap();
10008
10009 assert_eq!(row_ids.len(), 2);
10010 assert!(row_ids.contains(&100));
10011 assert!(row_ids.contains(&200));
10012 assert_eq!(row_ids.len(), scores.len());
10013
10014 let expected_idf = idf(2, 4);
10015 for score in scores {
10016 assert!(
10017 (score - expected_idf).abs() < 1e-6,
10018 "score: {}, expected: {}",
10019 score,
10020 expected_idf
10021 );
10022 }
10023 }
10024
10025 async fn write_test_metadata(
10026 store: &Arc<LanceIndexStore>,
10027 partition_ids: Vec<u64>,
10028 params: InvertedIndexParams,
10029 ) {
10030 let format_version = params.resolved_format_version();
10031 let metadata = HashMap::from([
10032 (
10033 "partitions".to_owned(),
10034 serde_json::to_string(&partition_ids).unwrap(),
10035 ),
10036 ("params".to_owned(), serde_json::to_string(¶ms).unwrap()),
10037 (
10038 TOKEN_SET_FORMAT_KEY.to_owned(),
10039 TokenSetFormat::default().to_string(),
10040 ),
10041 (
10042 POSTING_TAIL_CODEC_KEY.to_owned(),
10043 format_version.posting_tail_codec().as_str().to_owned(),
10044 ),
10045 (
10046 FTS_FORMAT_VERSION_KEY.to_owned(),
10047 format_version.index_version().to_string(),
10048 ),
10049 (
10050 POSTING_BLOCK_SIZE_KEY.to_owned(),
10051 params.posting_block_size().to_string(),
10052 ),
10053 ]);
10054 let mut writer = store
10055 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
10056 .await
10057 .unwrap();
10058 writer.finish_with_metadata(metadata).await.unwrap();
10059 }
10060
10061 async fn write_test_partition_with_optional_impacts(
10062 store: &Arc<LanceIndexStore>,
10063 partition_id: u64,
10064 mut builder: InnerBuilder,
10065 token_set_format: TokenSetFormat,
10066 with_impacts: bool,
10067 ) {
10068 let format_version = InvertedListFormatVersion::V1;
10069 let block_size = LEGACY_BLOCK_SIZE;
10070 let docs = std::mem::take(&mut builder.docs);
10071 let schema = inverted_list_schema_for_version_with_block_size_and_impacts(
10072 false,
10073 format_version,
10074 block_size,
10075 with_impacts,
10076 );
10077
10078 let mut posting_writer = store
10079 .new_index_file(&posting_file_path(partition_id), schema.clone())
10080 .await
10081 .unwrap();
10082 for posting_list in std::mem::take(&mut builder.posting_lists) {
10083 let batch = posting_list
10084 .to_batch_with_docs(&docs, schema.clone())
10085 .unwrap();
10086 posting_writer.write_record_batch(batch).await.unwrap();
10087 }
10088 posting_writer.finish().await.unwrap();
10089
10090 let token_batch = std::mem::take(&mut builder.tokens)
10091 .to_batch(token_set_format)
10092 .unwrap();
10093 let mut token_writer = store
10094 .new_index_file(&token_file_path(partition_id), token_batch.schema())
10095 .await
10096 .unwrap();
10097 token_writer.write_record_batch(token_batch).await.unwrap();
10098 token_writer.finish().await.unwrap();
10099
10100 let doc_batch = docs.to_batch().unwrap();
10101 let mut doc_writer = store
10102 .new_index_file(&doc_file_path(partition_id), doc_batch.schema())
10103 .await
10104 .unwrap();
10105 doc_writer.write_record_batch(doc_batch).await.unwrap();
10106 doc_writer.finish().await.unwrap();
10107 }
10108
10109 async fn load_global_scoring_test_index(
10110 second_partition_has_impacts: bool,
10111 ) -> (TempObjDir, Arc<InvertedIndex>) {
10112 let tmpdir = TempObjDir::default();
10113 let store = Arc::new(LanceIndexStore::new(
10114 ObjectStore::local().into(),
10115 tmpdir.clone(),
10116 Arc::new(LanceCache::no_cache()),
10117 ));
10118 let partition_specs = [
10119 (0, 100, 5_000, 101..111, 5_000, true),
10120 (1, 200, 1_000, 201..301, 1, second_partition_has_impacts),
10121 ];
10122 for (
10123 partition_id,
10124 matching_row_id,
10125 matching_doc_length,
10126 other_row_ids,
10127 other_doc_length,
10128 with_impacts,
10129 ) in partition_specs
10130 {
10131 let mut builder = InnerBuilder::new_with_format_version(
10132 partition_id,
10133 false,
10134 TokenSetFormat::default(),
10135 InvertedListFormatVersion::V1,
10136 );
10137 builder.tokens.add("alpha".to_owned());
10138 builder
10139 .posting_lists
10140 .push(PostingListBuilder::new_with_posting_tail_codec(
10141 false,
10142 InvertedListFormatVersion::V1.posting_tail_codec(),
10143 ));
10144 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
10145 builder.docs.append(matching_row_id, matching_doc_length);
10146 for row_id in other_row_ids {
10147 builder.docs.append(row_id, other_doc_length);
10148 }
10149 write_test_partition_with_optional_impacts(
10150 &store,
10151 partition_id,
10152 builder,
10153 TokenSetFormat::default(),
10154 with_impacts,
10155 )
10156 .await;
10157 }
10158
10159 write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
10160 let cache = LanceCache::with_capacity(4096);
10161 let index = InvertedIndex::load(store, None, &cache).await.unwrap();
10162 (tmpdir, index)
10163 }
10164
10165 async fn search_test_impact_partition(
10166 partition: &InvertedPartition,
10167 tokens: &Tokens,
10168 params: &FtsSearchParams,
10169 scorer: Arc<MemBM25Scorer>,
10170 shared_threshold: Arc<AtomicU32>,
10171 ) -> Vec<DocCandidate> {
10172 let LoadedPostings {
10173 postings,
10174 grouped_expansions,
10175 impact_safe,
10176 exact_scoring_required,
10177 } = partition
10178 .load_posting_lists(
10179 tokens,
10180 params,
10181 Operator::Or,
10182 scorer.as_ref(),
10183 &NoOpMetricsCollector,
10184 )
10185 .await
10186 .unwrap();
10187 assert!(impact_safe);
10188 assert!(!exact_scoring_required);
10189 assert!(grouped_expansions.is_empty());
10190
10191 let mask = NoFilter.mask();
10192 let docs_for_wand = partition.docs.docs_for_wand(mask.as_ref()).await.unwrap();
10193 let mut candidates = partition
10194 .bm25_search(
10195 docs_for_wand.as_ref(),
10196 params,
10197 Operator::Or,
10198 mask,
10199 postings,
10200 Some(scorer),
10201 &NoOpMetricsCollector,
10202 shared_threshold,
10203 )
10204 .unwrap();
10205 resolve_deferred_candidates(&partition.docs, &mut candidates)
10206 .await
10207 .unwrap();
10208 candidates
10209 }
10210
10211 #[tokio::test]
10212 async fn test_impact_partitions_share_global_threshold_without_pruning_winner() {
10213 let (_tmpdir, index) = load_global_scoring_test_index(true).await;
10217 let first_partition = index
10218 .partitions
10219 .iter()
10220 .find(|partition| partition.id() == 0)
10221 .unwrap();
10222 let second_partition = index
10223 .partitions
10224 .iter()
10225 .find(|partition| partition.id() == 1)
10226 .unwrap();
10227
10228 let tokens = Arc::new(Tokens::new(vec!["alpha".to_owned()], DocType::Text));
10229 let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
10230 let scorer = Arc::new(
10231 index
10232 .bm25_base_scorer(tokens.as_ref(), params.as_ref())
10233 .await
10234 .unwrap(),
10235 );
10236 first_partition
10237 .inverted_list
10238 .ensure_metadata_loaded()
10239 .await
10240 .unwrap();
10241 second_partition
10242 .inverted_list
10243 .ensure_metadata_loaded()
10244 .await
10245 .unwrap();
10246 let first_local_scorer = IndexBM25Scorer::new(std::iter::once(first_partition.as_ref()));
10247 let second_local_scorer = IndexBM25Scorer::new(std::iter::once(second_partition.as_ref()));
10248 let first_local_score =
10249 first_local_scorer.query_weight("alpha") * first_local_scorer.doc_weight(1, 5_000);
10250 let second_local_score =
10251 second_local_scorer.query_weight("alpha") * second_local_scorer.doc_weight(1, 1_000);
10252 assert!(first_local_score > second_local_score);
10253 let shared_threshold = Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()));
10254
10255 let first_candidates = search_test_impact_partition(
10258 first_partition,
10259 tokens.as_ref(),
10260 params.as_ref(),
10261 scorer.clone(),
10262 shared_threshold.clone(),
10263 )
10264 .await;
10265 assert_eq!(first_candidates.len(), 1);
10266 assert!(matches!(
10267 first_candidates[0].addr,
10268 CandidateAddr::RowId(100)
10269 ));
10270 let first_score =
10271 scorer.query_weight("alpha") * scorer.doc_weight(1, first_candidates[0].doc_length);
10272 let published_threshold = f32::from_bits(shared_threshold.load(Ordering::Relaxed));
10273 assert!(
10274 (published_threshold - first_score).abs() < 1e-6,
10275 "published threshold: {published_threshold}, expected global score: {first_score}"
10276 );
10277
10278 let second_candidates = search_test_impact_partition(
10279 second_partition,
10280 tokens.as_ref(),
10281 params.as_ref(),
10282 scorer.clone(),
10283 shared_threshold.clone(),
10284 )
10285 .await;
10286 assert_eq!(second_candidates.len(), 1);
10287 assert!(matches!(
10288 second_candidates[0].addr,
10289 CandidateAddr::RowId(200)
10290 ));
10291 let second_score =
10292 scorer.query_weight("alpha") * scorer.doc_weight(1, second_candidates[0].doc_length);
10293 assert!(
10294 second_score > first_score,
10295 "second score: {second_score}, first score: {first_score}"
10296 );
10297 assert!(
10298 (f32::from_bits(shared_threshold.load(Ordering::Relaxed)) - second_score).abs() < 1e-6
10299 );
10300
10301 let (row_ids, scores) = index
10302 .bm25_search(
10303 tokens,
10304 params,
10305 Operator::Or,
10306 Arc::new(NoFilter),
10307 Arc::new(NoOpMetricsCollector),
10308 None,
10309 )
10310 .await
10311 .unwrap();
10312 assert_eq!(row_ids, vec![200]);
10313 assert_eq!(scores.len(), 1);
10314 assert!((scores[0] - second_score).abs() < 1e-6);
10315 }
10316
10317 #[tokio::test]
10318 async fn test_mixed_impact_and_legacy_partitions_use_global_final_scores() {
10319 let (_tmpdir, index) = load_global_scoring_test_index(false).await;
10320
10321 let impact_partition = index
10322 .partitions
10323 .iter()
10324 .find(|partition| partition.id() == 0)
10325 .unwrap();
10326 let legacy_partition = index
10327 .partitions
10328 .iter()
10329 .find(|partition| partition.id() == 1)
10330 .unwrap();
10331
10332 let impact_posting = impact_partition
10333 .inverted_list
10334 .posting_list(0, false, &NoOpMetricsCollector)
10335 .await
10336 .unwrap();
10337 assert!(impact_posting.has_impacts());
10338
10339 let legacy_posting = legacy_partition
10340 .inverted_list
10341 .posting_list(0, false, &NoOpMetricsCollector)
10342 .await
10343 .unwrap();
10344 assert!(!legacy_posting.has_impacts());
10345
10346 let tokens = Arc::new(Tokens::new(vec!["alpha".to_string()], DocType::Text));
10347 let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
10348 let (row_ids, scores) = index
10349 .bm25_search(
10350 tokens.clone(),
10351 params.clone(),
10352 Operator::Or,
10353 Arc::new(NoFilter),
10354 Arc::new(NoOpMetricsCollector),
10355 None,
10356 )
10357 .await
10358 .unwrap();
10359
10360 assert_eq!(row_ids, vec![200]);
10361 assert_eq!(row_ids.len(), scores.len());
10362
10363 let scorer = index
10364 .bm25_base_scorer(tokens.as_ref(), params.as_ref())
10365 .await
10366 .unwrap();
10367 let expected_score = scorer.query_weight("alpha") * scorer.doc_weight(1, 1_000);
10368 assert!(
10369 (scores[0] - expected_score).abs() < 1e-6,
10370 "score: {}, expected: {}",
10371 scores[0],
10372 expected_score
10373 );
10374 }
10375
10376 #[tokio::test]
10377 async fn test_and_query_returns_empty_when_exact_term_missing() {
10378 let tmpdir = TempObjDir::default();
10379 let store = Arc::new(LanceIndexStore::new(
10380 ObjectStore::local().into(),
10381 tmpdir.clone(),
10382 Arc::new(LanceCache::no_cache()),
10383 ));
10384
10385 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10386 builder.tokens.add("alpha".to_owned());
10387 builder.posting_lists.push(PostingListBuilder::new(false));
10388 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
10389 builder.docs.append(100, 1);
10390 builder.write(store.as_ref()).await.unwrap();
10391
10392 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
10393 let cache = Arc::new(LanceCache::with_capacity(4096));
10394 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10395 .await
10396 .unwrap();
10397
10398 let tokens = Arc::new(Tokens::new(
10399 vec!["alpha".to_owned(), "missing".to_owned()],
10400 DocType::Text,
10401 ));
10402 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
10403 let prefilter = Arc::new(NoFilter);
10404 let metrics = Arc::new(NoOpMetricsCollector);
10405
10406 let (and_row_ids, _) = index
10407 .bm25_search(
10408 tokens.clone(),
10409 params.clone(),
10410 Operator::And,
10411 prefilter.clone(),
10412 metrics.clone(),
10413 None,
10414 )
10415 .await
10416 .unwrap();
10417 assert!(
10418 and_row_ids.is_empty(),
10419 "AND must not match when any required term is missing"
10420 );
10421
10422 let (or_row_ids, _) = index
10423 .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
10424 .await
10425 .unwrap();
10426 assert_eq!(
10427 or_row_ids,
10428 vec![100],
10429 "OR should still match the present term"
10430 );
10431 }
10432
10433 #[tokio::test]
10434 async fn test_and_query_accepts_same_position_alternatives() {
10435 let tmpdir = TempObjDir::default();
10436 let store = Arc::new(LanceIndexStore::new(
10437 ObjectStore::local().into(),
10438 tmpdir.clone(),
10439 Arc::new(LanceCache::no_cache()),
10440 ));
10441
10442 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10443 for token in ["getusername", "get", "user", "name"] {
10444 builder.tokens.add(token.to_owned());
10445 builder.posting_lists.push(PostingListBuilder::new(false));
10446 }
10447 builder.posting_lists[1].add(0, PositionRecorder::Count(1));
10451 builder.posting_lists[2].add(0, PositionRecorder::Count(1));
10452 builder.posting_lists[3].add(0, PositionRecorder::Count(1));
10453 builder.docs.append(100, 3);
10454
10455 builder.posting_lists[0].add(1, PositionRecorder::Count(1));
10456 builder.posting_lists[1].add(1, PositionRecorder::Count(1));
10457 builder.posting_lists[2].add(1, PositionRecorder::Count(1));
10458 builder.posting_lists[3].add(1, PositionRecorder::Count(1));
10459 builder.docs.append(101, 4);
10460 builder.write(store.as_ref()).await.unwrap();
10461
10462 write_test_metadata(&store, vec![0], InvertedIndexParams::code()).await;
10463 let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache())
10464 .await
10465 .unwrap();
10466
10467 let tokens = Arc::new(Tokens::with_positions(
10468 vec![
10469 "getusername".to_string(),
10470 "get".to_string(),
10471 "user".to_string(),
10472 "name".to_string(),
10473 ],
10474 vec![0, 0, 1, 2],
10475 DocType::Text,
10476 ));
10477 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
10478 let (mut row_ids, _) = index
10479 .bm25_search(
10480 tokens,
10481 params,
10482 Operator::And,
10483 Arc::new(NoFilter),
10484 Arc::new(NoOpMetricsCollector),
10485 None,
10486 )
10487 .await
10488 .unwrap();
10489 row_ids.sort_unstable();
10490 assert_eq!(row_ids, vec![100, 101]);
10491 }
10492
10493 #[tokio::test]
10494 async fn test_phrase_query_accepts_same_position_alternatives() {
10495 let tmpdir = TempObjDir::default();
10496 let store = Arc::new(LanceIndexStore::new(
10497 ObjectStore::local().into(),
10498 tmpdir.clone(),
10499 Arc::new(LanceCache::no_cache()),
10500 ));
10501
10502 let mut builder = InnerBuilder::new(0, true, TokenSetFormat::default());
10503 for token in ["getusername", "get", "user", "name"] {
10504 builder.tokens.add(token.to_owned());
10505 builder.posting_lists.push(PostingListBuilder::new(true));
10506 }
10507 builder.posting_lists[1].add(0, PositionRecorder::Position(vec![0].into()));
10511 builder.posting_lists[2].add(0, PositionRecorder::Position(vec![1].into()));
10512 builder.posting_lists[3].add(0, PositionRecorder::Position(vec![2].into()));
10513 builder.docs.append(100, 3);
10514
10515 builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into()));
10516 builder.posting_lists[1].add(1, PositionRecorder::Position(vec![0].into()));
10517 builder.posting_lists[2].add(1, PositionRecorder::Position(vec![1].into()));
10518 builder.posting_lists[3].add(1, PositionRecorder::Position(vec![2].into()));
10519 builder.docs.append(101, 3);
10520
10521 builder.posting_lists[0].add(2, PositionRecorder::Position(vec![0].into()));
10522 builder.posting_lists[2].add(2, PositionRecorder::Position(vec![2].into()));
10523 builder.posting_lists[3].add(2, PositionRecorder::Position(vec![3].into()));
10524 builder.docs.append(102, 3);
10525
10526 builder.write(store.as_ref()).await.unwrap();
10527
10528 write_test_metadata(
10529 &store,
10530 vec![0],
10531 InvertedIndexParams::code().with_position(true),
10532 )
10533 .await;
10534 let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache())
10535 .await
10536 .unwrap();
10537
10538 let tokens = Arc::new(Tokens::with_positions(
10539 vec![
10540 "getusername".to_string(),
10541 "get".to_string(),
10542 "user".to_string(),
10543 "name".to_string(),
10544 ],
10545 vec![0, 0, 1, 2],
10546 DocType::Text,
10547 ));
10548 let params = Arc::new(
10549 FtsSearchParams::new()
10550 .with_limit(Some(10))
10551 .with_phrase_slop(Some(0)),
10552 );
10553 let (mut row_ids, _) = index
10554 .bm25_search(
10555 tokens,
10556 params,
10557 Operator::And,
10558 Arc::new(NoFilter),
10559 Arc::new(NoOpMetricsCollector),
10560 None,
10561 )
10562 .await
10563 .unwrap();
10564 row_ids.sort_unstable();
10565 assert_eq!(row_ids, vec![100, 101]);
10566 }
10567
10568 const MANY_BATCH_TOKENS: u64 = 1000;
10572 const MANY_BATCH_ROW_ID_BASE: u64 = 1000;
10573
10574 async fn write_partition_spanning_many_batches(store: &dyn IndexStore) {
10577 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10578 for i in 0..MANY_BATCH_TOKENS {
10579 builder.tokens.add(format!("tok{i:05}"));
10581 let doc_id = builder.docs.append(MANY_BATCH_ROW_ID_BASE + i, 1);
10582 let mut posting_list = PostingListBuilder::new(false);
10583 posting_list.add(doc_id, PositionRecorder::Count(1));
10584 builder.posting_lists.push(posting_list);
10585 }
10586 builder
10587 .write(store)
10588 .await
10589 .expect("writing posting lists should succeed");
10590 }
10591
10592 #[tokio::test]
10604 async fn test_write_many_posting_list_batches_preserves_all_batches() {
10605 let tmpdir = TempObjDir::default();
10606 let store = Arc::new(LanceIndexStore::new(
10607 ObjectStore::local().into(),
10608 tmpdir.clone(),
10609 Arc::new(LanceCache::no_cache()),
10610 ));
10611
10612 write_partition_spanning_many_batches(store.as_ref()).await;
10613
10614 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
10615 let cache = Arc::new(LanceCache::with_capacity(4096));
10616 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10617 .await
10618 .unwrap();
10619
10620 for token_idx in [0u64, MANY_BATCH_TOKENS / 2, MANY_BATCH_TOKENS - 1] {
10623 let tokens = Arc::new(Tokens::new(
10624 vec![format!("tok{token_idx:05}")],
10625 DocType::Text,
10626 ));
10627 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
10628 let (row_ids, _) = index
10629 .bm25_search(
10630 tokens,
10631 params,
10632 Operator::Or,
10633 Arc::new(NoFilter),
10634 Arc::new(NoOpMetricsCollector),
10635 None,
10636 )
10637 .await
10638 .unwrap();
10639 assert_eq!(
10640 row_ids,
10641 vec![MANY_BATCH_ROW_ID_BASE + token_idx],
10642 "token tok{token_idx:05} should map to its single document"
10643 );
10644 }
10645 }
10646
10647 #[tokio::test]
10648 async fn test_and_query_skips_partition_missing_required_term() {
10649 let tmpdir = TempObjDir::default();
10650 let store = Arc::new(LanceIndexStore::new(
10651 ObjectStore::local().into(),
10652 tmpdir.clone(),
10653 Arc::new(LanceCache::no_cache()),
10654 ));
10655
10656 let mut builder0 = InnerBuilder::new(0, false, TokenSetFormat::default());
10657 builder0.tokens.add("alpha".to_owned());
10658 builder0.posting_lists.push(PostingListBuilder::new(false));
10659 builder0.posting_lists[0].add(0, PositionRecorder::Count(1));
10660 builder0.docs.append(100, 1);
10661 builder0.write(store.as_ref()).await.unwrap();
10662
10663 let mut builder1 = InnerBuilder::new(1, false, TokenSetFormat::default());
10664 builder1.tokens.add("alpha".to_owned());
10665 builder1.tokens.add("beta".to_owned());
10666 builder1.posting_lists.push(PostingListBuilder::new(false));
10667 builder1.posting_lists.push(PostingListBuilder::new(false));
10668 builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
10669 builder1.posting_lists[1].add(0, PositionRecorder::Count(1));
10670 builder1.docs.append(200, 2);
10671 builder1.write(store.as_ref()).await.unwrap();
10672
10673 write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
10674 let cache = Arc::new(LanceCache::with_capacity(4096));
10675 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10676 .await
10677 .unwrap();
10678
10679 let tokens = Arc::new(Tokens::new(
10680 vec!["alpha".to_owned(), "beta".to_owned()],
10681 DocType::Text,
10682 ));
10683 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
10684 let (mut row_ids, _) = index
10685 .bm25_search(
10686 tokens,
10687 params,
10688 Operator::And,
10689 Arc::new(NoFilter),
10690 Arc::new(NoOpMetricsCollector),
10691 None,
10692 )
10693 .await
10694 .unwrap();
10695 row_ids.sort_unstable();
10696 assert_eq!(
10697 row_ids,
10698 vec![200],
10699 "partition missing beta must not contribute alpha-only hits"
10700 );
10701 }
10702
10703 #[tokio::test]
10704 async fn test_fuzzy_and_groups_expansions_by_original_position() {
10705 let tmpdir = TempObjDir::default();
10706 let store = Arc::new(LanceIndexStore::new(
10707 ObjectStore::local().into(),
10708 tmpdir.clone(),
10709 Arc::new(LanceCache::no_cache()),
10710 ));
10711
10712 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10713 builder.tokens.add("alpha".to_owned());
10714 builder.tokens.add("alphi".to_owned());
10715 builder.tokens.add("beta".to_owned());
10716 builder.posting_lists.push(PostingListBuilder::new(false));
10717 builder.posting_lists.push(PostingListBuilder::new(false));
10718 builder.posting_lists.push(PostingListBuilder::new(false));
10719 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
10720 builder.posting_lists[1].add(1, PositionRecorder::Count(1));
10721 builder.posting_lists[2].add(0, PositionRecorder::Count(1));
10722 builder.posting_lists[2].add(1, PositionRecorder::Count(1));
10723 builder.docs.append(100, 2);
10724 builder.docs.append(101, 2);
10725 builder.write(store.as_ref()).await.unwrap();
10726
10727 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
10728 let cache = Arc::new(LanceCache::with_capacity(4096));
10729 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10730 .await
10731 .unwrap();
10732 let params = Arc::new(
10733 FtsSearchParams::new()
10734 .with_limit(Some(10))
10735 .with_fuzziness(Some(1)),
10736 );
10737
10738 let missing_position_tokens = Arc::new(Tokens::new(
10739 vec!["betx".to_owned(), "zzzzz".to_owned()],
10740 DocType::Text,
10741 ));
10742 let (missing_and_row_ids, _) = index
10743 .bm25_search(
10744 missing_position_tokens.clone(),
10745 params.clone(),
10746 Operator::And,
10747 Arc::new(NoFilter),
10748 Arc::new(NoOpMetricsCollector),
10749 None,
10750 )
10751 .await
10752 .unwrap();
10753 assert!(
10754 missing_and_row_ids.is_empty(),
10755 "fuzzy AND must require at least one expansion for every original position"
10756 );
10757
10758 let (mut or_row_ids, _) = index
10759 .bm25_search(
10760 missing_position_tokens,
10761 params.clone(),
10762 Operator::Or,
10763 Arc::new(NoFilter),
10764 Arc::new(NoOpMetricsCollector),
10765 None,
10766 )
10767 .await
10768 .unwrap();
10769 or_row_ids.sort_unstable();
10770 assert_eq!(
10771 or_row_ids,
10772 vec![100, 101],
10773 "OR should still match present fuzzy expansions"
10774 );
10775
10776 let grouped_tokens = Arc::new(Tokens::new(
10777 vec!["alphx".to_owned(), "betx".to_owned()],
10778 DocType::Text,
10779 ));
10780 let (mut grouped_row_ids, _) = index
10781 .bm25_search(
10782 grouped_tokens,
10783 params,
10784 Operator::And,
10785 Arc::new(NoFilter),
10786 Arc::new(NoOpMetricsCollector),
10787 None,
10788 )
10789 .await
10790 .unwrap();
10791 grouped_row_ids.sort_unstable();
10792 assert_eq!(
10793 grouped_row_ids,
10794 vec![100, 101],
10795 "each original fuzzy position should match any one of its expansions"
10796 );
10797 }
10798
10799 #[tokio::test]
10800 async fn test_fuzzy_expansion_cap_applies_to_whole_query() {
10801 let tmpdir = TempObjDir::default();
10802 let store = Arc::new(LanceIndexStore::new(
10803 ObjectStore::local().into(),
10804 tmpdir.clone(),
10805 Arc::new(LanceCache::no_cache()),
10806 ));
10807
10808 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10809 for token in ["alpha", "alphi", "beta", "beti"] {
10810 builder.tokens.add(token.to_owned());
10811 builder.posting_lists.push(PostingListBuilder::new(false));
10812 }
10813 for token_id in 0..4 {
10814 builder.posting_lists[token_id].add(token_id as u32, PositionRecorder::Count(1));
10815 builder.docs.append(100 + token_id as u64, 1);
10816 }
10817 builder.write(store.as_ref()).await.unwrap();
10818
10819 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
10820 let cache = Arc::new(LanceCache::with_capacity(4096));
10821 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10822 .await
10823 .unwrap();
10824 let partition = index.partitions[0].clone();
10825 let params = FtsSearchParams::new()
10826 .with_fuzziness(Some(1))
10827 .with_max_expansions(3);
10828 let tokens = Tokens::new(vec!["alphx".to_owned(), "betx".to_owned()], DocType::Text);
10829
10830 let expanded = partition.expand_fuzzy(&tokens, ¶ms).unwrap();
10831 let expanded_terms = (0..expanded.len())
10832 .map(|idx| (expanded.get_token(idx).to_owned(), expanded.position(idx)))
10833 .collect::<Vec<_>>();
10834
10835 assert_eq!(
10836 expanded_terms,
10837 vec![
10838 ("alpha".to_owned(), 0),
10839 ("alphi".to_owned(), 0),
10840 ("beta".to_owned(), 1),
10841 ],
10842 "max_expansions should cap the whole fuzzy query, not each token"
10843 );
10844 }
10845
10846 async fn write_variant_partition(
10849 store: &Arc<LanceIndexStore>,
10850 partition_id: u64,
10851 variants: &[&str],
10852 row_ids: &[u64],
10853 ) {
10854 let mut builder = InnerBuilder::new(partition_id, false, TokenSetFormat::default());
10855 for token in variants {
10856 builder.tokens.add((*token).to_owned());
10857 builder.posting_lists.push(PostingListBuilder::new(false));
10858 }
10859 for (local_idx, row_id) in row_ids.iter().enumerate() {
10860 builder.posting_lists[local_idx].add(local_idx as u32, PositionRecorder::Count(1));
10861 builder.docs.append(*row_id, 1);
10862 }
10863 builder.write(store.as_ref()).await.unwrap();
10864 }
10865
10866 #[tokio::test]
10867 async fn test_fuzzy_expansion_cap_is_global_across_partitions() {
10868 let tmpdir = TempObjDir::default();
10869 let store = Arc::new(LanceIndexStore::new(
10870 ObjectStore::local().into(),
10871 tmpdir.clone(),
10872 Arc::new(LanceCache::no_cache()),
10873 ));
10874
10875 write_variant_partition(&store, 0, &["alpha", "alphb"], &[100, 101]).await;
10876 write_variant_partition(&store, 1, &["alphc", "alphd"], &[102, 103]).await;
10877 write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
10878 let cache = Arc::new(LanceCache::with_capacity(4096));
10879 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10880 .await
10881 .unwrap();
10882
10883 let params = FtsSearchParams::new()
10884 .with_fuzziness(Some(1))
10885 .with_max_expansions(3);
10886 let tokens = Tokens::new(vec!["alphx".to_owned()], DocType::Text);
10887
10888 let expanded = index.expand_fuzzy_tokens(&tokens, ¶ms).unwrap();
10889 let expanded_terms = (0..expanded.len())
10890 .map(|idx| expanded.get_token(idx).to_owned())
10891 .collect::<Vec<_>>();
10892 assert_eq!(
10893 expanded_terms,
10894 vec!["alpha".to_owned(), "alphb".to_owned(), "alphc".to_owned()],
10895 "max_expansions must cap the whole query across partitions, \
10896 in lexicographic order"
10897 );
10898 }
10899
10900 #[tokio::test]
10901 async fn test_fuzzy_results_independent_of_partition_shape() {
10902 let single_dir = TempObjDir::default();
10906 let single_store = Arc::new(LanceIndexStore::new(
10907 ObjectStore::local().into(),
10908 single_dir.clone(),
10909 Arc::new(LanceCache::no_cache()),
10910 ));
10911 write_variant_partition(
10912 &single_store,
10913 0,
10914 &["alpha", "alphb", "alphc", "alphd"],
10915 &[100, 101, 102, 103],
10916 )
10917 .await;
10918 write_test_metadata(&single_store, vec![0], InvertedIndexParams::default()).await;
10919
10920 let split_dir = TempObjDir::default();
10921 let split_store = Arc::new(LanceIndexStore::new(
10922 ObjectStore::local().into(),
10923 split_dir.clone(),
10924 Arc::new(LanceCache::no_cache()),
10925 ));
10926 write_variant_partition(&split_store, 0, &["alpha", "alphb"], &[100, 101]).await;
10927 write_variant_partition(&split_store, 1, &["alphc", "alphd"], &[102, 103]).await;
10928 write_test_metadata(&split_store, vec![0, 1], InvertedIndexParams::default()).await;
10929
10930 let params = Arc::new(
10931 FtsSearchParams::new()
10932 .with_limit(Some(10))
10933 .with_fuzziness(Some(1))
10934 .with_max_expansions(3),
10935 );
10936
10937 let mut results = Vec::new();
10938 for store in [single_store, split_store] {
10939 let cache = LanceCache::with_capacity(4096);
10940 let index = InvertedIndex::load(store, None, &cache).await.unwrap();
10941 let tokens = Arc::new(Tokens::new(vec!["alphx".to_owned()], DocType::Text));
10942 let (row_ids, scores) = index
10943 .bm25_search(
10944 tokens,
10945 params.clone(),
10946 Operator::Or,
10947 Arc::new(NoFilter),
10948 Arc::new(NoOpMetricsCollector),
10949 None,
10950 )
10951 .await
10952 .unwrap();
10953 let mut scored = row_ids.into_iter().zip(scores).collect::<Vec<_>>();
10954 scored.sort_unstable_by_key(|(row_id, _)| *row_id);
10955 results.push(scored);
10956 }
10957
10958 assert_eq!(
10959 results[0]
10960 .iter()
10961 .map(|(row_id, _)| *row_id)
10962 .collect::<Vec<_>>(),
10963 vec![100, 101, 102],
10964 "a binding cap keeps the three lexicographically smallest variants"
10965 );
10966 assert_eq!(
10967 results[0], results[1],
10968 "fuzzy results must not depend on the partition shape"
10969 );
10970 }
10971
10972 #[tokio::test]
10973 async fn test_fuzzy_and_scores_grouped_expansions_by_matched_token() {
10974 let tmpdir = TempObjDir::default();
10975 let store = Arc::new(LanceIndexStore::new(
10976 ObjectStore::local().into(),
10977 tmpdir.clone(),
10978 Arc::new(LanceCache::no_cache()),
10979 ));
10980
10981 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10982 builder.tokens.add("alpha".to_owned());
10983 builder.tokens.add("alphi".to_owned());
10984 builder.tokens.add("beta".to_owned());
10985 builder.posting_lists.push(PostingListBuilder::new(false));
10986 builder.posting_lists.push(PostingListBuilder::new(false));
10987 builder.posting_lists.push(PostingListBuilder::new(false));
10988 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
10989 builder.posting_lists[0].add(2, PositionRecorder::Count(1));
10990 builder.posting_lists[0].add(3, PositionRecorder::Count(1));
10991 builder.posting_lists[0].add(4, PositionRecorder::Count(1));
10992 builder.posting_lists[0].add(5, PositionRecorder::Count(1));
10993 builder.posting_lists[1].add(1, PositionRecorder::Count(1));
10994 builder.posting_lists[2].add(0, PositionRecorder::Count(1));
10995 builder.posting_lists[2].add(1, PositionRecorder::Count(1));
10996 builder.docs.append(100, 2);
10997 builder.docs.append(101, 2);
10998 builder.docs.append(102, 1);
10999 builder.docs.append(103, 1);
11000 builder.docs.append(104, 1);
11001 builder.docs.append(105, 1);
11002 builder.write(store.as_ref()).await.unwrap();
11003
11004 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
11005 let cache = Arc::new(LanceCache::with_capacity(4096));
11006 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11007 .await
11008 .unwrap();
11009
11010 let tokens = Arc::new(Tokens::new(
11011 vec!["alphx".to_owned(), "betx".to_owned()],
11012 DocType::Text,
11013 ));
11014 let params = Arc::new(
11015 FtsSearchParams::new()
11016 .with_limit(Some(1))
11017 .with_fuzziness(Some(1)),
11018 );
11019 let (row_ids, _scores) = index
11020 .bm25_search(
11021 tokens,
11022 params,
11023 Operator::And,
11024 Arc::new(NoFilter),
11025 Arc::new(NoOpMetricsCollector),
11026 None,
11027 )
11028 .await
11029 .unwrap();
11030
11031 assert_eq!(
11032 row_ids,
11033 vec![101],
11034 "the rare matched expansion should outrank the common expansion"
11035 );
11036 }
11037
11038 #[rstest::rstest]
11039 #[case::and(Operator::And)]
11040 #[case::or(Operator::Or)]
11041 #[tokio::test]
11042 async fn test_grouped_scoring_keeps_exact_winner_outside_proxy_window(
11043 #[case] operator: Operator,
11044 ) {
11045 let tmpdir = TempObjDir::default();
11046 let store = Arc::new(LanceIndexStore::new(
11047 ObjectStore::local().into(),
11048 tmpdir.clone(),
11049 Arc::new(LanceCache::no_cache()),
11050 ));
11051
11052 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11053 builder.tokens.add("common".to_owned());
11054 builder.tokens.add("rare".to_owned());
11055 builder.posting_lists.push(PostingListBuilder::new(false));
11056 builder.posting_lists.push(PostingListBuilder::new(false));
11057 for doc_id in 0..3 {
11058 builder.posting_lists[0].add(doc_id, PositionRecorder::Count(1));
11059 builder.docs.append(100 + doc_id as u64, 1);
11060 }
11061 builder.posting_lists[1].add(3, PositionRecorder::Count(1));
11062 builder.docs.append(103, 2);
11063 builder.write(store.as_ref()).await.unwrap();
11064
11065 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
11066 let cache = Arc::new(LanceCache::with_capacity(4096));
11067 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11068 .await
11069 .unwrap();
11070
11071 let tokens = Arc::new(Tokens::with_positions(
11072 vec!["common".to_owned(), "rare".to_owned()],
11073 vec![0, 0],
11074 DocType::Text,
11075 ));
11076 let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
11077 let (row_ids, _scores) = index
11078 .bm25_search(
11079 tokens,
11080 params,
11081 operator,
11082 Arc::new(NoFilter),
11083 Arc::new(NoOpMetricsCollector),
11084 None,
11085 )
11086 .await
11087 .unwrap();
11088
11089 assert_eq!(
11090 row_ids,
11091 vec![103],
11092 "the rare term's exact IDF must win even when proxy scoring ranks it outside the old candidate cushion"
11093 );
11094 }
11095
11096 #[tokio::test]
11097 async fn test_fuzzy_and_grouped_rescore_keeps_wand_limit_bounded() {
11098 let tmpdir = TempObjDir::default();
11099 let store = Arc::new(LanceIndexStore::new(
11100 ObjectStore::local().into(),
11101 tmpdir.clone(),
11102 Arc::new(LanceCache::no_cache()),
11103 ));
11104
11105 let num_docs = BLOCK_SIZE * 2 + 4;
11106 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11107 builder.tokens.add("alpha".to_owned());
11108 builder.tokens.add("alphi".to_owned());
11109 builder.tokens.add("beta".to_owned());
11110 builder.posting_lists.push(PostingListBuilder::new(false));
11111 builder.posting_lists.push(PostingListBuilder::new(false));
11112 builder.posting_lists.push(PostingListBuilder::new(false));
11113
11114 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
11115 builder.posting_lists[1].add(1, PositionRecorder::Count(1));
11116 for doc_id in 0..num_docs {
11117 builder.posting_lists[2].add(doc_id as u32, PositionRecorder::Count(1));
11118 if doc_id >= 2 {
11119 builder.posting_lists[0].add(doc_id as u32, PositionRecorder::Count(1));
11120 }
11121 let num_tokens = if doc_id < 2 { 2 } else { 100 };
11122 builder.docs.append(100 + doc_id as u64, num_tokens);
11123 }
11124 builder.write(store.as_ref()).await.unwrap();
11125
11126 write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
11127 let cache = Arc::new(LanceCache::with_capacity(4096));
11128 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11129 .await
11130 .unwrap();
11131
11132 let tokens = Arc::new(Tokens::new(
11133 vec!["alphx".to_owned(), "betx".to_owned()],
11134 DocType::Text,
11135 ));
11136 let params = Arc::new(
11137 FtsSearchParams::new()
11138 .with_limit(Some(1))
11139 .with_fuzziness(Some(1)),
11140 );
11141 let metrics = Arc::new(LocalMetricsCollector::default());
11142 let (row_ids, _scores) = index
11143 .bm25_search(
11144 tokens,
11145 params,
11146 Operator::And,
11147 Arc::new(NoFilter),
11148 metrics.clone(),
11149 None,
11150 )
11151 .await
11152 .unwrap();
11153
11154 assert_eq!(
11155 row_ids,
11156 vec![101],
11157 "final rescoring should still rank by the matched expansion"
11158 );
11159 let comparisons = metrics.comparisons.load(Ordering::Relaxed);
11160 assert!(
11161 comparisons < num_docs,
11162 "grouped fuzzy AND should not clear the WAND top-k bound and scan every candidate; comparisons={comparisons}, num_docs={num_docs}"
11163 );
11164 }
11165
11166 #[tokio::test]
11167 async fn test_phrase_query_reads_legacy_per_doc_positions() {
11168 let tmpdir = TempObjDir::default();
11169 let store = Arc::new(LanceIndexStore::new(
11170 ObjectStore::local().into(),
11171 tmpdir.clone(),
11172 Arc::new(LanceCache::no_cache()),
11173 ));
11174
11175 let mut builder = InnerBuilder::new_with_format_version(
11176 0,
11177 true,
11178 TokenSetFormat::default(),
11179 InvertedListFormatVersion::V1,
11180 );
11181 builder.tokens.add("hello".to_owned());
11182 builder.tokens.add("world".to_owned());
11183 builder
11184 .posting_lists
11185 .push(PostingListBuilder::new_with_posting_tail_codec(
11186 true,
11187 PostingTailCodec::Fixed32,
11188 ));
11189 builder
11190 .posting_lists
11191 .push(PostingListBuilder::new_with_posting_tail_codec(
11192 true,
11193 PostingTailCodec::Fixed32,
11194 ));
11195 builder.posting_lists[0].add(0, PositionRecorder::Position(vec![0].into()));
11196 builder.posting_lists[1].add(0, PositionRecorder::Position(vec![1].into()));
11197 builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into()));
11198 builder.posting_lists[1].add(1, PositionRecorder::Position(vec![2].into()));
11199 builder.docs.append(100, 2);
11200 builder.docs.append(101, 2);
11201 builder.write(store.as_ref()).await.unwrap();
11202
11203 let metadata = std::collections::HashMap::from_iter(vec![
11204 (
11205 "partitions".to_owned(),
11206 serde_json::to_string(&vec![0_u64]).unwrap(),
11207 ),
11208 (
11209 "params".to_owned(),
11210 serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
11211 ),
11212 (
11213 TOKEN_SET_FORMAT_KEY.to_owned(),
11214 TokenSetFormat::default().to_string(),
11215 ),
11216 ]);
11217 let mut writer = store
11218 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
11219 .await
11220 .unwrap();
11221 writer.finish_with_metadata(metadata).await.unwrap();
11222
11223 let cache = Arc::new(LanceCache::with_capacity(4096));
11224 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11225 .await
11226 .unwrap();
11227
11228 let tokens = Arc::new(Tokens::new(
11229 vec!["hello".to_owned(), "world".to_owned()],
11230 DocType::Text,
11231 ));
11232 let params = Arc::new(
11233 FtsSearchParams::new()
11234 .with_limit(Some(10))
11235 .with_phrase_slop(Some(0)),
11236 );
11237 let prefilter = Arc::new(NoFilter);
11238 let metrics = Arc::new(NoOpMetricsCollector);
11239
11240 let (row_ids, _scores) = index
11241 .bm25_search(tokens, params, Operator::And, prefilter, metrics, None)
11242 .await
11243 .unwrap();
11244
11245 assert_eq!(row_ids, vec![100]);
11246 }
11247
11248 async fn build_multi_partition_index(
11251 store: &Arc<LanceIndexStore>,
11252 num_partitions: u64,
11253 ) -> (Arc<InvertedIndex>, Arc<LanceCache>) {
11254 for id in 0..num_partitions {
11255 let mut builder = InnerBuilder::new_with_format_version(
11256 id,
11257 false,
11258 TokenSetFormat::default(),
11259 InvertedListFormatVersion::V1,
11260 );
11261 for t in 0..4u32 {
11264 builder.tokens.add(format!("tok_{id}_{t}"));
11265 let mut posting = PostingListBuilder::new_with_posting_tail_codec(
11266 false,
11267 PostingTailCodec::Fixed32,
11268 );
11269 let base = id * 1000 + t as u64 * 10;
11270 for d in 0..5u32 {
11271 posting.add(d, PositionRecorder::Count(1));
11272 builder.docs.append(base + d as u64, 4);
11273 }
11274 builder.posting_lists.push(posting);
11275 }
11276 builder.write(store.as_ref()).await.unwrap();
11277 }
11278
11279 let partition_ids: Vec<u64> = (0..num_partitions).collect();
11280 let metadata = std::collections::HashMap::from_iter(vec![
11281 (
11282 "partitions".to_owned(),
11283 serde_json::to_string(&partition_ids).unwrap(),
11284 ),
11285 (
11286 "params".to_owned(),
11287 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
11288 ),
11289 (
11290 TOKEN_SET_FORMAT_KEY.to_owned(),
11291 TokenSetFormat::default().to_string(),
11292 ),
11293 ]);
11294 let mut writer = store
11295 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
11296 .await
11297 .unwrap();
11298 writer.finish_with_metadata(metadata).await.unwrap();
11299
11300 let cache = Arc::new(LanceCache::with_capacity(1 << 20));
11303 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11304 .await
11305 .unwrap();
11306 (index, cache)
11307 }
11308
11309 #[tokio::test]
11313 async fn test_posting_data_size_bytes_uses_file_length() {
11314 let tmpdir = TempObjDir::default();
11315 let store = Arc::new(LanceIndexStore::new(
11316 ObjectStore::local().into(),
11317 tmpdir.clone(),
11318 Arc::new(LanceCache::no_cache()),
11319 ));
11320 let (index, _cache) = build_multi_partition_index(&store, 3).await;
11321 for part in &index.partitions {
11322 let est = part.inverted_list.posting_data_size_bytes();
11325 assert!(
11326 est > 0,
11327 "expected a non-zero posting-data size estimate, got {est}"
11328 );
11329 }
11330 }
11331
11332 #[tokio::test]
11339 async fn test_partitions_load_with_distinct_priorities() {
11340 let tmpdir = TempObjDir::default();
11341 let store = Arc::new(LanceIndexStore::new(
11342 ObjectStore::local().into(),
11343 tmpdir.clone(),
11344 Arc::new(LanceCache::no_cache()),
11345 ));
11346 let (index, _cache) = build_multi_partition_index(&store, 5).await;
11347
11348 let mut priorities: Vec<u64> = index
11349 .partitions
11350 .iter()
11351 .map(|part| {
11352 part.store
11353 .as_any()
11354 .downcast_ref::<LanceIndexStore>()
11355 .expect("partition store should be a LanceIndexStore")
11356 .io_priority()
11357 })
11358 .collect();
11359
11360 priorities.sort_unstable();
11366 assert_eq!(
11367 priorities,
11368 (0..index.partitions.len() as u64).collect::<Vec<_>>()
11369 );
11370 }
11371
11372 #[tokio::test]
11373 async fn test_update_preserves_v2_format_version() -> Result<()> {
11374 let src_dir = TempObjDir::default();
11375 let dest_dir = TempObjDir::default();
11376 let src_store = Arc::new(LanceIndexStore::new(
11377 ObjectStore::local().into(),
11378 src_dir.clone(),
11379 Arc::new(LanceCache::no_cache()),
11380 ));
11381 let dest_store = Arc::new(LanceIndexStore::new(
11382 ObjectStore::local().into(),
11383 dest_dir.clone(),
11384 Arc::new(LanceCache::no_cache()),
11385 ));
11386
11387 let format_version = InvertedListFormatVersion::V2;
11388 let posting_tail_codec = format_version.posting_tail_codec();
11389 let mut partition = InnerBuilder::new_with_format_version(
11390 0,
11391 false,
11392 TokenSetFormat::default(),
11393 format_version,
11394 );
11395 partition.tokens.add("hello".to_owned());
11396 let mut posting_list =
11397 PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
11398 posting_list.add(0, PositionRecorder::Count(1));
11399 partition.posting_lists.push(posting_list);
11400 partition.docs.append(100, 1);
11401 partition.write(src_store.as_ref()).await?;
11402
11403 let metadata = HashMap::from([
11404 (
11405 "partitions".to_owned(),
11406 serde_json::to_string(&vec![0_u64]).unwrap(),
11407 ),
11408 (
11409 "params".to_owned(),
11410 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
11411 ),
11412 (
11413 TOKEN_SET_FORMAT_KEY.to_owned(),
11414 TokenSetFormat::default().to_string(),
11415 ),
11416 (
11417 POSTING_TAIL_CODEC_KEY.to_owned(),
11418 posting_tail_codec.as_str().to_owned(),
11419 ),
11420 ]);
11421 let mut writer = src_store
11422 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
11423 .await
11424 .unwrap();
11425 writer.finish_with_metadata(metadata).await.unwrap();
11426
11427 let index = InvertedIndex::load(src_store, None, &LanceCache::no_cache()).await?;
11428 assert_eq!(index.format_version(), format_version);
11429 assert_eq!(index.index_version(), INVERTED_INDEX_VERSION_V2);
11430
11431 let schema = Arc::new(Schema::new(vec![
11432 Field::new("doc", DataType::Utf8, true),
11433 Field::new(ROW_ID, DataType::UInt64, false),
11434 ]));
11435 let docs = Arc::new(StringArray::from(vec![Some("hello again")]));
11436 let row_ids = Arc::new(UInt64Array::from(vec![101u64]));
11437 let batch = RecordBatch::try_new(schema.clone(), vec![docs, row_ids])?;
11438 let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)]));
11439 let created = index
11440 .update(Box::pin(stream), dest_store.as_ref(), None)
11441 .await?;
11442
11443 assert_eq!(created.index_version, INVERTED_INDEX_VERSION_V2);
11444
11445 let updated = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
11446 assert_eq!(updated.format_version(), format_version);
11447 assert_eq!(updated.index_version(), INVERTED_INDEX_VERSION_V2);
11448 assert_eq!(updated.partitions.len(), 2);
11449 for partition in &updated.partitions {
11450 assert_eq!(
11451 partition.inverted_list.posting_tail_codec(),
11452 posting_tail_codec
11453 );
11454 }
11455
11456 Ok(())
11457 }
11458
11459 #[tokio::test]
11460 async fn test_block_size_256_writes_v3_metadata_and_index_version() -> Result<()> {
11461 let src_dir = TempObjDir::default();
11462 let dest_dir = TempObjDir::default();
11463 let src_store = Arc::new(LanceIndexStore::new(
11464 ObjectStore::local().into(),
11465 src_dir.clone(),
11466 Arc::new(LanceCache::no_cache()),
11467 ));
11468 let dest_store = Arc::new(LanceIndexStore::new(
11469 ObjectStore::local().into(),
11470 dest_dir.clone(),
11471 Arc::new(LanceCache::no_cache()),
11472 ));
11473
11474 let params = InvertedIndexParams::default().block_size(256)?;
11475 let format_version = params.resolved_format_version();
11476 assert_eq!(format_version, InvertedListFormatVersion::V3);
11477
11478 let mut partition = InnerBuilder::new_with_format_version_and_block_size(
11479 0,
11480 false,
11481 TokenSetFormat::default(),
11482 format_version,
11483 params.posting_block_size(),
11484 );
11485 partition.tokens.add("hello".to_owned());
11486 let mut posting_list = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
11487 false,
11488 format_version.posting_tail_codec(),
11489 params.posting_block_size(),
11490 );
11491 posting_list.add(0, PositionRecorder::Count(1));
11492 partition.posting_lists.push(posting_list);
11493 partition.docs.append(100, 1);
11494 partition.write(src_store.as_ref()).await?;
11495
11496 write_test_metadata(&src_store, vec![0], params).await;
11497
11498 let index = InvertedIndex::load(src_store, None, &LanceCache::no_cache()).await?;
11499 assert_eq!(index.format_version(), InvertedListFormatVersion::V3);
11500 assert_eq!(index.index_version(), INVERTED_INDEX_VERSION_V3);
11501
11502 let created = index
11503 .update(empty_doc_stream(), dest_store.as_ref(), None)
11504 .await?;
11505 assert_eq!(created.index_version, INVERTED_INDEX_VERSION_V3);
11506
11507 let updated = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
11508 assert_eq!(updated.format_version(), InvertedListFormatVersion::V3);
11509 assert_eq!(updated.index_version(), INVERTED_INDEX_VERSION_V3);
11510
11511 Ok(())
11512 }
11513
11514 #[tokio::test]
11515 async fn test_merge_segments_preserves_arrow_token_set_format() -> Result<()> {
11516 let src_dir = TempObjDir::default();
11517 let dest_dir = TempObjDir::default();
11518 let src_store = Arc::new(LanceIndexStore::new(
11519 ObjectStore::local().into(),
11520 src_dir.clone(),
11521 Arc::new(LanceCache::no_cache()),
11522 ));
11523 let dest_store = Arc::new(LanceIndexStore::new(
11524 ObjectStore::local().into(),
11525 dest_dir.clone(),
11526 Arc::new(LanceCache::no_cache()),
11527 ));
11528
11529 let index = write_single_partition_index(
11530 src_store,
11531 InvertedIndexParams::default().format_version(InvertedListFormatVersion::V2),
11532 TokenSetFormat::Arrow,
11533 "hello",
11534 100,
11535 )
11536 .await?;
11537 assert_eq!(index.index_version(), 0);
11538 let created = InvertedIndex::merge_segments(
11539 &[index],
11540 empty_doc_stream(),
11541 dest_store.as_ref(),
11542 None,
11543 crate::progress::noop_progress(),
11544 )
11545 .await?;
11546
11547 assert_eq!(created.index_version, 0);
11548 let merged = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
11549 assert_eq!(merged.index_version(), 0);
11550 assert_eq!(merged.token_set_format, TokenSetFormat::Arrow);
11551
11552 let tokens = Arc::new(Tokens::new(vec!["hello".to_string()], DocType::Text));
11553 let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
11554 let prefilter = Arc::new(NoFilter);
11555 let metrics = Arc::new(NoOpMetricsCollector);
11556 let (row_ids, _) = merged
11557 .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
11558 .await?;
11559 assert_eq!(row_ids, vec![100]);
11560
11561 Ok(())
11562 }
11563
11564 #[rstest::rstest]
11565 #[case::v1(InvertedListFormatVersion::V1, LEGACY_BLOCK_SIZE)]
11566 #[case::v2(InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)]
11567 #[case::v3_128(InvertedListFormatVersion::V3, LEGACY_BLOCK_SIZE)]
11568 #[case::v3_256(InvertedListFormatVersion::V3, 256)]
11569 #[tokio::test]
11570 async fn test_merge_segments_preserves_format_version(
11571 #[case] format_version: InvertedListFormatVersion,
11572 #[case] block_size: usize,
11573 ) -> Result<()> {
11574 let src_dir = TempObjDir::default();
11575 let dest_dir = TempObjDir::default();
11576 let src_store = Arc::new(LanceIndexStore::new(
11577 ObjectStore::local().into(),
11578 src_dir.clone(),
11579 Arc::new(LanceCache::no_cache()),
11580 ));
11581 let dest_store = Arc::new(LanceIndexStore::new(
11582 ObjectStore::local().into(),
11583 dest_dir.clone(),
11584 Arc::new(LanceCache::no_cache()),
11585 ));
11586 let params = InvertedIndexParams::default()
11587 .block_size(block_size)?
11588 .format_version(format_version);
11589
11590 let index =
11591 write_single_partition_index(src_store, params, TokenSetFormat::Fst, "hello", 100)
11592 .await?;
11593 assert_eq!(index.format_version(), format_version);
11594
11595 let created = InvertedIndex::merge_segments(
11596 &[index],
11597 empty_doc_stream(),
11598 dest_store.as_ref(),
11599 None,
11600 crate::progress::noop_progress(),
11601 )
11602 .await?;
11603 assert_eq!(created.index_version, format_version.index_version());
11604
11605 let merged = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
11606 assert_eq!(merged.format_version(), format_version);
11607 assert_eq!(merged.index_version(), format_version.index_version());
11608
11609 Ok(())
11610 }
11611
11612 #[tokio::test]
11613 async fn test_merge_segments_uses_memory_limit_for_old_partitions() -> Result<()> {
11614 let src_dir_1 = TempObjDir::default();
11615 let src_dir_2 = TempObjDir::default();
11616 let dest_dir = TempObjDir::default();
11617 let src_store_1 = Arc::new(LanceIndexStore::new(
11618 ObjectStore::local().into(),
11619 src_dir_1.clone(),
11620 Arc::new(LanceCache::no_cache()),
11621 ));
11622 let src_store_2 = Arc::new(LanceIndexStore::new(
11623 ObjectStore::local().into(),
11624 src_dir_2.clone(),
11625 Arc::new(LanceCache::no_cache()),
11626 ));
11627 let dest_store = Arc::new(LanceIndexStore::new(
11628 ObjectStore::local().into(),
11629 dest_dir.clone(),
11630 Arc::new(LanceCache::no_cache()),
11631 ));
11632
11633 let params = InvertedIndexParams::default().memory_limit_mb(0);
11634 let first = write_single_partition_index(
11635 src_store_1,
11636 params.clone(),
11637 TokenSetFormat::default(),
11638 "alpha",
11639 100,
11640 )
11641 .await?;
11642 let second = write_single_partition_index(
11643 src_store_2,
11644 params,
11645 TokenSetFormat::default(),
11646 "beta",
11647 200,
11648 )
11649 .await?;
11650
11651 let mut builder =
11652 InvertedIndexBuilder::new(InvertedIndexParams::default().memory_limit_mb(0))
11653 .with_token_set_format(TokenSetFormat::default());
11654 builder
11655 .update_from_segments(
11656 empty_doc_stream(),
11657 dest_store.as_ref(),
11658 &[first, second],
11659 None,
11660 )
11661 .await?;
11662
11663 let merged = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
11664 assert_eq!(merged.partitions.len(), 2);
11665 let mut partition_ids = merged
11666 .partitions
11667 .iter()
11668 .map(|partition| partition.id())
11669 .collect::<Vec<_>>();
11670 partition_ids.sort_unstable();
11671 assert_eq!(partition_ids, vec![0, 1]);
11672
11673 Ok(())
11674 }
11675
11676 #[tokio::test]
11677 async fn test_modern_index_without_deleted_col_has_empty_bitmap() {
11678 let tmpdir = TempObjDir::default();
11682 let store = Arc::new(LanceIndexStore::new(
11683 ObjectStore::local().into(),
11684 tmpdir.clone(),
11685 Arc::new(LanceCache::no_cache()),
11686 ));
11687
11688 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11689 builder.tokens.add("test".to_owned());
11690 builder.posting_lists.push(PostingListBuilder::new(false));
11691 builder.posting_lists[0].add(0, PositionRecorder::Count(1));
11692 builder.docs.append(100, 1);
11693 builder.write(store.as_ref()).await.unwrap();
11694
11695 let metadata = std::collections::HashMap::from_iter(vec![
11698 (
11699 "partitions".to_owned(),
11700 serde_json::to_string(&vec![0u64]).unwrap(),
11701 ),
11702 (
11703 "params".to_owned(),
11704 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
11705 ),
11706 (
11707 TOKEN_SET_FORMAT_KEY.to_owned(),
11708 TokenSetFormat::default().to_string(),
11709 ),
11710 ]);
11711 let mut writer = store
11712 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
11713 .await
11714 .unwrap();
11715 writer.finish_with_metadata(metadata).await.unwrap();
11716
11717 let index = InvertedIndex::load(store, None, &LanceCache::no_cache())
11718 .await
11719 .unwrap();
11720 assert!(
11721 index.deleted_fragments().is_empty(),
11722 "index without deleted_fragments column should have empty bitmap"
11723 );
11724 }
11725
11726 #[tokio::test]
11727 async fn flat_bm25_search_stream_with_metrics_records_elapsed_compute() {
11728 use crate::scalar::inverted::tokenizer::document_tokenizer::TextTokenizer;
11729 use arrow_array::{StringArray, UInt64Array};
11730 use lance_tokenizer::{SimpleTokenizer, TextAnalyzer};
11731
11732 let schema = Arc::new(Schema::new(vec![
11734 ROW_ID_FIELD.clone(),
11735 Field::new("text", DataType::Utf8, false),
11736 ]));
11737 let batch = RecordBatch::try_new(
11738 schema.clone(),
11739 vec![
11740 Arc::new(UInt64Array::from(vec![0u64, 1, 2, 3])),
11741 Arc::new(StringArray::from(vec![
11742 "the quick brown fox",
11743 "lazy dog sleeps",
11744 "the brown fox jumps over",
11745 "completely unrelated text",
11746 ])),
11747 ],
11748 )
11749 .unwrap();
11750
11751 let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
11752 schema.clone(),
11753 stream::iter(vec![Ok(batch)]),
11754 ));
11755
11756 let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
11757 TextAnalyzer::builder(SimpleTokenizer::default()).build(),
11758 ));
11759
11760 let elapsed_compute = Time::default();
11761 let result_stream = flat_bm25_search_stream_with_metrics(
11762 input,
11763 "text".to_string(),
11764 "fox".to_string(),
11765 tokenizer,
11766 None,
11767 100,
11768 Some(elapsed_compute.clone()),
11769 )
11770 .await
11771 .unwrap();
11772
11773 let batches: Vec<_> = result_stream.try_collect().await.unwrap();
11774 assert!(!batches.is_empty(), "expected at least one scored batch");
11775
11776 assert!(
11780 elapsed_compute.value() > 0,
11781 "elapsed_compute should have been populated; got 0"
11782 );
11783 }
11784
11785 #[tokio::test]
11786 async fn flat_bm25_skips_zero_token_documents_from_corpus_stats() {
11787 let schema = Arc::new(Schema::new(vec![
11788 ROW_ID_FIELD.clone(),
11789 Field::new("text", DataType::Utf8, true),
11790 ]));
11791 let batch = RecordBatch::try_new(
11792 schema,
11793 vec![
11794 Arc::new(UInt64Array::from(vec![0_u64, 1, 2, 3, 4, 5])) as ArrayRef,
11795 Arc::new(StringArray::from(vec![
11796 Some(""),
11797 Some(" "),
11798 Some("the"),
11799 Some("overlength"),
11800 None,
11801 Some("hello"),
11802 ])) as ArrayRef,
11803 ],
11804 )
11805 .unwrap();
11806 let params = InvertedIndexParams::new("whitespace".to_string(), Language::English)
11807 .remove_stop_words(true)
11808 .stem(false)
11809 .max_token_length(Some(6));
11810 let query_tokens = Arc::new(Tokens::new(vec!["hello".to_string()], DocType::Text));
11811
11812 let counted_input = tokenize_and_count(
11813 stream::iter(vec![Ok(batch)]),
11814 params.build().unwrap(),
11815 query_tokens.clone(),
11816 1,
11817 None,
11818 )
11819 .await
11820 .unwrap();
11821
11822 assert_eq!(counted_input.num_rows(), 1);
11823 assert_eq!(
11824 counted_input[ROW_ID].as_primitive::<UInt64Type>().values(),
11825 &[5]
11826 );
11827 let scorer = initialize_scorer(None, query_tokens.as_ref(), &counted_input);
11828 let expected_scorer = MemBM25Scorer::new(1, 1, HashMap::from([("hello".to_string(), 1)]));
11829 assert_eq!(scorer.total_tokens, 1);
11830 assert_eq!(scorer.num_docs(), 1);
11831 assert_eq!(scorer.num_docs_containing_token("hello"), 1);
11832 assert_eq!(scorer.avg_doc_length(), expected_scorer.avg_doc_length());
11833 assert_eq!(
11834 scorer.query_weight("hello"),
11835 expected_scorer.query_weight("hello")
11836 );
11837 }
11838
11839 #[tokio::test]
11840 async fn flat_bm25_search_uses_full_document_length_for_normalization() {
11841 let schema = Arc::new(Schema::new(vec![
11842 ROW_ID_FIELD.clone(),
11843 Field::new("text", DataType::Utf8, false),
11844 ]));
11845 let batch = RecordBatch::try_new(
11846 schema.clone(),
11847 vec![
11848 Arc::new(UInt64Array::from(vec![0u64, 1])),
11849 Arc::new(StringArray::from(vec![
11850 "alpha",
11851 "alpha filler filler filler filler filler filler filler filler filler",
11852 ])),
11853 ],
11854 )
11855 .unwrap();
11856
11857 let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
11858 schema.clone(),
11859 stream::iter(vec![Ok(batch)]),
11860 ));
11861 let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
11862 TextAnalyzer::builder(SimpleTokenizer::default()).build(),
11863 ));
11864
11865 let result_stream = flat_bm25_search_stream_with_metrics(
11866 input,
11867 "text".to_string(),
11868 "alpha".to_string(),
11869 tokenizer,
11870 None,
11871 100,
11872 None,
11873 )
11874 .await
11875 .unwrap();
11876 let batches: Vec<_> = result_stream.try_collect().await.unwrap();
11877 let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
11878 let row_ids = scored[ROW_ID].as_primitive::<UInt64Type>();
11879 let scores = scored[SCORE_COL].as_primitive::<Float32Type>();
11880
11881 assert_eq!(row_ids.values(), &[0, 1]);
11882 assert!(
11883 scores.value(0) > scores.value(1),
11884 "same term frequency should score shorter document higher; short={}, long={}",
11885 scores.value(0),
11886 scores.value(1)
11887 );
11888 }
11889
11890 #[tokio::test]
11891 async fn flat_bm25_search_treats_string_lists_as_row_documents() {
11892 let mut docs_builder =
11893 GenericListBuilder::<i32, _>::new(GenericStringBuilder::<i32>::new());
11894 docs_builder.values().append_value("alpha");
11895 docs_builder.values().append_value("alpha beta");
11896 docs_builder.append(true);
11897 docs_builder.values().append_value("beta");
11898 docs_builder.append(true);
11899 docs_builder.append(true);
11900 docs_builder.values().append_null();
11901 docs_builder.append(true);
11902 docs_builder.append(false);
11903
11904 let docs = Arc::new(docs_builder.finish()) as ArrayRef;
11905 let schema = Arc::new(Schema::new(vec![
11906 ROW_ID_FIELD.clone(),
11907 Field::new("text", docs.data_type().clone(), true),
11908 ]));
11909 let batch = RecordBatch::try_new(
11910 schema.clone(),
11911 vec![
11912 Arc::new(UInt64Array::from(vec![0u64, 1, 2, 3, 4])) as ArrayRef,
11913 docs,
11914 ],
11915 )
11916 .unwrap();
11917
11918 let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
11919 schema.clone(),
11920 stream::iter(vec![Ok(batch)]),
11921 ));
11922 let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
11923 TextAnalyzer::builder(SimpleTokenizer::default()).build(),
11924 ));
11925
11926 let result_stream = flat_bm25_search_stream_with_metrics(
11927 input,
11928 "text".to_string(),
11929 "alpha".to_string(),
11930 tokenizer,
11931 None,
11932 100,
11933 None,
11934 )
11935 .await
11936 .unwrap();
11937 let batches: Vec<_> = result_stream.try_collect().await.unwrap();
11938 let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
11939 let row_ids = scored[ROW_ID].as_primitive::<UInt64Type>();
11940
11941 assert_eq!(row_ids.values(), &[0]);
11942 }
11943
11944 #[tokio::test]
11945 async fn flat_bm25_search_code_and_uses_position_groups() {
11946 let schema = Arc::new(Schema::new(vec![
11947 ROW_ID_FIELD.clone(),
11948 Field::new("code", DataType::Utf8, false),
11949 ]));
11950 let batch = RecordBatch::try_new(
11951 schema.clone(),
11952 vec![
11953 Arc::new(UInt64Array::from(vec![0u64, 1, 2, 3])),
11954 Arc::new(StringArray::from(vec![
11955 "get user name",
11956 "getUserName",
11957 "get user",
11958 "username",
11959 ])),
11960 ],
11961 )
11962 .unwrap();
11963
11964 let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
11965 schema.clone(),
11966 stream::iter(vec![Ok(batch)]),
11967 ));
11968 let tokenizer = InvertedIndexParams::code()
11969 .split_identifiers(true)
11970 .build()
11971 .unwrap();
11972
11973 let result_stream = flat_bm25_search_stream_with_metrics_and_operator(
11974 input,
11975 "code".to_string(),
11976 "getUserName".to_string(),
11977 tokenizer,
11978 None,
11979 100,
11980 Operator::And,
11981 None,
11982 )
11983 .await
11984 .unwrap();
11985
11986 let batches: Vec<_> = result_stream.try_collect().await.unwrap();
11987 let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
11988 let mut row_ids = scored[ROW_ID]
11989 .as_primitive::<UInt64Type>()
11990 .values()
11991 .to_vec();
11992 row_ids.sort_unstable();
11993
11994 assert_eq!(row_ids, vec![0, 1]);
11995 }
11996
11997 #[tokio::test]
11998 async fn flat_bm25_search_code_and_counts_repeated_subwords() {
11999 let schema = Arc::new(Schema::new(vec![
12000 ROW_ID_FIELD.clone(),
12001 Field::new("code", DataType::Utf8, false),
12002 ]));
12003 let batch = RecordBatch::try_new(
12004 schema.clone(),
12005 vec![
12006 Arc::new(UInt64Array::from(vec![0u64, 1])),
12007 Arc::new(StringArray::from(vec![
12008 "pub fn edge_flat_generic_return<T>() -> Result<T, EdgeFlatError> where T: TryFrom<String> { todo!() }",
12009 "pub fn edge_flat_generic_return<T>() -> Result<T> { todo!() }",
12010 ])),
12011 ],
12012 )
12013 .unwrap();
12014
12015 let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
12016 schema.clone(),
12017 stream::iter(vec![Ok(batch)]),
12018 ));
12019 let tokenizer = InvertedIndexParams::code().build().unwrap();
12020
12021 let result_stream = flat_bm25_search_stream_with_metrics_and_operator(
12022 input,
12023 "code".to_string(),
12024 "edge_flat_generic_return TryFrom EdgeFlatError Result".to_string(),
12025 tokenizer,
12026 None,
12027 100,
12028 Operator::And,
12029 None,
12030 )
12031 .await
12032 .unwrap();
12033
12034 let batches: Vec<_> = result_stream.try_collect().await.unwrap();
12035 let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
12036 let row_ids = scored[ROW_ID].as_primitive::<UInt64Type>().values();
12037
12038 assert_eq!(row_ids, &[0]);
12039 }
12040
12041 fn posting_entries(posting: &PostingList) -> Vec<(u64, u32)> {
12042 posting.iter().map(|(doc, freq, _)| (doc, freq)).collect()
12043 }
12044
12045 #[tokio::test]
12048 async fn test_posting_list_synthetic_grouping_reads_group_boundaries() {
12049 let tmpdir = TempObjDir::default();
12050 let store = Arc::new(LanceIndexStore::new(
12051 ObjectStore::local().into(),
12052 tmpdir.clone(),
12053 Arc::new(LanceCache::no_cache()),
12054 ));
12055
12056 let num_tokens = runtime_posting_group_tokens() as u32 + 4;
12057 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12058 for t in 0..num_tokens {
12059 builder.tokens.add(format!("t{t}"));
12060 let mut pl = PostingListBuilder::new(false);
12061 pl.add(t, PositionRecorder::Count(1));
12062 builder.posting_lists.push(pl);
12063 builder.docs.append(1000 + t as u64, 1);
12064 }
12065 builder.write(store.as_ref()).await.unwrap();
12066
12067 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
12068 let cache = LanceCache::no_cache();
12069 let posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
12070 assert!(
12071 matches!(
12072 &posting_reader.grouping,
12073 PostingGrouping::SyntheticFixed { .. }
12074 ),
12075 "v2 reader must synthesize runtime posting groups",
12076 );
12077
12078 let metrics = NoOpMetricsCollector;
12079 for token in 0..num_tokens {
12080 let posting = posting_reader
12081 .posting_list(token, false, &metrics)
12082 .await
12083 .unwrap();
12084 assert_eq!(
12085 posting_entries(&posting),
12086 vec![(token as u64, 1)],
12087 "synthetic grouping mismatch for token {token}",
12088 );
12089 assert_eq!(posting.len(), 1, "length mismatch for token {token}");
12090 }
12091 }
12092
12093 #[tokio::test]
12099 async fn test_prewarm_group_keys_match_read_path() {
12100 let tmpdir = TempObjDir::default();
12101 let store = Arc::new(LanceIndexStore::new(
12102 ObjectStore::local().into(),
12103 tmpdir.clone(),
12104 Arc::new(LanceCache::no_cache()),
12105 ));
12106
12107 let num_tokens = runtime_posting_group_tokens() as u32 + 4;
12108 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12109 for t in 0..num_tokens {
12110 builder.tokens.add(format!("t{t}"));
12111 let mut pl = PostingListBuilder::new(false);
12112 pl.add(t, PositionRecorder::Count(1));
12113 builder.posting_lists.push(pl);
12114 builder.docs.append(1000 + t as u64, 1);
12115 }
12116 builder.write(store.as_ref()).await.unwrap();
12117
12118 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
12119 let cache = LanceCache::with_capacity(1 << 20);
12122 let posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
12123 assert!(
12124 matches!(
12125 &posting_reader.grouping,
12126 PostingGrouping::SyntheticFixed { .. }
12127 ),
12128 "v2 reader should use runtime synthetic groups",
12129 );
12130
12131 posting_reader
12132 .prewarm_posting_lists(false, 2)
12133 .await
12134 .unwrap();
12135
12136 for token in 0..num_tokens {
12137 let (start, end) = posting_reader.group_range_for_token(token).unwrap();
12138 assert!(
12139 posting_reader
12140 .index_cache
12141 .get_with_key(&posting_list_group_cache_key(
12142 start,
12143 end,
12144 posting_reader.has_impacts,
12145 ))
12146 .await
12147 .is_some(),
12148 "prewarm did not populate group [{start}, {end}) that the read \
12149 path requests for token {token}",
12150 );
12151 }
12152
12153 let (_, last_end) = posting_reader
12154 .group_range_for_token(num_tokens - 1)
12155 .unwrap();
12156 assert_eq!(
12157 last_end, num_tokens,
12158 "the last group must end at the posting count ({num_tokens})",
12159 );
12160 }
12161
12162 #[tokio::test]
12165 async fn test_empty_partition_has_no_synthetic_groups() {
12166 let tmpdir = TempObjDir::default();
12167 let store = Arc::new(LanceIndexStore::new(
12168 ObjectStore::local().into(),
12169 tmpdir.clone(),
12170 Arc::new(LanceCache::no_cache()),
12171 ));
12172
12173 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12174 builder.write(store.as_ref()).await.unwrap();
12175
12176 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
12177 let posting_reader = PostingListReader::try_new(reader, &LanceCache::no_cache())
12178 .await
12179 .unwrap();
12180 assert!(
12181 matches!(&posting_reader.grouping, PostingGrouping::None),
12182 "reader for an empty partition must not create cache groups",
12183 );
12184 assert!(posting_reader.is_empty());
12185 }
12186
12187 #[tokio::test]
12190 async fn test_large_posting_reads_inside_synthetic_group() {
12191 let tmpdir = TempObjDir::default();
12192 let store = Arc::new(LanceIndexStore::new(
12193 ObjectStore::local().into(),
12194 tmpdir.clone(),
12195 Arc::new(LanceCache::no_cache()),
12196 ));
12197
12198 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12199 let big_docs = (BLOCK_SIZE * 3 + 5) as u32;
12200 builder.tokens.add("big".to_owned());
12201 let mut big = PostingListBuilder::new(false);
12202 for d in 0..big_docs {
12203 big.add(d, PositionRecorder::Count(1));
12204 }
12205 builder.posting_lists.push(big);
12206 for t in 1..5u32 {
12207 builder.tokens.add(format!("t{t}"));
12208 let mut pl = PostingListBuilder::new(false);
12209 pl.add(0, PositionRecorder::Count(1));
12210 builder.posting_lists.push(pl);
12211 }
12212 for d in 0..big_docs as u64 {
12213 builder.docs.append(1000 + d, 1);
12214 }
12215 builder.write(store.as_ref()).await.unwrap();
12216
12217 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
12218 let posting_reader = PostingListReader::try_new(reader, &LanceCache::no_cache())
12219 .await
12220 .unwrap();
12221 let expected_end = runtime_posting_group_tokens().min(5) as u32;
12222
12223 assert_eq!(
12224 posting_reader.group_range_for_token(0),
12225 Some((0, expected_end)),
12226 "runtime synthetic grouping should group by token count, not posting bytes",
12227 );
12228 let big = posting_reader
12229 .posting_list(0, false, &NoOpMetricsCollector)
12230 .await
12231 .unwrap();
12232 assert_eq!(big.len(), big_docs as usize);
12233 let tiny = posting_reader
12235 .posting_list(2, false, &NoOpMetricsCollector)
12236 .await
12237 .unwrap();
12238 assert_eq!(tiny.len(), 1);
12239 }
12240
12241 #[tokio::test]
12245 async fn test_prewarm_synthetic_grouping_populates_group_entries() {
12246 let tmpdir = TempObjDir::default();
12247 let store = Arc::new(LanceIndexStore::new(
12248 ObjectStore::local().into(),
12249 tmpdir.clone(),
12250 Arc::new(LanceCache::no_cache()),
12251 ));
12252
12253 let num_tokens = 3u32;
12254 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12255 for t in 0..num_tokens {
12256 builder.tokens.add(format!("t{t}"));
12257 let mut pl = PostingListBuilder::new(false);
12258 pl.add(t, PositionRecorder::Count(1));
12259 builder.posting_lists.push(pl);
12260 builder.docs.append(1000 + t as u64, 1);
12261 }
12262 builder.write(store.as_ref()).await.unwrap();
12263
12264 let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
12265 let cache = LanceCache::with_capacity(1 << 20);
12266 let posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
12267 assert!(matches!(
12268 &posting_reader.grouping,
12269 PostingGrouping::SyntheticFixed { .. }
12270 ));
12271
12272 posting_reader
12273 .prewarm_posting_lists(false, 2)
12274 .await
12275 .unwrap();
12276
12277 for token_id in 0..num_tokens {
12278 let (start, end) = posting_reader.group_range_for_token(token_id).unwrap();
12279 let group = posting_reader
12280 .index_cache
12281 .get_with_key(&posting_list_group_cache_key(
12282 start,
12283 end,
12284 posting_reader.has_impacts,
12285 ))
12286 .await
12287 .unwrap_or_else(|| {
12288 panic!(
12289 "synthetic prewarm should populate group [{start}, {end}) for token {token_id}"
12290 )
12291 });
12292 assert!(
12293 group.is_packed(),
12294 "no-position synthetic prewarm should insert a packed group"
12295 );
12296 assert!(
12297 posting_reader
12298 .index_cache
12299 .get_with_key(&posting_list_cache_key(
12300 token_id,
12301 posting_reader.has_impacts,
12302 ))
12303 .await
12304 .is_none(),
12305 "synthetic prewarm should not populate per-token entry {token_id}",
12306 );
12307 }
12308 }
12309
12310 #[tokio::test]
12314 async fn test_grouped_bm25_search_correct_and_cache_stable() {
12315 let tmpdir = TempObjDir::default();
12316 let store = Arc::new(LanceIndexStore::new(
12317 ObjectStore::local().into(),
12318 tmpdir.clone(),
12319 Arc::new(LanceCache::no_cache()),
12320 ));
12321
12322 let num_rare = runtime_posting_group_tokens() as u32 + 2;
12326 let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12327 for t in 0..num_rare {
12328 builder.tokens.add(format!("t{t}"));
12329 builder.posting_lists.push(PostingListBuilder::new(false));
12330 }
12331 let common_id = builder.tokens.add("common".to_owned());
12332 builder.posting_lists.push(PostingListBuilder::new(false));
12333 for d in 0..num_rare {
12334 builder.posting_lists[d as usize].add(d, PositionRecorder::Count(1));
12335 builder.posting_lists[common_id as usize].add(d, PositionRecorder::Count(1));
12336 builder.docs.append(1000 + d as u64, 2);
12337 }
12338 builder.write(store.as_ref()).await.unwrap();
12339
12340 let metadata = HashMap::from([
12341 (
12342 "partitions".to_owned(),
12343 serde_json::to_string(&vec![0u64]).unwrap(),
12344 ),
12345 (
12346 "params".to_owned(),
12347 serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
12348 ),
12349 (
12350 TOKEN_SET_FORMAT_KEY.to_owned(),
12351 TokenSetFormat::default().to_string(),
12352 ),
12353 ]);
12354 let mut writer = store
12355 .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
12356 .await
12357 .unwrap();
12358 writer.finish_with_metadata(metadata).await.unwrap();
12359
12360 let cache = Arc::new(LanceCache::with_capacity(1 << 20));
12361 let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12362 .await
12363 .unwrap();
12364
12365 let query = |term: &str| {
12367 let index = index.clone();
12368 let term = term.to_string();
12369 async move {
12370 index
12371 .bm25_search(
12372 Arc::new(Tokens::new(vec![term], DocType::Text)),
12373 Arc::new(FtsSearchParams::new().with_limit(Some(num_rare as usize))),
12374 Operator::Or,
12375 Arc::new(NoFilter),
12376 Arc::new(NoOpMetricsCollector),
12377 None,
12378 )
12379 .await
12380 .unwrap()
12381 }
12382 };
12383
12384 let rare_query_id = num_rare / 2;
12385 let (rare_rows, _) = query(&format!("t{rare_query_id}")).await;
12386 assert_eq!(
12387 rare_rows,
12388 vec![1000 + rare_query_id as u64],
12389 "rare token must map to its single doc",
12390 );
12391
12392 let (cold_rows, cold_scores) = query("common").await;
12394 let (warm_rows, warm_scores) = query("common").await;
12395 assert_eq!(cold_rows.len(), num_rare as usize);
12396 assert_eq!(cold_rows, warm_rows, "warm-cache rows must match cold");
12397 assert_eq!(
12398 cold_scores, warm_scores,
12399 "warm-cache scores must match cold"
12400 );
12401 }
12402
12403 #[tokio::test]
12404 async fn flat_bm25_search_stop_word_query_over_unindexed_rows_returns_empty() {
12405 let schema = Arc::new(Schema::new(vec![
12406 ROW_ID_FIELD.clone(),
12407 Field::new("text", DataType::Utf8, false),
12408 ]));
12409 let batch = RecordBatch::try_new(
12410 schema.clone(),
12411 vec![
12412 Arc::new(UInt64Array::from(vec![0u64, 1, 2])),
12413 Arc::new(StringArray::from(vec![
12414 "the quick brown fox",
12415 "a lazy dog",
12416 "for the win",
12417 ])),
12418 ],
12419 )
12420 .unwrap();
12421
12422 let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
12423 schema.clone(),
12424 stream::iter(vec![Ok(batch)]),
12425 ));
12426
12427 let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
12430 TextAnalyzer::builder(SimpleTokenizer::default())
12431 .filter(StopWordFilter::new(Language::English).unwrap())
12432 .build(),
12433 ));
12434
12435 let result_stream = flat_bm25_search_stream_with_metrics(
12436 input,
12437 "text".to_string(),
12438 "the".to_string(),
12439 tokenizer,
12440 None,
12441 100,
12442 None,
12443 )
12444 .await
12445 .unwrap();
12446
12447 let batches: Vec<_> = result_stream.try_collect().await.unwrap();
12448 let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
12449 assert_eq!(
12450 total_rows, 0,
12451 "a stop-word-only query has no searchable terms and must match nothing"
12452 );
12453 }
12454}