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