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