Skip to main content

lance_index/scalar/
ngram.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use lance_core::utils::row_addr_remap::RowAddrRemap;
5use std::any::Any;
6use std::collections::BTreeMap;
7use std::iter::once;
8use std::pin::Pin;
9use std::time::Instant;
10use std::{
11    collections::{HashMap, HashSet},
12    sync::Arc,
13};
14
15use super::lance_format::LanceIndexStore;
16use super::{
17    AnyQuery, BuiltinIndexType, IndexFile, IndexReader, IndexStore, IndexWriter, MetricsCollector,
18    ScalarIndex, ScalarIndexParams, SearchResult, TextQuery,
19};
20use crate::frag_reuse::FragReuseIndex;
21use crate::metrics::NoOpMetricsCollector;
22use crate::pbold;
23use crate::scalar::expression::{ScalarQueryParser, TextQueryParser};
24use crate::scalar::registry::{
25    BasicTrainer, DefaultTrainingRequest, ScalarIndexPlugin, TrainingCriteria, TrainingOrdering,
26    TrainingRequest, VALUE_COLUMN_NAME,
27};
28use crate::scalar::{CreatedIndex, RowIdRemapper, UpdateCriteria};
29use crate::{Index, IndexType};
30use arrow::array::{AsArray, UInt32Builder};
31use arrow::buffer::{Buffer, OffsetBuffer, ScalarBuffer};
32use arrow::datatypes::{UInt32Type, UInt64Type};
33use arrow_array::{BinaryArray, RecordBatch, UInt32Array};
34use arrow_schema::{DataType, Field, Schema, SchemaRef};
35use async_trait::async_trait;
36use datafusion::execution::SendableRecordBatchStream;
37use futures::{FutureExt, Stream, StreamExt, TryStreamExt, stream};
38use lance_arrow::iter_str_array;
39use lance_core::cache::{CacheKey, CacheKeySchema, KeyBuilder, LanceCache, WeakLanceCache};
40use lance_core::deepsize::DeepSizeOf;
41use lance_core::error::LanceOptionExt;
42use lance_core::utils::address::RowAddress;
43use lance_core::utils::tempfile::TempDir;
44use lance_core::utils::tokio::get_num_compute_intensive_cpus;
45use lance_core::utils::tracing::{IO_TYPE_LOAD_SCALAR_PART, TRACE_IO_EVENTS};
46use lance_core::{Error, ROW_ID, Result};
47use lance_io::object_store::ObjectStore;
48use lance_select::{RowAddrTreeMap, RowSetOps};
49use lance_tokenizer::{
50    AlphaNumOnlyFilter, AsciiFoldingFilter, LowerCaser, NgramTokenizer, RawTokenizer, TextAnalyzer,
51};
52use log::info;
53use roaring::{RoaringBitmap, RoaringTreemap};
54use serde::Serialize;
55use tracing::instrument;
56
57mod ngram_regex;
58pub(crate) use ngram_regex::regex_can_use_index;
59
60const TOKENS_COL: &str = "tokens";
61const POSTING_LIST_COL: &str = "posting_list";
62pub const POSTINGS_FILENAME: &str = "ngram_postings.lance";
63const NGRAM_INDEX_VERSION: u32 = 0;
64
65/// An i32-offset Binary array can hold at most i32::MAX bytes of values in total,
66/// so a spill state whose serialized posting lists exceed that must be written as
67/// multiple record batches (same approach as the bitmap index).  Leave headroom.
68const MAX_POSTING_LIST_BATCH_BYTES: usize = i32::MAX as usize - 1024 * 1024;
69const POSTING_LIST_STREAM_BATCH_ROWS: usize = 64;
70
71use std::sync::LazyLock;
72
73pub static TOKENS_FIELD: LazyLock<Field> =
74    LazyLock::new(|| Field::new(TOKENS_COL, DataType::UInt32, true));
75pub static POSTINGS_FIELD: LazyLock<Field> = LazyLock::new(|| {
76    Field::new(POSTING_LIST_COL, DataType::Binary, false).with_metadata(HashMap::from([(
77        lance_encoding::constants::COMPRESSION_META_KEY.to_string(),
78        "none".to_string(),
79    )]))
80});
81pub static POSTINGS_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
82    Arc::new(Schema::new(vec![
83        TOKENS_FIELD.clone(),
84        POSTINGS_FIELD.clone(),
85    ]))
86});
87pub static TEXT_PREPPER: LazyLock<TextAnalyzer> = LazyLock::new(|| {
88    TextAnalyzer::builder(RawTokenizer::default())
89        .filter(LowerCaser)
90        .filter(AsciiFoldingFilter)
91        .build()
92});
93/// Currently we ALWAYS use trigrams with ascii folding and lower casing.  We may want to make this configurable in the future.
94pub static NGRAM_TOKENIZER: LazyLock<TextAnalyzer> = LazyLock::new(|| {
95    TextAnalyzer::builder(NgramTokenizer::all_ngrams(3, 3).unwrap())
96        .filter(AlphaNumOnlyFilter)
97        .build()
98});
99
100// Helper function to apply a function to each token in a text
101fn tokenize_visitor(tokenizer: &TextAnalyzer, text: &str, mut visitor: impl FnMut(&String)) {
102    // The token_stream method is mutable.  As far as I can tell this is to enforce exclusivity and not
103    // true mutability.  For example, the object returned by `token_stream` has thread-local state but
104    // it is reset each time `token_stream` is called.
105    //
106    // However, I don't see this documented anywhere and I'm not sure about relying on it.  For now, we
107    // make a clone as that seems to be the safer option.  All the tokenizers we use here should be trivially
108    // cloneable (although it requires a heap allocation so may be worth investigating in the future)
109    let mut prepper = TEXT_PREPPER.clone();
110    let mut tokenizer = tokenizer.clone();
111    let mut raw_stream = prepper.token_stream(text);
112    while raw_stream.advance() {
113        let mut token_stream = tokenizer.token_stream(&raw_stream.token().text);
114        while token_stream.advance() {
115            visitor(&token_stream.token().text);
116        }
117    }
118}
119
120const ALPHA_SPAN: usize = 37;
121const MAX_TOKEN: usize = ALPHA_SPAN.pow(2) + ALPHA_SPAN;
122const MIN_TOKEN: usize = 0;
123const NGRAM_N: usize = 3;
124
125// Convert an ngram (string) to a token (u32).  This helps avoid heap allocations
126// and it makes it easier to partition the tokens for shuffling
127//
128// There are 36 alphanumeric values and we add 1 for the NULL token giving us 37^3
129// potential tokens.
130//
131// "" => 0
132// "?" => 37^2 * ?
133// "?$" => 37^2 * ? + 37 * $
134// "?$#" => 37^2 * ? + 37 * $ + #
135// ...
136//
137// The ?,$,# represent the position in the alphabet (+1 to distinguish from NULL)
138//
139// Small strings get the larger multipliers because those ngrams are
140// less likely to be unique and will have larger bitmaps.  We want to
141// spread those out.
142//
143// NOTE: Today we hard-code trigrams and we do not include 1-grams or 2-grams so this
144// function is more general than it needs to be...just in case.
145fn ngram_to_token(ngram: &str, ngram_length: usize) -> u32 {
146    let mut token = 0;
147    // Empty string will get 0
148    for (idx, byte) in ngram.bytes().enumerate() {
149        let pos = if byte <= b'9' {
150            byte - b'0'
151        } else if byte <= b'z' {
152            byte - b'a' + 10
153        } else {
154            unreachable!()
155        } + 1;
156        debug_assert!(pos < ALPHA_SPAN as u8);
157        let mult = ALPHA_SPAN.pow(ngram_length as u32 - idx as u32 - 1) as u32;
158        token += pos as u32 * mult;
159    }
160    token
161}
162
163/// Basic stats about an ngram index
164#[derive(Serialize)]
165struct NGramStatistics {
166    num_ngrams: usize,
167}
168
169/// The row ids that contain a given ngram
170#[derive(Debug)]
171pub struct NGramPostingList {
172    bitmap: RoaringTreemap,
173}
174
175impl DeepSizeOf for NGramPostingList {
176    fn deep_size_of_children(&self, _: &mut lance_core::deepsize::Context) -> usize {
177        self.bitmap.serialized_size()
178    }
179}
180
181// Cache key implementation for type-safe cache access
182#[derive(Debug, Clone)]
183pub struct NGramPostingListKey {
184    pub row_offset: u32,
185}
186
187impl CacheKey for NGramPostingListKey {
188    type ValueType = NGramPostingList;
189
190    fn key(&self) -> std::borrow::Cow<'_, str> {
191        format!("posting-list-{}", self.row_offset).into()
192    }
193
194    fn type_name() -> &'static str {
195        "NGramPostingList"
196    }
197
198    fn schema() -> CacheKeySchema {
199        CacheKeySchema::new("lance.scalar.ngram-posting-list-key", 1)
200    }
201
202    fn write_key(&self, builder: &mut KeyBuilder) {
203        builder.write_u32(self.row_offset);
204    }
205}
206
207impl NGramPostingList {
208    fn try_from_batch(
209        batch: RecordBatch,
210        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
211    ) -> Result<Self> {
212        let bitmap_bytes = batch.column(0).as_binary::<i32>().value(0);
213        let mut bitmap = RoaringTreemap::deserialize_from(bitmap_bytes)
214            .map_err(|e| Error::internal(format!("Error deserializing ngram list: {}", e)))?;
215        if let Some(frag_reuse_index_ref) = frag_reuse_index.as_ref() {
216            bitmap = frag_reuse_index_ref.remap_row_ids_roaring_tree_map(&bitmap);
217        }
218        Ok(Self { bitmap })
219    }
220
221    fn intersect<'a>(lists: impl IntoIterator<Item = &'a Self>) -> RoaringTreemap {
222        let mut iter = lists.into_iter();
223        let mut result = iter
224            .next()
225            .map(|list| list.bitmap.clone())
226            .unwrap_or_default();
227        for list in iter {
228            result &= &list.bitmap;
229        }
230        result
231    }
232}
233
234/// Reads on-demand ngram posting lists from storage (and stores them in a cache)
235struct NGramPostingListReader {
236    reader: Arc<dyn IndexReader>,
237    frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
238    index_cache: WeakLanceCache,
239}
240
241impl DeepSizeOf for NGramPostingListReader {
242    fn deep_size_of_children(&self, _: &mut lance_core::deepsize::Context) -> usize {
243        0
244    }
245}
246
247impl std::fmt::Debug for NGramPostingListReader {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        f.debug_struct("NGramListReader").finish()
250    }
251}
252
253impl NGramPostingListReader {
254    #[instrument(level = "debug", skip(self, metrics))]
255    pub async fn ngram_list(
256        &self,
257        row_offset: u32,
258        metrics: &dyn MetricsCollector,
259    ) -> Result<Arc<NGramPostingList>> {
260        let result = self.index_cache.get_or_insert_with_key_hit(NGramPostingListKey { row_offset }, || async move {
261            metrics.record_part_load();
262                tracing::info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_SCALAR_PART, index_type="ngram", part_id=row_offset);
263                let batch = self
264                    .reader
265                    .read_range(
266                        row_offset as usize..row_offset as usize + 1,
267                        Some(&[POSTING_LIST_COL]),
268                    )
269                    .await?;
270                NGramPostingList::try_from_batch(batch, self.frag_reuse_index.clone())
271        }).await;
272        match &result {
273            Ok((_, true)) => metrics.record_index_cache_hit(),
274            _ => metrics.record_index_cache_miss(),
275        }
276        result.map(|(v, _)| v)
277    }
278}
279
280/// An ngram index
281///
282/// At a high level this is an inverted index that maps ngrams (small fixed size substrings) to the
283/// row ids that contain them.
284///
285/// As a simple example consider a 1-gram index.  It would basically be a mapping from
286/// each letter to the row ids that contain that letter.  Then, if the user searches for
287/// "cat", the index would look up the row ids for "c", "a", and "t", and return the intersection
288/// of those row ids because only rows have at least one c, a, and t could possible contain "cat".
289///
290/// This is an in-exact index, similar to a bloom filter.  It can return false positives and a
291/// recheck step is needed to confirm the results.
292///
293/// Note that it cannot return false negatives.
294pub struct NGramIndex {
295    /// The mapping from tokens to row offsets
296    tokens: HashMap<u32, u32>,
297    /// The reader for the posting lists
298    list_reader: Arc<NGramPostingListReader>,
299    /// The tokenizer used to tokenize text.  Note: not all tokenizers can be used with this index.  For
300    /// example, a stemming tokenizer would not work well because "dozing" would stem to "doze" and if the
301    /// search term is "zing" it would not match.  As a result, this tokenizer is not as configurable as the
302    /// tokenizers used in an inverted index.
303    tokenizer: TextAnalyzer,
304    io_parallelism: usize,
305    /// The store that owns the index
306    store: Arc<dyn IndexStore>,
307}
308
309impl std::fmt::Debug for NGramIndex {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        f.debug_struct("NGramIndex")
312            .field("tokens", &self.tokens)
313            .field("list_reader", &self.list_reader)
314            .finish()
315    }
316}
317
318impl DeepSizeOf for NGramIndex {
319    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
320        self.tokens.deep_size_of_children(context)
321    }
322}
323
324impl NGramIndex {
325    async fn from_store(
326        store: Arc<dyn IndexStore>,
327        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
328        index_cache: &LanceCache,
329    ) -> Result<Self> {
330        let tokens = store.open_index_file(POSTINGS_FILENAME).await?;
331        let tokens = tokens
332            .read_range(0..tokens.num_rows(), Some(&[TOKENS_COL]))
333            .await?;
334
335        let tokens_map = HashMap::from_iter(
336            tokens
337                .column(0)
338                .as_primitive::<UInt32Type>()
339                .values()
340                .iter()
341                .copied()
342                .enumerate()
343                .map(|(idx, token)| (token, idx as u32)),
344        );
345
346        let posting_reader = Arc::new(NGramPostingListReader {
347            reader: store.open_index_file(POSTINGS_FILENAME).await?,
348            frag_reuse_index,
349            index_cache: WeakLanceCache::from(index_cache),
350        });
351
352        Ok(Self {
353            io_parallelism: store.io_parallelism(),
354            tokens: tokens_map,
355            list_reader: posting_reader,
356            tokenizer: NGRAM_TOKENIZER.clone(),
357            store,
358        })
359    }
360
361    fn remap_state(
362        &self,
363        state: NGramIndexSpillState,
364        mapping: &RowAddrRemap,
365    ) -> Result<Vec<RecordBatch>> {
366        let bitmaps = state
367            .bitmaps
368            .into_iter()
369            .map(|posting_list| {
370                RoaringTreemap::from_iter(posting_list.into_iter().filter_map(|row_id| {
371                    match mapping.get(row_id) {
372                        Some(Some(new_row_id)) => Some(new_row_id),
373                        Some(None) => None,
374                        None => Some(row_id),
375                    }
376                }))
377            })
378            .collect();
379
380        NGramIndexSpillState {
381            tokens: state.tokens,
382            bitmaps,
383        }
384        .try_into_batches()
385    }
386
387    async fn load(
388        store: Arc<dyn IndexStore>,
389        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
390        index_cache: &LanceCache,
391    ) -> Result<Arc<Self>>
392    where
393        Self: Sized,
394    {
395        Ok(Arc::new(
396            Self::from_store(store, frag_reuse_index, index_cache).await?,
397        ))
398    }
399
400    /// Merge several built NGram segments (and optional new data) into a single
401    /// canonical segment in `dest_store`, unioning their posting lists by token
402    /// without rescanning the dataset.
403    pub async fn merge_segments(
404        segment_stores: &[Arc<dyn IndexStore>],
405        new_data: Option<SendableRecordBatchStream>,
406        dest_store: &dyn IndexStore,
407        old_data_filters: &[Option<super::OldIndexDataFilter>],
408        frag_reuse_index: Option<Arc<FragReuseIndex>>,
409    ) -> Result<CreatedIndex> {
410        let mut builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default())?;
411        // Pure consolidation has no new rows, so skip `train` (and its
412        // worker-per-partition tokenization pipeline) entirely.
413        let new_data_spills = match new_data {
414            Some(new_data) => builder.train(new_data).await?,
415            None => Vec::new(),
416        };
417        let file = builder
418            .merge_indices(
419                new_data_spills,
420                segment_stores,
421                old_data_filters,
422                frag_reuse_index,
423                dest_store,
424            )
425            .await?;
426        Ok(CreatedIndex {
427            index_details: prost_types::Any::from_msg(&pbold::NGramIndexDetails::default())?,
428            index_version: NGRAM_INDEX_VERSION,
429            files: vec![file],
430        })
431    }
432}
433
434#[async_trait]
435impl Index for NGramIndex {
436    fn as_any(&self) -> &dyn Any {
437        self
438    }
439
440    fn as_index(self: Arc<Self>) -> Arc<dyn Index> {
441        self
442    }
443
444    fn statistics(&self) -> Result<serde_json::Value> {
445        let ngram_stats = NGramStatistics {
446            num_ngrams: self.tokens.len(),
447        };
448        serde_json::to_value(ngram_stats)
449            .map_err(|e| Error::internal(format!("Error serializing statistics: {}", e)))
450    }
451
452    async fn prewarm(&self) -> Result<()> {
453        // TODO: NGram index can pre-warm by loading all posting lists into memory
454        Ok(())
455    }
456
457    fn index_type(&self) -> IndexType {
458        IndexType::NGram
459    }
460
461    async fn calculate_included_frags(&self) -> Result<RoaringBitmap> {
462        let mut frag_ids = RoaringBitmap::new();
463        for row_offset in self.tokens.values() {
464            let list = self
465                .list_reader
466                .ngram_list(*row_offset, &NoOpMetricsCollector)
467                .await?;
468            frag_ids.extend(
469                list.bitmap
470                    .iter()
471                    .map(|row_addr| RowAddress::from(row_addr).fragment_id()),
472            );
473        }
474        Ok(frag_ids)
475    }
476}
477
478#[async_trait]
479impl ScalarIndex for NGramIndex {
480    async fn search(
481        &self,
482        query: &dyn AnyQuery,
483        metrics: &dyn MetricsCollector,
484    ) -> Result<SearchResult> {
485        let query = query
486            .as_any()
487            .downcast_ref::<TextQuery>()
488            .ok_or_else(|| Error::invalid_input_source("Query is not a TextQuery".into()))?;
489        match query {
490            TextQuery::StringContains(substr) => {
491                if substr.len() < NGRAM_N {
492                    // We know nothing on short searches, need to recheck all
493                    return Ok(SearchResult::at_least(RowAddrTreeMap::new()));
494                }
495
496                let mut row_offsets = Vec::with_capacity(substr.len() * 3);
497                let mut missing = false;
498                tokenize_visitor(&self.tokenizer, substr, |ngram| {
499                    let token = ngram_to_token(ngram, NGRAM_N);
500                    if let Some(row_offset) = self.tokens.get(&token) {
501                        row_offsets.push(*row_offset);
502                    } else {
503                        missing = true;
504                    }
505                });
506                // At least one token was missing, so we know there are zero results
507                if missing {
508                    return Ok(SearchResult::exact(RowAddrTreeMap::new()));
509                }
510                let posting_lists = futures::stream::iter(
511                    row_offsets
512                        .into_iter()
513                        .map(|row_offset| self.list_reader.ngram_list(row_offset, metrics)),
514                )
515                .buffer_unordered(self.io_parallelism)
516                .try_collect::<Vec<_>>()
517                .await?;
518                metrics.record_comparisons(posting_lists.len());
519                let list_refs = posting_lists.iter().map(|list| list.as_ref());
520                let row_ids = NGramPostingList::intersect(list_refs);
521                Ok(SearchResult::at_most(RowAddrTreeMap::from(row_ids)))
522            }
523            TextQuery::Regex(pattern) => {
524                let trigram_query = ngram_regex::regex_to_trigram_query(pattern);
525                match &trigram_query {
526                    // No usable trigram structure (e.g. `a.b`, `.*`): the index
527                    // cannot prune, so every row must be rechecked.
528                    ngram_regex::TrigramQuery::All => {
529                        Ok(SearchResult::at_least(RowAddrTreeMap::new()))
530                    }
531                    // The pattern is provably unsatisfiable.
532                    ngram_regex::TrigramQuery::None => {
533                        Ok(SearchResult::exact(RowAddrTreeMap::new()))
534                    }
535                    _ => {
536                        let mut tokens = HashSet::new();
537                        ngram_regex::collect_tokens(&trigram_query, &mut tokens);
538                        // Fetch the posting list for every trigram the condition
539                        // references; a token absent from the index contributes
540                        // an empty list, which `eval_trigram_query` handles.
541                        let present = tokens.into_iter().filter_map(|token| {
542                            self.tokens.get(&token).map(|offset| (token, *offset))
543                        });
544                        let lists = futures::stream::iter(present.map(|(token, offset)| {
545                            self.list_reader
546                                .ngram_list(offset, metrics)
547                                .map(move |result| result.map(|list| (token, list)))
548                        }))
549                        .buffer_unordered(self.io_parallelism)
550                        .try_collect::<Vec<(u32, Arc<NGramPostingList>)>>()
551                        .await?;
552                        metrics.record_comparisons(lists.len());
553                        let bitmaps: HashMap<u32, RoaringTreemap> = lists
554                            .into_iter()
555                            .map(|(token, list)| (token, list.bitmap.clone()))
556                            .collect();
557                        let row_ids = ngram_regex::eval_trigram_query(&trigram_query, &bitmaps);
558                        Ok(SearchResult::at_most(RowAddrTreeMap::from(row_ids)))
559                    }
560                }
561            }
562        }
563    }
564
565    fn can_remap(&self) -> bool {
566        true
567    }
568
569    async fn remap(
570        &self,
571        mapping: &RowAddrRemap,
572        dest_store: &dyn IndexStore,
573    ) -> Result<CreatedIndex> {
574        let reader = self.store.open_index_file(POSTINGS_FILENAME).await?;
575        let mut writer = dest_store
576            .new_index_file(POSTINGS_FILENAME, POSTINGS_SCHEMA.clone())
577            .await?;
578
579        let mut spill_stream =
580            NGramIndexBuilder::stream_spill_reader(reader, MAX_POSTING_LIST_BATCH_BYTES)?;
581        while let Some(state) = spill_stream.try_next().await? {
582            for batch in self.remap_state(state, mapping)? {
583                writer.write_record_batch(batch).await?;
584            }
585        }
586
587        let file = writer.finish().await?;
588
589        Ok(CreatedIndex {
590            index_details: prost_types::Any::from_msg(&pbold::NGramIndexDetails::default())?,
591            index_version: NGRAM_INDEX_VERSION,
592            files: vec![file],
593        })
594    }
595
596    async fn update(
597        &self,
598        new_data: SendableRecordBatchStream,
599        dest_store: &dyn IndexStore,
600        _old_data_filter: Option<super::OldIndexDataFilter>,
601    ) -> Result<CreatedIndex> {
602        let mut builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default())?;
603        let spill_files = builder.train(new_data).await?;
604
605        let file = builder
606            .write_index(dest_store, spill_files, Some(self.store.clone()))
607            .await?;
608
609        Ok(CreatedIndex {
610            index_details: prost_types::Any::from_msg(&pbold::NGramIndexDetails::default())?,
611            index_version: NGRAM_INDEX_VERSION,
612            files: vec![file],
613        })
614    }
615
616    fn update_criteria(&self) -> UpdateCriteria {
617        UpdateCriteria::only_new_data(TrainingCriteria::new(TrainingOrdering::None).with_row_id())
618    }
619
620    fn derive_index_params(&self) -> Result<ScalarIndexParams> {
621        Ok(ScalarIndexParams::for_builtin(BuiltinIndexType::NGram))
622    }
623}
624
625#[derive(Debug, Clone)]
626pub struct NGramIndexBuilderOptions {
627    tokens_per_spill: usize,
628}
629
630// A higher value will use more RAM.  A lower value will have to do more spilling
631static DEFAULT_TOKENS_PER_SPILL: LazyLock<usize> = LazyLock::new(|| {
632    std::env::var("LANCE_NGRAM_TOKENS_PER_SPILL")
633        .unwrap_or_else(|_| "1000000000".to_string())
634        .parse()
635        .expect("failed to parse LANCE_NGRAM_TOKENS_PER_SPILL")
636});
637// How many partitions to use for shuffling out the work.  We slightly
638// over-allocate this since the amount of work per-partition is not uniform.
639//
640// Increasing this may increase the performance but it could increase RAM (since we will spill less often)
641// and could hurt performance (since there will be more files at the end for the final spill)
642static DEFAULT_NUM_PARTITIONS: LazyLock<usize> = LazyLock::new(|| {
643    std::env::var("LANCE_NGRAM_NUM_PARTITIONS")
644        .map(|s| s.parse().expect("failed to parse LANCE_NGRAM_PARALLELISM"))
645        .unwrap_or((get_num_compute_intensive_cpus() * 4).max(128))
646});
647// Just enough so that tokenizing is faster than I/O
648static DEFAULT_TOKENIZE_PARALLELISM: LazyLock<usize> = LazyLock::new(|| {
649    std::env::var("LANCE_NGRAM_TOKENIZE_PARALLELISM")
650        .map(|s| {
651            s.parse()
652                .expect("failed to parse LANCE_NGRAM_TOKENIZE_PARALLELISM")
653        })
654        .unwrap_or(8)
655});
656
657impl Default for NGramIndexBuilderOptions {
658    fn default() -> Self {
659        Self {
660            tokens_per_spill: *DEFAULT_TOKENS_PER_SPILL,
661        }
662    }
663}
664
665// An ordered list of tokens and bitmaps
666//
667// The `tokens` list is ordered by token value.  This makes it easier to merge spill files.
668struct NGramIndexSpillState {
669    tokens: UInt32Array,
670    bitmaps: Vec<RoaringTreemap>,
671}
672
673struct NGramIndexSpillStateBuilder {
674    tokens: UInt32Builder,
675    bitmaps: Vec<RoaringTreemap>,
676    serialized_bytes: usize,
677}
678
679impl NGramIndexSpillStateBuilder {
680    fn new() -> Self {
681        Self {
682            tokens: UInt32Builder::with_capacity(0),
683            bitmaps: Vec::new(),
684            serialized_bytes: 0,
685        }
686    }
687
688    fn is_empty(&self) -> bool {
689        self.bitmaps.is_empty()
690    }
691
692    fn len(&self) -> usize {
693        self.bitmaps.len()
694    }
695
696    fn push(
697        &mut self,
698        token: u32,
699        bitmap: RoaringTreemap,
700        max_batch_bytes: usize,
701    ) -> Result<Option<NGramIndexSpillState>> {
702        let posting_size = bitmap.serialized_size();
703        if posting_size > max_batch_bytes {
704            return Err(Error::invalid_input(format!(
705                "posting list for ngram token {} serializes to {} bytes, which exceeds the {} bytes that fit in a single binary array",
706                token, posting_size, max_batch_bytes,
707            )));
708        }
709
710        let new_size = self
711            .serialized_bytes
712            .checked_add(posting_size)
713            .ok_or_else(|| {
714                Error::invalid_input(format!(
715                    "posting list byte size overflowed while adding ngram token {}",
716                    token
717                ))
718            })?;
719        let full_state = if !self.is_empty() && new_size > max_batch_bytes {
720            Some(self.finish())
721        } else {
722            None
723        };
724
725        self.tokens.append_value(token);
726        self.bitmaps.push(bitmap);
727        self.serialized_bytes = posting_size
728            .checked_add(if full_state.is_some() {
729                0
730            } else {
731                self.serialized_bytes
732            })
733            .ok_or_else(|| {
734                Error::invalid_input(format!(
735                    "posting list byte size overflowed while adding ngram token {}",
736                    token
737                ))
738            })?;
739
740        Ok(full_state)
741    }
742
743    fn finish(&mut self) -> NGramIndexSpillState {
744        self.serialized_bytes = 0;
745        NGramIndexSpillState {
746            tokens: std::mem::replace(&mut self.tokens, UInt32Builder::with_capacity(0)).finish(),
747            bitmaps: std::mem::take(&mut self.bitmaps),
748        }
749    }
750}
751
752impl NGramIndexSpillState {
753    fn try_from_batch(batch: RecordBatch) -> Result<Self> {
754        let tokens = batch
755            .column_by_name(TOKENS_COL)
756            .expect_ok()?
757            .as_primitive::<UInt32Type>()
758            .clone();
759        let postings = batch
760            .column_by_name(POSTING_LIST_COL)
761            .expect_ok()?
762            .as_binary::<i32>();
763
764        let bitmaps = postings
765            .into_iter()
766            .map(|bytes| {
767                RoaringTreemap::deserialize_from(bytes.expect_ok()?)
768                    .map_err(|e| Error::internal(format!("Error deserializing ngram list: {}", e)))
769            })
770            .collect::<Result<Vec<_>>>()?;
771
772        Ok(Self { tokens, bitmaps })
773    }
774
775    fn try_into_batches(self) -> Result<Vec<RecordBatch>> {
776        self.try_into_batches_impl(MAX_POSTING_LIST_BATCH_BYTES)
777    }
778
779    // Split into multiple batches so that the cumulative serialized posting bytes
780    // of each batch stay under `max_batch_bytes`, avoiding i32 offset overflow in
781    // the Binary posting array.  Postings are serialized straight into each batch's
782    // values buffer to avoid a second contiguous copy of multi-GiB payloads.
783    fn try_into_batches_impl(self, max_batch_bytes: usize) -> Result<Vec<RecordBatch>> {
784        debug_assert_eq!(self.tokens.len(), self.bitmaps.len());
785        debug_assert!(max_batch_bytes <= i32::MAX as usize);
786        let make_batch =
787            |tokens: UInt32Array, values: Vec<u8>, offsets: Vec<i32>| -> Result<RecordBatch> {
788                let posting_array = BinaryArray::new(
789                    OffsetBuffer::new(ScalarBuffer::from(offsets)),
790                    Buffer::from_vec(values),
791                    None,
792                );
793                Ok(RecordBatch::try_new(
794                    POSTINGS_SCHEMA.clone(),
795                    vec![Arc::new(tokens), Arc::new(posting_array)],
796                )?)
797            };
798
799        let mut batches = Vec::new();
800        let mut values: Vec<u8> = Vec::new();
801        let mut offsets: Vec<i32> = vec![0];
802        let mut batch_start = 0;
803        for (idx, bitmap) in self.bitmaps.into_iter().enumerate() {
804            let posting_size = bitmap.serialized_size();
805            if posting_size > max_batch_bytes {
806                return Err(Error::invalid_input(format!(
807                    "posting list for ngram token {} serializes to {} bytes, which exceeds the {} bytes that fit in a single binary array",
808                    self.tokens.value(idx),
809                    posting_size,
810                    max_batch_bytes,
811                )));
812            }
813            if values.len() + posting_size > max_batch_bytes {
814                batches.push(make_batch(
815                    self.tokens.slice(batch_start, idx - batch_start),
816                    std::mem::take(&mut values),
817                    std::mem::replace(&mut offsets, vec![0]),
818                )?);
819                batch_start = idx;
820            }
821            bitmap.serialize_into(&mut values)?;
822            offsets.push(values.len() as i32);
823        }
824        if offsets.len() > 1 || batches.is_empty() {
825            batches.push(make_batch(
826                self.tokens.slice(batch_start, offsets.len() - 1),
827                values,
828                offsets,
829            )?);
830        }
831        Ok(batches)
832    }
833
834    fn remap_and_filter_rows(
835        self,
836        frag_reuse_index: Option<&Arc<FragReuseIndex>>,
837        filter: Option<&super::OldIndexDataFilter>,
838    ) -> Self {
839        if let Some(fri) = frag_reuse_index {
840            return self.remap_then_keep(fri, filter);
841        }
842        match filter {
843            None => self,
844            Some(filter) => self.retain_rows(filter),
845        }
846    }
847
848    /// Remap stale addresses per row, keeping only rows still selected by
849    /// `filter`. Used only under a pending deferred-remap compaction.
850    fn remap_then_keep(
851        self,
852        fri: &Arc<FragReuseIndex>,
853        filter: Option<&super::OldIndexDataFilter>,
854    ) -> Self {
855        let mut tokens = UInt32Builder::with_capacity(self.tokens.len());
856        let mut bitmaps = Vec::with_capacity(self.bitmaps.len());
857        for (token, bitmap) in self.tokens.values().iter().zip(self.bitmaps) {
858            let remapped = fri.remap_row_ids_roaring_tree_map(&bitmap);
859            let kept = match filter {
860                Some(filter) => RoaringTreemap::from_iter(
861                    remapped
862                        .into_iter()
863                        .filter(|row_id| old_filter_keeps(filter, *row_id)),
864                ),
865                None => remapped,
866            };
867            if !kept.is_empty() {
868                tokens.append_value(*token);
869                bitmaps.push(kept);
870            }
871        }
872        Self {
873            tokens: tokens.finish(),
874            bitmaps,
875        }
876    }
877
878    fn retain_rows(self, filter: &super::OldIndexDataFilter) -> Self {
879        if let super::OldIndexDataFilter::Fragments { to_keep, .. } = filter
880            && self.bitmaps.iter().all(|bitmap| {
881                bitmap
882                    .bitmaps()
883                    .all(|(fragment, _)| to_keep.contains(fragment))
884            })
885        {
886            return self;
887        }
888        let mut tokens = UInt32Builder::with_capacity(self.tokens.len());
889        let mut bitmaps = Vec::with_capacity(self.bitmaps.len());
890        for (token, bitmap) in self.tokens.values().iter().zip(self.bitmaps) {
891            let kept = RoaringTreemap::from_iter(
892                bitmap
893                    .into_iter()
894                    .filter(|row_id| old_filter_keeps(filter, *row_id)),
895            );
896            if !kept.is_empty() {
897                tokens.append_value(*token);
898                bitmaps.push(kept);
899            }
900        }
901        Self {
902            tokens: tokens.finish(),
903            bitmaps,
904        }
905    }
906}
907
908fn old_filter_keeps(filter: &super::OldIndexDataFilter, row_id: u64) -> bool {
909    match filter {
910        super::OldIndexDataFilter::Fragments { to_keep, .. } => {
911            to_keep.contains((row_id >> 32) as u32)
912        }
913        super::OldIndexDataFilter::RowIds(valid) => valid.contains(row_id),
914    }
915}
916
917// As we're building we create a map from ngram to row ids.  When this map gets too large
918// we spill it to disk.
919struct NGramIndexBuildState {
920    tokens_map: BTreeMap<u32, RoaringTreemap>,
921}
922
923impl NGramIndexBuildState {
924    fn starting() -> Self {
925        Self {
926            tokens_map: BTreeMap::new(),
927        }
928    }
929
930    fn take(&mut self) -> Self {
931        let mut taken = Self::starting();
932        std::mem::swap(&mut self.tokens_map, &mut taken.tokens_map);
933        taken
934    }
935
936    fn into_spill(self) -> NGramIndexSpillState {
937        // We can rely on these being in token order because of BTreeMap
938        let tokens = UInt32Array::from_iter_values(self.tokens_map.keys().copied());
939        let bitmaps = Vec::from_iter(self.tokens_map.into_values());
940
941        NGramIndexSpillState { bitmaps, tokens }
942    }
943}
944
945/// A builder for an ngram index
946///
947/// The builder is a small pipeline.  First, we read in the data and tokenize it.  This
948/// stage uses fan-out parallelism to tokenize the data because tokenization may be a little
949/// slower than I/O.
950///
951/// The second stage fans out much wider.  It partitions the tokens into a number of partitions.
952/// Each partition has a BTreemap that maps tokens to row ids.  The partitions then build up
953/// roaring treemaps.  When a partition gets too full it will spill to disk.
954///
955/// Once all the data is processed we spill all the parititons to disk and then we merge the
956/// spill files into a single index file.
957pub struct NGramIndexBuilder {
958    tokenizer: TextAnalyzer,
959    options: NGramIndexBuilderOptions,
960    tmpdir: Arc<TempDir>,
961    spill_store: Arc<dyn IndexStore>,
962
963    tokens_seen: usize,
964    worker_number: usize,
965    has_flushed: bool,
966
967    state: NGramIndexBuildState,
968}
969
970impl NGramIndexBuilder {
971    pub fn try_new(options: NGramIndexBuilderOptions) -> Result<Self> {
972        Self::from_state(NGramIndexBuildState::starting(), options)
973    }
974
975    fn clone_worker(&self, worker_number: usize) -> Self {
976        let mut bitmaps = Vec::with_capacity(36 * 36 * 36 + 1);
977        // Token 0 is always the NULL bitmap
978        bitmaps.push(RoaringTreemap::new());
979        Self {
980            tokenizer: self.tokenizer.clone(),
981            state: NGramIndexBuildState::starting(),
982            tmpdir: self.tmpdir.clone(),
983            spill_store: self.spill_store.clone(),
984            options: self.options.clone(),
985            tokens_seen: 0,
986            worker_number,
987            has_flushed: false,
988        }
989    }
990
991    fn from_state(state: NGramIndexBuildState, options: NGramIndexBuilderOptions) -> Result<Self> {
992        let tokenizer = NGRAM_TOKENIZER.clone();
993
994        let tmpdir = Arc::new(TempDir::default());
995        let spill_store = Arc::new(LanceIndexStore::new(
996            Arc::new(ObjectStore::local()),
997            tmpdir.obj_path(),
998            Arc::new(LanceCache::no_cache()),
999        ));
1000
1001        Ok(Self {
1002            tokenizer,
1003            state,
1004            tmpdir,
1005            spill_store,
1006            options,
1007            tokens_seen: 0,
1008            worker_number: 0,
1009            has_flushed: false,
1010        })
1011    }
1012
1013    fn validate_schema(schema: &Schema) -> Result<()> {
1014        if schema.fields().len() != 2 {
1015            return Err(Error::invalid_input_source(
1016                "Ngram index schema must have exactly two fields".into(),
1017            ));
1018        }
1019        let values_field = schema.field_with_name(VALUE_COLUMN_NAME)?;
1020        if *values_field.data_type() != DataType::Utf8
1021            && *values_field.data_type() != DataType::LargeUtf8
1022        {
1023            return Err(Error::invalid_input_source(
1024                "First field in ngram index schema must be of type Utf8/LargeUtf8".into(),
1025            ));
1026        }
1027        let row_id_field = schema.field_with_name(ROW_ID)?;
1028        if *row_id_field.data_type() != DataType::UInt64 {
1029            return Err(Error::invalid_input_source(
1030                "Second field in ngram index schema must be of type UInt64".into(),
1031            ));
1032        }
1033        Ok(())
1034    }
1035
1036    async fn process_batch(&mut self, tokens_and_ids: Vec<(u32, u64)>) -> Result<()> {
1037        let mut tokens_seen = 0;
1038        for (token, row_id) in tokens_and_ids {
1039            tokens_seen += 1;
1040            // This would be a bit simpler with entry API but, at scale, the vast majority
1041            // of cases will be a hit and we want to avoid cloning the string if we can.  So
1042            // for now we do the double-hash.  We can simplify in the future with raw_entry
1043            // when it stabilizes.
1044            self.state
1045                .tokens_map
1046                .entry(token)
1047                .or_default()
1048                .insert(row_id);
1049        }
1050        self.tokens_seen += tokens_seen;
1051        if self.tokens_seen >= self.options.tokens_per_spill {
1052            let state = self.state.take();
1053            self.flush(state).await?;
1054        }
1055        Ok(())
1056    }
1057
1058    fn spill_filename(id: usize) -> String {
1059        format!("spill-{}.lance", id)
1060    }
1061
1062    fn tmp_spill_filename(id: usize) -> String {
1063        format!("spill-{}.lance.tmp", id)
1064    }
1065
1066    async fn flush(&mut self, state: NGramIndexBuildState) -> Result<bool> {
1067        if self.tokens_seen == 0 {
1068            assert!(state.tokens_map.is_empty());
1069            return Ok(self.has_flushed);
1070        }
1071        self.tokens_seen = 0;
1072        let spill_state = state.into_spill();
1073        let flush_start = Instant::now();
1074        // The primary builder should never flush
1075        debug_assert_ne!(self.worker_number, 0);
1076        if self.has_flushed {
1077            info!("Merging flush for worker {}", self.worker_number);
1078            // If we have flushed before then we need to merge with the spill file
1079            let mut writer = self
1080                .spill_store
1081                .new_index_file(
1082                    &Self::tmp_spill_filename(self.worker_number),
1083                    POSTINGS_SCHEMA.clone(),
1084                )
1085                .await?;
1086
1087            let left_stream = stream::once(std::future::ready(Ok(spill_state)));
1088            let right_stream =
1089                Self::stream_spill(self.spill_store.clone(), self.worker_number).await?;
1090            Self::merge_spill_streams(left_stream, right_stream, writer.as_mut()).await?;
1091            drop(writer);
1092            self.spill_store
1093                .rename_index_file(
1094                    &Self::tmp_spill_filename(self.worker_number),
1095                    &Self::spill_filename(self.worker_number),
1096                )
1097                .await?;
1098        } else {
1099            // If we haven't flushed before we can just write to the spill file
1100            info!("Initial flush for worker {}", self.worker_number);
1101            self.has_flushed = true;
1102            let writer = self
1103                .spill_store
1104                .new_index_file(
1105                    &Self::spill_filename(self.worker_number),
1106                    POSTINGS_SCHEMA.clone(),
1107                )
1108                .await?;
1109            self.write(writer, spill_state).await?;
1110        }
1111        let flush_time = flush_start.elapsed();
1112        info!(
1113            "Flushed worker {} in {}ms",
1114            self.worker_number,
1115            flush_time.as_millis()
1116        );
1117        Ok(true)
1118    }
1119
1120    fn tokenize_and_partition(
1121        tokenizer: &TextAnalyzer,
1122        batch: RecordBatch,
1123        num_workers: usize,
1124    ) -> Result<Vec<Vec<(u32, u64)>>> {
1125        let text_iter = iter_str_array(batch.column_by_name(VALUE_COLUMN_NAME).expect_ok()?);
1126        let row_id_col = batch
1127            .column_by_name(ROW_ID)
1128            .expect_ok()?
1129            .as_primitive::<UInt64Type>();
1130        // Guessing 1000 tokens per row to at least avoid some of the earlier allocations
1131        let mut partitions = vec![Vec::with_capacity(batch.num_rows() * 1000); num_workers];
1132        let divisor = (MAX_TOKEN - MIN_TOKEN) / num_workers;
1133        for (text, row_id) in text_iter.zip(row_id_col.values()) {
1134            if let Some(text) = text {
1135                tokenize_visitor(tokenizer, text, |token| {
1136                    let token = ngram_to_token(token, NGRAM_N);
1137                    let partition_id = (token as usize).saturating_sub(MIN_TOKEN) / divisor;
1138                    partitions[partition_id % num_workers].push((token, *row_id));
1139                });
1140            } else {
1141                partitions[0].push((0, *row_id));
1142            }
1143        }
1144        Ok(partitions)
1145    }
1146
1147    pub async fn train(&mut self, data: SendableRecordBatchStream) -> Result<Vec<usize>> {
1148        let schema = data.schema();
1149        Self::validate_schema(schema.as_ref())?;
1150
1151        let num_workers = *DEFAULT_NUM_PARTITIONS;
1152        let mut senders = Vec::with_capacity(num_workers);
1153        let mut builders = Vec::with_capacity(num_workers);
1154        for worker_idx in 0..num_workers {
1155            let (send, mut recv) = tokio::sync::mpsc::channel(2);
1156            senders.push(send);
1157
1158            let mut builder = self.clone_worker(worker_idx + 1);
1159            let future = tokio::spawn(async move {
1160                while let Some(partition) = recv.recv().await {
1161                    builder.process_batch(partition).await?;
1162                }
1163                Result::Ok(builder)
1164            });
1165            builders.push(future);
1166        }
1167
1168        let mut partitions_stream = data
1169            .and_then(|batch| {
1170                let tokenizer = self.tokenizer.clone();
1171                std::future::ready(Ok(tokio::task::spawn(async move {
1172                    Ok(Self::tokenize_and_partition(
1173                        &tokenizer,
1174                        batch,
1175                        num_workers,
1176                    )?)
1177                })
1178                .map(|res| res.unwrap())))
1179            })
1180            .try_buffer_unordered(*DEFAULT_TOKENIZE_PARALLELISM);
1181
1182        while let Some(partitions) = partitions_stream.try_next().await? {
1183            for (part_idx, partition) in partitions.into_iter().enumerate() {
1184                senders[part_idx].send(partition).await.unwrap();
1185            }
1186        }
1187
1188        std::mem::drop(senders);
1189        let builders = futures::future::try_join_all(builders).await?;
1190
1191        // Final flush is serialized.  If we kick this off in parallel it can
1192        // use a lot of memory.
1193
1194        let mut to_spill = Vec::with_capacity(builders.len());
1195
1196        for builder in builders {
1197            let mut builder = builder?;
1198            let state = builder.state.take();
1199            if builder.flush(state).await? {
1200                to_spill.push(builder.worker_number);
1201            }
1202        }
1203
1204        Ok(to_spill)
1205    }
1206
1207    async fn write(
1208        &mut self,
1209        mut writer: Box<dyn IndexWriter>,
1210        state: NGramIndexSpillState,
1211    ) -> Result<()> {
1212        Self::write_state(writer.as_mut(), state).await?;
1213        writer.finish().await?;
1214
1215        Ok(())
1216    }
1217
1218    async fn write_state(writer: &mut dyn IndexWriter, state: NGramIndexSpillState) -> Result<()> {
1219        for batch in state.try_into_batches()? {
1220            writer.write_record_batch(batch).await?;
1221        }
1222        Ok(())
1223    }
1224
1225    fn stream_spill_reader(
1226        reader: Arc<dyn IndexReader>,
1227        max_batch_bytes: usize,
1228    ) -> Result<impl Stream<Item = Result<NGramIndexSpillState>>> {
1229        let num_rows = reader.num_rows();
1230
1231        Ok(stream::try_unfold(
1232            (0, NGramIndexSpillStateBuilder::new()),
1233            move |(mut offset, mut builder)| {
1234                let reader = reader.clone();
1235                async move {
1236                    loop {
1237                        if offset >= num_rows {
1238                            return if builder.is_empty() {
1239                                Ok(None)
1240                            } else {
1241                                let state = builder.finish();
1242                                Ok(Some((state, (offset, builder))))
1243                            };
1244                        }
1245
1246                        let state = NGramIndexSpillState::try_from_batch(
1247                            reader.read_range(offset..offset + 1, None).await?,
1248                        )?;
1249                        offset += 1;
1250                        for (token, bitmap) in
1251                            state.tokens.values().iter().copied().zip(state.bitmaps)
1252                        {
1253                            if let Some(full) = builder.push(token, bitmap, max_batch_bytes)? {
1254                                return Ok(Some((full, (offset, builder))));
1255                            }
1256                            if builder.len() >= POSTING_LIST_STREAM_BATCH_ROWS {
1257                                let state = builder.finish();
1258                                return Ok(Some((state, (offset, builder))));
1259                            }
1260                        }
1261                    }
1262                }
1263                .boxed()
1264            },
1265        ))
1266    }
1267
1268    async fn stream_spill(
1269        spill_store: Arc<dyn IndexStore>,
1270        id: usize,
1271    ) -> Result<impl Stream<Item = Result<NGramIndexSpillState>>> {
1272        let reader = spill_store
1273            .open_index_file(&Self::spill_filename(id))
1274            .await?;
1275        Self::stream_spill_reader(reader, MAX_POSTING_LIST_BATCH_BYTES)
1276    }
1277
1278    fn merge_spill_states(
1279        left_opt: &mut Option<NGramIndexSpillState>,
1280        right_opt: &mut Option<NGramIndexSpillState>,
1281    ) -> NGramIndexSpillState {
1282        let left = left_opt.take().unwrap();
1283        let right = right_opt.take().unwrap();
1284
1285        let item_capacity = left.tokens.len() + right.tokens.len();
1286        let mut merged_tokens = UInt32Builder::with_capacity(item_capacity);
1287        let mut merged_bitmaps = Vec::with_capacity(left.bitmaps.len() + right.bitmaps.len());
1288
1289        let mut left_tokens = left.tokens.values().iter().copied();
1290        let mut left_bitmaps = left.bitmaps.into_iter();
1291        let mut right_tokens = right.tokens.values().iter().copied();
1292        let mut right_bitmaps = right.bitmaps.into_iter();
1293
1294        let mut left_token = left_tokens.next();
1295        let mut left_bitmap = left_bitmaps.next();
1296        let mut right_token = right_tokens.next();
1297        let mut right_bitmap = right_bitmaps.next();
1298
1299        while left_token.is_some() && right_token.is_some() {
1300            let left_token_val = left_token.unwrap();
1301            let right_token_val = right_token.unwrap();
1302            match left_token_val.cmp(&right_token_val) {
1303                std::cmp::Ordering::Less => {
1304                    merged_tokens.append_value(left_token_val);
1305                    merged_bitmaps.push(left_bitmap.unwrap());
1306                    left_token = left_tokens.next();
1307                    left_bitmap = left_bitmaps.next();
1308                }
1309                std::cmp::Ordering::Greater => {
1310                    merged_tokens.append_value(right_token_val);
1311                    merged_bitmaps.push(right_bitmap.unwrap());
1312                    right_token = right_tokens.next();
1313                    right_bitmap = right_bitmaps.next();
1314                }
1315                std::cmp::Ordering::Equal => {
1316                    merged_tokens.append_value(left_token_val);
1317                    merged_bitmaps.push(left_bitmap.unwrap() | &right_bitmap.unwrap());
1318                    left_token = left_tokens.next();
1319                    left_bitmap = left_bitmaps.next();
1320                    right_token = right_tokens.next();
1321                    right_bitmap = right_bitmaps.next();
1322                }
1323            }
1324        }
1325
1326        let collect_remaining = |cur_token, tokens, cur_bitmap, bitmaps| {
1327            let tokens = UInt32Array::from_iter_values(once(cur_token).chain(tokens));
1328            let bitmaps = once(cur_bitmap).chain(bitmaps).collect::<Vec<_>>();
1329            NGramIndexSpillState { tokens, bitmaps }
1330        };
1331
1332        if let Some(left_token) = left_token {
1333            *left_opt = Some(collect_remaining(
1334                left_token,
1335                left_tokens,
1336                left_bitmap.unwrap(),
1337                left_bitmaps,
1338            ));
1339        } else {
1340            *left_opt = None;
1341        }
1342        if let Some(right_token) = right_token {
1343            *right_opt = Some(collect_remaining(
1344                right_token,
1345                right_tokens,
1346                right_bitmap.unwrap(),
1347                right_bitmaps,
1348            ));
1349        } else {
1350            *right_opt = None;
1351        }
1352
1353        NGramIndexSpillState {
1354            tokens: merged_tokens.finish(),
1355            bitmaps: merged_bitmaps,
1356        }
1357    }
1358
1359    async fn merge_spill_streams(
1360        mut left_stream: impl Stream<Item = Result<NGramIndexSpillState>> + Unpin,
1361        mut right_stream: impl Stream<Item = Result<NGramIndexSpillState>> + Unpin,
1362        writer: &mut dyn IndexWriter,
1363    ) -> Result<IndexFile> {
1364        let mut left_state = left_stream.try_next().await?;
1365        let mut right_state = right_stream.try_next().await?;
1366
1367        while left_state.is_some() || right_state.is_some() {
1368            if left_state.is_none() {
1369                // Left is done, full drain right
1370                let state = right_state.take().expect_ok()?;
1371                Self::write_state(writer, state).await?;
1372                while let Some(state) = right_stream.try_next().await? {
1373                    Self::write_state(writer, state).await?;
1374                }
1375            } else if right_state.is_none() {
1376                // Right is done, full drain left
1377                let state = left_state.take().expect_ok()?;
1378                Self::write_state(writer, state).await?;
1379                while let Some(state) = left_stream.try_next().await? {
1380                    Self::write_state(writer, state).await?;
1381                }
1382            } else {
1383                // There is a batch from both left and right.  Need to merge them
1384                let merged = Self::merge_spill_states(&mut left_state, &mut right_state);
1385                Self::write_state(writer, merged).await?;
1386                if left_state.is_none() {
1387                    left_state = left_stream.try_next().await?;
1388                }
1389                if right_state.is_none() {
1390                    right_state = right_stream.try_next().await?;
1391                }
1392            }
1393        }
1394
1395        writer.finish().await
1396    }
1397
1398    async fn merge_spill_files(
1399        spill_store: Arc<dyn IndexStore>,
1400        index_of_left: usize,
1401        index_of_right: usize,
1402        output_index: usize,
1403    ) -> Result<()> {
1404        // We fully load the small file into memory and then stream the large file
1405        info!(
1406            "Merge spill files {} and {} into {}",
1407            index_of_left, index_of_right, output_index
1408        );
1409
1410        let mut writer = spill_store
1411            .new_index_file(&Self::spill_filename(output_index), POSTINGS_SCHEMA.clone())
1412            .await?;
1413
1414        let (left_stream, right_stream) = futures::try_join!(
1415            Self::stream_spill(spill_store.clone(), index_of_left),
1416            Self::stream_spill(spill_store.clone(), index_of_right)
1417        )?;
1418
1419        Self::merge_spill_streams(left_stream, right_stream, writer.as_mut()).await?;
1420
1421        spill_store
1422            .delete_index_file(&Self::spill_filename(index_of_left))
1423            .await?;
1424        spill_store
1425            .delete_index_file(&Self::spill_filename(index_of_right))
1426            .await?;
1427
1428        Ok(())
1429    }
1430
1431    // Can potentially parallelize in the future if this step becomes a bottleneck
1432    //
1433    // We can also merge in a more balanced fashion (e.g. binary tree) to reduce the size of
1434    // intermediate files
1435    //
1436    // Note: worker indices start at 1 and not 0 (hence all the +1's)
1437    async fn merge_spills(&mut self, mut spill_files: Vec<usize>) -> Result<usize> {
1438        info!(
1439            "Merging {} index files into one combined index",
1440            spill_files.len()
1441        );
1442
1443        let mut spill_counter = spill_files.iter().max().expect_ok()? + 1;
1444        while spill_files.len() > 1 {
1445            let mut new_spills = Vec::with_capacity(spill_files.len() / 2);
1446            while spill_files.len() >= 2 {
1447                let left = spill_files.pop().expect_ok()?;
1448                let right = spill_files.pop().expect_ok()?;
1449                new_spills.push(tokio::spawn(Self::merge_spill_files(
1450                    self.spill_store.clone(),
1451                    left,
1452                    right,
1453                    spill_counter + new_spills.len(),
1454                )));
1455            }
1456            for i in 0..new_spills.len() {
1457                spill_files.push(spill_counter + i);
1458            }
1459            spill_counter += new_spills.len();
1460            for result in futures::future::try_join_all(new_spills).await? {
1461                result?;
1462            }
1463        }
1464
1465        spill_files.pop().expect_ok()
1466    }
1467
1468    async fn merge_old_index(
1469        &mut self,
1470        new_data_num: usize,
1471        old_index: Arc<dyn IndexStore>,
1472    ) -> Result<usize> {
1473        info!("Merging old index into new index");
1474        let final_num = new_data_num + 1;
1475
1476        let mut writer = self
1477            .spill_store
1478            .new_index_file(&Self::spill_filename(final_num), POSTINGS_SCHEMA.clone())
1479            .await?;
1480
1481        let left_stream = Self::stream_spill(self.spill_store.clone(), new_data_num).await?;
1482        let old_reader = old_index.open_index_file(POSTINGS_FILENAME).await?;
1483        let right_stream = Self::stream_spill_reader(old_reader, MAX_POSTING_LIST_BATCH_BYTES)?;
1484
1485        Self::merge_spill_streams(left_stream, right_stream, writer.as_mut()).await?;
1486
1487        self.spill_store
1488            .delete_index_file(&Self::spill_filename(new_data_num))
1489            .await?;
1490
1491        Ok(final_num)
1492    }
1493
1494    pub async fn write_index(
1495        mut self,
1496        store: &dyn IndexStore,
1497        spill_files: Vec<usize>,
1498        old_index: Option<Arc<dyn IndexStore>>,
1499    ) -> Result<IndexFile> {
1500        let mut writer = store
1501            .new_index_file(POSTINGS_FILENAME, POSTINGS_SCHEMA.clone())
1502            .await?;
1503
1504        if spill_files.is_empty() {
1505            if let Some(old_index) = old_index {
1506                // An update with no new data, just copy the old index to the new store
1507                return old_index.copy_index_file(POSTINGS_FILENAME, store).await;
1508            } else {
1509                // Training an index with no data, make an empty index
1510                let mut writer = store
1511                    .new_index_file(POSTINGS_FILENAME, POSTINGS_SCHEMA.clone())
1512                    .await?;
1513                return writer.finish().await;
1514            }
1515        }
1516
1517        let mut index_to_copy = self.merge_spills(spill_files).await?;
1518
1519        if let Some(old_index) = old_index {
1520            index_to_copy = self.merge_old_index(index_to_copy, old_index).await?;
1521        }
1522
1523        let reader = self
1524            .spill_store
1525            .open_index_file(&Self::spill_filename(index_to_copy))
1526            .await?;
1527
1528        let mut spill_stream = Self::stream_spill_reader(reader, MAX_POSTING_LIST_BATCH_BYTES)?;
1529        while let Some(state) = spill_stream.try_next().await? {
1530            Self::write_state(writer.as_mut(), state).await?;
1531        }
1532
1533        writer.finish().await
1534    }
1535
1536    async fn open_segment_stream(
1537        store: Arc<dyn IndexStore>,
1538        frag_reuse_index: Option<Arc<FragReuseIndex>>,
1539        filter: Option<super::OldIndexDataFilter>,
1540    ) -> Result<Pin<Box<dyn Stream<Item = Result<NGramIndexSpillState>> + Send>>> {
1541        let reader = store.open_index_file(POSTINGS_FILENAME).await?;
1542        let stream =
1543            Self::stream_spill_reader(reader, MAX_POSTING_LIST_BATCH_BYTES)?.map(move |res| {
1544                res.map(|state| {
1545                    state.remap_and_filter_rows(frag_reuse_index.as_ref(), filter.as_ref())
1546                })
1547            });
1548        Ok(Box::pin(stream))
1549    }
1550
1551    /// Merge the source segments' posting files with any new-data spills into a
1552    /// single canonical posting file in `dest_store`.
1553    async fn merge_indices(
1554        mut self,
1555        new_data_spills: Vec<usize>,
1556        segment_stores: &[Arc<dyn IndexStore>],
1557        old_data_filters: &[Option<super::OldIndexDataFilter>],
1558        frag_reuse_index: Option<Arc<FragReuseIndex>>,
1559        dest_store: &dyn IndexStore,
1560    ) -> Result<IndexFile> {
1561        if old_data_filters.len() != segment_stores.len() {
1562            return Err(Error::invalid_input(format!(
1563                "NGram merge: expected one old-data filter per source segment \
1564                 ({} segments) but got {}",
1565                segment_stores.len(),
1566                old_data_filters.len()
1567            )));
1568        }
1569
1570        let mut writer = dest_store
1571            .new_index_file(POSTINGS_FILENAME, POSTINGS_SCHEMA.clone())
1572            .await?;
1573
1574        // No segments and no new data: write an empty index.
1575        if segment_stores.is_empty() && new_data_spills.is_empty() {
1576            return writer.finish().await;
1577        }
1578
1579        // Fast path
1580        if segment_stores.len() == 1 {
1581            let seg_stream = Self::open_segment_stream(
1582                segment_stores[0].clone(),
1583                frag_reuse_index,
1584                old_data_filters[0].clone(),
1585            )
1586            .await?;
1587            let new_data_stream: Pin<Box<dyn Stream<Item = Result<NGramIndexSpillState>> + Send>> =
1588                if new_data_spills.is_empty() {
1589                    Box::pin(stream::empty::<Result<NGramIndexSpillState>>())
1590                } else {
1591                    let new_data = self.merge_spills(new_data_spills).await?;
1592                    Box::pin(Self::stream_spill(self.spill_store.clone(), new_data).await?)
1593                };
1594            return Self::merge_spill_streams(seg_stream, new_data_stream, writer.as_mut()).await;
1595        }
1596
1597        let mut spills = new_data_spills;
1598        let next_spill = spills.iter().copied().max().map_or(0, |id| id + 1);
1599        let mut tasks = Vec::new();
1600        for (offset, (stores, filters)) in segment_stores
1601            .chunks(2)
1602            .zip(old_data_filters.chunks(2))
1603            .enumerate()
1604        {
1605            let out = next_spill + offset;
1606            spills.push(out);
1607            let spill_store = self.spill_store.clone();
1608            let fri = frag_reuse_index.clone();
1609            let s0 = stores[0].clone();
1610            let f0 = filters[0].clone();
1611            let second = (stores.len() == 2).then(|| (stores[1].clone(), filters[1].clone()));
1612            tasks.push(tokio::spawn(async move {
1613                let mut writer = spill_store
1614                    .new_index_file(&Self::spill_filename(out), POSTINGS_SCHEMA.clone())
1615                    .await?;
1616                let mut left = Self::open_segment_stream(s0, fri.clone(), f0).await?;
1617                match second {
1618                    Some((s1, f1)) => {
1619                        let right = Self::open_segment_stream(s1, fri, f1).await?;
1620                        // `merge_spill_streams` finishes the writer itself.
1621                        Self::merge_spill_streams(left, right, writer.as_mut()).await?;
1622                    }
1623                    None => {
1624                        // Odd segment out: materialize it alone (cost of one segment).
1625                        while let Some(state) = left.try_next().await? {
1626                            Self::write_state(writer.as_mut(), state).await?;
1627                        }
1628                        writer.finish().await?;
1629                    }
1630                }
1631                Result::Ok(())
1632            }));
1633        }
1634        for result in futures::future::try_join_all(tasks).await? {
1635            result?;
1636        }
1637
1638        // Balanced parallel union of every spill (new data + paired segments).
1639        let final_spill = self.merge_spills(spills).await?;
1640        let reader = self
1641            .spill_store
1642            .open_index_file(&Self::spill_filename(final_spill))
1643            .await?;
1644        let mut spill_stream = Self::stream_spill_reader(reader, MAX_POSTING_LIST_BATCH_BYTES)?;
1645        while let Some(state) = spill_stream.try_next().await? {
1646            Self::write_state(writer.as_mut(), state).await?;
1647        }
1648        writer.finish().await
1649    }
1650}
1651
1652#[derive(Debug, Default)]
1653pub struct NGramIndexPlugin;
1654
1655impl NGramIndexPlugin {
1656    pub async fn train_ngram_index(
1657        batches_source: SendableRecordBatchStream,
1658        index_store: &dyn IndexStore,
1659    ) -> Result<IndexFile> {
1660        let mut builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default())?;
1661
1662        let spill_files = builder.train(batches_source).await?;
1663
1664        builder.write_index(index_store, spill_files, None).await
1665    }
1666}
1667
1668#[async_trait]
1669impl BasicTrainer for NGramIndexPlugin {
1670    fn new_training_request(
1671        &self,
1672        _params: &str,
1673        field: &Field,
1674    ) -> Result<Box<dyn TrainingRequest>> {
1675        if !matches!(field.data_type(), DataType::Utf8 | DataType::LargeUtf8) {
1676            return Err(Error::invalid_input_source(format!(
1677                "A ngram index can only be created on a Utf8 or LargeUtf8 field.  Column has type {:?}",
1678                field.data_type()
1679            )
1680            .into()));
1681        }
1682        Ok(Box::new(DefaultTrainingRequest::new(
1683            TrainingCriteria::new(TrainingOrdering::None).with_row_id(),
1684        )))
1685    }
1686
1687    async fn train_index(
1688        &self,
1689        data: SendableRecordBatchStream,
1690        index_store: &dyn IndexStore,
1691        _request: Box<dyn TrainingRequest>,
1692        _fragment_ids: Option<Vec<u32>>,
1693        _progress: Arc<dyn crate::progress::IndexBuildProgress>,
1694    ) -> Result<CreatedIndex> {
1695        // `fragment_ids` only scopes the rows scanned into `data`; the builder
1696        // always writes one canonical file, so a per-fragment build is a segment.
1697        let file = Self::train_ngram_index(data, index_store).await?;
1698        Ok(CreatedIndex {
1699            index_details: prost_types::Any::from_msg(&pbold::NGramIndexDetails::default())?,
1700            index_version: NGRAM_INDEX_VERSION,
1701            files: vec![file],
1702        })
1703    }
1704}
1705
1706#[async_trait]
1707impl ScalarIndexPlugin for NGramIndexPlugin {
1708    fn basic_trainer(&self) -> Option<&dyn BasicTrainer> {
1709        Some(self)
1710    }
1711
1712    fn name(&self) -> &str {
1713        "NGram"
1714    }
1715
1716    fn provides_exact_answer(&self) -> bool {
1717        false
1718    }
1719
1720    fn version(&self) -> u32 {
1721        NGRAM_INDEX_VERSION
1722    }
1723
1724    fn new_query_parser(
1725        &self,
1726        index_name: String,
1727        _index_details: &prost_types::Any,
1728    ) -> Option<Box<dyn ScalarQueryParser>> {
1729        Some(Box::new(TextQueryParser::new(
1730            index_name,
1731            self.name().to_string(),
1732            // needs_recheck: ngram results are an inexact candidate superset.
1733            true,
1734            // supports_regex: the ngram index can answer regex queries.
1735            true,
1736        )))
1737    }
1738
1739    async fn load_index(
1740        &self,
1741        index_store: Arc<dyn IndexStore>,
1742        _index_details: &prost_types::Any,
1743        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1744        cache: &LanceCache,
1745    ) -> Result<Arc<dyn ScalarIndex>> {
1746        Ok(NGramIndex::load(index_store, frag_reuse_index, cache).await? as Arc<dyn ScalarIndex>)
1747    }
1748}
1749
1750#[cfg(test)]
1751mod tests {
1752    use lance_core::utils::row_addr_remap::RowAddrRemap;
1753    use rstest::rstest;
1754    use std::{
1755        collections::{HashMap, HashSet},
1756        sync::Arc,
1757    };
1758
1759    use arrow::array::AsArray;
1760    use arrow::datatypes::{UInt32Type, UInt64Type};
1761    use arrow_array::{Array, RecordBatch, StringArray, UInt32Array, UInt64Array};
1762    use arrow_schema::{DataType, Field, Schema};
1763    use async_trait::async_trait;
1764    use datafusion::{
1765        execution::SendableRecordBatchStream, physical_plan::stream::RecordBatchStreamAdapter,
1766    };
1767    use datafusion_common::DataFusionError;
1768    use futures::{TryStreamExt, stream};
1769    use itertools::Itertools;
1770    use lance_core::{Error, ROW_ID, Result, cache::LanceCache, utils::tempfile::TempDir};
1771    use lance_datagen::{BatchCount, ByteCount, RowCount};
1772    use lance_io::object_store::ObjectStore;
1773    use lance_select::RowAddrTreeMap;
1774    use lance_tokenizer::TextAnalyzer;
1775    use roaring::RoaringTreemap;
1776
1777    use crate::scalar::{
1778        IndexReader, IndexStore, ScalarIndex, SearchResult, TextQuery,
1779        lance_format::LanceIndexStore,
1780        ngram::{NGramIndex, NGramIndexBuilder, NGramIndexBuilderOptions},
1781    };
1782    use crate::{metrics::NoOpMetricsCollector, scalar::registry::VALUE_COLUMN_NAME};
1783
1784    use super::{
1785        NGRAM_TOKENIZER, NGramIndexSpillState, POSTINGS_FILENAME, POSTINGS_SCHEMA, ngram_to_token,
1786        tokenize_visitor,
1787    };
1788
1789    struct MaxReadRangeReader {
1790        inner: Arc<dyn IndexReader>,
1791        max_rows: usize,
1792    }
1793
1794    #[async_trait]
1795    impl IndexReader for MaxReadRangeReader {
1796        async fn read_record_batch(&self, n: u64, batch_size: u64) -> Result<RecordBatch> {
1797            self.inner.read_record_batch(n, batch_size).await
1798        }
1799
1800        async fn read_range(
1801            &self,
1802            range: std::ops::Range<usize>,
1803            projection: Option<&[&str]>,
1804        ) -> Result<RecordBatch> {
1805            let rows = range.end - range.start;
1806            if rows > self.max_rows {
1807                return Err(Error::invalid_input(format!(
1808                    "read_range requested {} rows, max is {}",
1809                    rows, self.max_rows,
1810                )));
1811            }
1812            self.inner.read_range(range, projection).await
1813        }
1814
1815        async fn num_batches(&self, batch_size: u64) -> u32 {
1816            self.inner.num_batches(batch_size).await
1817        }
1818
1819        fn num_rows(&self) -> usize {
1820            self.inner.num_rows()
1821        }
1822
1823        fn schema(&self) -> &lance_core::datatypes::Schema {
1824            self.inner.schema()
1825        }
1826
1827        fn file_size_bytes(&self) -> Option<u64> {
1828            self.inner.file_size_bytes()
1829        }
1830    }
1831
1832    fn collect_tokens(analyzer: &TextAnalyzer, text: &str) -> Vec<String> {
1833        let mut tokens = Vec::with_capacity(text.len() * 3);
1834        tokenize_visitor(analyzer, text, |token| tokens.push(token.to_owned()));
1835        tokens
1836    }
1837
1838    #[test]
1839    fn test_tokenizer() {
1840        let tokenizer = NGRAM_TOKENIZER.clone();
1841
1842        // ASCII folding
1843        let tokens = collect_tokens(&tokenizer, "café");
1844        assert_eq!(
1845            tokens,
1846            vec!["caf", "afe"] // spellchecker:disable-line
1847        );
1848
1849        // Allow numbers
1850        let tokens = collect_tokens(&tokenizer, "a1b2");
1851        assert_eq!(tokens, vec!["a1b", "1b2"]);
1852
1853        // Remove symbols and UTF-8 that doesn't map to characters
1854        let tokens = collect_tokens(&tokenizer, "abc👍b!c24");
1855
1856        assert_eq!(tokens, vec!["abc", "c24"]);
1857
1858        let tokens = collect_tokens(&tokenizer, "anstoß");
1859
1860        assert_eq!(tokens, vec!["ans", "nst", "sto", "tos", "oss"]);
1861
1862        // Lower casing
1863        let tokens = collect_tokens(&tokenizer, "ABC");
1864        assert_eq!(tokens, vec!["abc"]);
1865
1866        // Duplicate tokens
1867        let tokens = collect_tokens(&tokenizer, "ababab");
1868        // Confirming that the tokenizer doesn't deduplicate tokens (this can be taken into consideration
1869        // when training the index)
1870        assert_eq!(
1871            tokens,
1872            vec!["aba", "bab", "aba", "bab"] // spellchecker:disable-line
1873        );
1874    }
1875
1876    async fn do_train(
1877        mut builder: NGramIndexBuilder,
1878        data: SendableRecordBatchStream,
1879    ) -> (NGramIndex, Arc<TempDir>) {
1880        let spill_files = builder.train(data).await.unwrap();
1881
1882        let tmpdir = Arc::new(TempDir::default());
1883        let test_store = LanceIndexStore::new(
1884            Arc::new(ObjectStore::local()),
1885            tmpdir.obj_path(),
1886            Arc::new(LanceCache::no_cache()),
1887        );
1888
1889        builder
1890            .write_index(&test_store, spill_files, None)
1891            .await
1892            .unwrap();
1893
1894        (
1895            NGramIndex::from_store(Arc::new(test_store), None, &LanceCache::no_cache())
1896                .await
1897                .unwrap(),
1898            tmpdir,
1899        )
1900    }
1901
1902    async fn get_posting_list_for_trigram(index: &NGramIndex, trigram: &str) -> Vec<u64> {
1903        let token = ngram_to_token(trigram, 3);
1904        let row_offset = index.tokens[&token];
1905        let list = index
1906            .list_reader
1907            .ngram_list(row_offset, &NoOpMetricsCollector)
1908            .await
1909            .unwrap();
1910        list.bitmap.iter().sorted().collect()
1911    }
1912
1913    async fn get_null_posting_list(index: &NGramIndex) -> Vec<u64> {
1914        let row_offset = index.tokens[&0];
1915        let list = index
1916            .list_reader
1917            .ngram_list(row_offset, &NoOpMetricsCollector)
1918            .await
1919            .unwrap();
1920        list.bitmap.iter().sorted().collect()
1921    }
1922
1923    #[test_log::test(tokio::test)]
1924    async fn test_basic_ngram_index() {
1925        let data = StringArray::from_iter_values([
1926            "cat",
1927            "dog",
1928            "cat dog",
1929            "dog cat",
1930            "elephant",
1931            "mouse",
1932            "rhino",
1933            "giraffe",
1934            "rhinos nose",
1935        ]);
1936        let row_ids = UInt64Array::from_iter_values((0..data.len()).map(|i| i as u64));
1937        let schema = Arc::new(Schema::new(vec![
1938            Field::new(VALUE_COLUMN_NAME, DataType::Utf8, false),
1939            Field::new(ROW_ID, DataType::UInt64, false),
1940        ]));
1941        let data =
1942            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
1943        let data = Box::pin(RecordBatchStreamAdapter::new(
1944            schema,
1945            stream::once(std::future::ready(Ok(data))),
1946        ));
1947
1948        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
1949
1950        let (index, _tmpdir) = do_train(builder, data).await;
1951        assert_eq!(index.tokens.len(), 21);
1952
1953        // Basic search
1954        let res = index
1955            .search(
1956                &TextQuery::StringContains("cat".to_string()),
1957                &NoOpMetricsCollector,
1958            )
1959            .await
1960            .unwrap();
1961
1962        let expected = SearchResult::at_most(RowAddrTreeMap::from_iter([0, 2, 3]));
1963
1964        assert_eq!(expected, res);
1965
1966        // Whitespace in query
1967        let res = index
1968            .search(
1969                &TextQuery::StringContains("nos nos".to_string()),
1970                &NoOpMetricsCollector,
1971            )
1972            .await
1973            .unwrap();
1974        let expected = SearchResult::at_most(RowAddrTreeMap::from_iter([8]));
1975        assert_eq!(expected, res);
1976
1977        // No matches
1978        let res = index
1979            .search(
1980                &TextQuery::StringContains("tdo".to_string()),
1981                &NoOpMetricsCollector,
1982            )
1983            .await
1984            .unwrap();
1985        let expected = SearchResult::exact(RowAddrTreeMap::new());
1986        assert_eq!(expected, res);
1987
1988        // False positive
1989        let res = index
1990            .search(
1991                &TextQuery::StringContains("inose".to_string()),
1992                &NoOpMetricsCollector,
1993            )
1994            .await
1995            .unwrap();
1996        let expected = SearchResult::at_most(RowAddrTreeMap::from_iter([8]));
1997        assert_eq!(expected, res);
1998
1999        // Too short, don't know anything
2000        let res = index
2001            .search(
2002                &TextQuery::StringContains("ab".to_string()),
2003                &NoOpMetricsCollector,
2004            )
2005            .await
2006            .unwrap();
2007        let expected = SearchResult::at_least(RowAddrTreeMap::new());
2008        assert_eq!(expected, res);
2009
2010        // One short string but we still get at least one trigram, this is ok
2011        let res = index
2012            .search(
2013                &TextQuery::StringContains("no nos".to_string()),
2014                &NoOpMetricsCollector,
2015            )
2016            .await
2017            .unwrap();
2018        let expected = SearchResult::at_most(RowAddrTreeMap::from_iter([8]));
2019        assert_eq!(expected, res);
2020    }
2021
2022    #[test_log::test(tokio::test)]
2023    async fn test_ngram_regex_search() {
2024        // Same corpus as test_basic_ngram_index.
2025        let data = StringArray::from_iter_values([
2026            "cat",         // 0
2027            "dog",         // 1
2028            "cat dog",     // 2
2029            "dog cat",     // 3
2030            "elephant",    // 4
2031            "mouse",       // 5
2032            "rhino",       // 6
2033            "giraffe",     // 7
2034            "rhinos nose", // 8
2035        ]);
2036        let row_ids = UInt64Array::from_iter_values((0..data.len()).map(|i| i as u64));
2037        let schema = Arc::new(Schema::new(vec![
2038            Field::new(VALUE_COLUMN_NAME, DataType::Utf8, false),
2039            Field::new(ROW_ID, DataType::UInt64, false),
2040        ]));
2041        let data =
2042            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
2043        let data = Box::pin(RecordBatchStreamAdapter::new(
2044            schema,
2045            stream::once(std::future::ready(Ok(data))),
2046        ));
2047
2048        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
2049        let (index, _tmpdir) = do_train(builder, data).await;
2050
2051        async fn search(index: &NGramIndex, pattern: &str) -> SearchResult {
2052            index
2053                .search(
2054                    &TextQuery::Regex(pattern.to_string()),
2055                    &NoOpMetricsCollector,
2056                )
2057                .await
2058                .unwrap()
2059        }
2060
2061        // A plain literal yields the same candidates as contains("cat").
2062        assert_eq!(
2063            search(&index, "cat").await,
2064            SearchResult::at_most(RowAddrTreeMap::from_iter([0, 2, 3]))
2065        );
2066
2067        // Alternation -> union of each branch's rows.
2068        assert_eq!(
2069            search(&index, "(cat|dog)").await,
2070            SearchResult::at_most(RowAddrTreeMap::from_iter([0, 1, 2, 3]))
2071        );
2072
2073        // AND across `.*`: must contain both the `rhino` and `nose` trigrams, so
2074        // row 6 ("rhino") is correctly excluded and only row 8 survives.
2075        assert_eq!(
2076            search(&index, "rhino.*nose").await,
2077            SearchResult::at_most(RowAddrTreeMap::from_iter([8]))
2078        );
2079
2080        // No derivable trigram -> recheck everything.
2081        assert_eq!(
2082            search(&index, "a.b").await,
2083            SearchResult::at_least(RowAddrTreeMap::new())
2084        );
2085
2086        // A trigram that is absent from the index -> empty candidate set.
2087        assert_eq!(
2088            search(&index, "zzz").await,
2089            SearchResult::at_most(RowAddrTreeMap::new())
2090        );
2091    }
2092
2093    #[test_log::test(tokio::test)]
2094    async fn test_ngram_regex_search_nulls() {
2095        // Rows: cat(0), dog(1), NULL(2), NULL(3), cat dog(4).
2096        let data = simple_data_with_nulls();
2097        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
2098        let (index, _tmpdir) = do_train(builder, data).await;
2099
2100        // The NULL rows (2, 3) must never appear in the candidate set.
2101        let res = index
2102            .search(&TextQuery::Regex("cat".to_string()), &NoOpMetricsCollector)
2103            .await
2104            .unwrap();
2105        assert_eq!(
2106            res,
2107            SearchResult::at_most(RowAddrTreeMap::from_iter([0, 4]))
2108        );
2109
2110        let res = index
2111            .search(
2112                &TextQuery::Regex("(cat|dog)".to_string()),
2113                &NoOpMetricsCollector,
2114            )
2115            .await
2116            .unwrap();
2117        assert_eq!(
2118            res,
2119            SearchResult::at_most(RowAddrTreeMap::from_iter([0, 1, 4]))
2120        );
2121    }
2122
2123    fn test_data_schema() -> Arc<Schema> {
2124        Arc::new(Schema::new(vec![
2125            Field::new(VALUE_COLUMN_NAME, DataType::Utf8, true),
2126            Field::new(ROW_ID, DataType::UInt64, false),
2127        ]))
2128    }
2129
2130    fn simple_data_with_nulls() -> SendableRecordBatchStream {
2131        let data = StringArray::from_iter(&[Some("cat"), Some("dog"), None, None, Some("cat dog")]);
2132        let row_ids = UInt64Array::from_iter_values((0..data.len()).map(|i| i as u64));
2133        let schema = test_data_schema();
2134        let data =
2135            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
2136        Box::pin(RecordBatchStreamAdapter::new(
2137            schema,
2138            stream::once(std::future::ready(Ok(data))),
2139        ))
2140    }
2141
2142    #[test_log::test(tokio::test)]
2143    async fn test_ngram_nulls() {
2144        let data = simple_data_with_nulls();
2145
2146        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
2147
2148        let (index, _tmpdir) = do_train(builder, data).await;
2149        assert_eq!(index.tokens.len(), 3);
2150
2151        let res = index
2152            .search(
2153                &TextQuery::StringContains("cat".to_string()),
2154                &NoOpMetricsCollector,
2155            )
2156            .await
2157            .unwrap();
2158        let expected = SearchResult::at_most(RowAddrTreeMap::from_iter([0, 4]));
2159        assert_eq!(expected, res);
2160
2161        let null_posting_list = get_null_posting_list(&index).await;
2162        assert_eq!(null_posting_list, vec![2, 3]);
2163
2164        // TODO: Support IS NULL queries
2165    }
2166
2167    fn empty_data() -> SendableRecordBatchStream {
2168        Box::pin(RecordBatchStreamAdapter::new(
2169            test_data_schema(),
2170            stream::empty::<lance_core::error::DataFusionResult<RecordBatch>>(),
2171        ))
2172    }
2173
2174    #[test_log::test(tokio::test)]
2175    async fn test_train_empty() {
2176        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
2177
2178        let (index, _tmpdir) = do_train(builder, empty_data()).await;
2179        assert_eq!(index.tokens.len(), 0);
2180    }
2181
2182    #[test_log::test(tokio::test)]
2183    async fn test_update_empty() {
2184        let data = simple_data_with_nulls();
2185
2186        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
2187        let (index, _tmpdir) = do_train(builder, empty_data()).await;
2188
2189        let new_tmpdir = Arc::new(TempDir::default());
2190        let test_store = Arc::new(LanceIndexStore::new(
2191            Arc::new(ObjectStore::local()),
2192            new_tmpdir.obj_path(),
2193            Arc::new(LanceCache::no_cache()),
2194        ));
2195
2196        index.update(data, test_store.as_ref(), None).await.unwrap();
2197
2198        let index = NGramIndex::from_store(test_store, None, &LanceCache::no_cache())
2199            .await
2200            .unwrap();
2201        assert_eq!(index.tokens.len(), 3);
2202    }
2203
2204    async fn row_ids_in_index(index: &NGramIndex) -> Vec<u64> {
2205        let mut row_ids = HashSet::new();
2206        for row_offset in index.tokens.values() {
2207            let list = index
2208                .list_reader
2209                .ngram_list(*row_offset, &NoOpMetricsCollector)
2210                .await
2211                .unwrap();
2212            row_ids.extend(list.bitmap.iter());
2213        }
2214        row_ids.into_iter().sorted().collect()
2215    }
2216
2217    #[test_log::test(tokio::test)]
2218    async fn test_ngram_index_remap() {
2219        let data = simple_data_with_nulls();
2220        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
2221        let (index, _tmpdir) = do_train(builder, data).await;
2222
2223        let row_ids = row_ids_in_index(&index).await;
2224        assert_eq!(row_ids, vec![0, 1, 2, 3, 4]);
2225
2226        let new_tmpdir = Arc::new(TempDir::default());
2227        let test_store = Arc::new(LanceIndexStore::new(
2228            Arc::new(ObjectStore::local()),
2229            new_tmpdir.obj_path(),
2230            Arc::new(LanceCache::no_cache()),
2231        ));
2232
2233        let remapping = HashMap::from([(2, Some(100)), (3, None), (4, Some(101))]);
2234        index
2235            .remap(&RowAddrRemap::direct(remapping), test_store.as_ref())
2236            .await
2237            .unwrap();
2238
2239        let index = NGramIndex::from_store(test_store, None, &LanceCache::no_cache())
2240            .await
2241            .unwrap();
2242        let row_ids = row_ids_in_index(&index).await;
2243        assert_eq!(row_ids, vec![0, 1, 100, 101]);
2244
2245        let null_posting_list = get_null_posting_list(&index).await;
2246        assert_eq!(null_posting_list, vec![100]);
2247    }
2248
2249    // Like `test_ngram_index_remap` but covering both RowAddrRemap modes: rows
2250    // 0..4 of frag 0 are rewritten into frag 10; row 4 is deleted.
2251    fn ngram_remap_compact() -> RowAddrRemap {
2252        use lance_core::utils::row_addr_remap::GroupInput;
2253        use roaring::RoaringTreemap;
2254        RowAddrRemap::compact([GroupInput {
2255            rewritten_old_row_addrs: RoaringTreemap::from_iter(0u64..4),
2256            old_frag_ids: vec![0],
2257            new_frags: vec![(10, 4)],
2258        }])
2259        .unwrap()
2260    }
2261
2262    fn ngram_remap_explicit() -> RowAddrRemap {
2263        RowAddrRemap::direct(
2264            (0u64..4)
2265                .map(|i| (i, Some((10u64 << 32) | i)))
2266                .chain(std::iter::once((4u64, None)))
2267                .collect(),
2268        )
2269    }
2270
2271    #[rstest]
2272    #[case(ngram_remap_compact())]
2273    #[case(ngram_remap_explicit())]
2274    #[tokio::test]
2275    async fn test_ngram_index_remap_compact(#[case] remap: RowAddrRemap) {
2276        let data = simple_data_with_nulls();
2277        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
2278        let (index, _tmpdir) = do_train(builder, data).await;
2279
2280        let row_ids = row_ids_in_index(&index).await;
2281        assert_eq!(row_ids, vec![0, 1, 2, 3, 4]);
2282
2283        let new_tmpdir = Arc::new(TempDir::default());
2284        let test_store = Arc::new(LanceIndexStore::new(
2285            Arc::new(ObjectStore::local()),
2286            new_tmpdir.obj_path(),
2287            Arc::new(LanceCache::no_cache()),
2288        ));
2289
2290        index.remap(&remap, test_store.as_ref()).await.unwrap();
2291
2292        let index = NGramIndex::from_store(test_store, None, &LanceCache::no_cache())
2293            .await
2294            .unwrap();
2295        let addr = |offset: u64| (10u64 << 32) | offset;
2296        let row_ids = row_ids_in_index(&index).await;
2297        assert_eq!(row_ids, vec![addr(0), addr(1), addr(2), addr(3)]);
2298
2299        // rows 2 and 3 are the null docs; both are rewritten into frag 10.
2300        let null_posting_list = get_null_posting_list(&index).await;
2301        assert_eq!(null_posting_list, vec![addr(2), addr(3)]);
2302    }
2303
2304    #[test_log::test(tokio::test)]
2305    async fn test_ngram_index_merge() {
2306        let data = simple_data_with_nulls();
2307        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
2308        let (index, _tmpdir) = do_train(builder, data).await;
2309
2310        let data = StringArray::from_iter(&[Some("giraffe"), Some("cat"), None]);
2311        let row_ids = UInt64Array::from_iter_values((0..data.len()).map(|i| i as u64 + 100));
2312        let schema = Arc::new(Schema::new(vec![
2313            Field::new(VALUE_COLUMN_NAME, DataType::Utf8, true),
2314            Field::new(ROW_ID, DataType::UInt64, false),
2315        ]));
2316        let data =
2317            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
2318        let data = Box::pin(RecordBatchStreamAdapter::new(
2319            schema,
2320            stream::once(std::future::ready(Ok(data))),
2321        ));
2322
2323        let posting_list = get_posting_list_for_trigram(&index, "cat").await;
2324        assert_eq!(posting_list, vec![0, 4]);
2325
2326        let new_tmpdir = Arc::new(TempDir::default());
2327        let test_store = Arc::new(LanceIndexStore::new(
2328            Arc::new(ObjectStore::local()),
2329            new_tmpdir.obj_path(),
2330            Arc::new(LanceCache::no_cache()),
2331        ));
2332
2333        index.update(data, test_store.as_ref(), None).await.unwrap();
2334
2335        let index = NGramIndex::from_store(test_store, None, &LanceCache::no_cache())
2336            .await
2337            .unwrap();
2338        let row_ids = row_ids_in_index(&index).await;
2339        assert_eq!(row_ids, vec![0, 1, 2, 3, 4, 100, 101, 102]);
2340
2341        let posting_list = get_posting_list_for_trigram(&index, "cat").await;
2342        assert_eq!(posting_list, vec![0, 4, 101]);
2343
2344        let posting_list = get_posting_list_for_trigram(&index, "ffe").await;
2345        assert_eq!(posting_list, vec![100]);
2346
2347        let posting_list = get_null_posting_list(&index).await;
2348        assert_eq!(posting_list, vec![2, 3, 102]);
2349    }
2350
2351    #[test]
2352    fn test_retain_row_ids_filters_by_allow_list() {
2353        use arrow_array::UInt32Array;
2354        use roaring::RoaringTreemap;
2355
2356        use super::NGramIndexSpillState;
2357        use crate::scalar::OldIndexDataFilter;
2358
2359        let addr = |frag: u32, local: u32| ((frag as u64) << 32) | local as u64;
2360
2361        // Three tokens whose postings span fragments 0, 1 and 2.
2362        let state = NGramIndexSpillState {
2363            tokens: UInt32Array::from(vec![10u32, 20, 30]),
2364            bitmaps: vec![
2365                RoaringTreemap::from_iter([addr(0, 0), addr(0, 1), addr(1, 0), addr(2, 0)]),
2366                RoaringTreemap::from_iter([addr(2, 5)]),
2367                RoaringTreemap::from_iter([addr(1, 0), addr(1, 1), addr(1, 2)]),
2368            ],
2369        };
2370
2371        // Allow-list: fragment 0 fully live, fragment 1 keeps only local row 0,
2372        // fragment 2 absent (fully retired).
2373        let mut valid = RowAddrTreeMap::new();
2374        valid.insert_fragment(0);
2375        valid.insert(addr(1, 0));
2376
2377        let filtered = state.remap_and_filter_rows(None, Some(&OldIndexDataFilter::RowIds(valid)));
2378
2379        // Token 20 lived only in retired fragment 2, so it is dropped entirely.
2380        assert_eq!(filtered.tokens.values().to_vec(), vec![10u32, 30]);
2381        // Token 10: fragment 0 kept whole, fragment 1 trimmed to local 0,
2382        // fragment 2 dropped.
2383        assert_eq!(
2384            filtered.bitmaps[0].iter().collect::<Vec<_>>(),
2385            vec![addr(0, 0), addr(0, 1), addr(1, 0)],
2386        );
2387        // Token 30: only the allow-listed local row 0 survives.
2388        assert_eq!(
2389            filtered.bitmaps[1].iter().collect::<Vec<_>>(),
2390            vec![addr(1, 0)]
2391        );
2392    }
2393
2394    #[test]
2395    fn test_remap_then_keep_relocates_and_filters() {
2396        use arrow_array::UInt32Array;
2397        use roaring::{RoaringBitmap, RoaringTreemap};
2398        use uuid::Uuid;
2399
2400        use super::NGramIndexSpillState;
2401        use crate::frag_reuse::{FragReuseIndex, FragReuseIndexDetails};
2402        use crate::scalar::OldIndexDataFilter;
2403
2404        let addr = |frag: u32, local: u32| ((frag as u64) << 32) | local as u64;
2405
2406        // One token spanning fragments 0 and 1.
2407        let state = NGramIndexSpillState {
2408            tokens: UInt32Array::from(vec![10u32]),
2409            bitmaps: vec![RoaringTreemap::from_iter([
2410                addr(0, 0),
2411                addr(0, 1),
2412                addr(1, 0),
2413            ])],
2414        };
2415
2416        // Compaction fused fragment 0 into fragment 2: row (0,0) survives at
2417        // (2,0), row (0,1) was deleted (maps to None). Row (1,0) isn't in the
2418        // map, so remap passes it through unchanged.
2419        let fri = Arc::new(FragReuseIndex::new(
2420            Uuid::new_v4(),
2421            vec![HashMap::from([
2422                (addr(0, 0), Some(addr(2, 0))),
2423                (addr(0, 1), None),
2424            ])],
2425            FragReuseIndexDetails { versions: vec![] },
2426        ));
2427
2428        // After remap the live rows sit in fragments 2 and 1; fragment 1 is retired.
2429        let filter = OldIndexDataFilter::Fragments {
2430            to_keep: RoaringBitmap::from_iter([2u32]),
2431            to_remove: RoaringBitmap::from_iter([1u32]),
2432        };
2433
2434        let filtered = state.remap_and_filter_rows(Some(&fri), Some(&filter));
2435
2436        // Only the relocated, still-live row survives: (0,0) -> (2,0). (0,1) was
2437        // dropped by remap; (1,0) was dropped by the retired-fragment filter.
2438        assert_eq!(filtered.tokens.values().to_vec(), vec![10u32]);
2439        assert_eq!(
2440            filtered.bitmaps[0].iter().collect::<Vec<_>>(),
2441            vec![addr(2, 0)]
2442        );
2443    }
2444
2445    #[test_log::test(tokio::test)]
2446    async fn test_ngram_index_with_spill() {
2447        let (data, schema) = lance_datagen::gen_batch()
2448            .col(
2449                VALUE_COLUMN_NAME,
2450                lance_datagen::array::rand_utf8(ByteCount::from(50), false),
2451            )
2452            .col(ROW_ID, lance_datagen::array::step::<UInt64Type>())
2453            .into_reader_stream(RowCount::from(128), BatchCount::from(32));
2454
2455        let data = Box::pin(RecordBatchStreamAdapter::new(
2456            schema,
2457            data.map_err(|arrow_err| DataFusionError::ArrowError(Box::new(arrow_err), None)),
2458        ));
2459
2460        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions {
2461            tokens_per_spill: 100,
2462        })
2463        .unwrap();
2464
2465        let (index, _tmpdir) = do_train(builder, data).await;
2466
2467        assert_eq!(index.tokens.len(), 29012);
2468    }
2469
2470    #[test]
2471    fn test_spill_state_chunks_by_byte_size() {
2472        let bitmaps = (0..8u64)
2473            .map(|i| RoaringTreemap::from_iter(0..(i + 1) * 100))
2474            .collect::<Vec<_>>();
2475        let tokens = UInt32Array::from_iter_values(0..8);
2476        let state = NGramIndexSpillState {
2477            tokens: tokens.clone(),
2478            bitmaps: bitmaps.clone(),
2479        };
2480
2481        // Small enough that several splits are required, large enough that some
2482        // batches hold more than one posting
2483        let max_batch_bytes = bitmaps.iter().map(|b| b.serialized_size()).max().unwrap() * 2;
2484        let batches = state.try_into_batches_impl(max_batch_bytes).unwrap();
2485        assert!(batches.len() > 1);
2486
2487        // Token order and posting contents survive the chunking
2488        let mut row = 0;
2489        for batch in &batches {
2490            let batch_tokens = batch["tokens"].as_primitive::<UInt32Type>();
2491            let batch_postings = batch["posting_list"].as_binary::<i32>();
2492            let mut batch_bytes = 0;
2493            for i in 0..batch.num_rows() {
2494                assert_eq!(batch_tokens.value(i), tokens.value(row));
2495                let posting = batch_postings.value(i);
2496                batch_bytes += posting.len();
2497                assert_eq!(
2498                    RoaringTreemap::deserialize_from(posting).unwrap(),
2499                    bitmaps[row]
2500                );
2501                row += 1;
2502            }
2503            assert!(batch_bytes <= max_batch_bytes || batch.num_rows() == 1);
2504        }
2505        assert_eq!(row, 8);
2506    }
2507
2508    #[test_log::test(tokio::test)]
2509    async fn test_spill_reader_does_not_materialize_multirow_posting_batches() {
2510        let bitmaps = (0..8u64)
2511            .map(|i| RoaringTreemap::from_iter(0..(i + 1) * 100))
2512            .collect::<Vec<_>>();
2513        let tokens = UInt32Array::from_iter_values(0..8);
2514        let state = NGramIndexSpillState {
2515            tokens: tokens.clone(),
2516            bitmaps: bitmaps.clone(),
2517        };
2518        let max_batch_bytes = bitmaps.iter().map(|b| b.serialized_size()).max().unwrap() * 2;
2519
2520        let tmpdir = Arc::new(TempDir::default());
2521        let store = LanceIndexStore::new(
2522            Arc::new(ObjectStore::local()),
2523            tmpdir.obj_path(),
2524            Arc::new(LanceCache::no_cache()),
2525        );
2526        let mut writer = store
2527            .new_index_file(POSTINGS_FILENAME, POSTINGS_SCHEMA.clone())
2528            .await
2529            .unwrap();
2530        for batch in state.try_into_batches().unwrap() {
2531            writer.write_record_batch(batch).await.unwrap();
2532        }
2533        writer.finish().await.unwrap();
2534
2535        let reader = store.open_index_file(POSTINGS_FILENAME).await.unwrap();
2536        let reader = Arc::new(MaxReadRangeReader {
2537            inner: reader,
2538            max_rows: 1,
2539        });
2540        let states = NGramIndexBuilder::stream_spill_reader(reader, max_batch_bytes)
2541            .unwrap()
2542            .try_collect::<Vec<_>>()
2543            .await
2544            .unwrap();
2545        assert!(states.len() > 1);
2546
2547        let mut row = 0;
2548        for state in states {
2549            let batch_bytes = state
2550                .bitmaps
2551                .iter()
2552                .map(RoaringTreemap::serialized_size)
2553                .sum::<usize>();
2554            assert!(batch_bytes <= max_batch_bytes || state.bitmaps.len() == 1);
2555            for (token, bitmap) in state.tokens.values().iter().zip(state.bitmaps) {
2556                assert_eq!(*token, tokens.value(row));
2557                assert_eq!(bitmap, bitmaps[row]);
2558                row += 1;
2559            }
2560        }
2561        assert_eq!(row, 8);
2562    }
2563
2564    #[test]
2565    fn test_spill_state_rejects_oversized_posting() {
2566        let bitmap = RoaringTreemap::from_iter(0..1000u64);
2567        let too_small = bitmap.serialized_size() - 1;
2568        let state = NGramIndexSpillState {
2569            tokens: UInt32Array::from_iter_values([42]),
2570            bitmaps: vec![bitmap],
2571        };
2572        let err = state.try_into_batches_impl(too_small).unwrap_err();
2573        assert!(err.to_string().contains("token 42"), "{}", err);
2574    }
2575
2576    #[test]
2577    fn test_empty_spill_state_yields_one_empty_batch() {
2578        let state = NGramIndexSpillState {
2579            tokens: UInt32Array::from_iter_values([]),
2580            bitmaps: vec![],
2581        };
2582        let batches = state.try_into_batches().unwrap();
2583        assert_eq!(batches.len(), 1);
2584        assert_eq!(batches[0].num_rows(), 0);
2585    }
2586
2587    // Reproduces https://linear.app/lancedb/issue/ENT-874: serialized posting lists
2588    // totalling more than i32::MAX bytes used to panic with "byte array offset
2589    // overflow" when packed into a single Binary array.
2590    #[test]
2591    #[ignore = "needs ~8 GiB of RAM and a couple of minutes; run manually"]
2592    fn test_spill_state_over_i32_max_bytes() {
2593        // Every 16th value keeps each container an array container (4096 entries,
2594        // 2 bytes per value, immune to run compression), so the treemap serializes
2595        // to ~450 MiB.  Six copies exceed i32::MAX total bytes.
2596        let bitmap = RoaringTreemap::from_sorted_iter((0..225_000_000u64).map(|v| v * 16)).unwrap();
2597        assert!(bitmap.serialized_size() > 400 * 1024 * 1024);
2598        let bitmaps = vec![bitmap; 6];
2599        let tokens = UInt32Array::from_iter_values(0..6);
2600        let state = NGramIndexSpillState { tokens, bitmaps };
2601
2602        let batches = state.try_into_batches().unwrap();
2603        assert!(batches.len() > 1);
2604        assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 6);
2605        for batch in &batches {
2606            let postings = batch["posting_list"].as_binary::<i32>();
2607            assert!(postings.value_data().len() <= i32::MAX as usize);
2608        }
2609    }
2610}