1use 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});
73pub 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
80fn tokenize_visitor(tokenizer: &TextAnalyzer, text: &str, mut visitor: impl FnMut(&String)) {
82 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
105fn ngram_to_token(ngram: &str, ngram_length: usize) -> u32 {
126 let mut token = 0;
127 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#[derive(Serialize)]
145struct NGramStatistics {
146 num_ngrams: usize,
147}
148
149#[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#[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
202struct 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
243pub struct NGramIndex {
258 tokens: HashMap<u32, u32>,
260 list_reader: Arc<NGramPostingListReader>,
262 tokenizer: TextAnalyzer,
267 io_parallelism: usize,
268 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 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 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 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
544static 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});
551static 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});
561static 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
579struct 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
623struct 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 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
651pub 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 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 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 debug_assert_ne!(self.worker_number, 0);
782 if self.has_flushed {
783 info!("Merging flush for worker {}", self.worker_number);
784 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 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 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 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 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 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 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 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 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 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 old_index.copy_index_file(POSTINGS_FILENAME, store).await?;
1185 } else {
1186 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 let tokens = collect_tokens(&tokenizer, "café");
1354 assert_eq!(
1355 tokens,
1356 vec!["caf", "afe"] );
1358
1359 let tokens = collect_tokens(&tokenizer, "a1b2");
1361 assert_eq!(tokens, vec!["a1b", "1b2"]);
1362
1363 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 let tokens = collect_tokens(&tokenizer, "ABC");
1374 assert_eq!(tokens, vec!["abc"]);
1375
1376 let tokens = collect_tokens(&tokenizer, "ababab");
1378 assert_eq!(
1381 tokens,
1382 vec!["aba", "bab", "aba", "bab"] );
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 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 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 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 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 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 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 }
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}