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 std::any::Any;
5use std::collections::BTreeMap;
6use std::iter::once;
7use std::time::Instant;
8use std::{collections::HashMap, sync::Arc};
9
10use super::lance_format::LanceIndexStore;
11use super::{
12    AnyQuery, BuiltinIndexType, IndexReader, IndexStore, IndexWriter, MetricsCollector,
13    ScalarIndex, ScalarIndexParams, SearchResult, TextQuery,
14};
15use crate::frag_reuse::FragReuseIndex;
16use crate::metrics::NoOpMetricsCollector;
17use crate::pbold;
18use crate::scalar::expression::{ScalarQueryParser, TextQueryParser};
19use crate::scalar::registry::{
20    DefaultTrainingRequest, ScalarIndexPlugin, TrainingCriteria, TrainingOrdering, TrainingRequest,
21    VALUE_COLUMN_NAME,
22};
23use crate::scalar::{CreatedIndex, UpdateCriteria};
24use crate::vector::VectorIndex;
25use crate::{Index, IndexType};
26use arrow::array::{AsArray, UInt32Builder};
27use arrow::datatypes::{UInt32Type, UInt64Type};
28use arrow_array::{BinaryArray, RecordBatch, UInt32Array};
29use arrow_schema::{DataType, Field, Schema, SchemaRef};
30use async_trait::async_trait;
31use datafusion::execution::SendableRecordBatchStream;
32use deepsize::DeepSizeOf;
33use futures::{FutureExt, Stream, StreamExt, TryStreamExt, stream};
34use lance_arrow::iter_str_array;
35use lance_core::cache::{CacheKey, LanceCache, WeakLanceCache};
36use lance_core::error::LanceOptionExt;
37use lance_core::utils::address::RowAddress;
38use lance_core::utils::tempfile::TempDir;
39use lance_core::utils::tokio::get_num_compute_intensive_cpus;
40use lance_core::utils::tracing::{IO_TYPE_LOAD_SCALAR_PART, TRACE_IO_EVENTS};
41use lance_core::{Error, utils::mask::RowAddrTreeMap};
42use lance_core::{ROW_ID, Result};
43use lance_io::object_store::ObjectStore;
44use log::info;
45use roaring::{RoaringBitmap, RoaringTreemap};
46use serde::Serialize;
47use tantivy::tokenizer::TextAnalyzer;
48use tracing::instrument;
49
50const TOKENS_COL: &str = "tokens";
51const POSTING_LIST_COL: &str = "posting_list";
52const POSTINGS_FILENAME: &str = "ngram_postings.lance";
53const NGRAM_INDEX_VERSION: u32 = 0;
54
55use std::sync::LazyLock;
56
57pub static TOKENS_FIELD: LazyLock<Field> =
58    LazyLock::new(|| Field::new(TOKENS_COL, DataType::UInt32, true));
59pub static POSTINGS_FIELD: LazyLock<Field> =
60    LazyLock::new(|| Field::new(POSTING_LIST_COL, DataType::Binary, false));
61pub static POSTINGS_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
62    Arc::new(Schema::new(vec![
63        TOKENS_FIELD.clone(),
64        POSTINGS_FIELD.clone(),
65    ]))
66});
67pub static TEXT_PREPPER: LazyLock<TextAnalyzer> = LazyLock::new(|| {
68    TextAnalyzer::builder(tantivy::tokenizer::RawTokenizer::default())
69        .filter(tantivy::tokenizer::LowerCaser)
70        .filter(tantivy::tokenizer::AsciiFoldingFilter)
71        .build()
72});
73/// Currently we ALWAYS use trigrams with ascii folding and lower casing.  We may want to make this configurable in the future.
74pub static NGRAM_TOKENIZER: LazyLock<TextAnalyzer> = LazyLock::new(|| {
75    TextAnalyzer::builder(tantivy::tokenizer::NgramTokenizer::all_ngrams(3, 3).unwrap())
76        .filter(tantivy::tokenizer::AlphaNumOnlyFilter)
77        .build()
78});
79
80// Helper function to apply a function to each token in a text
81fn tokenize_visitor(tokenizer: &TextAnalyzer, text: &str, mut visitor: impl FnMut(&String)) {
82    // The token_stream method is mutable.  As far as I can tell this is to enforce exclusivity and not
83    // true mutability.  For example, the object returned by `token_stream` has thread-local state but
84    // it is reset each time `token_stream` is called.
85    //
86    // However, I don't see this documented anywhere and I'm not sure about relying on it.  For now, we
87    // make a clone as that seems to be the safer option.  All the tokenizers we use here should be trivially
88    // cloneable (although it requires a heap allocation so may be worth investigating in the future)
89    let mut prepper = TEXT_PREPPER.clone();
90    let mut tokenizer = tokenizer.clone();
91    let mut raw_stream = prepper.token_stream(text);
92    while raw_stream.advance() {
93        let mut token_stream = tokenizer.token_stream(&raw_stream.token().text);
94        while token_stream.advance() {
95            visitor(&token_stream.token().text);
96        }
97    }
98}
99
100const ALPHA_SPAN: usize = 37;
101const MAX_TOKEN: usize = ALPHA_SPAN.pow(2) + ALPHA_SPAN;
102const MIN_TOKEN: usize = 0;
103const NGRAM_N: usize = 3;
104
105// Convert an ngram (string) to a token (u32).  This helps avoid heap allocations
106// and it makes it easier to partition the tokens for shuffling
107//
108// There are 36 alphanumeric values and we add 1 for the NULL token giving us 37^3
109// potential tokens.
110//
111// "" => 0
112// "?" => 37^2 * ?
113// "?$" => 37^2 * ? + 37 * $
114// "?$#" => 37^2 * ? + 37 * $ + #
115// ...
116//
117// The ?,$,# represent the position in the alphabet (+1 to distinguish from NULL)
118//
119// Small strings get the larger multipliers because those ngrams are
120// less likely to be unique and will have larger bitmaps.  We want to
121// spread those out.
122//
123// NOTE: Today we hard-code trigrams and we do not include 1-grams or 2-grams so this
124// function is more general than it needs to be...just in case.
125fn ngram_to_token(ngram: &str, ngram_length: usize) -> u32 {
126    let mut token = 0;
127    // Empty string will get 0
128    for (idx, byte) in ngram.bytes().enumerate() {
129        let pos = if byte <= b'9' {
130            byte - b'0'
131        } else if byte <= b'z' {
132            byte - b'a' + 10
133        } else {
134            unreachable!()
135        } + 1;
136        debug_assert!(pos < ALPHA_SPAN as u8);
137        let mult = ALPHA_SPAN.pow(ngram_length as u32 - idx as u32 - 1) as u32;
138        token += pos as u32 * mult;
139    }
140    token
141}
142
143/// Basic stats about an ngram index
144#[derive(Serialize)]
145struct NGramStatistics {
146    num_ngrams: usize,
147}
148
149/// The row ids that contain a given ngram
150#[derive(Debug)]
151pub struct NGramPostingList {
152    bitmap: RoaringTreemap,
153}
154
155impl DeepSizeOf for NGramPostingList {
156    fn deep_size_of_children(&self, _: &mut deepsize::Context) -> usize {
157        self.bitmap.serialized_size()
158    }
159}
160
161// Cache key implementation for type-safe cache access
162#[derive(Debug, Clone)]
163pub struct NGramPostingListKey {
164    pub row_offset: u32,
165}
166
167impl CacheKey for NGramPostingListKey {
168    type ValueType = NGramPostingList;
169
170    fn key(&self) -> std::borrow::Cow<'_, str> {
171        format!("posting-list-{}", self.row_offset).into()
172    }
173}
174
175impl NGramPostingList {
176    fn try_from_batch(
177        batch: RecordBatch,
178        frag_reuse_index: Option<Arc<FragReuseIndex>>,
179    ) -> Result<Self> {
180        let bitmap_bytes = batch.column(0).as_binary::<i32>().value(0);
181        let mut bitmap = RoaringTreemap::deserialize_from(bitmap_bytes)
182            .map_err(|e| Error::internal(format!("Error deserializing ngram list: {}", e)))?;
183        if let Some(frag_reuse_index_ref) = frag_reuse_index.as_ref() {
184            bitmap = frag_reuse_index_ref.remap_row_ids_roaring_tree_map(&bitmap);
185        }
186        Ok(Self { bitmap })
187    }
188
189    fn intersect<'a>(lists: impl IntoIterator<Item = &'a Self>) -> RoaringTreemap {
190        let mut iter = lists.into_iter();
191        let mut result = iter
192            .next()
193            .map(|list| list.bitmap.clone())
194            .unwrap_or_default();
195        for list in iter {
196            result &= &list.bitmap;
197        }
198        result
199    }
200}
201
202/// Reads on-demand ngram posting lists from storage (and stores them in a cache)
203struct NGramPostingListReader {
204    reader: Arc<dyn IndexReader>,
205    frag_reuse_index: Option<Arc<FragReuseIndex>>,
206    index_cache: WeakLanceCache,
207}
208
209impl DeepSizeOf for NGramPostingListReader {
210    fn deep_size_of_children(&self, _: &mut deepsize::Context) -> usize {
211        0
212    }
213}
214
215impl std::fmt::Debug for NGramPostingListReader {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        f.debug_struct("NGramListReader").finish()
218    }
219}
220
221impl NGramPostingListReader {
222    #[instrument(level = "debug", skip(self, metrics))]
223    pub async fn ngram_list(
224        &self,
225        row_offset: u32,
226        metrics: &dyn MetricsCollector,
227    ) -> Result<Arc<NGramPostingList>> {
228        self.index_cache.get_or_insert_with_key(NGramPostingListKey { row_offset }, || async move {
229            metrics.record_part_load();
230                tracing::info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_SCALAR_PART, index_type="ngram", part_id=row_offset);
231                let batch = self
232                    .reader
233                    .read_range(
234                        row_offset as usize..row_offset as usize + 1,
235                        Some(&[POSTING_LIST_COL]),
236                    )
237                    .await?;
238                NGramPostingList::try_from_batch(batch, self.frag_reuse_index.clone())
239        }).await
240    }
241}
242
243/// An ngram index
244///
245/// At a high level this is an inverted index that maps ngrams (small fixed size substrings) to the
246/// row ids that contain them.
247///
248/// As a simple example consider a 1-gram index.  It would basically be a mapping from
249/// each letter to the row ids that contain that letter.  Then, if the user searches for
250/// "cat", the index would look up the row ids for "c", "a", and "t", and return the intersection
251/// of those row ids because only rows have at least one c, a, and t could possible contain "cat".
252///
253/// This is an in-exact index, similar to a bloom filter.  It can return false positives and a
254/// recheck step is needed to confirm the results.
255///
256/// Note that it cannot return false negatives.
257pub struct NGramIndex {
258    /// The mapping from tokens to row offsets
259    tokens: HashMap<u32, u32>,
260    /// The reader for the posting lists
261    list_reader: Arc<NGramPostingListReader>,
262    /// The tokenizer used to tokenize text.  Note: not all tokenizers can be used with this index.  For
263    /// example, a stemming tokenizer would not work well because "dozing" would stem to "doze" and if the
264    /// search term is "zing" it would not match.  As a result, this tokenizer is not as configurable as the
265    /// tokenizers used in an inverted index.
266    tokenizer: TextAnalyzer,
267    io_parallelism: usize,
268    /// The store that owns the index
269    store: Arc<dyn IndexStore>,
270}
271
272impl std::fmt::Debug for NGramIndex {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        f.debug_struct("NGramIndex")
275            .field("tokens", &self.tokens)
276            .field("list_reader", &self.list_reader)
277            .finish()
278    }
279}
280
281impl DeepSizeOf for NGramIndex {
282    fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize {
283        self.tokens.deep_size_of_children(context)
284    }
285}
286
287impl NGramIndex {
288    async fn from_store(
289        store: Arc<dyn IndexStore>,
290        frag_reuse_index: Option<Arc<FragReuseIndex>>,
291        index_cache: &LanceCache,
292    ) -> Result<Self> {
293        let tokens = store.open_index_file(POSTINGS_FILENAME).await?;
294        let tokens = tokens
295            .read_range(0..tokens.num_rows(), Some(&[TOKENS_COL]))
296            .await?;
297
298        let tokens_map = HashMap::from_iter(
299            tokens
300                .column(0)
301                .as_primitive::<UInt32Type>()
302                .values()
303                .iter()
304                .copied()
305                .enumerate()
306                .map(|(idx, token)| (token, idx as u32)),
307        );
308
309        let posting_reader = Arc::new(NGramPostingListReader {
310            reader: store.open_index_file(POSTINGS_FILENAME).await?,
311            frag_reuse_index,
312            index_cache: WeakLanceCache::from(index_cache),
313        });
314
315        Ok(Self {
316            io_parallelism: store.io_parallelism(),
317            tokens: tokens_map,
318            list_reader: posting_reader,
319            tokenizer: NGRAM_TOKENIZER.clone(),
320            store,
321        })
322    }
323
324    fn remap_batch(
325        &self,
326        batch: RecordBatch,
327        mapping: &HashMap<u64, Option<u64>>,
328    ) -> Result<RecordBatch> {
329        let posting_lists_array = batch
330            .column_by_name(POSTING_LIST_COL)
331            .expect_ok()?
332            .as_binary::<i32>();
333
334        let new_posting_lists = posting_lists_array
335            .iter()
336            .map(|posting_list| {
337                let posting_list = posting_list.unwrap();
338                let posting_list = RoaringTreemap::deserialize_from(posting_list)?;
339                let new_posting_list =
340                    RoaringTreemap::from_iter(posting_list.into_iter().filter_map(|row_id| {
341                        match mapping.get(&row_id) {
342                            Some(Some(new_row_id)) => Some(*new_row_id),
343                            Some(None) => None,
344                            None => Some(row_id),
345                        }
346                    }));
347                let mut buf = Vec::with_capacity(new_posting_list.serialized_size());
348                new_posting_list.serialize_into(&mut buf)?;
349                Ok(buf)
350            })
351            .collect::<Result<Vec<_>>>()?;
352
353        let new_posting_lists_array = BinaryArray::from_iter_values(new_posting_lists);
354
355        Ok(RecordBatch::try_new(
356            POSTINGS_SCHEMA.clone(),
357            vec![
358                batch.column_by_name(TOKENS_COL).expect_ok()?.clone(),
359                Arc::new(new_posting_lists_array),
360            ],
361        )?)
362    }
363
364    async fn load(
365        store: Arc<dyn IndexStore>,
366        frag_reuse_index: Option<Arc<FragReuseIndex>>,
367        index_cache: &LanceCache,
368    ) -> Result<Arc<Self>>
369    where
370        Self: Sized,
371    {
372        Ok(Arc::new(
373            Self::from_store(store, frag_reuse_index, index_cache).await?,
374        ))
375    }
376}
377
378#[async_trait]
379impl Index for NGramIndex {
380    fn as_any(&self) -> &dyn Any {
381        self
382    }
383
384    fn as_index(self: Arc<Self>) -> Arc<dyn Index> {
385        self
386    }
387
388    fn as_vector_index(self: Arc<Self>) -> Result<Arc<dyn VectorIndex>> {
389        Err(Error::invalid_input_source(
390            "NGramIndex is not a vector index".into(),
391        ))
392    }
393
394    fn statistics(&self) -> Result<serde_json::Value> {
395        let ngram_stats = NGramStatistics {
396            num_ngrams: self.tokens.len(),
397        };
398        serde_json::to_value(ngram_stats)
399            .map_err(|e| Error::internal(format!("Error serializing statistics: {}", e)))
400    }
401
402    async fn prewarm(&self) -> Result<()> {
403        // TODO: NGram index can pre-warm by loading all posting lists into memory
404        Ok(())
405    }
406
407    fn index_type(&self) -> IndexType {
408        IndexType::NGram
409    }
410
411    async fn calculate_included_frags(&self) -> Result<RoaringBitmap> {
412        let mut frag_ids = RoaringBitmap::new();
413        for row_offset in self.tokens.values() {
414            let list = self
415                .list_reader
416                .ngram_list(*row_offset, &NoOpMetricsCollector)
417                .await?;
418            frag_ids.extend(
419                list.bitmap
420                    .iter()
421                    .map(|row_addr| RowAddress::from(row_addr).fragment_id()),
422            );
423        }
424        Ok(frag_ids)
425    }
426}
427
428#[async_trait]
429impl ScalarIndex for NGramIndex {
430    async fn search(
431        &self,
432        query: &dyn AnyQuery,
433        metrics: &dyn MetricsCollector,
434    ) -> Result<SearchResult> {
435        let query = query
436            .as_any()
437            .downcast_ref::<TextQuery>()
438            .ok_or_else(|| Error::invalid_input_source("Query is not a TextQuery".into()))?;
439        match query {
440            TextQuery::StringContains(substr) => {
441                if substr.len() < NGRAM_N {
442                    // We know nothing on short searches, need to recheck all
443                    return Ok(SearchResult::at_least(RowAddrTreeMap::new()));
444                }
445
446                let mut row_offsets = Vec::with_capacity(substr.len() * 3);
447                let mut missing = false;
448                tokenize_visitor(&self.tokenizer, substr, |ngram| {
449                    let token = ngram_to_token(ngram, NGRAM_N);
450                    if let Some(row_offset) = self.tokens.get(&token) {
451                        row_offsets.push(*row_offset);
452                    } else {
453                        missing = true;
454                    }
455                });
456                // At least one token was missing, so we know there are zero results
457                if missing {
458                    return Ok(SearchResult::exact(RowAddrTreeMap::new()));
459                }
460                let posting_lists = futures::stream::iter(
461                    row_offsets
462                        .into_iter()
463                        .map(|row_offset| self.list_reader.ngram_list(row_offset, metrics)),
464                )
465                .buffer_unordered(self.io_parallelism)
466                .try_collect::<Vec<_>>()
467                .await?;
468                metrics.record_comparisons(posting_lists.len());
469                let list_refs = posting_lists.iter().map(|list| list.as_ref());
470                let row_ids = NGramPostingList::intersect(list_refs);
471                Ok(SearchResult::at_most(RowAddrTreeMap::from(row_ids)))
472            }
473        }
474    }
475
476    fn can_remap(&self) -> bool {
477        true
478    }
479
480    async fn remap(
481        &self,
482        mapping: &HashMap<u64, Option<u64>>,
483        dest_store: &dyn IndexStore,
484    ) -> Result<CreatedIndex> {
485        let reader = self.store.open_index_file(POSTINGS_FILENAME).await?;
486        let mut writer = dest_store
487            .new_index_file(POSTINGS_FILENAME, POSTINGS_SCHEMA.clone())
488            .await?;
489
490        let mut offset = 0;
491        let num_rows = reader.num_rows();
492        const BATCH_SIZE: usize = 64;
493        while offset < num_rows {
494            let batch_size = BATCH_SIZE.min(num_rows - offset);
495            let batch = reader.read_range(offset..offset + batch_size, None).await?;
496            let batch = self.remap_batch(batch, mapping)?;
497            writer.write_record_batch(batch).await?;
498            offset += BATCH_SIZE;
499        }
500
501        writer.finish().await?;
502
503        Ok(CreatedIndex {
504            index_details: prost_types::Any::from_msg(&pbold::NGramIndexDetails::default())
505                .unwrap(),
506            index_version: NGRAM_INDEX_VERSION,
507        })
508    }
509
510    async fn update(
511        &self,
512        new_data: SendableRecordBatchStream,
513        dest_store: &dyn IndexStore,
514        _valid_old_fragments: Option<&RoaringBitmap>,
515    ) -> Result<CreatedIndex> {
516        let mut builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default())?;
517        let spill_files = builder.train(new_data).await?;
518
519        builder
520            .write_index(dest_store, spill_files, Some(self.store.clone()))
521            .await?;
522
523        Ok(CreatedIndex {
524            index_details: prost_types::Any::from_msg(&pbold::NGramIndexDetails::default())
525                .unwrap(),
526            index_version: NGRAM_INDEX_VERSION,
527        })
528    }
529
530    fn update_criteria(&self) -> UpdateCriteria {
531        UpdateCriteria::only_new_data(TrainingCriteria::new(TrainingOrdering::None).with_row_id())
532    }
533
534    fn derive_index_params(&self) -> Result<ScalarIndexParams> {
535        Ok(ScalarIndexParams::for_builtin(BuiltinIndexType::NGram))
536    }
537}
538
539#[derive(Debug, Clone)]
540pub struct NGramIndexBuilderOptions {
541    tokens_per_spill: usize,
542}
543
544// A higher value will use more RAM.  A lower value will have to do more spilling
545static DEFAULT_TOKENS_PER_SPILL: LazyLock<usize> = LazyLock::new(|| {
546    std::env::var("LANCE_NGRAM_TOKENS_PER_SPILL")
547        .unwrap_or_else(|_| "1000000000".to_string())
548        .parse()
549        .expect("failed to parse LANCE_NGRAM_TOKENS_PER_SPILL")
550});
551// How many partitions to use for shuffling out the work.  We slightly
552// over-allocate this since the amount of work per-partition is not uniform.
553//
554// Increasing this may increase the performance but it could increase RAM (since we will spill less often)
555// and could hurt performance (since there will be more files at the end for the final spill)
556static DEFAULT_NUM_PARTITIONS: LazyLock<usize> = LazyLock::new(|| {
557    std::env::var("LANCE_NGRAM_NUM_PARTITIONS")
558        .map(|s| s.parse().expect("failed to parse LANCE_NGRAM_PARALLELISM"))
559        .unwrap_or((get_num_compute_intensive_cpus() * 4).max(128))
560});
561// Just enough so that tokenizing is faster than I/O
562static DEFAULT_TOKENIZE_PARALLELISM: LazyLock<usize> = LazyLock::new(|| {
563    std::env::var("LANCE_NGRAM_TOKENIZE_PARALLELISM")
564        .map(|s| {
565            s.parse()
566                .expect("failed to parse LANCE_NGRAM_TOKENIZE_PARALLELISM")
567        })
568        .unwrap_or(8)
569});
570
571impl Default for NGramIndexBuilderOptions {
572    fn default() -> Self {
573        Self {
574            tokens_per_spill: *DEFAULT_TOKENS_PER_SPILL,
575        }
576    }
577}
578
579// An ordered list of tokens and bitmaps
580//
581// The `tokens` list is ordered by token value.  This makes it easier to merge spill files.
582struct NGramIndexSpillState {
583    tokens: UInt32Array,
584    bitmaps: Vec<RoaringTreemap>,
585}
586
587impl NGramIndexSpillState {
588    fn try_from_batch(batch: RecordBatch) -> Result<Self> {
589        let tokens = batch
590            .column_by_name(TOKENS_COL)
591            .expect_ok()?
592            .as_primitive::<UInt32Type>()
593            .clone();
594        let postings = batch
595            .column_by_name(POSTING_LIST_COL)
596            .expect_ok()?
597            .as_binary::<i32>();
598
599        let bitmaps = postings
600            .into_iter()
601            .map(|bytes| {
602                RoaringTreemap::deserialize_from(bytes.expect_ok()?)
603                    .map_err(|e| Error::internal(format!("Error deserializing ngram list: {}", e)))
604            })
605            .collect::<Result<Vec<_>>>()?;
606
607        Ok(Self { tokens, bitmaps })
608    }
609
610    fn try_into_batch(self) -> Result<RecordBatch> {
611        let bitmap_array = BinaryArray::from_iter_values(self.bitmaps.into_iter().map(|bitmap| {
612            let mut buf = Vec::with_capacity(bitmap.serialized_size());
613            bitmap.serialize_into(&mut buf).unwrap();
614            buf
615        }));
616        Ok(RecordBatch::try_new(
617            POSTINGS_SCHEMA.clone(),
618            vec![Arc::new(self.tokens), Arc::new(bitmap_array)],
619        )?)
620    }
621}
622
623// As we're building we create a map from ngram to row ids.  When this map gets too large
624// we spill it to disk.
625struct NGramIndexBuildState {
626    tokens_map: BTreeMap<u32, RoaringTreemap>,
627}
628
629impl NGramIndexBuildState {
630    fn starting() -> Self {
631        Self {
632            tokens_map: BTreeMap::new(),
633        }
634    }
635
636    fn take(&mut self) -> Self {
637        let mut taken = Self::starting();
638        std::mem::swap(&mut self.tokens_map, &mut taken.tokens_map);
639        taken
640    }
641
642    fn into_spill(self) -> NGramIndexSpillState {
643        // We can rely on these being in token order because of BTreeMap
644        let tokens = UInt32Array::from_iter_values(self.tokens_map.keys().copied());
645        let bitmaps = Vec::from_iter(self.tokens_map.into_values());
646
647        NGramIndexSpillState { bitmaps, tokens }
648    }
649}
650
651/// A builder for an ngram index
652///
653/// The builder is a small pipeline.  First, we read in the data and tokenize it.  This
654/// stage uses fan-out parallelism to tokenize the data because tokenization may be a little
655/// slower than I/O.
656///
657/// The second stage fans out much wider.  It partitions the tokens into a number of partitions.
658/// Each partition has a BTreemap that maps tokens to row ids.  The partitions then build up
659/// roaring treemaps.  When a partition gets too full it will spill to disk.
660///
661/// Once all the data is processed we spill all the parititons to disk and then we merge the
662/// spill files into a single index file.
663pub struct NGramIndexBuilder {
664    tokenizer: TextAnalyzer,
665    options: NGramIndexBuilderOptions,
666    tmpdir: Arc<TempDir>,
667    spill_store: Arc<dyn IndexStore>,
668
669    tokens_seen: usize,
670    worker_number: usize,
671    has_flushed: bool,
672
673    state: NGramIndexBuildState,
674}
675
676impl NGramIndexBuilder {
677    pub fn try_new(options: NGramIndexBuilderOptions) -> Result<Self> {
678        Self::from_state(NGramIndexBuildState::starting(), options)
679    }
680
681    fn clone_worker(&self, worker_number: usize) -> Self {
682        let mut bitmaps = Vec::with_capacity(36 * 36 * 36 + 1);
683        // Token 0 is always the NULL bitmap
684        bitmaps.push(RoaringTreemap::new());
685        Self {
686            tokenizer: self.tokenizer.clone(),
687            state: NGramIndexBuildState::starting(),
688            tmpdir: self.tmpdir.clone(),
689            spill_store: self.spill_store.clone(),
690            options: self.options.clone(),
691            tokens_seen: 0,
692            worker_number,
693            has_flushed: false,
694        }
695    }
696
697    fn from_state(state: NGramIndexBuildState, options: NGramIndexBuilderOptions) -> Result<Self> {
698        let tokenizer = NGRAM_TOKENIZER.clone();
699
700        let tmpdir = Arc::new(TempDir::default());
701        let spill_store = Arc::new(LanceIndexStore::new(
702            Arc::new(ObjectStore::local()),
703            tmpdir.obj_path(),
704            Arc::new(LanceCache::no_cache()),
705        ));
706
707        Ok(Self {
708            tokenizer,
709            state,
710            tmpdir,
711            spill_store,
712            options,
713            tokens_seen: 0,
714            worker_number: 0,
715            has_flushed: false,
716        })
717    }
718
719    fn validate_schema(schema: &Schema) -> Result<()> {
720        if schema.fields().len() != 2 {
721            return Err(Error::invalid_input_source(
722                "Ngram index schema must have exactly two fields".into(),
723            ));
724        }
725        let values_field = schema.field_with_name(VALUE_COLUMN_NAME)?;
726        if *values_field.data_type() != DataType::Utf8
727            && *values_field.data_type() != DataType::LargeUtf8
728        {
729            return Err(Error::invalid_input_source(
730                "First field in ngram index schema must be of type Utf8/LargeUtf8".into(),
731            ));
732        }
733        let row_id_field = schema.field_with_name(ROW_ID)?;
734        if *row_id_field.data_type() != DataType::UInt64 {
735            return Err(Error::invalid_input_source(
736                "Second field in ngram index schema must be of type UInt64".into(),
737            ));
738        }
739        Ok(())
740    }
741
742    async fn process_batch(&mut self, tokens_and_ids: Vec<(u32, u64)>) -> Result<()> {
743        let mut tokens_seen = 0;
744        for (token, row_id) in tokens_and_ids {
745            tokens_seen += 1;
746            // This would be a bit simpler with entry API but, at scale, the vast majority
747            // of cases will be a hit and we want to avoid cloning the string if we can.  So
748            // for now we do the double-hash.  We can simplify in the future with raw_entry
749            // when it stabilizes.
750            self.state
751                .tokens_map
752                .entry(token)
753                .or_default()
754                .insert(row_id);
755        }
756        self.tokens_seen += tokens_seen;
757        if self.tokens_seen >= self.options.tokens_per_spill {
758            let state = self.state.take();
759            self.flush(state).await?;
760        }
761        Ok(())
762    }
763
764    fn spill_filename(id: usize) -> String {
765        format!("spill-{}.lance", id)
766    }
767
768    fn tmp_spill_filename(id: usize) -> String {
769        format!("spill-{}.lance.tmp", id)
770    }
771
772    async fn flush(&mut self, state: NGramIndexBuildState) -> Result<bool> {
773        if self.tokens_seen == 0 {
774            assert!(state.tokens_map.is_empty());
775            return Ok(self.has_flushed);
776        }
777        self.tokens_seen = 0;
778        let spill_state = state.into_spill();
779        let flush_start = Instant::now();
780        // The primary builder should never flush
781        debug_assert_ne!(self.worker_number, 0);
782        if self.has_flushed {
783            info!("Merging flush for worker {}", self.worker_number);
784            // If we have flushed before then we need to merge with the spill file
785            let mut writer = self
786                .spill_store
787                .new_index_file(
788                    &Self::tmp_spill_filename(self.worker_number),
789                    POSTINGS_SCHEMA.clone(),
790                )
791                .await?;
792
793            let left_stream = stream::once(std::future::ready(Ok(spill_state)));
794            let right_stream =
795                Self::stream_spill(self.spill_store.clone(), self.worker_number).await?;
796            Self::merge_spill_streams(left_stream, right_stream, writer.as_mut()).await?;
797            drop(writer);
798            self.spill_store
799                .rename_index_file(
800                    &Self::tmp_spill_filename(self.worker_number),
801                    &Self::spill_filename(self.worker_number),
802                )
803                .await?;
804        } else {
805            // If we haven't flushed before we can just write to the spill file
806            info!("Initial flush for worker {}", self.worker_number);
807            self.has_flushed = true;
808            let writer = self
809                .spill_store
810                .new_index_file(
811                    &Self::spill_filename(self.worker_number),
812                    POSTINGS_SCHEMA.clone(),
813                )
814                .await?;
815            self.write(writer, spill_state).await?;
816        }
817        let flush_time = flush_start.elapsed();
818        info!(
819            "Flushed worker {} in {}ms",
820            self.worker_number,
821            flush_time.as_millis()
822        );
823        Ok(true)
824    }
825
826    fn tokenize_and_partition(
827        tokenizer: &TextAnalyzer,
828        batch: RecordBatch,
829        num_workers: usize,
830    ) -> Result<Vec<Vec<(u32, u64)>>> {
831        let text_iter = iter_str_array(batch.column_by_name(VALUE_COLUMN_NAME).expect_ok()?);
832        let row_id_col = batch
833            .column_by_name(ROW_ID)
834            .expect_ok()?
835            .as_primitive::<UInt64Type>();
836        // Guessing 1000 tokens per row to at least avoid some of the earlier allocations
837        let mut partitions = vec![Vec::with_capacity(batch.num_rows() * 1000); num_workers];
838        let divisor = (MAX_TOKEN - MIN_TOKEN) / num_workers;
839        for (text, row_id) in text_iter.zip(row_id_col.values()) {
840            if let Some(text) = text {
841                tokenize_visitor(tokenizer, text, |token| {
842                    let token = ngram_to_token(token, NGRAM_N);
843                    let partition_id = (token as usize).saturating_sub(MIN_TOKEN) / divisor;
844                    partitions[partition_id % num_workers].push((token, *row_id));
845                });
846            } else {
847                partitions[0].push((0, *row_id));
848            }
849        }
850        Ok(partitions)
851    }
852
853    pub async fn train(&mut self, data: SendableRecordBatchStream) -> Result<Vec<usize>> {
854        let schema = data.schema();
855        Self::validate_schema(schema.as_ref())?;
856
857        let num_workers = *DEFAULT_NUM_PARTITIONS;
858        let mut senders = Vec::with_capacity(num_workers);
859        let mut builders = Vec::with_capacity(num_workers);
860        for worker_idx in 0..num_workers {
861            let (send, mut recv) = tokio::sync::mpsc::channel(2);
862            senders.push(send);
863
864            let mut builder = self.clone_worker(worker_idx + 1);
865            let future = tokio::spawn(async move {
866                while let Some(partition) = recv.recv().await {
867                    builder.process_batch(partition).await?;
868                }
869                Result::Ok(builder)
870            });
871            builders.push(future);
872        }
873
874        let mut partitions_stream = data
875            .and_then(|batch| {
876                let tokenizer = self.tokenizer.clone();
877                std::future::ready(Ok(tokio::task::spawn(async move {
878                    Ok(Self::tokenize_and_partition(
879                        &tokenizer,
880                        batch,
881                        num_workers,
882                    )?)
883                })
884                .map(|res| res.unwrap())))
885            })
886            .try_buffer_unordered(*DEFAULT_TOKENIZE_PARALLELISM);
887
888        while let Some(partitions) = partitions_stream.try_next().await? {
889            for (part_idx, partition) in partitions.into_iter().enumerate() {
890                senders[part_idx].send(partition).await.unwrap();
891            }
892        }
893
894        std::mem::drop(senders);
895        let builders = futures::future::try_join_all(builders).await?;
896
897        // Final flush is serialized.  If we kick this off in parallel it can
898        // use a lot of memory.
899
900        let mut to_spill = Vec::with_capacity(builders.len());
901
902        for builder in builders {
903            let mut builder = builder?;
904            let state = builder.state.take();
905            if builder.flush(state).await? {
906                to_spill.push(builder.worker_number);
907            }
908        }
909
910        Ok(to_spill)
911    }
912
913    async fn write(
914        &mut self,
915        mut writer: Box<dyn IndexWriter>,
916        state: NGramIndexSpillState,
917    ) -> Result<()> {
918        writer.write_record_batch(state.try_into_batch()?).await?;
919        writer.finish().await?;
920
921        Ok(())
922    }
923
924    async fn stream_spill_reader(
925        reader: Arc<dyn IndexReader>,
926    ) -> Result<impl Stream<Item = Result<NGramIndexSpillState>>> {
927        let num_rows = reader.num_rows();
928
929        Ok(stream::try_unfold(0, move |offset| {
930            let reader = reader.clone();
931            async move {
932                // These are small batches but, in the worst case scenario, each row could
933                // be massive (up to 128MB per row at 1B rows) and we end up breaking memory
934                let batch_size = std::cmp::min(num_rows - offset, 64);
935                if batch_size == 0 {
936                    return Ok(None);
937                }
938                let batch = reader.read_range(offset..offset + batch_size, None).await?;
939                let state = NGramIndexSpillState::try_from_batch(batch)?;
940                let new_offset = offset + batch_size;
941                Ok(Some((state, new_offset)))
942            }
943            .boxed()
944        }))
945    }
946
947    async fn stream_spill(
948        spill_store: Arc<dyn IndexStore>,
949        id: usize,
950    ) -> Result<impl Stream<Item = Result<NGramIndexSpillState>>> {
951        let reader = spill_store
952            .open_index_file(&Self::spill_filename(id))
953            .await?;
954        Self::stream_spill_reader(reader).await
955    }
956
957    fn merge_spill_states(
958        left_opt: &mut Option<NGramIndexSpillState>,
959        right_opt: &mut Option<NGramIndexSpillState>,
960    ) -> NGramIndexSpillState {
961        let left = left_opt.take().unwrap();
962        let right = right_opt.take().unwrap();
963
964        let item_capacity = left.tokens.len() + right.tokens.len();
965        let mut merged_tokens = UInt32Builder::with_capacity(item_capacity);
966        let mut merged_bitmaps = Vec::with_capacity(left.bitmaps.len() + right.bitmaps.len());
967
968        let mut left_tokens = left.tokens.values().iter().copied();
969        let mut left_bitmaps = left.bitmaps.into_iter();
970        let mut right_tokens = right.tokens.values().iter().copied();
971        let mut right_bitmaps = right.bitmaps.into_iter();
972
973        let mut left_token = left_tokens.next();
974        let mut left_bitmap = left_bitmaps.next();
975        let mut right_token = right_tokens.next();
976        let mut right_bitmap = right_bitmaps.next();
977
978        while left_token.is_some() && right_token.is_some() {
979            let left_token_val = left_token.unwrap();
980            let right_token_val = right_token.unwrap();
981            match left_token_val.cmp(&right_token_val) {
982                std::cmp::Ordering::Less => {
983                    merged_tokens.append_value(left_token_val);
984                    merged_bitmaps.push(left_bitmap.unwrap());
985                    left_token = left_tokens.next();
986                    left_bitmap = left_bitmaps.next();
987                }
988                std::cmp::Ordering::Greater => {
989                    merged_tokens.append_value(right_token_val);
990                    merged_bitmaps.push(right_bitmap.unwrap());
991                    right_token = right_tokens.next();
992                    right_bitmap = right_bitmaps.next();
993                }
994                std::cmp::Ordering::Equal => {
995                    merged_tokens.append_value(left_token_val);
996                    merged_bitmaps.push(left_bitmap.unwrap() | &right_bitmap.unwrap());
997                    left_token = left_tokens.next();
998                    left_bitmap = left_bitmaps.next();
999                    right_token = right_tokens.next();
1000                    right_bitmap = right_bitmaps.next();
1001                }
1002            }
1003        }
1004
1005        let collect_remaining = |cur_token, tokens, cur_bitmap, bitmaps| {
1006            let tokens = UInt32Array::from_iter_values(once(cur_token).chain(tokens));
1007            let bitmaps = once(cur_bitmap).chain(bitmaps).collect::<Vec<_>>();
1008            NGramIndexSpillState { tokens, bitmaps }
1009        };
1010
1011        if let Some(left_token) = left_token {
1012            *left_opt = Some(collect_remaining(
1013                left_token,
1014                left_tokens,
1015                left_bitmap.unwrap(),
1016                left_bitmaps,
1017            ));
1018        } else {
1019            *left_opt = None;
1020        }
1021        if let Some(right_token) = right_token {
1022            *right_opt = Some(collect_remaining(
1023                right_token,
1024                right_tokens,
1025                right_bitmap.unwrap(),
1026                right_bitmaps,
1027            ));
1028        } else {
1029            *right_opt = None;
1030        }
1031
1032        NGramIndexSpillState {
1033            tokens: merged_tokens.finish(),
1034            bitmaps: merged_bitmaps,
1035        }
1036    }
1037
1038    async fn merge_spill_streams(
1039        mut left_stream: impl Stream<Item = Result<NGramIndexSpillState>> + Unpin,
1040        mut right_stream: impl Stream<Item = Result<NGramIndexSpillState>> + Unpin,
1041        writer: &mut dyn IndexWriter,
1042    ) -> Result<()> {
1043        let mut left_state = left_stream.try_next().await?;
1044        let mut right_state = right_stream.try_next().await?;
1045
1046        while left_state.is_some() || right_state.is_some() {
1047            if left_state.is_none() {
1048                // Left is done, full drain right
1049                let state = right_state.take().expect_ok()?;
1050                writer.write_record_batch(state.try_into_batch()?).await?;
1051                while let Some(state) = right_stream.try_next().await? {
1052                    writer.write_record_batch(state.try_into_batch()?).await?;
1053                }
1054            } else if right_state.is_none() {
1055                // Right is done, full drain left
1056                let state = left_state.take().expect_ok()?;
1057                writer.write_record_batch(state.try_into_batch()?).await?;
1058                while let Some(state) = left_stream.try_next().await? {
1059                    writer.write_record_batch(state.try_into_batch()?).await?;
1060                }
1061            } else {
1062                // There is a batch from both left and right.  Need to merge them
1063                let merged = Self::merge_spill_states(&mut left_state, &mut right_state);
1064                writer.write_record_batch(merged.try_into_batch()?).await?;
1065                if left_state.is_none() {
1066                    left_state = left_stream.try_next().await?;
1067                }
1068                if right_state.is_none() {
1069                    right_state = right_stream.try_next().await?;
1070                }
1071            }
1072        }
1073
1074        writer.finish().await
1075    }
1076
1077    async fn merge_spill_files(
1078        spill_store: Arc<dyn IndexStore>,
1079        index_of_left: usize,
1080        index_of_right: usize,
1081        output_index: usize,
1082    ) -> Result<()> {
1083        // We fully load the small file into memory and then stream the large file
1084        info!(
1085            "Merge spill files {} and {} into {}",
1086            index_of_left, index_of_right, output_index
1087        );
1088
1089        let mut writer = spill_store
1090            .new_index_file(&Self::spill_filename(output_index), POSTINGS_SCHEMA.clone())
1091            .await?;
1092
1093        let (left_stream, right_stream) = futures::try_join!(
1094            Self::stream_spill(spill_store.clone(), index_of_left),
1095            Self::stream_spill(spill_store.clone(), index_of_right)
1096        )?;
1097
1098        Self::merge_spill_streams(left_stream, right_stream, writer.as_mut()).await?;
1099
1100        spill_store
1101            .delete_index_file(&Self::spill_filename(index_of_left))
1102            .await?;
1103        spill_store
1104            .delete_index_file(&Self::spill_filename(index_of_right))
1105            .await?;
1106
1107        Ok(())
1108    }
1109
1110    // Can potentially parallelize in the future if this step becomes a bottleneck
1111    //
1112    // We can also merge in a more balanced fashion (e.g. binary tree) to reduce the size of
1113    // intermediate files
1114    //
1115    // Note: worker indices start at 1 and not 0 (hence all the +1's)
1116    async fn merge_spills(&mut self, mut spill_files: Vec<usize>) -> Result<usize> {
1117        info!(
1118            "Merging {} index files into one combined index",
1119            spill_files.len()
1120        );
1121
1122        let mut spill_counter = spill_files.iter().max().expect_ok()? + 1;
1123        while spill_files.len() > 1 {
1124            let mut new_spills = Vec::with_capacity(spill_files.len() / 2);
1125            while spill_files.len() >= 2 {
1126                let left = spill_files.pop().expect_ok()?;
1127                let right = spill_files.pop().expect_ok()?;
1128                new_spills.push(tokio::spawn(Self::merge_spill_files(
1129                    self.spill_store.clone(),
1130                    left,
1131                    right,
1132                    spill_counter + new_spills.len(),
1133                )));
1134            }
1135            for i in 0..new_spills.len() {
1136                spill_files.push(spill_counter + i);
1137            }
1138            spill_counter += new_spills.len();
1139            futures::future::try_join_all(new_spills).await?;
1140        }
1141
1142        spill_files.pop().expect_ok()
1143    }
1144
1145    async fn merge_old_index(
1146        &mut self,
1147        new_data_num: usize,
1148        old_index: Arc<dyn IndexStore>,
1149    ) -> Result<usize> {
1150        info!("Merging old index into new index");
1151        let final_num = new_data_num + 1;
1152
1153        let mut writer = self
1154            .spill_store
1155            .new_index_file(&Self::spill_filename(final_num), POSTINGS_SCHEMA.clone())
1156            .await?;
1157
1158        let left_stream = Self::stream_spill(self.spill_store.clone(), new_data_num).await?;
1159        let old_reader = old_index.open_index_file(POSTINGS_FILENAME).await?;
1160        let right_stream = Self::stream_spill_reader(old_reader).await?;
1161
1162        Self::merge_spill_streams(left_stream, right_stream, writer.as_mut()).await?;
1163
1164        self.spill_store
1165            .delete_index_file(&Self::spill_filename(new_data_num))
1166            .await?;
1167
1168        Ok(final_num)
1169    }
1170
1171    pub async fn write_index(
1172        mut self,
1173        store: &dyn IndexStore,
1174        spill_files: Vec<usize>,
1175        old_index: Option<Arc<dyn IndexStore>>,
1176    ) -> Result<()> {
1177        let mut writer = store
1178            .new_index_file(POSTINGS_FILENAME, POSTINGS_SCHEMA.clone())
1179            .await?;
1180
1181        if spill_files.is_empty() {
1182            if let Some(old_index) = old_index {
1183                // An update with no new data, just copy the old index to the new store
1184                old_index.copy_index_file(POSTINGS_FILENAME, store).await?;
1185            } else {
1186                // Training an index with no data, make an empty index
1187                let mut writer = store
1188                    .new_index_file(POSTINGS_FILENAME, POSTINGS_SCHEMA.clone())
1189                    .await?;
1190                writer.finish().await?;
1191            }
1192            return Ok(());
1193        }
1194
1195        let mut index_to_copy = self.merge_spills(spill_files).await?;
1196
1197        if let Some(old_index) = old_index {
1198            index_to_copy = self.merge_old_index(index_to_copy, old_index).await?;
1199        }
1200
1201        let reader = self
1202            .spill_store
1203            .open_index_file(&Self::spill_filename(index_to_copy))
1204            .await?;
1205
1206        let num_rows = reader.num_rows();
1207        let mut offset = 0;
1208
1209        while offset < num_rows {
1210            let batch_size = std::cmp::min(num_rows - offset, 64);
1211            let batch = reader.read_range(offset..offset + batch_size, None).await?;
1212            writer.write_record_batch(batch).await?;
1213            offset += batch_size;
1214        }
1215
1216        writer.finish().await
1217    }
1218}
1219
1220#[derive(Debug, Default)]
1221pub struct NGramIndexPlugin;
1222
1223impl NGramIndexPlugin {
1224    pub async fn train_ngram_index(
1225        batches_source: SendableRecordBatchStream,
1226        index_store: &dyn IndexStore,
1227    ) -> Result<()> {
1228        let mut builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default())?;
1229
1230        let spill_files = builder.train(batches_source).await?;
1231
1232        builder.write_index(index_store, spill_files, None).await
1233    }
1234}
1235
1236#[async_trait]
1237impl ScalarIndexPlugin for NGramIndexPlugin {
1238    fn name(&self) -> &str {
1239        "NGram"
1240    }
1241
1242    fn new_training_request(
1243        &self,
1244        _params: &str,
1245        field: &Field,
1246    ) -> Result<Box<dyn TrainingRequest>> {
1247        if !matches!(field.data_type(), DataType::Utf8 | DataType::LargeUtf8) {
1248            return Err(Error::invalid_input_source(format!(
1249                "A ngram index can only be created on a Utf8 or LargeUtf8 field.  Column has type {:?}",
1250                field.data_type()
1251            )
1252            .into()));
1253        }
1254        Ok(Box::new(DefaultTrainingRequest::new(
1255            TrainingCriteria::new(TrainingOrdering::None).with_row_id(),
1256        )))
1257    }
1258
1259    fn provides_exact_answer(&self) -> bool {
1260        false
1261    }
1262
1263    fn version(&self) -> u32 {
1264        NGRAM_INDEX_VERSION
1265    }
1266
1267    fn new_query_parser(
1268        &self,
1269        index_name: String,
1270        _index_details: &prost_types::Any,
1271    ) -> Option<Box<dyn ScalarQueryParser>> {
1272        Some(Box::new(TextQueryParser::new(index_name, true)))
1273    }
1274
1275    async fn train_index(
1276        &self,
1277        data: SendableRecordBatchStream,
1278        index_store: &dyn IndexStore,
1279        _request: Box<dyn TrainingRequest>,
1280        fragment_ids: Option<Vec<u32>>,
1281        _progress: Arc<dyn crate::progress::IndexBuildProgress>,
1282    ) -> Result<CreatedIndex> {
1283        if fragment_ids.is_some() {
1284            return Err(Error::invalid_input_source(
1285                "NGram index does not support fragment training".into(),
1286            ));
1287        }
1288
1289        Self::train_ngram_index(data, index_store).await?;
1290        Ok(CreatedIndex {
1291            index_details: prost_types::Any::from_msg(&pbold::NGramIndexDetails::default())
1292                .unwrap(),
1293            index_version: NGRAM_INDEX_VERSION,
1294        })
1295    }
1296
1297    async fn load_index(
1298        &self,
1299        index_store: Arc<dyn IndexStore>,
1300        _index_details: &prost_types::Any,
1301        frag_reuse_index: Option<Arc<FragReuseIndex>>,
1302        cache: &LanceCache,
1303    ) -> Result<Arc<dyn ScalarIndex>> {
1304        Ok(NGramIndex::load(index_store, frag_reuse_index, cache).await? as Arc<dyn ScalarIndex>)
1305    }
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310    use std::{
1311        collections::{HashMap, HashSet},
1312        sync::Arc,
1313    };
1314
1315    use arrow::datatypes::UInt64Type;
1316    use arrow_array::{Array, RecordBatch, StringArray, UInt64Array};
1317    use arrow_schema::{DataType, Field, Schema};
1318    use datafusion::{
1319        execution::SendableRecordBatchStream, physical_plan::stream::RecordBatchStreamAdapter,
1320    };
1321    use datafusion_common::DataFusionError;
1322    use futures::{TryStreamExt, stream};
1323    use itertools::Itertools;
1324    use lance_core::{
1325        ROW_ID,
1326        cache::LanceCache,
1327        utils::{mask::RowAddrTreeMap, tempfile::TempDir},
1328    };
1329    use lance_datagen::{BatchCount, ByteCount, RowCount};
1330    use lance_io::object_store::ObjectStore;
1331    use tantivy::tokenizer::TextAnalyzer;
1332
1333    use crate::scalar::{
1334        ScalarIndex, SearchResult, TextQuery,
1335        lance_format::LanceIndexStore,
1336        ngram::{NGramIndex, NGramIndexBuilder, NGramIndexBuilderOptions},
1337    };
1338    use crate::{metrics::NoOpMetricsCollector, scalar::registry::VALUE_COLUMN_NAME};
1339
1340    use super::{NGRAM_TOKENIZER, ngram_to_token, tokenize_visitor};
1341
1342    fn collect_tokens(analyzer: &TextAnalyzer, text: &str) -> Vec<String> {
1343        let mut tokens = Vec::with_capacity(text.len() * 3);
1344        tokenize_visitor(analyzer, text, |token| tokens.push(token.to_owned()));
1345        tokens
1346    }
1347
1348    #[test]
1349    fn test_tokenizer() {
1350        let tokenizer = NGRAM_TOKENIZER.clone();
1351
1352        // ASCII folding
1353        let tokens = collect_tokens(&tokenizer, "café");
1354        assert_eq!(
1355            tokens,
1356            vec!["caf", "afe"] // spellchecker:disable-line
1357        );
1358
1359        // Allow numbers
1360        let tokens = collect_tokens(&tokenizer, "a1b2");
1361        assert_eq!(tokens, vec!["a1b", "1b2"]);
1362
1363        // Remove symbols and UTF-8 that doesn't map to characters
1364        let tokens = collect_tokens(&tokenizer, "abc👍b!c24");
1365
1366        assert_eq!(tokens, vec!["abc", "c24"]);
1367
1368        let tokens = collect_tokens(&tokenizer, "anstoß");
1369
1370        assert_eq!(tokens, vec!["ans", "nst", "sto", "tos", "oss"]);
1371
1372        // Lower casing
1373        let tokens = collect_tokens(&tokenizer, "ABC");
1374        assert_eq!(tokens, vec!["abc"]);
1375
1376        // Duplicate tokens
1377        let tokens = collect_tokens(&tokenizer, "ababab");
1378        // Confirming that the tokenizer doesn't deduplicate tokens (this can be taken into consideration
1379        // when training the index)
1380        assert_eq!(
1381            tokens,
1382            vec!["aba", "bab", "aba", "bab"] // spellchecker:disable-line
1383        );
1384    }
1385
1386    async fn do_train(
1387        mut builder: NGramIndexBuilder,
1388        data: SendableRecordBatchStream,
1389    ) -> (NGramIndex, Arc<TempDir>) {
1390        let spill_files = builder.train(data).await.unwrap();
1391
1392        let tmpdir = Arc::new(TempDir::default());
1393        let test_store = LanceIndexStore::new(
1394            Arc::new(ObjectStore::local()),
1395            tmpdir.obj_path(),
1396            Arc::new(LanceCache::no_cache()),
1397        );
1398
1399        builder
1400            .write_index(&test_store, spill_files, None)
1401            .await
1402            .unwrap();
1403
1404        (
1405            NGramIndex::from_store(Arc::new(test_store), None, &LanceCache::no_cache())
1406                .await
1407                .unwrap(),
1408            tmpdir,
1409        )
1410    }
1411
1412    async fn get_posting_list_for_trigram(index: &NGramIndex, trigram: &str) -> Vec<u64> {
1413        let token = ngram_to_token(trigram, 3);
1414        let row_offset = index.tokens[&token];
1415        let list = index
1416            .list_reader
1417            .ngram_list(row_offset, &NoOpMetricsCollector)
1418            .await
1419            .unwrap();
1420        list.bitmap.iter().sorted().collect()
1421    }
1422
1423    async fn get_null_posting_list(index: &NGramIndex) -> Vec<u64> {
1424        let row_offset = index.tokens[&0];
1425        let list = index
1426            .list_reader
1427            .ngram_list(row_offset, &NoOpMetricsCollector)
1428            .await
1429            .unwrap();
1430        list.bitmap.iter().sorted().collect()
1431    }
1432
1433    #[test_log::test(tokio::test)]
1434    async fn test_basic_ngram_index() {
1435        let data = StringArray::from_iter_values([
1436            "cat",
1437            "dog",
1438            "cat dog",
1439            "dog cat",
1440            "elephant",
1441            "mouse",
1442            "rhino",
1443            "giraffe",
1444            "rhinos nose",
1445        ]);
1446        let row_ids = UInt64Array::from_iter_values((0..data.len()).map(|i| i as u64));
1447        let schema = Arc::new(Schema::new(vec![
1448            Field::new(VALUE_COLUMN_NAME, DataType::Utf8, false),
1449            Field::new(ROW_ID, DataType::UInt64, false),
1450        ]));
1451        let data =
1452            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
1453        let data = Box::pin(RecordBatchStreamAdapter::new(
1454            schema,
1455            stream::once(std::future::ready(Ok(data))),
1456        ));
1457
1458        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
1459
1460        let (index, _tmpdir) = do_train(builder, data).await;
1461        assert_eq!(index.tokens.len(), 21);
1462
1463        // Basic search
1464        let res = index
1465            .search(
1466                &TextQuery::StringContains("cat".to_string()),
1467                &NoOpMetricsCollector,
1468            )
1469            .await
1470            .unwrap();
1471
1472        let expected = SearchResult::at_most(RowAddrTreeMap::from_iter([0, 2, 3]));
1473
1474        assert_eq!(expected, res);
1475
1476        // Whitespace in query
1477        let res = index
1478            .search(
1479                &TextQuery::StringContains("nos nos".to_string()),
1480                &NoOpMetricsCollector,
1481            )
1482            .await
1483            .unwrap();
1484        let expected = SearchResult::at_most(RowAddrTreeMap::from_iter([8]));
1485        assert_eq!(expected, res);
1486
1487        // No matches
1488        let res = index
1489            .search(
1490                &TextQuery::StringContains("tdo".to_string()),
1491                &NoOpMetricsCollector,
1492            )
1493            .await
1494            .unwrap();
1495        let expected = SearchResult::exact(RowAddrTreeMap::new());
1496        assert_eq!(expected, res);
1497
1498        // False positive
1499        let res = index
1500            .search(
1501                &TextQuery::StringContains("inose".to_string()),
1502                &NoOpMetricsCollector,
1503            )
1504            .await
1505            .unwrap();
1506        let expected = SearchResult::at_most(RowAddrTreeMap::from_iter([8]));
1507        assert_eq!(expected, res);
1508
1509        // Too short, don't know anything
1510        let res = index
1511            .search(
1512                &TextQuery::StringContains("ab".to_string()),
1513                &NoOpMetricsCollector,
1514            )
1515            .await
1516            .unwrap();
1517        let expected = SearchResult::at_least(RowAddrTreeMap::new());
1518        assert_eq!(expected, res);
1519
1520        // One short string but we still get at least one trigram, this is ok
1521        let res = index
1522            .search(
1523                &TextQuery::StringContains("no nos".to_string()),
1524                &NoOpMetricsCollector,
1525            )
1526            .await
1527            .unwrap();
1528        let expected = SearchResult::at_most(RowAddrTreeMap::from_iter([8]));
1529        assert_eq!(expected, res);
1530    }
1531
1532    fn test_data_schema() -> Arc<Schema> {
1533        Arc::new(Schema::new(vec![
1534            Field::new(VALUE_COLUMN_NAME, DataType::Utf8, true),
1535            Field::new(ROW_ID, DataType::UInt64, false),
1536        ]))
1537    }
1538
1539    fn simple_data_with_nulls() -> SendableRecordBatchStream {
1540        let data = StringArray::from_iter(&[Some("cat"), Some("dog"), None, None, Some("cat dog")]);
1541        let row_ids = UInt64Array::from_iter_values((0..data.len()).map(|i| i as u64));
1542        let schema = test_data_schema();
1543        let data =
1544            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
1545        Box::pin(RecordBatchStreamAdapter::new(
1546            schema,
1547            stream::once(std::future::ready(Ok(data))),
1548        ))
1549    }
1550
1551    #[test_log::test(tokio::test)]
1552    async fn test_ngram_nulls() {
1553        let data = simple_data_with_nulls();
1554
1555        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
1556
1557        let (index, _tmpdir) = do_train(builder, data).await;
1558        assert_eq!(index.tokens.len(), 3);
1559
1560        let res = index
1561            .search(
1562                &TextQuery::StringContains("cat".to_string()),
1563                &NoOpMetricsCollector,
1564            )
1565            .await
1566            .unwrap();
1567        let expected = SearchResult::at_most(RowAddrTreeMap::from_iter([0, 4]));
1568        assert_eq!(expected, res);
1569
1570        let null_posting_list = get_null_posting_list(&index).await;
1571        assert_eq!(null_posting_list, vec![2, 3]);
1572
1573        // TODO: Support IS NULL queries
1574    }
1575
1576    fn empty_data() -> SendableRecordBatchStream {
1577        Box::pin(RecordBatchStreamAdapter::new(
1578            test_data_schema(),
1579            stream::empty::<lance_core::error::DataFusionResult<RecordBatch>>(),
1580        ))
1581    }
1582
1583    #[test_log::test(tokio::test)]
1584    async fn test_train_empty() {
1585        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
1586
1587        let (index, _tmpdir) = do_train(builder, empty_data()).await;
1588        assert_eq!(index.tokens.len(), 0);
1589    }
1590
1591    #[test_log::test(tokio::test)]
1592    async fn test_update_empty() {
1593        let data = simple_data_with_nulls();
1594
1595        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
1596        let (index, _tmpdir) = do_train(builder, empty_data()).await;
1597
1598        let new_tmpdir = Arc::new(TempDir::default());
1599        let test_store = Arc::new(LanceIndexStore::new(
1600            Arc::new(ObjectStore::local()),
1601            new_tmpdir.obj_path(),
1602            Arc::new(LanceCache::no_cache()),
1603        ));
1604
1605        index.update(data, test_store.as_ref(), None).await.unwrap();
1606
1607        let index = NGramIndex::from_store(test_store, None, &LanceCache::no_cache())
1608            .await
1609            .unwrap();
1610        assert_eq!(index.tokens.len(), 3);
1611    }
1612
1613    async fn row_ids_in_index(index: &NGramIndex) -> Vec<u64> {
1614        let mut row_ids = HashSet::new();
1615        for row_offset in index.tokens.values() {
1616            let list = index
1617                .list_reader
1618                .ngram_list(*row_offset, &NoOpMetricsCollector)
1619                .await
1620                .unwrap();
1621            row_ids.extend(list.bitmap.iter());
1622        }
1623        row_ids.into_iter().sorted().collect()
1624    }
1625
1626    #[test_log::test(tokio::test)]
1627    async fn test_ngram_index_remap() {
1628        let data = simple_data_with_nulls();
1629        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
1630        let (index, _tmpdir) = do_train(builder, data).await;
1631
1632        let row_ids = row_ids_in_index(&index).await;
1633        assert_eq!(row_ids, vec![0, 1, 2, 3, 4]);
1634
1635        let new_tmpdir = Arc::new(TempDir::default());
1636        let test_store = Arc::new(LanceIndexStore::new(
1637            Arc::new(ObjectStore::local()),
1638            new_tmpdir.obj_path(),
1639            Arc::new(LanceCache::no_cache()),
1640        ));
1641
1642        let remapping = HashMap::from([(2, Some(100)), (3, None), (4, Some(101))]);
1643        index.remap(&remapping, test_store.as_ref()).await.unwrap();
1644
1645        let index = NGramIndex::from_store(test_store, None, &LanceCache::no_cache())
1646            .await
1647            .unwrap();
1648        let row_ids = row_ids_in_index(&index).await;
1649        assert_eq!(row_ids, vec![0, 1, 100, 101]);
1650
1651        let null_posting_list = get_null_posting_list(&index).await;
1652        assert_eq!(null_posting_list, vec![100]);
1653    }
1654
1655    #[test_log::test(tokio::test)]
1656    async fn test_ngram_index_merge() {
1657        let data = simple_data_with_nulls();
1658        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions::default()).unwrap();
1659        let (index, _tmpdir) = do_train(builder, data).await;
1660
1661        let data = StringArray::from_iter(&[Some("giraffe"), Some("cat"), None]);
1662        let row_ids = UInt64Array::from_iter_values((0..data.len()).map(|i| i as u64 + 100));
1663        let schema = Arc::new(Schema::new(vec![
1664            Field::new(VALUE_COLUMN_NAME, DataType::Utf8, true),
1665            Field::new(ROW_ID, DataType::UInt64, false),
1666        ]));
1667        let data =
1668            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
1669        let data = Box::pin(RecordBatchStreamAdapter::new(
1670            schema,
1671            stream::once(std::future::ready(Ok(data))),
1672        ));
1673
1674        let posting_list = get_posting_list_for_trigram(&index, "cat").await;
1675        assert_eq!(posting_list, vec![0, 4]);
1676
1677        let new_tmpdir = Arc::new(TempDir::default());
1678        let test_store = Arc::new(LanceIndexStore::new(
1679            Arc::new(ObjectStore::local()),
1680            new_tmpdir.obj_path(),
1681            Arc::new(LanceCache::no_cache()),
1682        ));
1683
1684        index.update(data, test_store.as_ref(), None).await.unwrap();
1685
1686        let index = NGramIndex::from_store(test_store, None, &LanceCache::no_cache())
1687            .await
1688            .unwrap();
1689        let row_ids = row_ids_in_index(&index).await;
1690        assert_eq!(row_ids, vec![0, 1, 2, 3, 4, 100, 101, 102]);
1691
1692        let posting_list = get_posting_list_for_trigram(&index, "cat").await;
1693        assert_eq!(posting_list, vec![0, 4, 101]);
1694
1695        let posting_list = get_posting_list_for_trigram(&index, "ffe").await;
1696        assert_eq!(posting_list, vec![100]);
1697
1698        let posting_list = get_null_posting_list(&index).await;
1699        assert_eq!(posting_list, vec![2, 3, 102]);
1700    }
1701
1702    #[test_log::test(tokio::test)]
1703    async fn test_ngram_index_with_spill() {
1704        let (data, schema) = lance_datagen::gen_batch()
1705            .col(
1706                VALUE_COLUMN_NAME,
1707                lance_datagen::array::rand_utf8(ByteCount::from(50), false),
1708            )
1709            .col(ROW_ID, lance_datagen::array::step::<UInt64Type>())
1710            .into_reader_stream(RowCount::from(128), BatchCount::from(32));
1711
1712        let data = Box::pin(RecordBatchStreamAdapter::new(
1713            schema,
1714            data.map_err(|arrow_err| DataFusionError::ArrowError(Box::new(arrow_err), None)),
1715        ));
1716
1717        let builder = NGramIndexBuilder::try_new(NGramIndexBuilderOptions {
1718            tokens_per_spill: 100,
1719        })
1720        .unwrap();
1721
1722        let (index, _tmpdir) = do_train(builder, data).await;
1723
1724        assert_eq!(index.tokens.len(), 29012);
1725    }
1726}