This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 8f35469 Implement global index search modes (#446)
8f35469 is described below
commit 8f35469eee7556949b61ca68cd84834e8190a3b6
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Jul 3 18:47:06 2026 +0800
Implement global index search modes (#446)
---
.../datafusion/src/full_text_search.rs | 13 +-
crates/paimon/src/spec/core_options.rs | 71 +++
crates/paimon/src/table/data_file_reader.rs | 75 ++-
.../paimon/src/table/full_text_search_builder.rs | 420 +++++++++++--
crates/paimon/src/table/global_index_scanner.rs | 548 ++++++++++++++--
crates/paimon/src/table/table_scan.rs | 61 +-
crates/paimon/src/table/vector_search_builder.rs | 690 ++++++++++++++++++---
7 files changed, 1663 insertions(+), 215 deletions(-)
diff --git a/crates/integrations/datafusion/src/full_text_search.rs
b/crates/integrations/datafusion/src/full_text_search.rs
index 20ff38f..7e9dc39 100644
--- a/crates/integrations/datafusion/src/full_text_search.rs
+++ b/crates/integrations/datafusion/src/full_text_search.rs
@@ -32,9 +32,11 @@ use async_trait::async_trait;
use datafusion::arrow::datatypes::SchemaRef as ArrowSchemaRef;
use datafusion::catalog::Session;
use datafusion::catalog::TableFunctionImpl;
+use datafusion::common::project_schema;
use datafusion::datasource::{TableProvider, TableType};
use datafusion::error::Result as DFResult;
use datafusion::logical_expr::{Expr, TableProviderFilterPushDown};
+use datafusion::physical_plan::empty::EmptyExec;
use datafusion::physical_plan::ExecutionPlan;
use datafusion::prelude::SessionContext;
use paimon::catalog::Catalog;
@@ -169,16 +171,17 @@ impl TableProvider for FullTextSearchTableProvider {
})
.await?;
+ if row_ranges.is_empty() {
+ let schema = project_schema(&self.schema(), projection)?;
+ return Ok(Arc::new(EmptyExec::new(schema)));
+ }
+
// Convert search results to row ranges and inject into the scan.
let mut read_builder = table.new_read_builder();
if let Some(limit) = limit {
read_builder.with_limit(limit);
}
- let scan = if row_ranges.is_empty() {
- read_builder.new_scan()
- } else {
- read_builder.new_scan().with_row_ranges(row_ranges)
- };
+ let scan = read_builder.new_scan().with_row_ranges(row_ranges);
let plan = await_with_runtime(scan.plan())
.await
.map_err(to_datafusion_error)?;
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index 80e42e2..f11298f 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -20,6 +20,7 @@ use std::collections::{HashMap, HashSet};
const DELETION_VECTORS_ENABLED_OPTION: &str = "deletion-vectors.enabled";
const DATA_EVOLUTION_ENABLED_OPTION: &str = "data-evolution.enabled";
const GLOBAL_INDEX_ENABLED_OPTION: &str = "global-index.enabled";
+const GLOBAL_INDEX_SEARCH_MODE_OPTION: &str = "global-index.search-mode";
const GLOBAL_INDEX_ROW_COUNT_PER_SHARD_OPTION: &str =
"global-index.row-count-per-shard";
const GLOBAL_INDEX_COLUMN_UPDATE_ACTION_OPTION: &str =
"global-index.column-update-action";
const SOURCE_SPLIT_TARGET_SIZE_OPTION: &str = "source.split.target-size";
@@ -117,6 +118,19 @@ pub enum GlobalIndexColumnUpdateAction {
DropPartitionIndex,
}
+/// Search mode for global index queries.
+///
+/// Reference: Java `CoreOptions.GlobalIndexSearchMode`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GlobalIndexSearchMode {
+ /// Only search indexed data.
+ Fast,
+ /// Use snapshot `next_row_id` and global index coverage to detect missing
row IDs.
+ Full,
+ /// Use actual data-file row ID ranges to detect exact missing row IDs.
+ Detail,
+}
+
/// Bucket function used to map bucket keys to fixed bucket ids.
///
/// Reference: Java `CoreOptions.BucketFunctionType`.
@@ -262,6 +276,23 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(false)
}
+ pub fn global_index_search_mode(&self) ->
crate::Result<GlobalIndexSearchMode> {
+ match self
+ .options
+ .get(GLOBAL_INDEX_SEARCH_MODE_OPTION)
+ .map(|v| v.to_ascii_lowercase())
+ .as_deref()
+ .unwrap_or("fast")
+ {
+ "fast" => Ok(GlobalIndexSearchMode::Fast),
+ "full" => Ok(GlobalIndexSearchMode::Full),
+ "detail" => Ok(GlobalIndexSearchMode::Detail),
+ other => Err(crate::Error::ConfigInvalid {
+ message: format!("Unsupported global-index.search-mode:
{other}"),
+ }),
+ }
+ }
+
pub fn global_index_row_count_per_shard(&self) -> crate::Result<i64> {
let value = self
.parse_i64_option(GLOBAL_INDEX_ROW_COUNT_PER_SHARD_OPTION)?
@@ -657,6 +688,10 @@ mod tests {
core_options.global_index_column_update_action().unwrap(),
GlobalIndexColumnUpdateAction::ThrowError
);
+ assert_eq!(
+ core_options.global_index_search_mode().unwrap(),
+ GlobalIndexSearchMode::Fast
+ );
}
#[test]
@@ -678,6 +713,10 @@ mod tests {
GLOBAL_INDEX_COLUMN_UPDATE_ACTION_OPTION.to_string(),
"DROP_PARTITION_INDEX".to_string(),
),
+ (
+ GLOBAL_INDEX_SEARCH_MODE_OPTION.to_string(),
+ "detail".to_string(),
+ ),
]);
let core_options = CoreOptions::new(&options);
@@ -691,6 +730,38 @@ mod tests {
core_options.global_index_column_update_action().unwrap(),
GlobalIndexColumnUpdateAction::DropPartitionIndex
);
+ assert_eq!(
+ core_options.global_index_search_mode().unwrap(),
+ GlobalIndexSearchMode::Detail
+ );
+ }
+
+ #[test]
+ fn test_global_index_search_mode_values() {
+ for (raw, expected) in [
+ ("fast", GlobalIndexSearchMode::Fast),
+ ("FAST", GlobalIndexSearchMode::Fast),
+ ("full", GlobalIndexSearchMode::Full),
+ ("detail", GlobalIndexSearchMode::Detail),
+ ] {
+ let options =
+ HashMap::from([(GLOBAL_INDEX_SEARCH_MODE_OPTION.to_string(),
raw.to_string())]);
+ let core = CoreOptions::new(&options);
+ assert_eq!(core.global_index_search_mode().unwrap(), expected);
+ }
+ }
+
+ #[test]
+ fn test_global_index_search_mode_rejects_invalid_value() {
+ let options = HashMap::from([(
+ GLOBAL_INDEX_SEARCH_MODE_OPTION.to_string(),
+ "slow".to_string(),
+ )]);
+ let core = CoreOptions::new(&options);
+
+ let err = core.global_index_search_mode().expect_err("invalid mode");
+ assert!(matches!(err, crate::Error::ConfigInvalid { message }
+ if message.contains(GLOBAL_INDEX_SEARCH_MODE_OPTION)));
}
#[test]
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index 111e23e..60ecdf4 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -20,7 +20,7 @@ use crate::arrow::format::create_format_reader;
use crate::arrow::schema_evolution::{create_index_mapping, NULL_FIELD_INDEX};
use crate::deletion_vector::{DeletionVector, DeletionVectorFactory};
use crate::io::FileIO;
-use crate::spec::{DataField, DataFileMeta, Predicate};
+use crate::spec::{DataField, DataFileMeta, Predicate, ROW_ID_FIELD_NAME};
use crate::table::schema_manager::SchemaManager;
use crate::table::ArrowRecordBatchStream;
use crate::table::RowRange;
@@ -174,7 +174,14 @@ impl DataFileReader {
None => (df.clone(), None),
}
} else {
- (read_type.clone(), None)
+ (
+ read_type
+ .iter()
+ .filter(|field| field.name() != ROW_ID_FIELD_NAME)
+ .cloned()
+ .collect(),
+ None,
+ )
};
let format_read_fields = if is_row_file {
file_fields.clone()
@@ -213,6 +220,14 @@ impl DataFileReader {
dv.as_deref(),
local_ranges.as_deref(),
);
+ let selected_row_ids = match (file_meta.first_row_id,
row_selection.as_ref()) {
+ (Some(first_row_id), Some(ranges)) => {
+ Some(expand_local_selected_row_ids(first_row_id, ranges))
+ }
+ _ => None,
+ };
+ let mut row_id_cursor = file_meta.first_row_id.unwrap_or(0);
+ let mut row_id_offset = 0usize;
let mut batch_stream = format_reader.read_batch_stream(
Box::new(file_reader),
@@ -231,6 +246,17 @@ impl DataFileReader {
// Build output columns using index mapping (field-ID-based)
or by name.
let mut columns: Vec<Arc<dyn arrow_array::Array>> =
Vec::with_capacity(target_schema.fields().len());
for (i, target_field) in
target_schema.fields().iter().enumerate() {
+ if target_field.name() == ROW_ID_FIELD_NAME {
+ columns.push(row_id_column_for_batch(
+ file_meta.first_row_id,
+ num_rows,
+ &mut row_id_cursor,
+ selected_row_ids.as_deref(),
+ &mut row_id_offset,
+ )?);
+ continue;
+ }
+
let source_col = if let Some(ref idx_map) = index_mapping {
let data_idx = idx_map[i];
if data_idx == NULL_FIELD_INDEX {
@@ -420,6 +446,51 @@ pub(super) fn expand_selected_row_ids(
ids
}
+fn expand_local_selected_row_ids(first_row_id: i64, local_ranges: &[RowRange])
-> Vec<i64> {
+ let mut ids = Vec::new();
+ for range in local_ranges {
+ for local_id in range.from()..=range.to() {
+ ids.push(first_row_id + local_id);
+ }
+ }
+ ids
+}
+
+fn row_id_column_for_batch(
+ first_row_id: Option<i64>,
+ num_rows: usize,
+ row_id_cursor: &mut i64,
+ selected_row_ids: Option<&[i64]>,
+ row_id_offset: &mut usize,
+) -> crate::Result<Arc<dyn arrow_array::Array>> {
+ let Some(_) = first_row_id else {
+ return Ok(Arc::new(Int64Array::new_null(num_rows)));
+ };
+
+ if let Some(selected_row_ids) = selected_row_ids {
+ let end = *row_id_offset + num_rows;
+ if end > selected_row_ids.len() {
+ return Err(Error::UnexpectedError {
+ message: format!(
+ "Row ID offset out of bounds: need {}..{} but
selected_row_ids has {} entries",
+ *row_id_offset,
+ end,
+ selected_row_ids.len()
+ ),
+ source: None,
+ });
+ }
+ let batch_ids = &selected_row_ids[*row_id_offset..end];
+ *row_id_offset = end;
+ return Ok(Arc::new(Int64Array::from(batch_ids.to_vec())));
+ }
+
+ let start = *row_id_cursor;
+ let end = start + num_rows as i64;
+ *row_id_cursor = end;
+ Ok(Arc::new(Int64Array::from((start..end).collect::<Vec<_>>())))
+}
+
pub(super) fn attach_row_id(
batch: RecordBatch,
row_id_index: usize,
diff --git a/crates/paimon/src/table/full_text_search_builder.rs
b/crates/paimon/src/table/full_text_search_builder.rs
index 41297b7..04ffe21 100644
--- a/crates/paimon/src/table/full_text_search_builder.rs
+++ b/crates/paimon/src/table/full_text_search_builder.rs
@@ -19,11 +19,21 @@
//!
//! Reference:
[FullTextSearchBuilderImpl.java](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextSearchBuilderImpl.java)
-use crate::spec::{DataField, FileKind, IndexManifest};
+use crate::io::{FileIO, FileIOBuilder};
+use crate::spec::{
+ CoreOptions, DataField, FileKind, GlobalIndexSearchMode, IndexFileMeta,
IndexManifest,
+ IndexManifestEntry, ROW_ID_FIELD_NAME,
+};
+use
crate::table::global_index_scanner::unindexed_ranges_for_global_index_entries;
use crate::table::snapshot_manager::SnapshotManager;
-use crate::table::{find_field_id_by_name, RowRange, Table};
+use crate::table::{find_field_id_by_name, merge_row_ranges, RowRange, Table};
use crate::tantivy::full_text_search::{FullTextSearch, SearchResult};
use crate::tantivy::reader::TantivyFullTextReader;
+use crate::tantivy::writer::TantivyFullTextWriter;
+use arrow_array::{Array, Int64Array, LargeStringArray, RecordBatch,
StringArray};
+use futures::TryStreamExt;
+use std::collections::{HashMap, HashSet};
+use uuid::Uuid;
const INDEX_DIR: &str = "index";
const TANTIVY_FULLTEXT_INDEX_TYPE: &str = "tantivy-fulltext";
@@ -111,40 +121,53 @@ impl<'a> FullTextSearchBuilder<'a> {
None => return Ok(Vec::new()),
};
- let index_manifest_name = match snapshot.index_manifest() {
- Some(name) => name.to_string(),
- None => return Ok(Vec::new()),
+ let index_entries = match snapshot.index_manifest() {
+ Some(index_manifest_name) => {
+ let manifest_path = format!(
+ "{}/manifest/{}",
+ self.table.location().trim_end_matches('/'),
+ index_manifest_name
+ );
+ IndexManifest::read(self.table.file_io(),
&manifest_path).await?
+ }
+ None => Vec::new(),
};
- let manifest_path = format!(
- "{}/manifest/{}",
- self.table.location().trim_end_matches('/'),
- index_manifest_name
- );
- let index_entries = IndexManifest::read(self.table.file_io(),
&manifest_path).await?;
-
evaluate_full_text_search(
- self.table.file_io(),
- self.table.location(),
+ FullTextSearchEvaluation {
+ table: Some(self.table),
+ file_io: self.table.file_io(),
+ table_path: self.table.location(),
+ table_options: self.table.schema().options(),
+ schema_fields: self.table.schema().fields(),
+ next_row_id: snapshot.next_row_id(),
+ },
&index_entries,
&search,
- self.table.schema().fields(),
)
.await
}
}
/// Evaluate a full-text search query against Tantivy indexes found in the
index manifest.
+struct FullTextSearchEvaluation<'a> {
+ table: Option<&'a Table>,
+ file_io: &'a FileIO,
+ table_path: &'a str,
+ table_options: &'a HashMap<String, String>,
+ schema_fields: &'a [DataField],
+ next_row_id: Option<i64>,
+}
+
async fn evaluate_full_text_search(
- file_io: &crate::io::FileIO,
- table_path: &str,
- index_entries: &[crate::spec::IndexManifestEntry],
+ evaluation: FullTextSearchEvaluation<'_>,
+ index_entries: &[IndexManifestEntry],
search: &FullTextSearch,
- schema_fields: &[DataField],
) -> crate::Result<Vec<RowRange>> {
- let table_path = table_path.trim_end_matches('/');
+ let table_path = evaluation.table_path.trim_end_matches('/');
+ let search_mode =
CoreOptions::new(evaluation.table_options).global_index_search_mode()?;
- let field_id = match find_field_id_by_name(schema_fields,
&search.field_name) {
+ let field_id = match find_field_id_by_name(evaluation.schema_fields,
&search.field_name) {
Some(id) => id,
None => return Ok(Vec::new()),
};
@@ -162,42 +185,335 @@ async fn evaluate_full_text_search(
})
.collect();
- if fulltext_entries.is_empty() {
+ if fulltext_entries.is_empty() && search_mode ==
GlobalIndexSearchMode::Fast {
return Ok(Vec::new());
}
- let futures: Vec<_> = fulltext_entries
- .into_iter()
- .map(|entry| {
- let global_meta =
entry.index_file.global_index_meta.as_ref().unwrap();
- let path = format!("{table_path}/{INDEX_DIR}/{}",
entry.index_file.file_name);
- let file_name = entry.index_file.file_name.clone();
- let query_text = search.query_text.clone();
- let limit = search.limit;
- let row_range_start = global_meta.row_range_start;
- let input = file_io.new_input(&path);
- async move {
- let input = input?;
- let reader = TantivyFullTextReader::from_input_file(&input)
- .await
- .map_err(|e| crate::Error::UnexpectedError {
- message: format!(
- "Failed to open Tantivy full-text index '{}': {}",
- file_name, e
- ),
- source: None,
- })?;
- let result = reader.search(&query_text, limit)?;
- Ok::<_, crate::Error>(result.offset(row_range_start))
- }
- })
- .collect();
-
- let results = futures::future::try_join_all(futures).await?;
let mut merged = SearchResult::empty();
- for r in &results {
- merged = merged.or(r);
+ if !fulltext_entries.is_empty() {
+ let futures: Vec<_> = fulltext_entries
+ .into_iter()
+ .map(|entry| {
+ let global_meta =
entry.index_file.global_index_meta.as_ref().unwrap();
+ let path = format!("{table_path}/{INDEX_DIR}/{}",
entry.index_file.file_name);
+ let file_name = entry.index_file.file_name.clone();
+ let query_text = search.query_text.clone();
+ let limit = search.limit;
+ let row_range_start = global_meta.row_range_start;
+ let input = evaluation.file_io.new_input(&path);
+ async move {
+ let input = input?;
+ let reader = TantivyFullTextReader::from_input_file(&input)
+ .await
+ .map_err(|e| crate::Error::UnexpectedError {
+ message: format!(
+ "Failed to open Tantivy full-text index '{}':
{}",
+ file_name, e
+ ),
+ source: None,
+ })?;
+ let result = reader.search(&query_text, limit)?;
+ Ok::<_, crate::Error>(result.offset(row_range_start))
+ }
+ })
+ .collect();
+
+ let results = futures::future::try_join_all(futures).await?;
+ for r in &results {
+ merged = merged.or(r);
+ }
+ }
+
+ if search_mode != GlobalIndexSearchMode::Fast {
+ let detail_ranges = if search_mode == GlobalIndexSearchMode::Detail {
+ let table = evaluation.table.ok_or_else(||
crate::Error::DataInvalid {
+ message: "Full-text raw search in detail mode requires table
context".to_string(),
+ source: None,
+ })?;
+ detail_data_ranges_for_table(table).await?
+ } else {
+ Vec::new()
+ };
+ let field_ids = HashSet::from([field_id]);
+ let raw_ranges = unindexed_ranges_for_global_index_entries(
+ index_entries,
+ &field_ids,
+ search_mode,
+ evaluation.next_row_id,
+ &detail_ranges,
+ is_tantivy_fulltext_index_file,
+ );
+ if !raw_ranges.is_empty() {
+ let table = evaluation.table.ok_or_else(||
crate::Error::DataInvalid {
+ message: "Full-text raw search requires table
context".to_string(),
+ source: None,
+ })?;
+ let raw_result = read_raw_full_text_search(table, search,
&raw_ranges).await?;
+ merged = merged.or(&raw_result);
+ }
}
Ok(merged.top_k(search.limit).to_row_ranges())
}
+
+fn is_tantivy_fulltext_index_file(index_file: &IndexFileMeta) -> bool {
+ index_file.index_type == TANTIVY_FULLTEXT_INDEX_TYPE
+}
+
+async fn detail_data_ranges_for_table(table: &Table) ->
crate::Result<Vec<RowRange>> {
+ let plan = table
+ .new_read_builder()
+ .new_scan()
+ .with_scan_all_files()
+ .plan()
+ .await?;
+ let mut ranges = Vec::new();
+ for split in plan.splits() {
+ for file in split.data_files() {
+ if let Some((from, to)) = file.row_id_range() {
+ ranges.push(RowRange::new(from, to));
+ }
+ }
+ }
+ Ok(merge_row_ranges(ranges))
+}
+
+async fn read_raw_full_text_search(
+ table: &Table,
+ search: &FullTextSearch,
+ raw_ranges: &[RowRange],
+) -> crate::Result<SearchResult> {
+ if raw_ranges.is_empty() {
+ return Ok(SearchResult::empty());
+ }
+
+ let mut read_builder = table.new_read_builder();
+ read_builder
+ .with_projection(&[search.field_name.as_str(), ROW_ID_FIELD_NAME])
+ .with_row_ranges(raw_ranges.to_vec());
+ let plan = read_builder.new_scan().plan().await?;
+ if plan.splits().is_empty() {
+ return Ok(SearchResult::empty());
+ }
+ let read = read_builder.new_read()?;
+ let mut stream = read.to_arrow(plan.splits())?;
+
+ let mut writer = TantivyFullTextWriter::new()?;
+ while let Some(batch) = stream.try_next().await? {
+ add_raw_full_text_batch(&batch, search, &mut writer)?;
+ }
+
+ let memory_io = FileIOBuilder::new("memory").build()?;
+ let output = memory_io.new_output(&format!("/raw-fulltext-{}.archive",
Uuid::new_v4()))?;
+ if !writer.finish(&output).await? {
+ return Ok(SearchResult::empty());
+ }
+ let input = output.to_input_file();
+ let reader = TantivyFullTextReader::from_input_file(&input)
+ .await
+ .map_err(|e| crate::Error::UnexpectedError {
+ message: format!("Failed to open raw Tantivy full-text index:
{e}"),
+ source: None,
+ })?;
+ reader.search(&search.query_text, search.limit)
+}
+
+fn add_raw_full_text_batch(
+ batch: &RecordBatch,
+ search: &FullTextSearch,
+ writer: &mut TantivyFullTextWriter,
+) -> crate::Result<()> {
+ let text_index =
+ batch
+ .schema()
+ .index_of(&search.field_name)
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!(
+ "Full-text column '{}' not found in raw search batch: {}",
+ search.field_name, e
+ ),
+ source: None,
+ })?;
+ let row_id_index =
+ batch
+ .schema()
+ .index_of(ROW_ID_FIELD_NAME)
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!("_ROW_ID column not found in raw search
batch: {e}"),
+ source: None,
+ })?;
+ let row_ids = batch
+ .column(row_id_index)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "Full-text raw search requires non-null Int64
_ROW_ID".to_string(),
+ source: None,
+ })?;
+ let column = batch.column(text_index);
+
+ if let Some(strings) = column.as_any().downcast_ref::<StringArray>() {
+ for row in 0..batch.num_rows() {
+ add_raw_full_text_row(row_ids, row, get_string_value(strings,
row), writer)?;
+ }
+ return Ok(());
+ }
+
+ if let Some(strings) = column.as_any().downcast_ref::<LargeStringArray>() {
+ for row in 0..batch.num_rows() {
+ add_raw_full_text_row(row_ids, row,
get_large_string_value(strings, row), writer)?;
+ }
+ return Ok(());
+ }
+
+ Err(crate::Error::DataInvalid {
+ message: "Full-text raw search requires Utf8 or LargeUtf8 text
column".to_string(),
+ source: None,
+ })
+}
+
+fn get_string_value(strings: &StringArray, row: usize) -> Option<&str> {
+ if strings.is_null(row) {
+ None
+ } else {
+ Some(strings.value(row))
+ }
+}
+
+fn get_large_string_value(strings: &LargeStringArray, row: usize) ->
Option<&str> {
+ if strings.is_null(row) {
+ None
+ } else {
+ Some(strings.value(row))
+ }
+}
+
+fn add_raw_full_text_row(
+ row_ids: &Int64Array,
+ row: usize,
+ text: Option<&str>,
+ writer: &mut TantivyFullTextWriter,
+) -> crate::Result<()> {
+ if row_ids.is_null(row) {
+ return Err(crate::Error::DataInvalid {
+ message: "Full-text raw search found null _ROW_ID".to_string(),
+ source: None,
+ });
+ }
+ let row_id = u64::try_from(row_ids.value(row)).map_err(|_|
crate::Error::DataInvalid {
+ message: format!(
+ "Negative _ROW_ID {} cannot be used for global index search",
+ row_ids.value(row)
+ ),
+ source: None,
+ })?;
+ writer.add_document(row_id, text)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::catalog::Identifier;
+ use crate::spec::{DataType, IntType, Schema, TableSchema, VarCharType};
+ use crate::table::table_write::TableWrite;
+ use crate::table::TableCommit;
+ use arrow_array::StringArray;
+ use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
+ use std::sync::Arc;
+
+ #[tokio::test]
+ async fn test_evaluate_full_mode_without_fulltext_entries_uses_raw_path() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let fields = vec![DataField::new(
+ 1,
+ "body".to_string(),
+ DataType::Int(IntType::default()),
+ )];
+ let search = FullTextSearch::new("hello".to_string(), 10,
"body".to_string()).unwrap();
+ let options = HashMap::from([("global-index.search-mode".to_string(),
"full".to_string())]);
+
+ let err = evaluate_full_text_search(
+ FullTextSearchEvaluation {
+ table: None,
+ file_io: &file_io,
+ table_path: "memory:///test_table",
+ table_options: &options,
+ schema_fields: &fields,
+ next_row_id: Some(10),
+ },
+ &[],
+ &search,
+ )
+ .await
+ .unwrap_err();
+ assert!(
+ err.to_string()
+ .contains("Full-text raw search requires table context"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_execute_full_mode_without_index_manifest_searches_raw_rows()
{
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_path = "memory:/full_text_raw_no_manifest";
+ setup_dirs(&file_io, table_path).await;
+ let table = full_text_raw_table(&file_io, table_path);
+
+ let mut table_write = TableWrite::new(&table,
"test-user".to_string()).unwrap();
+ table_write
+ .write_arrow_batch(&text_batch(vec!["hello world", "goodbye"]))
+ .await
+ .unwrap();
+ let messages = table_write.prepare_commit().await.unwrap();
+ TableCommit::new(table.clone(), "test-user".to_string())
+ .commit(messages)
+ .await
+ .unwrap();
+
+ let mut builder = table.new_full_text_search_builder();
+ builder
+ .with_text_column("body")
+ .with_query_text("hello")
+ .with_limit(10);
+ let row_ranges = builder.execute().await.unwrap();
+
+ assert_eq!(row_ranges, vec![RowRange::new(0, 0)]);
+ }
+
+ async fn setup_dirs(file_io: &FileIO, table_path: &str) {
+ file_io
+ .mkdirs(&format!("{table_path}/snapshot/"))
+ .await
+ .unwrap();
+ file_io
+ .mkdirs(&format!("{table_path}/manifest/"))
+ .await
+ .unwrap();
+ }
+
+ fn full_text_raw_table(file_io: &FileIO, table_path: &str) -> Table {
+ let schema = Schema::builder()
+ .column("body", DataType::VarChar(VarCharType::string_type()))
+ .option("row-tracking.enabled", "true")
+ .option("global-index.search-mode", "full")
+ .build()
+ .unwrap();
+ Table::new(
+ file_io.clone(),
+ Identifier::new("default", "full_text_raw_no_manifest"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ )
+ }
+
+ fn text_batch(values: Vec<&str>) -> RecordBatch {
+ let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new(
+ "body",
+ ArrowDataType::Utf8,
+ false,
+ )]));
+ RecordBatch::try_new(schema,
vec![Arc::new(StringArray::from(values))]).unwrap()
+ }
+}
diff --git a/crates/paimon/src/table/global_index_scanner.rs
b/crates/paimon/src/table/global_index_scanner.rs
index dd2ab09..8ed18a9 100644
--- a/crates/paimon/src/table/global_index_scanner.rs
+++ b/crates/paimon/src/table/global_index_scanner.rs
@@ -24,13 +24,14 @@ use crate::btree::query::{extract_between, IndexQuery};
use crate::btree::{make_key_comparator, serialize_datum, BTreeIndexMeta,
BTreeIndexReader};
use crate::io::FileIO;
use crate::spec::{
- DataField, DataType, Datum, FileKind, IndexManifestEntry, Predicate,
PredicateOperator,
+ DataField, DataType, Datum, FileKind, GlobalIndexSearchMode,
IndexFileMeta, IndexManifestEntry,
+ Predicate, PredicateOperator,
};
use crate::table::RowRange;
use crate::Result;
use roaring::RoaringTreemap;
use std::cmp::Ordering;
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
type BoxedCmp = Box<dyn Fn(&[u8], &[u8]) -> Ordering + Send + Sync>;
@@ -54,6 +55,8 @@ pub(crate) struct GlobalIndexScanner {
table_path: String,
/// Global index entries grouped by field_id.
entries_by_field: Vec<(i32, Vec<GlobalIndexEntry>)>,
+ /// Indexed row-id coverage grouped by field_id.
+ coverage_by_field: HashMap<i32, Vec<RowRange>>,
/// Schema fields for field_id lookup.
schema_fields: Vec<DataField>,
/// Cache of opened BTree readers, keyed by file name.
@@ -78,6 +81,7 @@ impl GlobalIndexScanner {
) -> Option<Self> {
let mut entries_by_field: std::collections::HashMap<i32,
Vec<GlobalIndexEntry>> =
std::collections::HashMap::new();
+ let mut coverage_by_field: HashMap<i32, Vec<RowRange>> =
HashMap::new();
for entry in index_entries {
if entry.kind != FileKind::Add {
@@ -103,6 +107,20 @@ impl GlobalIndexScanner {
meta: btree_meta,
};
+ let row_range = RowRange::new(global_meta.row_range_start,
global_meta.row_range_end);
+ coverage_by_field
+ .entry(global_meta.index_field_id)
+ .or_default()
+ .push(row_range.clone());
+ if let Some(extra_field_ids) =
global_meta.extra_field_ids.as_ref() {
+ for extra_field_id in extra_field_ids {
+ coverage_by_field
+ .entry(*extra_field_id)
+ .or_default()
+ .push(row_range.clone());
+ }
+ }
+
entries_by_field
.entry(global_meta.index_field_id)
.or_default()
@@ -117,6 +135,7 @@ impl GlobalIndexScanner {
file_io: file_io.clone(),
table_path: table_path.trim_end_matches('/').to_string(),
entries_by_field: entries_by_field.into_iter().collect(),
+ coverage_by_field,
schema_fields: schema_fields.to_vec(),
reader_cache: Mutex::new(HashMap::new()),
})
@@ -397,6 +416,57 @@ impl GlobalIndexScanner {
.find(|(id, _)| *id == field_id)
.map(|(_, entries)| entries.as_slice())
}
+
+ /// Return row ranges not covered by global indexes for this predicate.
+ ///
+ /// `full` uses `[0, snapshot.next_row_id - 1]`; `detail` uses actual
+ /// data-file row ranges collected by the scan. The caller unions these
+ /// ranges with indexed matches, and the normal read filter evaluates the
+ /// predicate on the raw rows.
+ fn unindexed_ranges(
+ &self,
+ predicate: &Predicate,
+ search_mode: GlobalIndexSearchMode,
+ next_row_id: Option<i64>,
+ data_ranges: &[RowRange],
+ ) -> Result<Vec<RowRange>> {
+ let field_ids = self.collect_field_ids(predicate)?;
+ Ok(unindexed_ranges_for_coverage(
+ &self.coverage_by_field,
+ &field_ids,
+ search_mode,
+ next_row_id,
+ data_ranges,
+ ))
+ }
+
+ fn collect_field_ids(&self, predicate: &Predicate) -> Result<HashSet<i32>>
{
+ let mut field_ids = HashSet::new();
+ self.collect_field_ids_inner(predicate, &mut field_ids)?;
+ Ok(field_ids)
+ }
+
+ fn collect_field_ids_inner(
+ &self,
+ predicate: &Predicate,
+ field_ids: &mut HashSet<i32>,
+ ) -> Result<()> {
+ match predicate {
+ Predicate::Leaf { column, .. } => {
+ if let Some(field_id) = self.find_field_id_by_name(column)? {
+ field_ids.insert(field_id);
+ }
+ }
+ Predicate::And(children) | Predicate::Or(children) => {
+ for child in children {
+ self.collect_field_ids_inner(child, field_ids)?;
+ }
+ }
+ Predicate::Not(inner) => self.collect_field_ids_inner(inner,
field_ids)?,
+ Predicate::AlwaysTrue | Predicate::AlwaysFalse => {}
+ }
+ Ok(())
+ }
}
/// Whether the b-tree global index can evaluate this operator directly.
@@ -455,6 +525,144 @@ fn intersect_sorted_ranges(a: &[RowRange], b:
&[RowRange]) -> Vec<RowRange> {
result
}
+fn exclude_row_ranges(data_ranges: &[RowRange], indexed_ranges: &[RowRange])
-> Vec<RowRange> {
+ let data_ranges = super::merge_row_ranges(data_ranges.to_vec());
+ if data_ranges.is_empty() {
+ return Vec::new();
+ }
+ let indexed_ranges = super::merge_row_ranges(indexed_ranges.to_vec());
+ if indexed_ranges.is_empty() {
+ return data_ranges;
+ }
+
+ let mut result = Vec::new();
+ for data_range in data_ranges {
+ let mut cursor = data_range.from();
+ let mut exhausted = false;
+ for indexed_range in &indexed_ranges {
+ if indexed_range.to() < cursor {
+ continue;
+ }
+ if indexed_range.from() > data_range.to() {
+ break;
+ }
+ if indexed_range.from() > cursor {
+ result.push(RowRange::new(cursor, indexed_range.from() - 1));
+ }
+ if indexed_range.to() >= data_range.to() {
+ exhausted = true;
+ break;
+ }
+ cursor = cursor.max(indexed_range.to() + 1);
+ }
+ if !exhausted && cursor <= data_range.to() {
+ result.push(RowRange::new(cursor, data_range.to()));
+ }
+ }
+ super::merge_row_ranges(result)
+}
+
+fn data_ranges_for_search_mode(
+ search_mode: GlobalIndexSearchMode,
+ next_row_id: Option<i64>,
+ data_ranges: &[RowRange],
+) -> Option<Vec<RowRange>> {
+ match search_mode {
+ GlobalIndexSearchMode::Fast => None,
+ GlobalIndexSearchMode::Full => match next_row_id {
+ Some(next_row_id) if next_row_id > 0 => Some(vec![RowRange::new(0,
next_row_id - 1)]),
+ _ => None,
+ },
+ GlobalIndexSearchMode::Detail => {
+ if data_ranges.is_empty() {
+ None
+ } else {
+ Some(data_ranges.to_vec())
+ }
+ }
+ }
+}
+
+fn indexed_ranges_from_coverage(
+ coverage_by_field: &HashMap<i32, Vec<RowRange>>,
+ field_ids: &HashSet<i32>,
+) -> Vec<RowRange> {
+ let mut ranges: Option<Vec<RowRange>> = None;
+ for field_id in field_ids {
+ let Some(field_ranges) = coverage_by_field.get(field_id) else {
+ return Vec::new();
+ };
+ if field_ranges.is_empty() {
+ return Vec::new();
+ }
+ let field_ranges = super::merge_row_ranges(field_ranges.clone());
+ ranges = Some(match ranges {
+ None => field_ranges,
+ Some(existing) => intersect_sorted_ranges(&existing,
&field_ranges),
+ });
+ }
+ ranges.map(super::merge_row_ranges).unwrap_or_default()
+}
+
+fn unindexed_ranges_for_coverage(
+ coverage_by_field: &HashMap<i32, Vec<RowRange>>,
+ field_ids: &HashSet<i32>,
+ search_mode: GlobalIndexSearchMode,
+ next_row_id: Option<i64>,
+ data_ranges: &[RowRange],
+) -> Vec<RowRange> {
+ let Some(data_ranges) = data_ranges_for_search_mode(search_mode,
next_row_id, data_ranges)
+ else {
+ return Vec::new();
+ };
+ let indexed_ranges = indexed_ranges_from_coverage(coverage_by_field,
field_ids);
+ exclude_row_ranges(&data_ranges, &indexed_ranges)
+}
+
+/// Compute row ranges not covered by a family of global index files.
+///
+/// This mirrors Java `GlobalIndexCoverage`: `full` compares index coverage
+/// against `[0, snapshot.next_row_id - 1]`, while `detail` compares against
+/// exact data-file row ranges supplied by the caller.
+pub(crate) fn unindexed_ranges_for_global_index_entries(
+ index_entries: &[IndexManifestEntry],
+ field_ids: &HashSet<i32>,
+ search_mode: GlobalIndexSearchMode,
+ next_row_id: Option<i64>,
+ data_ranges: &[RowRange],
+ index_file_filter: impl Fn(&IndexFileMeta) -> bool,
+) -> Vec<RowRange> {
+ let mut coverage_by_field: HashMap<i32, Vec<RowRange>> = HashMap::new();
+ for entry in index_entries {
+ if entry.kind != FileKind::Add ||
!index_file_filter(&entry.index_file) {
+ continue;
+ }
+ let Some(global_meta) = entry.index_file.global_index_meta.as_ref()
else {
+ continue;
+ };
+ let row_range = RowRange::new(global_meta.row_range_start,
global_meta.row_range_end);
+ coverage_by_field
+ .entry(global_meta.index_field_id)
+ .or_default()
+ .push(row_range.clone());
+ if let Some(extra_field_ids) = global_meta.extra_field_ids.as_ref() {
+ for extra_field_id in extra_field_ids {
+ coverage_by_field
+ .entry(*extra_field_id)
+ .or_default()
+ .push(row_range.clone());
+ }
+ }
+ }
+ unindexed_ranges_for_coverage(
+ &coverage_by_field,
+ field_ids,
+ search_mode,
+ next_row_id,
+ data_ranges,
+ )
+}
+
/// Index for row ranges. Stores sorted, non-overlapping ranges and supports
/// efficient intersection queries via binary search.
///
@@ -550,22 +758,43 @@ fn lower_bound(sorted: &[i64], target: i64) -> usize {
/// This is the main entry point for the table scan integration.
///
/// Returns `None` if global index is not available or predicates can't be
evaluated.
+pub(crate) struct GlobalIndexEvaluation<'a> {
+ pub(crate) file_io: &'a FileIO,
+ pub(crate) table_path: &'a str,
+ pub(crate) index_entries: &'a [IndexManifestEntry],
+ pub(crate) predicates: &'a [Predicate],
+ pub(crate) schema_fields: &'a [DataField],
+ pub(crate) search_mode: GlobalIndexSearchMode,
+ pub(crate) next_row_id: Option<i64>,
+ pub(crate) data_ranges: &'a [RowRange],
+}
+
pub(crate) async fn evaluate_global_index(
- file_io: &FileIO,
- table_path: &str,
- index_entries: &[IndexManifestEntry],
- predicates: &[Predicate],
- schema_fields: &[DataField],
+ evaluation: GlobalIndexEvaluation<'_>,
) -> Result<Option<Vec<RowRange>>> {
- let scanner =
- match GlobalIndexScanner::create(file_io, table_path, index_entries,
schema_fields) {
- Some(s) => s,
- None => return Ok(None),
- };
-
- let combined = Predicate::and(predicates.to_vec());
-
- scanner.evaluate(&combined).await
+ let scanner = match GlobalIndexScanner::create(
+ evaluation.file_io,
+ evaluation.table_path,
+ evaluation.index_entries,
+ evaluation.schema_fields,
+ ) {
+ Some(s) => s,
+ None => return Ok(None),
+ };
+
+ let combined = Predicate::and(evaluation.predicates.to_vec());
+
+ let mut row_ranges = match scanner.evaluate(&combined).await? {
+ Some(row_ranges) => row_ranges,
+ None => return Ok(None),
+ };
+ row_ranges.extend(scanner.unindexed_ranges(
+ &combined,
+ evaluation.search_mode,
+ evaluation.next_row_id,
+ evaluation.data_ranges,
+ )?);
+ Ok(Some(super::merge_row_ranges(row_ranges)))
}
#[cfg(test)]
@@ -741,6 +970,176 @@ mod tests {
)]
}
+ async fn evaluate_global_index_fast(
+ file_io: &FileIO,
+ table_path: &str,
+ entries: &[IndexManifestEntry],
+ predicates: &[Predicate],
+ fields: &[DataField],
+ ) -> Result<Option<Vec<RowRange>>> {
+ super::evaluate_global_index(super::GlobalIndexEvaluation {
+ file_io,
+ table_path,
+ index_entries: entries,
+ predicates,
+ schema_fields: fields,
+ search_mode: GlobalIndexSearchMode::Fast,
+ next_row_id: None,
+ data_ranges: &[],
+ })
+ .await
+ }
+
+ fn two_field_schema_fields() -> Vec<DataField> {
+ vec![
+ DataField::new(
+ 1,
+ "id".to_string(),
+ DataType::Int(crate::spec::IntType::new()),
+ ),
+ DataField::new(
+ 2,
+ "value".to_string(),
+ DataType::Int(crate::spec::IntType::new()),
+ ),
+ ]
+ }
+
+ fn int_eq(column: &str, index: usize, value: i32) -> Predicate {
+ Predicate::Leaf {
+ column: column.to_string(),
+ index,
+ data_type: DataType::Int(crate::spec::IntType::new()),
+ op: PredicateOperator::Eq,
+ literals: vec![Datum::Int(value)],
+ }
+ }
+
+ #[test]
+ fn test_unindexed_ranges_fast_mode_empty() {
+ let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let meta = BTreeIndexMeta::new(None, None, false);
+ let entries = vec![make_global_index_entry("idx", 1, 0, 49, &meta)];
+ let fields = int_schema_fields();
+ let scanner =
+ GlobalIndexScanner::create(&file_io, "memory:/t", &entries,
&fields).expect("scanner");
+
+ let ranges = scanner
+ .unindexed_ranges(
+ &int_eq("id", 0, 7),
+ GlobalIndexSearchMode::Fast,
+ Some(100),
+ &[RowRange::new(50, 99)],
+ )
+ .unwrap();
+ assert!(ranges.is_empty());
+ }
+
+ #[test]
+ fn test_unindexed_ranges_full_uses_snapshot_next_row_id() {
+ let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let meta = BTreeIndexMeta::new(None, None, false);
+ let entries = vec![make_global_index_entry("idx", 1, 0, 49, &meta)];
+ let fields = int_schema_fields();
+ let scanner =
+ GlobalIndexScanner::create(&file_io, "memory:/t", &entries,
&fields).expect("scanner");
+
+ let ranges = scanner
+ .unindexed_ranges(
+ &int_eq("id", 0, 7),
+ GlobalIndexSearchMode::Full,
+ Some(100),
+ &[],
+ )
+ .unwrap();
+ assert_eq!(ranges, vec![RowRange::new(50, 99)]);
+ }
+
+ #[test]
+ fn test_unindexed_ranges_detail_uses_data_file_ranges() {
+ let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let meta = BTreeIndexMeta::new(None, None, false);
+ let entries = vec![make_global_index_entry("idx", 1, 0, 49, &meta)];
+ let fields = int_schema_fields();
+ let scanner =
+ GlobalIndexScanner::create(&file_io, "memory:/t", &entries,
&fields).expect("scanner");
+
+ let ranges = scanner
+ .unindexed_ranges(
+ &int_eq("id", 0, 7),
+ GlobalIndexSearchMode::Detail,
+ Some(100),
+ &[
+ RowRange::new(0, 10),
+ RowRange::new(40, 60),
+ RowRange::new(80, 90),
+ ],
+ )
+ .unwrap();
+ assert_eq!(ranges, vec![RowRange::new(50, 60), RowRange::new(80, 90)]);
+ }
+
+ #[test]
+ fn test_unindexed_ranges_uses_all_predicate_field_coverage() {
+ let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let meta = BTreeIndexMeta::new(None, None, false);
+ let entries = vec![
+ make_global_index_entry("idx_id", 1, 0, 49, &meta),
+ make_global_index_entry("idx_value", 2, 0, 99, &meta),
+ ];
+ let fields = two_field_schema_fields();
+ let scanner =
+ GlobalIndexScanner::create(&file_io, "memory:/t", &entries,
&fields).expect("scanner");
+ let predicate = Predicate::and(vec![int_eq("id", 0, 7),
int_eq("value", 1, 8)]);
+
+ let ranges = scanner
+ .unindexed_ranges(&predicate, GlobalIndexSearchMode::Full,
Some(100), &[])
+ .unwrap();
+ assert_eq!(ranges, vec![RowRange::new(50, 99)]);
+ }
+
+ #[test]
+ fn test_unindexed_ranges_missing_field_coverage_reads_all_data_ranges() {
+ let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let meta = BTreeIndexMeta::new(None, None, false);
+ let entries = vec![make_global_index_entry("idx_id", 1, 0, 49, &meta)];
+ let fields = two_field_schema_fields();
+ let scanner =
+ GlobalIndexScanner::create(&file_io, "memory:/t", &entries,
&fields).expect("scanner");
+ let predicate = Predicate::and(vec![int_eq("id", 0, 7),
int_eq("value", 1, 8)]);
+
+ let ranges = scanner
+ .unindexed_ranges(&predicate, GlobalIndexSearchMode::Full,
Some(100), &[])
+ .unwrap();
+ assert_eq!(ranges, vec![RowRange::new(0, 99)]);
+ }
+
+ #[test]
+ fn test_unindexed_ranges_counts_extra_field_coverage() {
+ let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let meta = BTreeIndexMeta::new(None, None, false);
+ let mut entry = make_global_index_entry("idx_id_value", 1, 0, 99,
&meta);
+ entry
+ .index_file
+ .global_index_meta
+ .as_mut()
+ .unwrap()
+ .extra_field_ids = Some(vec![2]);
+ let fields = two_field_schema_fields();
+ let scanner =
+ GlobalIndexScanner::create(&file_io, "memory:/t", &[entry],
&fields).expect("scanner");
+
+ let ranges = scanner
+ .unindexed_ranges(
+ &int_eq("value", 1, 8),
+ GlobalIndexSearchMode::Full,
+ Some(100),
+ &[],
+ )
+ .unwrap();
+ assert!(ranges.is_empty());
+ }
+
#[tokio::test]
async fn test_evaluate_global_index_eq() {
let (file_io, table_path, file_name, _tmp) =
@@ -758,13 +1157,75 @@ mod tests {
literals: vec![Datum::Int(50)],
}];
- let result = evaluate_global_index(&file_io, &table_path, &entries,
&predicates, &fields)
- .await
- .unwrap();
+ let result =
+ evaluate_global_index_fast(&file_io, &table_path, &entries,
&predicates, &fields)
+ .await
+ .unwrap();
let ranges = result.unwrap();
assert_eq!(ranges, vec![RowRange::new(25, 25)]);
}
+ #[tokio::test]
+ async fn test_evaluate_global_index_full_mode_includes_unindexed_tail() {
+ let (file_io, table_path, file_name, _tmp) =
+ setup_testdata_table("btree_int_100_no_compress.bin");
+ let meta = BTreeIndexMeta::new(Some(le_int_key(0)),
Some(le_int_key(198)), false);
+ let entries = vec![make_global_index_entry(&file_name, 1, 0, 99,
&meta)];
+ let fields = int_schema_fields();
+ let predicates = vec![int_eq("id", 0, 50)];
+
+ let result = super::evaluate_global_index(super::GlobalIndexEvaluation
{
+ file_io: &file_io,
+ table_path: &table_path,
+ index_entries: &entries,
+ predicates: &predicates,
+ schema_fields: &fields,
+ search_mode: GlobalIndexSearchMode::Full,
+ next_row_id: Some(150),
+ data_ranges: &[],
+ })
+ .await
+ .unwrap();
+
+ assert_eq!(
+ result.unwrap(),
+ vec![RowRange::new(25, 25), RowRange::new(100, 149)]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_evaluate_global_index_detail_mode_uses_data_ranges() {
+ let (file_io, table_path, file_name, _tmp) =
+ setup_testdata_table("btree_int_100_no_compress.bin");
+ let meta = BTreeIndexMeta::new(Some(le_int_key(0)),
Some(le_int_key(198)), false);
+ let entries = vec![make_global_index_entry(&file_name, 1, 0, 99,
&meta)];
+ let fields = int_schema_fields();
+ let predicates = vec![int_eq("id", 0, 50)];
+
+ let data_ranges = [RowRange::new(90, 120), RowRange::new(140, 145)];
+ let result = super::evaluate_global_index(super::GlobalIndexEvaluation
{
+ file_io: &file_io,
+ table_path: &table_path,
+ index_entries: &entries,
+ predicates: &predicates,
+ schema_fields: &fields,
+ search_mode: GlobalIndexSearchMode::Detail,
+ next_row_id: Some(150),
+ data_ranges: &data_ranges,
+ })
+ .await
+ .unwrap();
+
+ assert_eq!(
+ result.unwrap(),
+ vec![
+ RowRange::new(25, 25),
+ RowRange::new(100, 120),
+ RowRange::new(140, 145),
+ ]
+ );
+ }
+
#[tokio::test]
async fn test_evaluate_global_index_range() {
let (file_io, table_path, file_name, _tmp) =
@@ -791,9 +1252,10 @@ mod tests {
},
];
- let result = evaluate_global_index(&file_io, &table_path, &entries,
&predicates, &fields)
- .await
- .unwrap();
+ let result =
+ evaluate_global_index_fast(&file_io, &table_path, &entries,
&predicates, &fields)
+ .await
+ .unwrap();
let ranges = result.unwrap();
assert_eq!(ranges, vec![RowRange::new(5, 10)]);
}
@@ -815,9 +1277,10 @@ mod tests {
literals: vec![Datum::Int(0), Datum::Int(50), Datum::Int(198)],
}];
- let result = evaluate_global_index(&file_io, &table_path, &entries,
&predicates, &fields)
- .await
- .unwrap();
+ let result =
+ evaluate_global_index_fast(&file_io, &table_path, &entries,
&predicates, &fields)
+ .await
+ .unwrap();
let ranges = result.unwrap();
assert_eq!(
ranges,
@@ -846,9 +1309,10 @@ mod tests {
literals: vec![Datum::Int(999)],
}];
- let result = evaluate_global_index(&file_io, &table_path, &entries,
&predicates, &fields)
- .await
- .unwrap();
+ let result =
+ evaluate_global_index_fast(&file_io, &table_path, &entries,
&predicates, &fields)
+ .await
+ .unwrap();
let ranges = result.unwrap();
assert!(ranges.is_empty());
}
@@ -871,9 +1335,10 @@ mod tests {
literals: vec![Datum::Int(50)],
}];
- let result = evaluate_global_index(&file_io, &table_path, &entries,
&predicates, &fields)
- .await
- .unwrap();
+ let result =
+ evaluate_global_index_fast(&file_io, &table_path, &entries,
&predicates, &fields)
+ .await
+ .unwrap();
let ranges = result.unwrap();
assert_eq!(ranges, vec![RowRange::new(1025, 1025)]);
}
@@ -895,9 +1360,10 @@ mod tests {
literals: vec![Datum::Int(50)],
}];
- let result = evaluate_global_index(&file_io, &table_path, &entries,
&predicates, &fields)
- .await
- .unwrap();
+ let result =
+ evaluate_global_index_fast(&file_io, &table_path, &entries,
&predicates, &fields)
+ .await
+ .unwrap();
assert!(result.is_none());
}
@@ -972,9 +1438,10 @@ mod tests {
},
];
- let result = evaluate_global_index(&file_io, &table_path, &entries,
&predicates, &fields)
- .await
- .unwrap();
+ let result =
+ evaluate_global_index_fast(&file_io, &table_path, &entries,
&predicates, &fields)
+ .await
+ .unwrap();
let ranges = result.unwrap();
assert_eq!(ranges, vec![RowRange::new(22, 26)]);
}
@@ -1015,9 +1482,10 @@ mod tests {
},
])];
- let result = evaluate_global_index(&file_io, &table_path, &entries,
&predicates, &fields)
- .await
- .unwrap();
+ let result =
+ evaluate_global_index_fast(&file_io, &table_path, &entries,
&predicates, &fields)
+ .await
+ .unwrap();
let ranges = result.unwrap();
assert!(
ranges.is_empty(),
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index 0c9dc95..409f40f 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -31,9 +31,10 @@ use super::Table;
use crate::io::FileIO;
use crate::spec::{
avro::SharedSchemaCache, bucket_dir_name, BinaryRow, BucketFunctionType,
CoreOptions,
- DataField, DataFileMeta, FileKind, IndexManifest, ManifestEntry,
PartitionComputer, Predicate,
- Snapshot, ROW_ID_FIELD_ID, ROW_ID_FIELD_NAME, SEQUENCE_NUMBER_FIELD_ID,
- SEQUENCE_NUMBER_FIELD_NAME, VALUE_KIND_FIELD_ID, VALUE_KIND_FIELD_NAME,
+ DataField, DataFileMeta, FileKind, GlobalIndexSearchMode, IndexManifest,
ManifestEntry,
+ PartitionComputer, Predicate, Snapshot, ROW_ID_FIELD_ID, ROW_ID_FIELD_NAME,
+ SEQUENCE_NUMBER_FIELD_ID, SEQUENCE_NUMBER_FIELD_NAME, VALUE_KIND_FIELD_ID,
+ VALUE_KIND_FIELD_NAME,
};
use crate::table::bin_pack::split_for_batch;
use crate::table::merge_tree_split_generator::{
@@ -286,6 +287,20 @@ pub(super) fn can_push_down_limit_hint_for_scan(
data_predicates.is_empty() && row_ranges.is_none()
}
+type BucketDataFileGroups = HashMap<(Vec<u8>, i32), (i32, Vec<DataFileMeta>)>;
+
+fn global_index_detail_data_ranges(groups: &BucketDataFileGroups) ->
Vec<RowRange> {
+ let mut ranges = Vec::new();
+ for (_, data_files) in groups.values() {
+ for file in data_files {
+ if let Some((from, to)) = file.row_id_range() {
+ ranges.push(RowRange::new(from, to));
+ }
+ }
+ }
+ merge_row_ranges(ranges)
+}
+
fn should_skip_level_zero_for_scan(
scan_all_files: bool,
has_primary_keys: bool,
@@ -802,8 +817,7 @@ impl<'a> TableScan<'a> {
}
// Group by (partition, bucket), decomposing entries to avoid cloning
partition.
- let mut groups: HashMap<(Vec<u8>, i32), (i32, Vec<DataFileMeta>)> =
- HashMap::with_capacity(entries.len());
+ let mut groups: BucketDataFileGroups =
HashMap::with_capacity(entries.len());
for e in entries {
let (partition, bucket, total_buckets, file) = e.into_parts();
let entry = groups
@@ -812,6 +826,23 @@ impl<'a> TableScan<'a> {
entry.1.push(file);
}
+ let global_index_search_mode = if data_evolution_enabled
+ && core_options.global_index_enabled()
+ && !self.data_predicates.is_empty()
+ {
+ Some(core_options.global_index_search_mode()?)
+ } else {
+ None
+ };
+ let global_index_detail_data_ranges = if matches!(
+ global_index_search_mode,
+ Some(GlobalIndexSearchMode::Detail)
+ ) {
+ global_index_detail_data_ranges(&groups)
+ } else {
+ Vec::new()
+ };
+
let snapshot_id = snapshot.id();
let base_path = table_path.trim_end_matches('/');
let mut splits = Vec::with_capacity(groups.len());
@@ -856,16 +887,18 @@ impl<'a> TableScan<'a> {
// Use pushed-down row_ranges first; otherwise try global
index.
let row_ranges = if self.row_ranges.is_some() {
self.row_ranges.clone()
- } else if data_evolution_enabled
- && core_options.global_index_enabled()
- && !self.data_predicates.is_empty()
- {
+ } else if let Some(search_mode) = global_index_search_mode {
super::global_index_scanner::evaluate_global_index(
- file_io,
- base_path,
- &index_entries,
- &self.data_predicates,
- self.table.schema().fields(),
+ super::global_index_scanner::GlobalIndexEvaluation {
+ file_io,
+ table_path: base_path,
+ index_entries: &index_entries,
+ predicates: &self.data_predicates,
+ schema_fields: self.table.schema().fields(),
+ search_mode,
+ next_row_id: snapshot.next_row_id(),
+ data_ranges: &global_index_detail_data_ranges,
+ },
)
.await?
} else {
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index 48271aa..1ebd422 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -15,15 +15,24 @@
// specific language governing permissions and limitations
// under the License.
-use crate::lumina::is_lumina_index_type;
+use crate::io::FileIO;
use crate::lumina::reader::LuminaVectorGlobalIndexReader;
-use crate::spec::{DataField, FileKind, IndexManifest};
+use crate::lumina::{is_lumina_index_type, LuminaIndexMeta, LuminaVectorMetric};
+use crate::spec::{
+ CoreOptions, DataField, FileKind, GlobalIndexSearchMode, IndexFileMeta,
IndexManifest,
+ IndexManifestEntry, ROW_ID_FIELD_NAME,
+};
+use
crate::table::global_index_scanner::unindexed_ranges_for_global_index_entries;
use crate::table::snapshot_manager::SnapshotManager;
-use crate::table::{find_field_id_by_name, RowRange, Table};
+use crate::table::{find_field_id_by_name, merge_row_ranges, RowRange, Table};
use crate::vector_search::{GlobalIndexIOMeta, SearchResult, VectorSearch};
use crate::vindex::is_vindex_index_type;
use crate::vindex::reader::VindexVectorGlobalIndexReader;
-use std::collections::HashMap;
+use arrow_array::{Array, FixedSizeListArray, Float32Array, Int64Array,
ListArray, RecordBatch};
+use futures::TryStreamExt;
+use paimon_vindex_core::distance::MetricType;
+use paimon_vindex_core::index::VectorIndexReader as VIndexReader;
+use std::collections::{HashMap, HashSet};
use std::io::Cursor;
const INDEX_DIR: &str = "index";
@@ -115,41 +124,53 @@ impl<'a> VectorSearchBuilder<'a> {
None => return Ok(Vec::new()),
};
- let index_manifest_name = match snapshot.index_manifest() {
- Some(name) => name.to_string(),
- None => return Ok(Vec::new()),
+ let index_entries = match snapshot.index_manifest() {
+ Some(index_manifest_name) => {
+ let manifest_path = format!(
+ "{}/manifest/{}",
+ self.table.location().trim_end_matches('/'),
+ index_manifest_name
+ );
+ IndexManifest::read(self.table.file_io(),
&manifest_path).await?
+ }
+ None => Vec::new(),
};
- let manifest_path = format!(
- "{}/manifest/{}",
- self.table.location().trim_end_matches('/'),
- index_manifest_name
- );
- let index_entries = IndexManifest::read(self.table.file_io(),
&manifest_path).await?;
-
evaluate_vector_search(
- self.table.file_io(),
- self.table.location(),
- self.table.schema().options(),
+ VectorSearchEvaluation {
+ table: Some(self.table),
+ file_io: self.table.file_io(),
+ table_path: self.table.location(),
+ table_options: self.table.schema().options(),
+ schema_fields: self.table.schema().fields(),
+ next_row_id: snapshot.next_row_id(),
+ },
&index_entries,
&vector_search,
- self.table.schema().fields(),
)
.await
}
}
+struct VectorSearchEvaluation<'a> {
+ table: Option<&'a Table>,
+ file_io: &'a FileIO,
+ table_path: &'a str,
+ table_options: &'a HashMap<String, String>,
+ schema_fields: &'a [DataField],
+ next_row_id: Option<i64>,
+}
+
async fn evaluate_vector_search(
- file_io: &crate::io::FileIO,
- table_path: &str,
- table_options: &HashMap<String, String>,
- index_entries: &[crate::spec::IndexManifestEntry],
+ evaluation: VectorSearchEvaluation<'_>,
+ index_entries: &[IndexManifestEntry],
vector_search: &VectorSearch,
- schema_fields: &[DataField],
) -> crate::Result<Vec<RowRange>> {
- let table_path = table_path.trim_end_matches('/');
+ let table_path = evaluation.table_path.trim_end_matches('/');
+ let search_mode =
CoreOptions::new(evaluation.table_options).global_index_search_mode()?;
- let field_id = match find_field_id_by_name(schema_fields,
&vector_search.field_name) {
+ let field_id = match find_field_id_by_name(evaluation.schema_fields,
&vector_search.field_name)
+ {
Some(id) => id,
None => return Ok(Vec::new()),
};
@@ -166,69 +187,473 @@ async fn evaluate_vector_search(
})
.collect();
- if vector_entries.is_empty() {
+ if vector_entries.is_empty() && search_mode == GlobalIndexSearchMode::Fast
{
return Ok(Vec::new());
}
- let futures: Vec<_> = vector_entries
- .into_iter()
- .map(|entry| {
- let global_meta =
entry.index_file.global_index_meta.as_ref().unwrap();
- let backend =
VectorIndexBackend::from_index_type(&entry.index_file.index_type)
- .expect("filtered vector index type");
- let path = format!("{table_path}/{INDEX_DIR}/{}",
entry.index_file.file_name);
- let file_name = entry.index_file.file_name.clone();
- let file_size = entry.index_file.file_size as u64;
- let index_meta_bytes =
global_meta.index_meta.clone().unwrap_or_default();
- let row_range_start = global_meta.row_range_start;
- let vector_search_clone = vector_search.clone();
- let options = table_options.clone();
- let input = file_io.new_input(&path);
- async move {
- let input = input?;
+ let mut merged = SearchResult::empty();
+ if !vector_entries.is_empty() {
+ let futures: Vec<_> = vector_entries
+ .into_iter()
+ .map(|entry| {
+ let global_meta =
entry.index_file.global_index_meta.as_ref().unwrap();
+ let backend =
VectorIndexBackend::from_index_type(&entry.index_file.index_type)
+ .expect("filtered vector index type");
+ let path = format!("{table_path}/{INDEX_DIR}/{}",
entry.index_file.file_name);
+ let file_name = entry.index_file.file_name.clone();
+ let file_size = entry.index_file.file_size as u64;
+ let index_meta_bytes =
global_meta.index_meta.clone().unwrap_or_default();
+ let row_range_start = global_meta.row_range_start;
+ let vector_search_clone = vector_search.clone();
+ let options = evaluation.table_options.clone();
+ let input = evaluation.file_io.new_input(&path);
+ async move {
+ let input = input?;
+ let bytes = input.read().await.map_err(|e|
crate::Error::DataInvalid {
+ message: format!(
+ "Failed to read {} index file '{}': {}",
+ backend.error_name(),
+ file_name,
+ e
+ ),
+ source: None,
+ })?;
+
+ let io_meta =
+ GlobalIndexIOMeta::new(file_name.clone(), file_size,
index_meta_bytes);
+ let data = bytes.to_vec();
+ let result = match backend {
+ VectorIndexBackend::Lumina => {
+ let mut reader =
LuminaVectorGlobalIndexReader::new(io_meta, options);
+ reader.visit_vector_search(&vector_search_clone,
|_| {
+ Ok(Cursor::new(data))
+ })?
+ }
+ VectorIndexBackend::Vindex => {
+ let mut reader =
VindexVectorGlobalIndexReader::new(io_meta, options);
+ reader.visit_vector_search(&vector_search_clone,
|_| {
+ Ok(Cursor::new(data))
+ })?
+ }
+ };
+
+ match result {
+ Some(scored_map) => Ok::<_, crate::Error>(
+
SearchResult::from_scored_map(scored_map).offset(row_range_start),
+ ),
+ None => Ok(SearchResult::empty()),
+ }
+ }
+ })
+ .collect();
+
+ let results = futures::future::try_join_all(futures).await?;
+ for r in &results {
+ merged = merged.or(r);
+ }
+ }
+
+ if search_mode != GlobalIndexSearchMode::Fast {
+ let detail_ranges = if search_mode == GlobalIndexSearchMode::Detail {
+ let table = evaluation.table.ok_or_else(||
crate::Error::DataInvalid {
+ message: "Vector raw search in detail mode requires table
context".to_string(),
+ source: None,
+ })?;
+ detail_data_ranges_for_table(table).await?
+ } else {
+ Vec::new()
+ };
+ let field_ids = HashSet::from([field_id]);
+ let raw_ranges = unindexed_ranges_for_global_index_entries(
+ index_entries,
+ &field_ids,
+ search_mode,
+ evaluation.next_row_id,
+ &detail_ranges,
+ is_vector_global_index_file,
+ );
+ if !raw_ranges.is_empty() {
+ let table = evaluation.table.ok_or_else(||
crate::Error::DataInvalid {
+ message: "Vector raw search requires table
context".to_string(),
+ source: None,
+ })?;
+ let metric = resolve_raw_vector_metric(
+ evaluation.file_io,
+ table_path,
+ evaluation.table_options,
+ index_entries,
+ field_id,
+ &vector_search.field_name,
+ )
+ .await?;
+ let raw_result =
+ read_raw_vector_search(table, vector_search, &raw_ranges,
metric).await?;
+ merged = merged.or(&raw_result);
+ }
+ }
+
+ merged.top_k(vector_search.limit).to_row_ranges()
+}
+
+fn is_vector_global_index_file(index_file: &IndexFileMeta) -> bool {
+ VectorIndexBackend::from_index_type(&index_file.index_type).is_some()
+}
+
+async fn detail_data_ranges_for_table(table: &Table) ->
crate::Result<Vec<RowRange>> {
+ let plan = table
+ .new_read_builder()
+ .new_scan()
+ .with_scan_all_files()
+ .plan()
+ .await?;
+ let mut ranges = Vec::new();
+ for split in plan.splits() {
+ for file in split.data_files() {
+ if let Some((from, to)) = file.row_id_range() {
+ ranges.push(RowRange::new(from, to));
+ }
+ }
+ }
+ Ok(merge_row_ranges(ranges))
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum RawVectorMetric {
+ L2,
+ Cosine,
+ InnerProduct,
+}
+
+impl RawVectorMetric {
+ fn parse(value: &str) -> crate::Result<Self> {
+ Self::parse_normalized(&normalize_metric(value)).ok_or_else(||
crate::Error::DataInvalid {
+ message: format!("Unknown vector search metric: {value}"),
+ source: None,
+ })
+ }
+
+ fn parse_normalized(value: &str) -> Option<Self> {
+ match value {
+ "l2" => Some(Self::L2),
+ "cosine" => Some(Self::Cosine),
+ "inner_product" => Some(Self::InnerProduct),
+ _ => None,
+ }
+ }
+
+ fn from_lumina(metric: LuminaVectorMetric) -> Self {
+ match metric {
+ LuminaVectorMetric::L2 => Self::L2,
+ LuminaVectorMetric::Cosine => Self::Cosine,
+ LuminaVectorMetric::InnerProduct => Self::InnerProduct,
+ }
+ }
+
+ fn from_vindex(metric: MetricType) -> Self {
+ match metric {
+ MetricType::L2 => Self::L2,
+ MetricType::Cosine => Self::Cosine,
+ MetricType::InnerProduct => Self::InnerProduct,
+ }
+ }
+}
+
+fn normalize_metric(metric: &str) -> String {
+ metric.to_ascii_lowercase().replace('-', "_")
+}
+
+async fn resolve_raw_vector_metric(
+ file_io: &FileIO,
+ table_path: &str,
+ table_options: &HashMap<String, String>,
+ index_entries: &[IndexManifestEntry],
+ field_id: i32,
+ field_name: &str,
+) -> crate::Result<RawVectorMetric> {
+ for entry in index_entries {
+ if entry.kind != FileKind::Add {
+ continue;
+ }
+ let Some(global_meta) = entry.index_file.global_index_meta.as_ref()
else {
+ continue;
+ };
+ if global_meta.index_field_id != field_id {
+ continue;
+ }
+ let Some(backend) =
VectorIndexBackend::from_index_type(&entry.index_file.index_type)
+ else {
+ continue;
+ };
+ match backend {
+ VectorIndexBackend::Lumina => {
+ if let Some(index_meta) = global_meta.index_meta.as_ref() {
+ if !index_meta.is_empty() {
+ let metric =
LuminaIndexMeta::deserialize(index_meta)?.metric()?;
+ return Ok(RawVectorMetric::from_lumina(metric));
+ }
+ }
+ }
+ VectorIndexBackend::Vindex => {
+ let path = format!("{table_path}/{INDEX_DIR}/{}",
entry.index_file.file_name);
+ let input = file_io.new_input(&path)?;
let bytes = input.read().await.map_err(|e|
crate::Error::DataInvalid {
message: format!(
- "Failed to read {} index file '{}': {}",
- backend.error_name(),
- file_name,
- e
+ "Failed to read vindex index file '{}' for raw search
metric: {}",
+ entry.index_file.file_name, e
),
source: None,
})?;
-
- let io_meta =
- GlobalIndexIOMeta::new(file_name.clone(), file_size,
index_meta_bytes);
- let data = bytes.to_vec();
- let result = match backend {
- VectorIndexBackend::Lumina => {
- let mut reader =
LuminaVectorGlobalIndexReader::new(io_meta, options);
- reader
- .visit_vector_search(&vector_search_clone, |_|
Ok(Cursor::new(data)))?
+ let reader =
VIndexReader::open(Cursor::new(bytes.to_vec())).map_err(|e| {
+ crate::Error::DataInvalid {
+ message: format!(
+ "Failed to open paimon-vindex-core reader for raw
search metric: {}",
+ e
+ ),
+ source: Some(Box::new(e)),
}
- VectorIndexBackend::Vindex => {
- let mut reader =
VindexVectorGlobalIndexReader::new(io_meta, options);
- reader
- .visit_vector_search(&vector_search_clone, |_|
Ok(Cursor::new(data)))?
- }
- };
+ })?;
+ return
Ok(RawVectorMetric::from_vindex(reader.metadata().metric));
+ }
+ }
+ }
- match result {
- Some(scored_map) => Ok::<_, crate::Error>(
-
SearchResult::from_scored_map(scored_map).offset(row_range_start),
- ),
- None => Ok(SearchResult::empty()),
- }
+ configured_raw_vector_metric(table_options, field_name)
+}
+
+fn configured_raw_vector_metric(
+ options: &HashMap<String, String>,
+ field_name: &str,
+) -> crate::Result<RawVectorMetric> {
+ let direct_keys = [
+ format!("fields.{field_name}.distance.metric"),
+ format!("fields.{field_name}.metric"),
+ "test.vector.metric".to_string(),
+ "lumina.distance.metric".to_string(),
+ "distance.metric".to_string(),
+ "metric".to_string(),
+ ];
+ for key in direct_keys {
+ if let Some(value) = options.get(&key) {
+ return RawVectorMetric::parse(value);
+ }
+ }
+
+ let mut inferred = None;
+ for (key, value) in options {
+ if !(key.ends_with(".distance.metric") || key.ends_with(".metric")) {
+ continue;
+ }
+ let normalized = normalize_metric(value);
+ let Some(metric) = RawVectorMetric::parse_normalized(&normalized) else
{
+ continue;
+ };
+ if let Some(existing) = inferred {
+ if existing != metric {
+ return Ok(RawVectorMetric::L2);
}
- })
- .collect();
+ } else {
+ inferred = Some(metric);
+ }
+ }
+ Ok(inferred.unwrap_or(RawVectorMetric::L2))
+}
- let results = futures::future::try_join_all(futures).await?;
- let mut merged = SearchResult::empty();
- for r in &results {
- merged = merged.or(r);
+async fn read_raw_vector_search(
+ table: &Table,
+ vector_search: &VectorSearch,
+ raw_ranges: &[RowRange],
+ metric: RawVectorMetric,
+) -> crate::Result<SearchResult> {
+ if raw_ranges.is_empty() {
+ return Ok(SearchResult::empty());
}
- merged.top_k(vector_search.limit).to_row_ranges()
+ let mut read_builder = table.new_read_builder();
+ read_builder
+ .with_projection(&[vector_search.field_name.as_str(),
ROW_ID_FIELD_NAME])
+ .with_row_ranges(raw_ranges.to_vec());
+ let plan = read_builder.new_scan().plan().await?;
+ if plan.splits().is_empty() {
+ return Ok(SearchResult::empty());
+ }
+ let read = read_builder.new_read()?;
+ let mut stream = read.to_arrow(plan.splits())?;
+
+ let mut row_ids = Vec::new();
+ let mut scores = Vec::new();
+ while let Some(batch) = stream.try_next().await? {
+ collect_raw_vector_batch(&batch, vector_search, metric, &mut row_ids,
&mut scores)?;
+ }
+
+ Ok(SearchResult::new(row_ids, scores).top_k(vector_search.limit))
+}
+
+fn collect_raw_vector_batch(
+ batch: &RecordBatch,
+ vector_search: &VectorSearch,
+ metric: RawVectorMetric,
+ row_ids_out: &mut Vec<u64>,
+ scores_out: &mut Vec<f32>,
+) -> crate::Result<()> {
+ let vector_index = batch
+ .schema()
+ .index_of(&vector_search.field_name)
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!(
+ "Vector column '{}' not found in raw search batch: {}",
+ vector_search.field_name, e
+ ),
+ source: None,
+ })?;
+ let row_id_index =
+ batch
+ .schema()
+ .index_of(ROW_ID_FIELD_NAME)
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!("_ROW_ID column not found in raw search
batch: {e}"),
+ source: None,
+ })?;
+
+ let row_ids = batch
+ .column(row_id_index)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "Vector raw search requires non-null Int64
_ROW_ID".to_string(),
+ source: None,
+ })?;
+
+ let column = batch.column(vector_index);
+ enum VectorLayout<'a> {
+ List(&'a ListArray),
+ Fixed(&'a FixedSizeListArray),
+ }
+ let layout = if let Some(a) = column.as_any().downcast_ref::<ListArray>() {
+ VectorLayout::List(a)
+ } else if let Some(a) =
column.as_any().downcast_ref::<FixedSizeListArray>() {
+ VectorLayout::Fixed(a)
+ } else {
+ return Err(crate::Error::DataInvalid {
+ message: "Vector raw search requires Arrow List<Float32> or
FixedSizeList<Float32>"
+ .to_string(),
+ source: None,
+ });
+ };
+ let values = match layout {
+ VectorLayout::List(a) => a.values(),
+ VectorLayout::Fixed(a) => a.values(),
+ }
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "Vector raw search requires Float32 vector
elements".to_string(),
+ source: None,
+ })?;
+
+ for row in 0..batch.num_rows() {
+ if row_ids.is_null(row) {
+ return Err(crate::Error::DataInvalid {
+ message: "Vector raw search found null _ROW_ID".to_string(),
+ source: None,
+ });
+ }
+ let row_id = row_id_to_u64(row_ids.value(row))?;
+ if vector_search
+ .include_row_ids
+ .as_ref()
+ .is_some_and(|include_row_ids| !include_row_ids.contains(row_id))
+ {
+ continue;
+ }
+
+ let is_null = match layout {
+ VectorLayout::List(a) => a.is_null(row),
+ VectorLayout::Fixed(a) => a.is_null(row),
+ };
+ if is_null {
+ continue;
+ }
+
+ let (start, end) = match layout {
+ VectorLayout::List(a) => {
+ let offsets = a.value_offsets();
+ (offsets[row] as usize, offsets[row + 1] as usize)
+ }
+ VectorLayout::Fixed(a) => {
+ let len = a.value_length() as usize;
+ (row * len, (row + 1) * len)
+ }
+ };
+ if end - start != vector_search.vector.len() {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Query vector dimension mismatch: raw row has {}, but
query has {}",
+ end - start,
+ vector_search.vector.len()
+ ),
+ source: None,
+ });
+ }
+
+ let mut stored = Vec::with_capacity(vector_search.vector.len());
+ for value_index in start..end {
+ if values.is_null(value_index) {
+ return Err(crate::Error::DataInvalid {
+ message: "Vector raw search found null vector
element".to_string(),
+ source: None,
+ });
+ }
+ stored.push(values.value(value_index));
+ }
+ row_ids_out.push(row_id);
+ scores_out.push(compute_raw_vector_score(
+ &vector_search.vector,
+ &stored,
+ metric,
+ ));
+ }
+
+ Ok(())
+}
+
+fn row_id_to_u64(row_id: i64) -> crate::Result<u64> {
+ u64::try_from(row_id).map_err(|_| crate::Error::DataInvalid {
+ message: format!("Negative _ROW_ID {row_id} cannot be used for global
index search"),
+ source: None,
+ })
+}
+
+fn compute_raw_vector_score(query: &[f32], stored: &[f32], metric:
RawVectorMetric) -> f32 {
+ match metric {
+ RawVectorMetric::L2 => {
+ let sum_sq = query
+ .iter()
+ .zip(stored.iter())
+ .map(|(q, s)| {
+ let diff = q - s;
+ diff * diff
+ })
+ .sum::<f32>();
+ 1.0 / (1.0 + sum_sq)
+ }
+ RawVectorMetric::Cosine => {
+ let mut dot = 0.0;
+ let mut norm_a = 0.0;
+ let mut norm_b = 0.0;
+ for (q, s) in query.iter().zip(stored.iter()) {
+ dot += q * s;
+ norm_a += q * q;
+ norm_b += s * s;
+ }
+ let denominator = norm_a.sqrt() * norm_b.sqrt();
+ if denominator == 0.0 {
+ 0.0
+ } else {
+ dot / denominator
+ }
+ }
+ RawVectorMetric::InnerProduct =>
query.iter().zip(stored.iter()).map(|(q, s)| q * s).sum(),
+ }
}
#[cfg(test)]
@@ -242,6 +667,22 @@ mod tests {
DataField::new(id, name.to_string(), DataType::Int(IntType::default()))
}
+ fn eval_context<'a>(
+ file_io: &'a FileIO,
+ options: &'a HashMap<String, String>,
+ fields: &'a [DataField],
+ next_row_id: Option<i64>,
+ ) -> VectorSearchEvaluation<'a> {
+ VectorSearchEvaluation {
+ table: None,
+ file_io,
+ table_path: "memory:///test_table",
+ table_options: options,
+ schema_fields: fields,
+ next_row_id,
+ }
+ }
+
#[test]
fn test_find_field_id_by_name() {
let fields = vec![make_field(1, "id"), make_field(2, "embedding")];
@@ -249,11 +690,50 @@ mod tests {
assert_eq!(find_field_id_by_name(&fields, "nonexistent"), None);
}
+ #[test]
+ fn test_raw_vector_score_matches_java_metric_semantics() {
+ let l2 = compute_raw_vector_score(&[1.0, 2.0], &[1.0, 4.0],
RawVectorMetric::L2);
+ assert!((l2 - 0.2).abs() < 1e-6);
+ assert_eq!(
+ compute_raw_vector_score(&[1.0, 2.0], &[3.0, 4.0],
RawVectorMetric::InnerProduct),
+ 11.0
+ );
+ let cosine = compute_raw_vector_score(&[1.0, 0.0], &[1.0, 1.0],
RawVectorMetric::Cosine);
+ assert!((cosine - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-6);
+ assert_eq!(
+ compute_raw_vector_score(&[0.0, 0.0], &[1.0, 1.0],
RawVectorMetric::Cosine),
+ 0.0
+ );
+ }
+
+ #[test]
+ fn test_configured_raw_vector_metric_precedence_and_conflict_default() {
+ let mut options = HashMap::new();
+ options.insert(
+ "fields.embedding.distance.metric".to_string(),
+ "inner-product".to_string(),
+ );
+ options.insert("metric".to_string(), "cosine".to_string());
+ assert_eq!(
+ configured_raw_vector_metric(&options, "embedding").unwrap(),
+ RawVectorMetric::InnerProduct
+ );
+
+ options.clear();
+ options.insert("foo.metric".to_string(), "cosine".to_string());
+ options.insert("bar.distance.metric".to_string(), "l2".to_string());
+ assert_eq!(
+ configured_raw_vector_metric(&options, "embedding").unwrap(),
+ RawVectorMetric::L2
+ );
+ }
+
#[tokio::test]
async fn test_evaluate_no_matching_entries() {
let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
let fields = vec![make_field(1, "id"), make_field(2, "embedding")];
let vs = VectorSearch::new(vec![1.0, 2.0], 10,
"embedding".to_string()).unwrap();
+ let options = HashMap::new();
let entry = IndexManifestEntry {
kind: FileKind::Add,
@@ -271,12 +751,9 @@ mod tests {
};
let result = evaluate_vector_search(
- &file_io,
- "memory:///test_table",
- &HashMap::new(),
+ eval_context(&file_io, &options, &fields, None),
&[entry],
&vs,
- &fields,
)
.await
.unwrap();
@@ -288,27 +765,47 @@ mod tests {
let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
let fields = vec![make_field(2, "embedding")];
let vs = VectorSearch::new(vec![1.0], 10,
"embedding".to_string()).unwrap();
+ let options = HashMap::new();
let entry = make_lumina_entry("test.idx", "btree", FileKind::Add, 2);
let result = evaluate_vector_search(
- &file_io,
- "memory:///test_table",
- &HashMap::new(),
+ eval_context(&file_io, &options, &fields, None),
&[entry],
&vs,
- &fields,
)
.await
.unwrap();
assert!(result.is_empty());
}
+ #[tokio::test]
+ async fn test_evaluate_full_mode_without_vector_entries_uses_raw_path() {
+ let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let fields = vec![make_field(2, "embedding")];
+ let vs = VectorSearch::new(vec![1.0], 10,
"embedding".to_string()).unwrap();
+ let options = HashMap::from([("global-index.search-mode".to_string(),
"full".to_string())]);
+
+ let err = evaluate_vector_search(
+ eval_context(&file_io, &options, &fields, Some(10)),
+ &[],
+ &vs,
+ )
+ .await
+ .unwrap_err();
+ assert!(
+ err.to_string()
+ .contains("Vector raw search requires table context"),
+ "unexpected error: {err}"
+ );
+ }
+
#[tokio::test]
async fn test_evaluate_no_matching_field() {
let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
let fields = vec![make_field(1, "id")];
let vs = VectorSearch::new(vec![1.0], 10,
"embedding".to_string()).unwrap();
+ let options = HashMap::new();
let entry = make_lumina_entry(
"test.idx",
@@ -318,12 +815,9 @@ mod tests {
);
let result = evaluate_vector_search(
- &file_io,
- "memory:///test_table",
- &HashMap::new(),
+ eval_context(&file_io, &options, &fields, None),
&[entry],
&vs,
- &fields,
)
.await
.unwrap();
@@ -335,6 +829,7 @@ mod tests {
let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
let fields = vec![make_field(2, "embedding")];
let vs = VectorSearch::new(vec![1.0], 10,
"embedding".to_string()).unwrap();
+ let options = HashMap::new();
let entry = make_lumina_entry(
"test.idx",
@@ -344,12 +839,9 @@ mod tests {
);
let result = evaluate_vector_search(
- &file_io,
- "memory:///test_table",
- &HashMap::new(),
+ eval_context(&file_io, &options, &fields, None),
&[entry],
&vs,
- &fields,
)
.await
.unwrap();
@@ -361,16 +853,14 @@ mod tests {
let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
let fields = vec![make_field(2, "embedding")];
let vs = VectorSearch::new(vec![1.0], 10,
"embedding".to_string()).unwrap();
+ let options = HashMap::new();
let entry = make_lumina_entry("missing.idx", LUMINA_IDENTIFIER,
FileKind::Add, 2);
let err = evaluate_vector_search(
- &file_io,
- "memory:///test_table",
- &HashMap::new(),
+ eval_context(&file_io, &options, &fields, None),
&[entry],
&vs,
- &fields,
)
.await
.unwrap_err();
@@ -386,6 +876,7 @@ mod tests {
let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
let fields = vec![make_field(2, "embedding")];
let vs = VectorSearch::new(vec![1.0], 10,
"embedding".to_string()).unwrap();
+ let options = HashMap::new();
let entry = make_lumina_entry(
"missing.idx",
@@ -395,12 +886,9 @@ mod tests {
);
let err = evaluate_vector_search(
- &file_io,
- "memory:///test_table",
- &HashMap::new(),
+ eval_context(&file_io, &options, &fields, None),
&[entry],
&vs,
- &fields,
)
.await
.unwrap_err();
@@ -416,16 +904,14 @@ mod tests {
let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
let fields = vec![make_field(2, "embedding")];
let vs = VectorSearch::new(vec![1.0], 10,
"embedding".to_string()).unwrap();
+ let options = HashMap::new();
let entry = make_lumina_entry("missing.idx", IVF_FLAT_IDENTIFIER,
FileKind::Add, 2);
let err = evaluate_vector_search(
- &file_io,
- "memory:///test_table",
- &HashMap::new(),
+ eval_context(&file_io, &options, &fields, None),
&[entry],
&vs,
- &fields,
)
.await
.unwrap_err();