Skip to main content

lance_index/scalar/inverted/
builder.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use super::{
5    InvertedIndexParams,
6    index::*,
7    merger::{Merger, PartitionSource, SizeBasedMerger},
8};
9use crate::scalar::IndexStore;
10use crate::scalar::inverted::json::JsonTextStream;
11use crate::scalar::inverted::lance_tokenizer::DocType;
12use crate::scalar::inverted::tokenizer::lance_tokenizer::LanceTokenizer;
13use crate::scalar::lance_format::LanceIndexStore;
14use crate::vector::graph::OrderedFloat;
15use crate::{progress::IndexBuildProgress, progress::noop_progress};
16use arrow::array::AsArray;
17use arrow::datatypes;
18use arrow_array::{Array, RecordBatch, UInt64Array};
19use arrow_schema::{DataType, Field, Schema, SchemaRef};
20use bitpacking::{BitPacker, BitPacker4x};
21use datafusion::execution::{RecordBatchStream, SendableRecordBatchStream};
22use deepsize::DeepSizeOf;
23use futures::{Stream, StreamExt, TryStreamExt};
24use lance_arrow::json::JSON_EXT_NAME;
25use lance_arrow::{ARROW_EXT_NAME_KEY, iter_str_array};
26use lance_core::cache::LanceCache;
27use lance_core::utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu};
28use lance_core::{Error, ROW_ID, ROW_ID_FIELD, Result};
29use lance_core::{error::LanceOptionExt, utils::tempfile::TempDir};
30use lance_io::object_store::ObjectStore;
31use object_store::path::Path;
32use smallvec::SmallVec;
33use std::collections::HashMap;
34use std::pin::Pin;
35use std::str::FromStr;
36use std::sync::Arc;
37use std::sync::LazyLock;
38use std::task::{Context, Poll};
39use std::{fmt::Debug, sync::atomic::AtomicU64};
40use tracing::instrument;
41
42// the number of elements in each block
43// each block contains 128 row ids and 128 frequencies
44// WARNING: changing this value will break the compatibility with existing indexes
45pub const BLOCK_SIZE: usize = BitPacker4x::BLOCK_LEN;
46
47// the number of shards to split the indexing work,
48// the indexing process would spawn `LANCE_FTS_NUM_SHARDS` workers to build FTS,
49// higher for faster indexing performance, but more memory usage,
50// it's `the number of compute intensive CPUs` by default
51pub static LANCE_FTS_NUM_SHARDS: LazyLock<usize> = LazyLock::new(|| {
52    std::env::var("LANCE_FTS_NUM_SHARDS")
53        .unwrap_or_else(|_| get_num_compute_intensive_cpus().to_string())
54        .parse()
55        .expect("failed to parse LANCE_FTS_NUM_SHARDS")
56});
57// the partition size limit in MiB (uncompressed format)
58// higher for better indexing & query performance, but more memory usage,
59pub static LANCE_FTS_PARTITION_SIZE: LazyLock<u64> = LazyLock::new(|| {
60    std::env::var("LANCE_FTS_PARTITION_SIZE")
61        .unwrap_or_else(|_| "256".to_string())
62        .parse()
63        .expect("failed to parse LANCE_FTS_PARTITION_SIZE")
64});
65// the target size of partition after merging in MiB (uncompressed format)
66pub static LANCE_FTS_TARGET_SIZE: LazyLock<u64> = LazyLock::new(|| {
67    std::env::var("LANCE_FTS_TARGET_SIZE")
68        .unwrap_or_else(|_| "4096".to_string())
69        .parse()
70        .expect("failed to parse LANCE_FTS_TARGET_SIZE")
71});
72
73#[derive(Debug)]
74pub struct InvertedIndexBuilder {
75    params: InvertedIndexParams,
76    pub(crate) partitions: Vec<u64>,
77    new_partitions: Vec<u64>,
78    fragment_mask: Option<u64>,
79    token_set_format: TokenSetFormat,
80    _tmpdir: TempDir,
81    local_store: Arc<dyn IndexStore>,
82    src_store: Arc<dyn IndexStore>,
83    progress: Arc<dyn IndexBuildProgress>,
84}
85
86impl InvertedIndexBuilder {
87    pub fn new(params: InvertedIndexParams) -> Self {
88        Self::new_with_fragment_mask(params, None)
89    }
90
91    pub fn new_with_fragment_mask(params: InvertedIndexParams, fragment_mask: Option<u64>) -> Self {
92        Self::from_existing_index(
93            params,
94            None,
95            Vec::new(),
96            TokenSetFormat::default(),
97            fragment_mask,
98        )
99    }
100
101    /// Creates an InvertedIndexBuilder from existing index with fragment filtering.
102    /// This method is used to create a builder from an existing index while applying
103    /// fragment-based filtering for distributed indexing scenarios.
104    /// fragment_mask Optional mask with fragment_id in high 32 bits for filtering.
105    /// Constructed as `(fragment_id as u64) << 32`.
106    /// When provided, ensures that generated IDs belong to the specified fragment.
107    pub fn from_existing_index(
108        params: InvertedIndexParams,
109        store: Option<Arc<dyn IndexStore>>,
110        partitions: Vec<u64>,
111        token_set_format: TokenSetFormat,
112        fragment_mask: Option<u64>,
113    ) -> Self {
114        let tmpdir = TempDir::default();
115        let local_store = Arc::new(LanceIndexStore::new(
116            ObjectStore::local().into(),
117            tmpdir.obj_path(),
118            Arc::new(LanceCache::no_cache()),
119        ));
120        let src_store = store.unwrap_or_else(|| local_store.clone());
121        Self {
122            params,
123            partitions,
124            new_partitions: Vec::new(),
125            _tmpdir: tmpdir,
126            local_store,
127            src_store,
128            token_set_format,
129            fragment_mask,
130            progress: noop_progress(),
131        }
132    }
133
134    pub fn with_progress(mut self, progress: Arc<dyn IndexBuildProgress>) -> Self {
135        self.progress = progress;
136        self
137    }
138
139    pub async fn update(
140        &mut self,
141        new_data: SendableRecordBatchStream,
142        dest_store: &dyn IndexStore,
143    ) -> Result<()> {
144        let schema = new_data.schema();
145        let doc_col = schema.field(0).name();
146
147        // infer lance_tokenizer based on document type
148        if self.params.lance_tokenizer.is_none() {
149            let schema = new_data.schema();
150            let field = schema.column_with_name(doc_col).expect_ok()?.1;
151            let doc_type = DocType::try_from(field)?;
152            self.params.lance_tokenizer = Some(doc_type.as_ref().to_string());
153        }
154
155        let new_data = document_input(new_data, doc_col)?;
156
157        self.progress
158            .stage_start("tokenize_docs", None, "rows")
159            .await?;
160        self.update_index(new_data).await?;
161        self.progress.stage_complete("tokenize_docs").await?;
162        self.write(dest_store).await?;
163        Ok(())
164    }
165
166    #[instrument(level = "debug", skip_all)]
167    async fn update_index(&mut self, stream: SendableRecordBatchStream) -> Result<()> {
168        let num_workers = *LANCE_FTS_NUM_SHARDS;
169        let tokenizer = self.params.build()?;
170        let with_position = self.params.with_position;
171        let next_id = self.partitions.iter().map(|id| id + 1).max().unwrap_or(0);
172        let id_alloc = Arc::new(AtomicU64::new(next_id));
173        let tokenized_count = Arc::new(AtomicU64::new(0));
174        let (sender, receiver) = async_channel::bounded(num_workers);
175        let mut index_tasks = Vec::with_capacity(num_workers);
176        for _ in 0..num_workers {
177            let store = self.local_store.clone();
178            let tokenizer = tokenizer.clone();
179            let receiver: async_channel::Receiver<RecordBatch> = receiver.clone();
180            let id_alloc = id_alloc.clone();
181            let progress = self.progress.clone();
182            let fragment_mask = self.fragment_mask;
183            let token_set_format = self.token_set_format;
184            let tokenized_count = tokenized_count.clone();
185            let task = tokio::task::spawn(async move {
186                let mut worker = IndexWorker::new(
187                    store,
188                    tokenizer,
189                    with_position,
190                    id_alloc,
191                    fragment_mask,
192                    token_set_format,
193                )
194                .await?;
195                while let Ok(batch) = receiver.recv().await {
196                    let num_rows = batch.num_rows();
197                    worker.process_batch(batch).await?;
198                    let tokenized_count = tokenized_count
199                        .fetch_add(num_rows as u64, std::sync::atomic::Ordering::Relaxed)
200                        + num_rows as u64;
201                    progress
202                        .stage_progress("tokenize_docs", tokenized_count)
203                        .await?;
204                }
205                let partitions = worker.finish().await?;
206                Result::Ok(partitions)
207            });
208            index_tasks.push(task);
209        }
210
211        let sender = Arc::new(sender);
212
213        let mut stream = Box::pin(stream.then({
214            |batch_result| {
215                let sender = sender.clone();
216                async move {
217                    let sender = sender.clone();
218                    let batch = batch_result?;
219                    let num_rows = batch.num_rows();
220                    sender.send(batch).await.expect("failed to send batch");
221                    Result::Ok(num_rows)
222                }
223            }
224        }));
225        log::info!("indexing FTS with {} workers", num_workers);
226
227        let mut last_num_rows = 0;
228        let mut total_num_rows = 0;
229        let start = std::time::Instant::now();
230        while let Some(num_rows) = stream.try_next().await? {
231            total_num_rows += num_rows;
232            if total_num_rows >= last_num_rows + 1_000_000 {
233                log::debug!(
234                    "indexed {} documents, elapsed: {:?}, speed: {}rows/s",
235                    total_num_rows,
236                    start.elapsed(),
237                    total_num_rows as f32 / start.elapsed().as_secs_f32()
238                );
239                last_num_rows = total_num_rows;
240            }
241        }
242        // drop the sender to stop receivers
243        drop(stream);
244        debug_assert_eq!(sender.sender_count(), 1);
245        drop(sender);
246        log::info!("dispatching elapsed: {:?}", start.elapsed());
247
248        // wait for the workers to finish
249        let start = std::time::Instant::now();
250        for index_task in index_tasks {
251            self.new_partitions.extend(index_task.await??);
252        }
253        log::info!("wait workers indexing elapsed: {:?}", start.elapsed());
254        Ok(())
255    }
256
257    pub async fn remap(
258        &mut self,
259        mapping: &HashMap<u64, Option<u64>>,
260        src_store: Arc<dyn IndexStore>,
261        dest_store: &dyn IndexStore,
262    ) -> Result<()> {
263        for part in self.partitions.iter() {
264            let part = InvertedPartition::load(
265                src_store.clone(),
266                *part,
267                None,
268                &LanceCache::no_cache(),
269                self.token_set_format,
270            )
271            .await?;
272            let mut builder = part.into_builder().await?;
273            builder.remap(mapping).await?;
274            builder.write(dest_store).await?;
275        }
276        if self.fragment_mask.is_none() {
277            self.write_metadata(dest_store, &self.partitions).await?;
278        } else {
279            // in distributed mode, the part_temp_metadata is written by the worker
280            for &partition_id in &self.partitions {
281                self.write_part_metadata(dest_store, partition_id).await?;
282            }
283        }
284        Ok(())
285    }
286
287    async fn write_metadata(&self, dest_store: &dyn IndexStore, partitions: &[u64]) -> Result<()> {
288        let metadata = HashMap::from_iter(vec![
289            ("partitions".to_owned(), serde_json::to_string(&partitions)?),
290            ("params".to_owned(), serde_json::to_string(&self.params)?),
291            (
292                TOKEN_SET_FORMAT_KEY.to_owned(),
293                self.token_set_format.to_string(),
294            ),
295        ]);
296        let mut writer = dest_store
297            .new_index_file(METADATA_FILE, Arc::new(Schema::empty()))
298            .await?;
299        writer.finish_with_metadata(metadata).await?;
300        Ok(())
301    }
302
303    /// Write partition metadata file for a single partition
304    ///
305    /// In a distributed environment, each worker node can write partition metadata files for the partitions it processes,
306    /// which are then merged into a final metadata file using the `merge_metadata_files` function.
307    pub(crate) async fn write_part_metadata(
308        &self,
309        dest_store: &dyn IndexStore,
310        partition: u64, // Modify parameter type
311    ) -> Result<()> {
312        let partitions = vec![partition];
313        let metadata = HashMap::from_iter(vec![
314            ("partitions".to_owned(), serde_json::to_string(&partitions)?),
315            ("params".to_owned(), serde_json::to_string(&self.params)?),
316            (
317                TOKEN_SET_FORMAT_KEY.to_owned(),
318                self.token_set_format.to_string(),
319            ),
320        ]);
321        // Use partition ID to generate a unique temporary filename
322        let file_name = part_metadata_file_path(partition);
323        let mut writer = dest_store
324            .new_index_file(&file_name, Arc::new(Schema::empty()))
325            .await?;
326        writer.finish_with_metadata(metadata).await?;
327        Ok(())
328    }
329
330    async fn write_metadata_with_progress(
331        &self,
332        dest_store: &dyn IndexStore,
333        partitions: &[u64],
334    ) -> Result<()> {
335        let total = if self.fragment_mask.is_none() {
336            Some(1)
337        } else {
338            Some(partitions.len() as u64)
339        };
340        self.progress
341            .stage_start("write_metadata", total, "files")
342            .await?;
343        if self.fragment_mask.is_none() {
344            self.write_metadata(dest_store, partitions).await?;
345            self.progress.stage_progress("write_metadata", 1).await?;
346        } else {
347            let mut completed = 0;
348            for &partition_id in partitions {
349                self.write_part_metadata(dest_store, partition_id).await?;
350                completed += 1;
351                self.progress
352                    .stage_progress("write_metadata", completed)
353                    .await?;
354            }
355        }
356        self.progress.stage_complete("write_metadata").await?;
357        Ok(())
358    }
359
360    async fn write(&self, dest_store: &dyn IndexStore) -> Result<()> {
361        if self.params.skip_merge {
362            let mut partitions =
363                Vec::with_capacity(self.partitions.len() + self.new_partitions.len());
364            partitions.extend_from_slice(&self.partitions);
365            partitions.extend_from_slice(&self.new_partitions);
366            partitions.sort_unstable();
367
368            self.progress
369                .stage_start(
370                    "copy_partitions",
371                    Some(partitions.len() as u64),
372                    "partitions",
373                )
374                .await?;
375            let mut copied = 0;
376            for part in self.partitions.iter() {
377                self.src_store
378                    .copy_index_file(&token_file_path(*part), dest_store)
379                    .await?;
380                self.src_store
381                    .copy_index_file(&posting_file_path(*part), dest_store)
382                    .await?;
383                self.src_store
384                    .copy_index_file(&doc_file_path(*part), dest_store)
385                    .await?;
386                copied += 1;
387                self.progress
388                    .stage_progress("copy_partitions", copied)
389                    .await?;
390            }
391            for part in self.new_partitions.iter() {
392                self.local_store
393                    .copy_index_file(&token_file_path(*part), dest_store)
394                    .await?;
395                self.local_store
396                    .copy_index_file(&posting_file_path(*part), dest_store)
397                    .await?;
398                self.local_store
399                    .copy_index_file(&doc_file_path(*part), dest_store)
400                    .await?;
401                copied += 1;
402                self.progress
403                    .stage_progress("copy_partitions", copied)
404                    .await?;
405            }
406            self.progress.stage_complete("copy_partitions").await?;
407
408            self.write_metadata_with_progress(dest_store, &partitions)
409                .await?;
410            return Ok(());
411        }
412
413        let partitions = self
414            .partitions
415            .iter()
416            .map(|part| PartitionSource::new(self.src_store.clone(), *part))
417            .chain(
418                self.new_partitions
419                    .iter()
420                    .map(|part| PartitionSource::new(self.local_store.clone(), *part)),
421            )
422            .collect::<Vec<_>>();
423        self.progress
424            .stage_start(
425                "merge_partitions",
426                Some(partitions.len() as u64),
427                "partitions",
428            )
429            .await?;
430        let mut merger = SizeBasedMerger::new(
431            dest_store,
432            partitions,
433            *LANCE_FTS_TARGET_SIZE << 20,
434            self.token_set_format,
435            self.progress.clone(),
436        );
437        let partitions = merger.merge().await?;
438        self.progress.stage_complete("merge_partitions").await?;
439
440        self.write_metadata_with_progress(dest_store, &partitions)
441            .await?;
442        Ok(())
443    }
444}
445
446impl Default for InvertedIndexBuilder {
447    fn default() -> Self {
448        let params = InvertedIndexParams::default();
449        Self::new(params)
450    }
451}
452
453// builder for single partition
454#[derive(Debug)]
455pub struct InnerBuilder {
456    id: u64,
457    with_position: bool,
458    token_set_format: TokenSetFormat,
459    pub(crate) tokens: TokenSet,
460    pub(crate) posting_lists: Vec<PostingListBuilder>,
461    pub(crate) docs: DocSet,
462}
463
464impl InnerBuilder {
465    pub fn new(id: u64, with_position: bool, token_set_format: TokenSetFormat) -> Self {
466        Self {
467            id,
468            with_position,
469            token_set_format,
470            tokens: TokenSet::default(),
471            posting_lists: Vec::new(),
472            docs: DocSet::default(),
473        }
474    }
475
476    pub fn id(&self) -> u64 {
477        self.id
478    }
479
480    /// Set the token set for this builder.
481    pub fn set_tokens(&mut self, tokens: TokenSet) {
482        self.tokens = tokens;
483    }
484
485    /// Set the document set for this builder.
486    pub fn set_docs(&mut self, docs: DocSet) {
487        self.docs = docs;
488    }
489
490    /// Set the posting lists for this builder.
491    pub fn set_posting_lists(&mut self, posting_lists: Vec<PostingListBuilder>) {
492        self.posting_lists = posting_lists;
493    }
494
495    pub async fn remap(&mut self, mapping: &HashMap<u64, Option<u64>>) -> Result<()> {
496        // for the docs, we need to remove the rows that are removed from the doc set,
497        // and update the row ids of the rows that are updated
498        let removed = self.docs.remap(mapping);
499
500        // for the posting lists, we need to remap the doc ids:
501        // - if the a row is removed, we need to shift the doc ids of the following rows
502        // - if a row is updated (assigned a new row id), we don't need to do anything with the posting lists
503        let mut token_id = 0;
504        let mut removed_token_ids = Vec::new();
505        self.posting_lists.retain_mut(|posting_list| {
506            posting_list.remap(&removed);
507            let keep = !posting_list.is_empty();
508            if !keep {
509                removed_token_ids.push(token_id as u32);
510            }
511            token_id += 1;
512            keep
513        });
514
515        // for the tokens, remap the token ids if any posting list is empty
516        self.tokens.remap(&removed_token_ids);
517
518        Ok(())
519    }
520
521    pub async fn write(&mut self, store: &dyn IndexStore) -> Result<()> {
522        let docs = Arc::new(std::mem::take(&mut self.docs));
523        self.write_posting_lists(store, docs.clone()).await?;
524        self.write_tokens(store).await?;
525        self.write_docs(store, docs).await?;
526        Ok(())
527    }
528
529    #[instrument(level = "debug", skip_all)]
530    async fn write_posting_lists(
531        &mut self,
532        store: &dyn IndexStore,
533        docs: Arc<DocSet>,
534    ) -> Result<()> {
535        let id = self.id;
536        let mut writer = store
537            .new_index_file(
538                &posting_file_path(self.id),
539                inverted_list_schema(self.with_position),
540            )
541            .await?;
542        let posting_lists = std::mem::take(&mut self.posting_lists);
543
544        log::info!(
545            "writing {} posting lists of partition {}, with position {}",
546            posting_lists.len(),
547            id,
548            self.with_position
549        );
550        let schema = inverted_list_schema(self.with_position);
551        let docs_for_batches = docs.clone();
552        let schema_for_batches = schema.clone();
553        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
554        let producer = spawn_cpu(move || {
555            for posting_list in posting_lists {
556                let batch = posting_list
557                    .to_batch_with_docs(&docs_for_batches, schema_for_batches.clone())?;
558                if let Err(err) = tx.send(batch) {
559                    return Err(Error::execution(format!(
560                        "failed to send posting list batch to writer: {err}"
561                    )));
562                }
563            }
564            Result::Ok(())
565        });
566
567        let mut write_duration = std::time::Duration::ZERO;
568        let mut num_posting_lists = 0;
569        while let Some(batch) = rx.recv().await {
570            num_posting_lists += 1;
571            let start = std::time::Instant::now();
572            if let Err(err) = writer.write_record_batch(batch).await {
573                drop(rx);
574                // Wait for producer to stop; preserve the write error as the primary failure.
575                let _ = producer.await;
576                return Err(err);
577            }
578            write_duration += start.elapsed();
579
580            if num_posting_lists % 500_000 == 0 {
581                log::info!(
582                    "wrote {} posting lists of partition {}, writing elapsed: {:?}",
583                    num_posting_lists,
584                    id,
585                    write_duration,
586                );
587            }
588        }
589        drop(rx);
590        producer.await?;
591
592        writer.finish().await?;
593        Ok(())
594    }
595
596    #[instrument(level = "debug", skip_all)]
597    async fn write_tokens(&mut self, store: &dyn IndexStore) -> Result<()> {
598        log::info!("writing tokens of partition {}", self.id);
599        let tokens = std::mem::take(&mut self.tokens);
600        let batch = tokens.to_batch(self.token_set_format)?;
601        let mut writer = store
602            .new_index_file(&token_file_path(self.id), batch.schema())
603            .await?;
604        writer.write_record_batch(batch).await?;
605        writer.finish().await?;
606        Ok(())
607    }
608
609    #[instrument(level = "debug", skip_all)]
610    async fn write_docs(&mut self, store: &dyn IndexStore, docs: Arc<DocSet>) -> Result<()> {
611        log::info!("writing docs of partition {}", self.id);
612        let batch = docs.to_batch()?;
613        let mut writer = store
614            .new_index_file(&doc_file_path(self.id), batch.schema())
615            .await?;
616        writer.write_record_batch(batch).await?;
617        writer.finish().await?;
618        Ok(())
619    }
620}
621
622struct IndexWorker {
623    store: Arc<dyn IndexStore>,
624    tokenizer: Box<dyn LanceTokenizer>,
625    id_alloc: Arc<AtomicU64>,
626    builder: InnerBuilder,
627    partitions: Vec<u64>,
628    schema: SchemaRef,
629    estimated_size: u64,
630    total_doc_length: usize,
631    fragment_mask: Option<u64>,
632    token_set_format: TokenSetFormat,
633    token_occurrences: HashMap<u32, PositionRecorder>,
634    token_ids: Vec<u32>,
635    last_token_count: usize,
636    last_unique_token_count: usize,
637}
638
639impl IndexWorker {
640    async fn new(
641        store: Arc<dyn IndexStore>,
642        tokenizer: Box<dyn LanceTokenizer>,
643        with_position: bool,
644        id_alloc: Arc<AtomicU64>,
645        fragment_mask: Option<u64>,
646        token_set_format: TokenSetFormat,
647    ) -> Result<Self> {
648        let schema = inverted_list_schema(with_position);
649
650        Ok(Self {
651            store,
652            tokenizer,
653            builder: InnerBuilder::new(
654                id_alloc.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
655                    | fragment_mask.unwrap_or(0),
656                with_position,
657                token_set_format,
658            ),
659            partitions: Vec::new(),
660            id_alloc,
661            schema,
662            estimated_size: 0,
663            total_doc_length: 0,
664            fragment_mask,
665            token_set_format,
666            token_occurrences: HashMap::new(),
667            token_ids: Vec::new(),
668            last_token_count: 0,
669            last_unique_token_count: 0,
670        })
671    }
672
673    fn has_position(&self) -> bool {
674        self.schema.column_with_name(POSITION_COL).is_some()
675    }
676
677    async fn process_batch(&mut self, batch: RecordBatch) -> Result<()> {
678        let doc_col = batch.column(0);
679        let doc_iter = iter_str_array(doc_col);
680        let row_id_col = batch[ROW_ID].as_primitive::<datatypes::UInt64Type>();
681        let docs = doc_iter
682            .zip(row_id_col.values().iter())
683            .filter_map(|(doc, row_id)| doc.map(|doc| (doc, *row_id)));
684
685        let with_position = self.has_position();
686        for (doc, row_id) in docs {
687            let mut token_num: u32 = 0;
688            if with_position {
689                if self.token_occurrences.capacity() < self.last_unique_token_count {
690                    self.token_occurrences
691                        .reserve(self.last_unique_token_count - self.token_occurrences.capacity());
692                }
693                self.token_occurrences.clear();
694
695                let mut token_stream = self.tokenizer.token_stream_for_doc(doc);
696                while token_stream.advance() {
697                    let token = token_stream.token_mut();
698                    let token_text = std::mem::take(&mut token.text);
699                    let token_id = self.builder.tokens.add(token_text);
700                    self.token_occurrences
701                        .entry(token_id)
702                        .or_insert_with(|| PositionRecorder::new(true))
703                        .push(token.position as u32);
704                    token_num += 1;
705                }
706            } else {
707                if self.token_ids.capacity() < self.last_token_count {
708                    self.token_ids
709                        .reserve(self.last_token_count - self.token_ids.capacity());
710                }
711                self.token_ids.clear();
712
713                let mut token_stream = self.tokenizer.token_stream_for_doc(doc);
714                while token_stream.advance() {
715                    let token = token_stream.token_mut();
716                    let token_text = std::mem::take(&mut token.text);
717                    let token_id = self.builder.tokens.add(token_text);
718                    self.token_ids.push(token_id);
719                    token_num += 1;
720                }
721            }
722            self.builder
723                .posting_lists
724                .resize_with(self.builder.tokens.len(), || {
725                    PostingListBuilder::new(with_position)
726                });
727            let doc_id = self.builder.docs.append(row_id, token_num);
728            self.total_doc_length += doc.len();
729
730            if with_position {
731                let unique_tokens = self.token_occurrences.len();
732                for (token_id, term_positions) in self.token_occurrences.drain() {
733                    let posting_list = &mut self.builder.posting_lists[token_id as usize];
734
735                    let old_size = posting_list.size();
736                    posting_list.add(doc_id, term_positions);
737                    let new_size = posting_list.size();
738                    self.estimated_size += new_size - old_size;
739                }
740                self.last_unique_token_count = unique_tokens;
741            } else if token_num > 0 {
742                self.token_ids.sort_unstable();
743                let mut iter = self.token_ids.iter();
744                let mut current = *iter.next().unwrap();
745                let mut count = 1u32;
746                for &token_id in iter {
747                    if token_id == current {
748                        count += 1;
749                        continue;
750                    }
751
752                    let posting_list = &mut self.builder.posting_lists[current as usize];
753                    let old_size = posting_list.size();
754                    posting_list.add(doc_id, PositionRecorder::Count(count));
755                    let new_size = posting_list.size();
756                    self.estimated_size += new_size - old_size;
757
758                    current = token_id;
759                    count = 1;
760                }
761                let posting_list = &mut self.builder.posting_lists[current as usize];
762                let old_size = posting_list.size();
763                posting_list.add(doc_id, PositionRecorder::Count(count));
764                let new_size = posting_list.size();
765                self.estimated_size += new_size - old_size;
766            }
767            self.last_token_count = token_num as usize;
768
769            if self.builder.docs.len() as u32 == u32::MAX
770                || self.estimated_size >= *LANCE_FTS_PARTITION_SIZE << 20
771            {
772                self.flush().await?;
773            }
774        }
775
776        Ok(())
777    }
778
779    #[instrument(level = "debug", skip_all)]
780    async fn flush(&mut self) -> Result<()> {
781        if self.builder.tokens.is_empty() {
782            return Ok(());
783        }
784
785        log::info!(
786            "flushing posting lists, estimated size: {} MiB",
787            self.estimated_size / (1024 * 1024)
788        );
789        self.estimated_size = 0;
790        let with_position = self.has_position();
791        let mut builder = std::mem::replace(
792            &mut self.builder,
793            InnerBuilder::new(
794                self.id_alloc
795                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
796                    | self.fragment_mask.unwrap_or(0),
797                with_position,
798                self.token_set_format,
799            ),
800        );
801        builder.write(self.store.as_ref()).await?;
802        self.partitions.push(builder.id());
803        Ok(())
804    }
805
806    async fn finish(mut self) -> Result<Vec<u64>> {
807        if !self.builder.tokens.is_empty() {
808            self.flush().await?;
809        }
810        Ok(self.partitions)
811    }
812}
813
814#[derive(Debug, Clone)]
815pub enum PositionRecorder {
816    Position(SmallVec<[u32; 4]>),
817    Count(u32),
818}
819
820impl PositionRecorder {
821    fn new(with_position: bool) -> Self {
822        if with_position {
823            Self::Position(SmallVec::new())
824        } else {
825            Self::Count(0)
826        }
827    }
828
829    fn push(&mut self, position: u32) {
830        match self {
831            Self::Position(positions) => positions.push(position),
832            Self::Count(count) => *count += 1,
833        }
834    }
835
836    pub fn len(&self) -> u32 {
837        match self {
838            Self::Position(positions) => positions.len() as u32,
839            Self::Count(count) => *count,
840        }
841    }
842
843    pub fn is_empty(&self) -> bool {
844        self.len() == 0
845    }
846
847    pub fn into_vec(self) -> Vec<u32> {
848        match self {
849            Self::Position(positions) => positions.into_vec(),
850            Self::Count(_) => vec![0],
851        }
852    }
853}
854
855#[derive(Debug, Eq, PartialEq, Clone, DeepSizeOf)]
856pub struct ScoredDoc {
857    pub row_id: u64,
858    pub score: OrderedFloat,
859}
860
861impl ScoredDoc {
862    pub fn new(row_id: u64, score: f32) -> Self {
863        Self {
864            row_id,
865            score: OrderedFloat(score),
866        }
867    }
868}
869
870impl PartialOrd for ScoredDoc {
871    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
872        Some(self.cmp(other))
873    }
874}
875
876impl Ord for ScoredDoc {
877    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
878        self.score.cmp(&other.score)
879    }
880}
881
882pub fn legacy_inverted_list_schema(with_position: bool) -> SchemaRef {
883    let mut fields = vec![
884        arrow_schema::Field::new(ROW_ID, arrow_schema::DataType::UInt64, false),
885        arrow_schema::Field::new(FREQUENCY_COL, arrow_schema::DataType::Float32, false),
886    ];
887    if with_position {
888        fields.push(arrow_schema::Field::new(
889            POSITION_COL,
890            arrow_schema::DataType::List(Arc::new(arrow_schema::Field::new(
891                "item",
892                arrow_schema::DataType::Int32,
893                true,
894            ))),
895            false,
896        ));
897    }
898    Arc::new(arrow_schema::Schema::new(fields))
899}
900
901pub fn inverted_list_schema(with_position: bool) -> SchemaRef {
902    let mut fields = vec![
903        // we compress the posting lists (including row ids and frequencies),
904        // and store the compressed posting lists, so it's a large binary array
905        arrow_schema::Field::new(
906            POSTING_COL,
907            datatypes::DataType::List(Arc::new(Field::new(
908                "item",
909                datatypes::DataType::LargeBinary,
910                true,
911            ))),
912            false,
913        ),
914        arrow_schema::Field::new(MAX_SCORE_COL, datatypes::DataType::Float32, false),
915        arrow_schema::Field::new(LENGTH_COL, datatypes::DataType::UInt32, false),
916    ];
917    if with_position {
918        fields.push(arrow_schema::Field::new(
919            POSITION_COL,
920            arrow_schema::DataType::List(Arc::new(arrow_schema::Field::new(
921                "item",
922                arrow_schema::DataType::List(Arc::new(arrow_schema::Field::new(
923                    "item",
924                    arrow_schema::DataType::LargeBinary,
925                    true,
926                ))),
927                true,
928            ))),
929            false,
930        ));
931    }
932    Arc::new(arrow_schema::Schema::new(fields))
933}
934
935/// Flatten the string list stream into a string stream
936pub struct FlattenStream {
937    /// Inner record batch stream with 2 columns:
938    /// 1. doc_col: List(Utf8) or List(LargeUtf8)
939    /// 2. row_id_col: UInt64
940    inner: SendableRecordBatchStream,
941    field_type: DataType,
942    data_type: DataType,
943}
944
945impl FlattenStream {
946    pub fn new(input: SendableRecordBatchStream) -> Self {
947        let schema = input.schema();
948        let field = schema.field(0);
949        let data_type = match field.data_type() {
950            DataType::List(f) if matches!(f.data_type(), DataType::Utf8) => DataType::Utf8,
951            DataType::List(f) if matches!(f.data_type(), DataType::LargeUtf8) => {
952                DataType::LargeUtf8
953            }
954            DataType::LargeList(f) if matches!(f.data_type(), DataType::Utf8) => DataType::Utf8,
955            DataType::LargeList(f) if matches!(f.data_type(), DataType::LargeUtf8) => {
956                DataType::LargeUtf8
957            }
958            _ => panic!(
959                "expect data type List(Utf8) or List(LargeUtf8) but got {:?}",
960                field.data_type()
961            ),
962        };
963        Self {
964            inner: input,
965            field_type: field.data_type().clone(),
966            data_type,
967        }
968    }
969}
970
971impl Stream for FlattenStream {
972    type Item = datafusion_common::Result<RecordBatch>;
973
974    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
975        match Pin::new(&mut self.inner).poll_next(cx) {
976            Poll::Ready(Some(Ok(batch))) => {
977                let doc_col = batch.column(0);
978                let batch = match self.field_type {
979                    DataType::List(_) => flatten_string_list::<i32>(&batch, doc_col).map_err(|e| {
980                        datafusion_common::error::DataFusionError::Execution(format!(
981                            "flatten string list error: {}",
982                            e
983                        ))
984                    }),
985                    DataType::LargeList(_) => {
986                        flatten_string_list::<i64>(&batch, doc_col).map_err(|e| {
987                            datafusion_common::error::DataFusionError::Execution(format!(
988                                "flatten string list error: {}",
989                                e
990                            ))
991                        })
992                    }
993                    _ => unreachable!(
994                        "expect data type List or LargeList but got {:?}",
995                        self.field_type
996                    ),
997                };
998                Poll::Ready(Some(batch))
999            }
1000            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
1001            Poll::Ready(None) => Poll::Ready(None),
1002            Poll::Pending => Poll::Pending,
1003        }
1004    }
1005}
1006
1007impl RecordBatchStream for FlattenStream {
1008    fn schema(&self) -> SchemaRef {
1009        let schema = Schema::new(vec![
1010            Field::new(
1011                self.inner.schema().field(0).name(),
1012                self.data_type.clone(),
1013                true,
1014            ),
1015            ROW_ID_FIELD.clone(),
1016        ]);
1017
1018        Arc::new(schema)
1019    }
1020}
1021
1022fn flatten_string_list<Offset: arrow::array::OffsetSizeTrait>(
1023    batch: &RecordBatch,
1024    doc_col: &Arc<dyn Array>,
1025) -> Result<RecordBatch> {
1026    let docs = doc_col.as_list::<Offset>();
1027    let row_ids = batch[ROW_ID].as_primitive::<datatypes::UInt64Type>();
1028
1029    let row_ids = row_ids
1030        .values()
1031        .iter()
1032        .zip(docs.iter())
1033        .flat_map(|(row_id, doc)| std::iter::repeat_n(*row_id, doc.map(|d| d.len()).unwrap_or(0)));
1034
1035    let row_ids = Arc::new(UInt64Array::from_iter_values(row_ids));
1036    let docs = match docs.value_type() {
1037        datatypes::DataType::Utf8 | datatypes::DataType::LargeUtf8 => docs.values().clone(),
1038        _ => {
1039            return Err(Error::index(format!(
1040                "expect data type String or LargeString but got {}",
1041                docs.value_type()
1042            )));
1043        }
1044    };
1045
1046    let schema = Schema::new(vec![
1047        Field::new(
1048            batch.schema().field(0).name(),
1049            docs.data_type().clone(),
1050            true,
1051        ),
1052        ROW_ID_FIELD.clone(),
1053    ]);
1054    let batch = RecordBatch::try_new(Arc::new(schema), vec![docs, row_ids])?;
1055    Ok(batch)
1056}
1057
1058pub(crate) fn token_file_path(partition_id: u64) -> String {
1059    format!("part_{}_{}", partition_id, TOKENS_FILE)
1060}
1061
1062pub(crate) fn posting_file_path(partition_id: u64) -> String {
1063    format!("part_{}_{}", partition_id, INVERT_LIST_FILE)
1064}
1065
1066pub(crate) fn doc_file_path(partition_id: u64) -> String {
1067    format!("part_{}_{}", partition_id, DOCS_FILE)
1068}
1069
1070pub(crate) fn part_metadata_file_path(partition_id: u64) -> String {
1071    format!("part_{}_{}", partition_id, METADATA_FILE)
1072}
1073
1074pub async fn merge_index_files(
1075    object_store: &ObjectStore,
1076    index_dir: &Path,
1077    store: Arc<dyn IndexStore>,
1078) -> Result<()> {
1079    // List all partition metadata files in the index directory
1080    let part_metadata_files = list_metadata_files(object_store, index_dir).await?;
1081
1082    // Call merge_metadata_files function for inverted index
1083    merge_metadata_files(store, &part_metadata_files).await
1084}
1085
1086/// List and filter metadata files from the index directory
1087/// Returns partition metadata files
1088async fn list_metadata_files(object_store: &ObjectStore, index_dir: &Path) -> Result<Vec<String>> {
1089    // List all partition metadata files in the index directory
1090    let mut part_metadata_files = Vec::new();
1091    let mut list_stream = object_store.list(Some(index_dir.clone()));
1092
1093    while let Some(item) = list_stream.next().await {
1094        match item {
1095            Ok(meta) => {
1096                let file_name = meta.location.filename().unwrap_or_default();
1097                // Filter files matching the pattern part_*_metadata.lance
1098                if file_name.starts_with("part_") && file_name.ends_with("_metadata.lance") {
1099                    part_metadata_files.push(file_name.to_string());
1100                }
1101            }
1102            Err(_) => continue,
1103        }
1104    }
1105
1106    if part_metadata_files.is_empty() {
1107        return Err(Error::invalid_input_source(
1108            format!(
1109                "No partition metadata files found in index directory: {}",
1110                index_dir
1111            )
1112            .into(),
1113        ));
1114    }
1115
1116    Ok(part_metadata_files)
1117}
1118
1119/// Merge partition metadata files with partition ID remapping to sequential IDs starting from 0
1120async fn merge_metadata_files(
1121    store: Arc<dyn IndexStore>,
1122    part_metadata_files: &[String],
1123) -> Result<()> {
1124    // Collect all partition IDs and params
1125    let mut all_partitions = Vec::new();
1126    let mut params = None;
1127    let mut token_set_format = None;
1128
1129    for file_name in part_metadata_files {
1130        let reader = store.open_index_file(file_name).await?;
1131        let metadata = &reader.schema().metadata;
1132
1133        let partitions_str = metadata.get("partitions").ok_or(Error::index(format!(
1134            "partitions not found in {}",
1135            file_name
1136        )))?;
1137
1138        let partition_ids: Vec<u64> = serde_json::from_str(partitions_str)
1139            .map_err(|e| Error::index(format!("Failed to parse partitions: {}", e)))?;
1140
1141        all_partitions.extend(partition_ids);
1142
1143        if params.is_none() {
1144            let params_str = metadata
1145                .get("params")
1146                .ok_or(Error::index(format!("params not found in {}", file_name)))?;
1147            params = Some(
1148                serde_json::from_str::<InvertedIndexParams>(params_str)
1149                    .map_err(|e| Error::index(format!("Failed to parse params: {}", e)))?,
1150            );
1151        }
1152
1153        if token_set_format.is_none()
1154            && let Some(name) = metadata.get(TOKEN_SET_FORMAT_KEY)
1155        {
1156            token_set_format = Some(TokenSetFormat::from_str(name)?);
1157        }
1158    }
1159
1160    // Create ID mapping: sorted original IDs -> 0,1,2...
1161    let mut sorted_ids = all_partitions.clone();
1162    sorted_ids.sort();
1163    sorted_ids.dedup();
1164
1165    let id_mapping: HashMap<u64, u64> = sorted_ids
1166        .iter()
1167        .enumerate()
1168        .map(|(new_id, &old_id)| (old_id, new_id as u64))
1169        .collect();
1170
1171    // Safe rename partition files using temporary files to avoid overwrite
1172    let timestamp = std::time::SystemTime::now()
1173        .duration_since(std::time::UNIX_EPOCH)
1174        .unwrap()
1175        .as_secs();
1176
1177    // Phase 1: Move files to temporary locations
1178    let mut temp_files: Vec<(String, String, String)> = Vec::new(); // (temp_path, old_path, final_path)
1179
1180    for (&old_id, &new_id) in &id_mapping {
1181        if old_id != new_id {
1182            for suffix in [TOKENS_FILE, INVERT_LIST_FILE, DOCS_FILE] {
1183                let old_path = format!("part_{}_{}", old_id, suffix);
1184                let new_path = format!("part_{}_{}", new_id, suffix);
1185                let temp_path = format!("temp_{}_{}", timestamp, old_path);
1186
1187                // Move to temporary location first to avoid overwrite
1188                if let Err(e) = store.rename_index_file(&old_path, &temp_path).await {
1189                    // Rollback phase 1: restore files from temp locations
1190                    for (temp_name, old_name, _) in temp_files.iter().rev() {
1191                        let _ = store.rename_index_file(temp_name, old_name).await;
1192                    }
1193                    return Err(Error::index(format!(
1194                        "Failed to move {} to temp {}: {}",
1195                        old_path, temp_path, e
1196                    )));
1197                }
1198                temp_files.push((temp_path, old_path, new_path));
1199            }
1200        }
1201    }
1202
1203    // Phase 2: Move from temporary to final locations
1204    let mut completed_renames: Vec<(String, String)> = Vec::new(); // (final_path, temp_path)
1205
1206    for (temp_path, _old_path, final_path) in &temp_files {
1207        if let Err(e) = store.rename_index_file(temp_path, final_path).await {
1208            // Rollback phase 2: restore completed renames and remaining temps
1209            for (final_name, temp_name) in completed_renames.iter().rev() {
1210                let _ = store.rename_index_file(final_name, temp_name).await;
1211            }
1212            // Restore remaining temp files to original locations
1213            for (temp_name, orig_name, _) in temp_files.iter() {
1214                if !completed_renames.iter().any(|(_, t)| t == temp_name) {
1215                    let _ = store.rename_index_file(temp_name, orig_name).await;
1216                }
1217            }
1218            return Err(Error::index(format!(
1219                "Failed to rename {} to {}: {}",
1220                temp_path, final_path, e
1221            )));
1222        }
1223        completed_renames.push((final_path.clone(), temp_path.clone()));
1224    }
1225
1226    // Write merged metadata with remapped IDs
1227    let remapped_partitions: Vec<u64> = (0..id_mapping.len() as u64).collect();
1228    let params = params.unwrap_or_default();
1229    let token_set_format = token_set_format.unwrap_or(TokenSetFormat::Arrow);
1230    let builder = InvertedIndexBuilder::from_existing_index(
1231        params,
1232        None,
1233        remapped_partitions.clone(),
1234        token_set_format,
1235        None,
1236    );
1237    builder
1238        .write_metadata(&*store, &remapped_partitions)
1239        .await?;
1240
1241    // Cleanup partition metadata files
1242    for file_name in part_metadata_files {
1243        if file_name.starts_with("part_") && file_name.ends_with("_metadata.lance") {
1244            let _ = store.delete_index_file(file_name).await;
1245        }
1246    }
1247
1248    Ok(())
1249}
1250
1251/// Convert input stream into a stream of documents.
1252///
1253/// The input stream must be one of:
1254/// 1. Document in Utf8 or LargeUtf8 format.
1255/// 2. Document in List(Utf8) or List(LargeUtf8) format.
1256/// 3. Json document in LargeBinary format.
1257pub fn document_input(
1258    input: SendableRecordBatchStream,
1259    column: &str,
1260) -> Result<SendableRecordBatchStream> {
1261    let schema = input.schema();
1262    let field = schema.column_with_name(column).expect_ok()?.1;
1263    match field.data_type() {
1264        DataType::Utf8 | DataType::LargeUtf8 => Ok(input),
1265        DataType::List(field) | DataType::LargeList(field)
1266            if matches!(field.data_type(), DataType::Utf8 | DataType::LargeUtf8) =>
1267        {
1268            Ok(Box::pin(FlattenStream::new(input)))
1269        }
1270        DataType::LargeBinary => match field.metadata().get(ARROW_EXT_NAME_KEY) {
1271            Some(name) if name.as_str() == JSON_EXT_NAME => {
1272                Ok(Box::pin(JsonTextStream::new(input, column.to_string())))
1273            }
1274            _ => Err(Error::invalid_input_source(
1275                format!("column {} is not json", column).into(),
1276            )),
1277        },
1278        _ => Err(Error::invalid_input_source(
1279            format!(
1280                "column {} has type {}, is not utf8, large utf8 type/list, or large binary",
1281                column,
1282                field.data_type()
1283            )
1284            .into(),
1285        )),
1286    }
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291    use super::*;
1292    use crate::metrics::NoOpMetricsCollector;
1293    use crate::progress::IndexBuildProgress;
1294    use crate::scalar::{IndexReader, IndexWriter};
1295    use arrow_array::{RecordBatch, StringArray, UInt64Array};
1296    use arrow_schema::{DataType, Field, Schema};
1297    use async_trait::async_trait;
1298    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
1299    use futures::stream;
1300    use lance_core::ROW_ID;
1301    use lance_core::cache::LanceCache;
1302    use lance_core::utils::tempfile::TempDir;
1303    use std::any::Any;
1304    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
1305    use tokio::sync::Mutex;
1306
1307    fn make_doc_batch(doc: &str, row_id: u64) -> RecordBatch {
1308        let schema = Arc::new(Schema::new(vec![
1309            Field::new("doc", DataType::Utf8, true),
1310            Field::new(ROW_ID, DataType::UInt64, false),
1311        ]));
1312        let docs = Arc::new(StringArray::from(vec![Some(doc)]));
1313        let row_ids = Arc::new(UInt64Array::from(vec![row_id]));
1314        RecordBatch::try_new(schema, vec![docs, row_ids]).unwrap()
1315    }
1316
1317    #[derive(Debug, Default)]
1318    struct CountingStore {
1319        write_count: Arc<AtomicUsize>,
1320    }
1321
1322    impl CountingStore {
1323        fn new() -> Self {
1324            Self {
1325                write_count: Arc::new(AtomicUsize::new(0)),
1326            }
1327        }
1328
1329        fn write_count(&self) -> usize {
1330            self.write_count.load(Ordering::SeqCst)
1331        }
1332    }
1333
1334    impl DeepSizeOf for CountingStore {
1335        fn deep_size_of_children(&self, _context: &mut deepsize::Context) -> usize {
1336            0
1337        }
1338    }
1339
1340    #[derive(Debug)]
1341    struct CountingWriter {
1342        write_count: Arc<AtomicUsize>,
1343    }
1344
1345    #[async_trait]
1346    impl IndexWriter for CountingWriter {
1347        async fn write_record_batch(&mut self, _batch: RecordBatch) -> Result<u64> {
1348            Ok(self.write_count.fetch_add(1, Ordering::SeqCst) as u64)
1349        }
1350
1351        async fn finish(&mut self) -> Result<()> {
1352            Ok(())
1353        }
1354
1355        async fn finish_with_metadata(&mut self, _metadata: HashMap<String, String>) -> Result<()> {
1356            Ok(())
1357        }
1358    }
1359
1360    #[async_trait]
1361    impl IndexStore for CountingStore {
1362        fn as_any(&self) -> &dyn Any {
1363            self
1364        }
1365
1366        fn io_parallelism(&self) -> usize {
1367            1
1368        }
1369
1370        async fn new_index_file(
1371            &self,
1372            _name: &str,
1373            _schema: Arc<Schema>,
1374        ) -> Result<Box<dyn IndexWriter>> {
1375            Ok(Box::new(CountingWriter {
1376                write_count: self.write_count.clone(),
1377            }))
1378        }
1379
1380        async fn open_index_file(&self, _name: &str) -> Result<Arc<dyn IndexReader>> {
1381            Err(Error::not_supported(
1382                "CountingStore does not support reading",
1383            ))
1384        }
1385
1386        async fn copy_index_file(&self, _name: &str, _dest_store: &dyn IndexStore) -> Result<()> {
1387            Err(Error::not_supported(
1388                "CountingStore does not support copying",
1389            ))
1390        }
1391
1392        async fn rename_index_file(&self, _name: &str, _new_name: &str) -> Result<()> {
1393            Err(Error::not_supported(
1394                "CountingStore does not support renaming",
1395            ))
1396        }
1397
1398        async fn delete_index_file(&self, _name: &str) -> Result<()> {
1399            Err(Error::not_supported(
1400                "CountingStore does not support deleting",
1401            ))
1402        }
1403    }
1404
1405    #[tokio::test]
1406    async fn test_write_posting_lists_writes_each_batch() -> Result<()> {
1407        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
1408        for doc_id in 0..3u64 {
1409            builder.docs.append(doc_id, 1);
1410        }
1411
1412        for doc_id in 0..3u32 {
1413            let mut posting_list = PostingListBuilder::new(false);
1414            posting_list.add(doc_id, PositionRecorder::Count(1));
1415            builder.posting_lists.push(posting_list);
1416        }
1417
1418        let store = CountingStore::new();
1419        let docs = Arc::new(std::mem::take(&mut builder.docs));
1420        builder.write_posting_lists(&store, docs).await?;
1421
1422        assert_eq!(store.write_count(), 3);
1423        Ok(())
1424    }
1425
1426    #[tokio::test]
1427    async fn test_skip_merge_writes_partitions_as_is() -> Result<()> {
1428        let src_dir = TempDir::default();
1429        let dest_dir = TempDir::default();
1430        let src_store = Arc::new(LanceIndexStore::new(
1431            ObjectStore::local().into(),
1432            src_dir.obj_path(),
1433            Arc::new(LanceCache::no_cache()),
1434        ));
1435        let dest_store = Arc::new(LanceIndexStore::new(
1436            ObjectStore::local().into(),
1437            dest_dir.obj_path(),
1438            Arc::new(LanceCache::no_cache()),
1439        ));
1440
1441        let params = InvertedIndexParams::default();
1442        let tokenizer = params.build()?;
1443        let token_set_format = TokenSetFormat::default();
1444        let id_alloc = Arc::new(AtomicU64::new(0));
1445
1446        let mut worker1 = IndexWorker::new(
1447            src_store.clone(),
1448            tokenizer.clone(),
1449            params.with_position,
1450            id_alloc.clone(),
1451            None,
1452            token_set_format,
1453        )
1454        .await?;
1455        worker1
1456            .process_batch(make_doc_batch("hello world", 0))
1457            .await?;
1458        let mut partitions = worker1.finish().await?;
1459
1460        let mut worker2 = IndexWorker::new(
1461            src_store.clone(),
1462            tokenizer.clone(),
1463            params.with_position,
1464            id_alloc.clone(),
1465            None,
1466            token_set_format,
1467        )
1468        .await?;
1469        worker2
1470            .process_batch(make_doc_batch("goodbye world", 1))
1471            .await?;
1472        partitions.extend(worker2.finish().await?);
1473        partitions.sort_unstable();
1474        assert_eq!(partitions.len(), 2);
1475        assert_ne!(partitions[0], partitions[1]);
1476
1477        let builder = InvertedIndexBuilder::from_existing_index(
1478            InvertedIndexParams::default().skip_merge(true),
1479            Some(src_store.clone()),
1480            partitions.clone(),
1481            token_set_format,
1482            None,
1483        );
1484        builder.write(dest_store.as_ref()).await?;
1485
1486        let metadata_reader = dest_store.open_index_file(METADATA_FILE).await?;
1487        let metadata = &metadata_reader.schema().metadata;
1488        let partitions_str = metadata
1489            .get("partitions")
1490            .expect("partitions missing from metadata");
1491        let written_partitions: Vec<u64> = serde_json::from_str(partitions_str).unwrap();
1492        assert_eq!(written_partitions, partitions);
1493
1494        for id in &partitions {
1495            dest_store.open_index_file(&token_file_path(*id)).await?;
1496            dest_store.open_index_file(&posting_file_path(*id)).await?;
1497            dest_store.open_index_file(&doc_file_path(*id)).await?;
1498        }
1499
1500        Ok(())
1501    }
1502
1503    #[tokio::test]
1504    async fn test_inverted_index_without_positions_tracks_frequency() -> Result<()> {
1505        let index_dir = TempDir::default();
1506        let store = Arc::new(LanceIndexStore::new(
1507            ObjectStore::local().into(),
1508            index_dir.obj_path(),
1509            Arc::new(LanceCache::no_cache()),
1510        ));
1511
1512        let schema = Arc::new(Schema::new(vec![
1513            Field::new("doc", DataType::Utf8, true),
1514            Field::new(ROW_ID, DataType::UInt64, false),
1515        ]));
1516        let docs = Arc::new(StringArray::from(vec![Some("hello hello world")]));
1517        let row_ids = Arc::new(UInt64Array::from(vec![0u64]));
1518        let batch = RecordBatch::try_new(schema.clone(), vec![docs, row_ids])?;
1519        let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)]));
1520        let stream = Box::pin(stream);
1521
1522        let params = InvertedIndexParams::new(
1523            "whitespace".to_string(),
1524            tantivy::tokenizer::Language::English,
1525        )
1526        .with_position(false)
1527        .remove_stop_words(false)
1528        .stem(false)
1529        .max_token_length(None);
1530
1531        let mut builder = InvertedIndexBuilder::new(params);
1532        builder.update(stream, store.as_ref()).await?;
1533
1534        let index = InvertedIndex::load(store, None, &LanceCache::no_cache()).await?;
1535        assert_eq!(index.partitions.len(), 1);
1536        let partition = &index.partitions[0];
1537        let token_id = partition.tokens.get("hello").unwrap();
1538        let posting = partition
1539            .inverted_list
1540            .posting_list(token_id, false, &NoOpMetricsCollector)
1541            .await?;
1542
1543        let mut iter = posting.iter();
1544        let (doc_id, freq, positions) = iter.next().unwrap();
1545        assert_eq!(doc_id, 0);
1546        assert_eq!(freq, 2);
1547        assert!(positions.is_none());
1548        assert!(iter.next().is_none());
1549
1550        Ok(())
1551    }
1552
1553    #[derive(Debug, Default)]
1554    struct RecordingProgress {
1555        events: Mutex<Vec<(String, String, u64)>>,
1556    }
1557
1558    #[async_trait]
1559    impl IndexBuildProgress for RecordingProgress {
1560        async fn stage_start(&self, stage: &str, total: Option<u64>, _unit: &str) -> Result<()> {
1561            self.events.lock().await.push((
1562                "start".to_string(),
1563                stage.to_string(),
1564                total.unwrap_or(0),
1565            ));
1566            Ok(())
1567        }
1568
1569        async fn stage_progress(&self, stage: &str, completed: u64) -> Result<()> {
1570            self.events
1571                .lock()
1572                .await
1573                .push(("progress".to_string(), stage.to_string(), completed));
1574            Ok(())
1575        }
1576
1577        async fn stage_complete(&self, stage: &str) -> Result<()> {
1578            self.events
1579                .lock()
1580                .await
1581                .push(("complete".to_string(), stage.to_string(), 0));
1582            Ok(())
1583        }
1584    }
1585
1586    #[tokio::test]
1587    async fn test_builder_reports_progress_stages() -> Result<()> {
1588        let index_dir = TempDir::default();
1589        let store = Arc::new(LanceIndexStore::new(
1590            ObjectStore::local().into(),
1591            index_dir.obj_path(),
1592            Arc::new(LanceCache::no_cache()),
1593        ));
1594
1595        let batch1 = make_doc_batch("hello world", 0);
1596        let batch2 = make_doc_batch("goodbye world", 1);
1597        let total_rows = 2u64;
1598        let stream = RecordBatchStreamAdapter::new(
1599            batch1.schema(),
1600            stream::iter(vec![Ok(batch1), Ok(batch2)]),
1601        );
1602        let stream = Box::pin(stream);
1603
1604        let progress = Arc::new(RecordingProgress::default());
1605        let mut builder =
1606            InvertedIndexBuilder::new(InvertedIndexParams::default().skip_merge(true))
1607                .with_progress(progress.clone());
1608        builder.update(stream, store.as_ref()).await?;
1609
1610        let events = progress.events.lock().await.clone();
1611        let tags = events
1612            .iter()
1613            .map(|(kind, stage, _)| format!("{kind}:{stage}"))
1614            .collect::<Vec<_>>();
1615        let tokenize_progress = events
1616            .iter()
1617            .filter_map(|(kind, stage, completed)| {
1618                if kind == "progress" && stage == "tokenize_docs" {
1619                    Some(*completed)
1620                } else {
1621                    None
1622                }
1623            })
1624            .collect::<Vec<_>>();
1625
1626        let tokenize_start = tags
1627            .iter()
1628            .position(|e| e == "start:tokenize_docs")
1629            .expect("missing tokenize_docs start");
1630        let tokenize_complete = tags
1631            .iter()
1632            .position(|e| e == "complete:tokenize_docs")
1633            .expect("missing tokenize_docs complete");
1634        let copy_start = tags
1635            .iter()
1636            .position(|e| e == "start:copy_partitions")
1637            .expect("missing copy_partitions start");
1638        let copy_complete = tags
1639            .iter()
1640            .position(|e| e == "complete:copy_partitions")
1641            .expect("missing copy_partitions complete");
1642        let metadata_start = tags
1643            .iter()
1644            .position(|e| e == "start:write_metadata")
1645            .expect("missing write_metadata start");
1646        let metadata_complete = tags
1647            .iter()
1648            .position(|e| e == "complete:write_metadata")
1649            .expect("missing write_metadata complete");
1650
1651        assert!(tokenize_start < tokenize_complete);
1652        assert!(tokenize_complete < copy_start);
1653        assert!(copy_start < copy_complete);
1654        assert!(copy_complete < metadata_start);
1655        assert!(metadata_start < metadata_complete);
1656
1657        assert!(
1658            tags.iter().any(|e| e == "progress:tokenize_docs"),
1659            "expected progress callback for tokenize_docs"
1660        );
1661        assert!(
1662            tokenize_progress.len() >= 2,
1663            "expected at least two progress callbacks for tokenize_docs, got {tokenize_progress:?}"
1664        );
1665        assert_eq!(
1666            tokenize_progress.iter().copied().max().unwrap_or_default(),
1667            total_rows,
1668            "expected tokenize_docs progress to reach all rows"
1669        );
1670        assert!(
1671            tags.iter().any(|e| e == "progress:copy_partitions"),
1672            "expected progress callback for copy_partitions"
1673        );
1674        assert!(
1675            tags.iter().any(|e| e == "progress:write_metadata"),
1676            "expected progress callback for write_metadata"
1677        );
1678        assert!(
1679            !tags.iter().any(|e| e == "start:merge_partitions"),
1680            "merge_partitions should not run in skip_merge mode"
1681        );
1682
1683        Ok(())
1684    }
1685}