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 f5cdd745 feat(python): expose core vector search and scored results
(#1087)
f5cdd745 is described below
commit f5cdd7450c20c9316c94591919e351b259db26a1
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 10 09:39:21 2026 +0800
feat(python): expose core vector search and scored results (#1087)
---
bindings/python/README.md | 24 ++
.../python/python/pypaimon_rust/datafusion.pyi | 63 +++
bindings/python/src/context.rs | 8 +
bindings/python/src/lib.rs | 1 +
bindings/python/src/table.rs | 8 +
bindings/python/src/vector_search.rs | 430 +++++++++++++++++++
bindings/python/tests/test_vector_search.py | 127 ++++++
crates/paimon/src/spec/core_options.rs | 10 +
.../src/table/batch_vector_search_builder.rs | 11 +-
.../src/table/batch_vector_search_builder/tests.rs | 83 ++++
crates/paimon/src/table/de_vector_read.rs | 463 +++++++++++++++++----
crates/paimon/src/table/de_vector_read/tests.rs | 257 +++++++++++-
crates/paimon/src/table/de_vector_scan.rs | 37 +-
crates/paimon/src/table/de_vector_scan/tests.rs | 210 +++++++++-
crates/paimon/src/table/global_index_scanner.rs | 42 ++
.../src/table/global_index_scanner/composite.rs | 3 +
.../src/table/global_index_scanner/evaluator.rs | 90 ++--
.../paimon/src/table/global_index_scanner/tests.rs | 120 ++++++
crates/paimon/src/table/pk_vector_orchestrator.rs | 3 +-
crates/paimon/src/table/source.rs | 59 ++-
crates/paimon/src/table/vector_search_builder.rs | 27 +-
crates/paimon/src/table/vector_search_common.rs | 26 +-
crates/paimon/src/table/vector_search_result.rs | 15 +
23 files changed, 1969 insertions(+), 148 deletions(-)
diff --git a/bindings/python/README.md b/bindings/python/README.md
index 6de276a0..b4710b85 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -56,6 +56,30 @@ ctx.sql("DROP TEMPORARY TABLE paimon.default.my_temp")
For the full SQL reference, see the [SQL Integration
docs](https://paimon.apache.org/docs/master/sql/).
+### Native Vector Search
+
+A table with an existing vector column can search through the Rust core without
+materializing user rows in Python:
+
+```python
+search = (table.new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector([1.0, 0.0])
+ .with_limit(10))
+plan = search.new_vector_search_scan().scan()
+result = search.new_vector_search_read().read_plan(plan)
+batches = result.new_read_builder().with_projection(["id"]).read()
+```
+
+The plan pins one snapshot and can be reused with different queries. Scalar and
+partition filters are applied before ranking. Batch builders return one result
+per input query, in input order, including empty results.
+
+Data Evolution results expose scored global row IDs through `row_ids()`. PK
+results expose physical positions and Java-compatible scored split bytes;
+`new_read_builder()` reads both result types from their source snapshot and
+returns projected columns plus `__paimon_search_score` in relevance order.
+
### Native Read / Write
Beyond SQL, you can use the lower-level read and write APIs directly from
Python.
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 5cce8692..4ff2065d 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -221,6 +221,8 @@ class Table:
def schema(self) -> TableSchema: ...
def new_read_builder(self, options: Optional[Dict[str, str]] = None) ->
ReadBuilder: ...
def new_stream_scan(self) -> StreamTableScan: ...
+ def new_vector_search_builder(self) -> "VectorSearchBuilder": ...
+ def new_batch_vector_search_builder(self) -> "BatchVectorSearchBuilder":
...
def new_batch_write_builder(self) -> "BatchWriteBuilder": ...
def new_postpone_fixed_bucket_write_builder(self) ->
"PostponeFixedBucketWriteBuilder": ...
def new_stream_write_builder(self) -> "StreamWriteBuilder": ...
@@ -534,3 +536,64 @@ class SQLContext:
def sql_with_options(
self, sql: str, options: SQLOptions
) -> List[pyarrow.RecordBatch]: ...
+
+
+class VectorSearchBuilder:
+ def with_vector_column(self, name: str) -> "VectorSearchBuilder": ...
+ def with_limit(self, limit: int) -> "VectorSearchBuilder": ...
+ def with_query_vector(self, vector: Sequence[float]) ->
"VectorSearchBuilder": ...
+ def with_option(self, key: str, value: str) -> "VectorSearchBuilder": ...
+ def with_options(self, options: Dict[str, str]) -> "VectorSearchBuilder":
...
+ def with_filter(self, predicate: Dict[str, Any]) -> "VectorSearchBuilder":
...
+ def with_partition_filter(self, predicate: Dict[str, Any]) ->
"VectorSearchBuilder":
+ """Add a partition-only predicate, resolved by table field names."""
+ ...
+ def new_vector_search_scan(self) -> "VectorScan": ...
+ def new_vector_search_read(self) -> "VectorRead": ...
+ def execute_local(self) -> "SearchResult": ...
+
+class BatchVectorSearchBuilder:
+ def with_vector_column(self, name: str) -> "BatchVectorSearchBuilder": ...
+ def with_limit(self, limit: int) -> "BatchVectorSearchBuilder": ...
+ def with_query_vectors(self, vectors: Sequence[Sequence[float]]) ->
"BatchVectorSearchBuilder": ...
+ def with_option(self, key: str, value: str) -> "BatchVectorSearchBuilder":
...
+ def with_options(self, options: Dict[str, str]) ->
"BatchVectorSearchBuilder": ...
+ def with_filter(self, predicate: Dict[str, Any]) ->
"BatchVectorSearchBuilder": ...
+ def with_partition_filter(self, predicate: Dict[str, Any]) ->
"BatchVectorSearchBuilder": ...
+ def new_vector_search_scan(self) -> "VectorScan": ...
+ def new_batch_vector_search_read(self) -> "BatchVectorRead": ...
+ def execute_batch_local(self) -> List["SearchResult"]: ...
+
+class VectorScan:
+ def scan(self) -> "VectorScanPlan": ...
+
+class VectorScanPlan:
+ def snapshot_id(self) -> Optional[int]: ...
+
+class VectorRead:
+ def read_plan(self, plan: VectorScanPlan) -> "SearchResult": ...
+
+class BatchVectorRead:
+ def read_batch_plan(self, plan: VectorScanPlan) -> List["SearchResult"]:
...
+
+class SearchResult:
+ """Snapshot-scoped global row IDs or primary-key physical positions."""
+ def snapshot_id(self) -> Optional[int]: ...
+ def __len__(self) -> int: ...
+ def new_read_builder(self) -> "SearchResultReadBuilder": ...
+ def row_ids(self) -> Dict[int, float]:
+ """DE scores keyed by global row ID; rejects PK results, including
empty ones."""
+ ...
+ def positions(self) -> List[Tuple[bytes, int, str, int, float]]:
+ """PK partition bytes, bucket, file, position and score in relevance
order."""
+ ...
+ def splits(self) -> List[bytes]:
+ """PK physical selections in Java SplitSerializer v1 format, including
scores."""
+ ...
+
+
+class SearchResultReadBuilder:
+ def with_projection(self, columns: List[str]) ->
"SearchResultReadBuilder": ...
+ def read(self) -> List[pyarrow.RecordBatch]:
+ """Read user columns and scores in relevance order from the search
snapshot."""
+ ...
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index b6a10ff8..1c50299c 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -726,6 +726,14 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_,
PyModule>) -> PyResult<()>
let this = PyModule::new(py, "datafusion")?;
this.add_class::<PaimonCatalog>()?;
this.add_class::<crate::table::PyTable>()?;
+ this.add_class::<crate::vector_search::PyVectorSearchBuilder>()?;
+ this.add_class::<crate::vector_search::PyBatchVectorSearchBuilder>()?;
+ this.add_class::<crate::vector_search::PyVectorScan>()?;
+ this.add_class::<crate::vector_search::PyVectorScanPlan>()?;
+ this.add_class::<crate::vector_search::PyVectorRead>()?;
+ this.add_class::<crate::vector_search::PyBatchVectorRead>()?;
+ this.add_class::<crate::vector_search::PySearchResult>()?;
+ this.add_class::<crate::vector_search::PySearchResultReadBuilder>()?;
this.add_class::<crate::read::PyReadBuilder>()?;
this.add_class::<crate::read::PyTableScan>()?;
this.add_class::<crate::read::PyStreamTableScan>()?;
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index b4e270f7..900e19ef 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -30,6 +30,7 @@ mod table;
mod udf;
mod update_assignment;
mod variant;
+mod vector_search;
mod write;
// ---- #285: observability ----
mod partition;
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index 2421a0ab..fd39b0c9 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -198,6 +198,14 @@ impl PyTable {
}
}
+ fn new_vector_search_builder(&self) ->
crate::vector_search::PyVectorSearchBuilder {
+
crate::vector_search::PyVectorSearchBuilder::new(Arc::clone(&self.inner))
+ }
+
+ fn new_batch_vector_search_builder(&self) ->
crate::vector_search::PyBatchVectorSearchBuilder {
+
crate::vector_search::PyBatchVectorSearchBuilder::new(Arc::clone(&self.inner))
+ }
+
fn new_batch_write_builder(&self) -> PyBatchWriteBuilder {
PyBatchWriteBuilder::new(Arc::clone(&self.inner))
}
diff --git a/bindings/python/src/vector_search.rs
b/bindings/python/src/vector_search.rs
new file mode 100644
index 00000000..49fe05b6
--- /dev/null
+++ b/bindings/python/src/vector_search.rs
@@ -0,0 +1,430 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use arrow::pyarrow::ToPyArrow;
+use futures::TryStreamExt;
+use paimon::spec::Predicate;
+use paimon::table::{BatchVectorRead, Table, VectorRead, VectorScan,
VectorScanPlan};
+use paimon::vector_search::SearchResult;
+use paimon_datafusion::runtime::runtime;
+use pyo3::prelude::*;
+use pyo3::types::{PyBytes, PyDict};
+
+use crate::error::to_py_err;
+use crate::predicate::dict_to_table_predicate;
+
+#[derive(Clone, Default)]
+struct SearchConfig {
+ column: Option<String>,
+ limit: Option<usize>,
+ options: Vec<HashMap<String, String>>,
+ filters: Vec<Predicate>,
+ partition_filters: Vec<Predicate>,
+}
+
+impl SearchConfig {
+ fn single<'a>(
+ &self,
+ table: &'a Table,
+ ) -> paimon::Result<paimon::table::VectorSearchBuilder<'a>> {
+ let mut builder = table.new_vector_search_builder();
+ if let Some(column) = &self.column {
+ builder.with_vector_column(column);
+ }
+ if let Some(limit) = self.limit {
+ builder.with_limit(limit);
+ }
+ for options in &self.options {
+ builder.with_options(options.clone());
+ }
+ for filter in &self.filters {
+ builder.with_filter(filter.clone());
+ }
+ for filter in &self.partition_filters {
+ builder.with_partition_filter(filter.clone())?;
+ }
+ Ok(builder)
+ }
+
+ fn batch<'a>(
+ &self,
+ table: &'a Table,
+ ) -> paimon::Result<paimon::table::BatchVectorSearchBuilder<'a>> {
+ let mut builder = table.new_batch_vector_search_builder();
+ if let Some(column) = &self.column {
+ builder.with_vector_column(column);
+ }
+ if let Some(limit) = self.limit {
+ builder.with_limit(limit);
+ }
+ for options in &self.options {
+ builder.with_options(options.clone());
+ }
+ for filter in &self.filters {
+ builder.with_filter(filter.clone());
+ }
+ for filter in &self.partition_filters {
+ builder.with_partition_filter(filter.clone())?;
+ }
+ Ok(builder)
+ }
+}
+
+#[pyclass(name = "VectorSearchBuilder", module = "pypaimon_rust.datafusion")]
+pub struct PyVectorSearchBuilder {
+ table: Arc<Table>,
+ config: SearchConfig,
+ query: Option<Vec<f32>>,
+}
+
+impl PyVectorSearchBuilder {
+ pub(crate) fn new(table: Arc<Table>) -> Self {
+ Self {
+ table,
+ config: SearchConfig::default(),
+ query: None,
+ }
+ }
+}
+
+#[pymethods]
+impl PyVectorSearchBuilder {
+ fn with_vector_column(mut slf: PyRefMut<'_, Self>, name: String) ->
PyRefMut<'_, Self> {
+ slf.config.column = Some(name);
+ slf
+ }
+ fn with_limit(mut slf: PyRefMut<'_, Self>, limit: usize) -> PyRefMut<'_,
Self> {
+ slf.config.limit = Some(limit);
+ slf
+ }
+ fn with_query_vector(mut slf: PyRefMut<'_, Self>, vector: Vec<f32>) ->
PyRefMut<'_, Self> {
+ slf.query = Some(vector);
+ slf
+ }
+ fn with_option(mut slf: PyRefMut<'_, Self>, key: String, value: String) ->
PyRefMut<'_, Self> {
+ slf.config.options.push(HashMap::from([(key, value)]));
+ slf
+ }
+ fn with_options(
+ mut slf: PyRefMut<'_, Self>,
+ options: HashMap<String, String>,
+ ) -> PyRefMut<'_, Self> {
+ slf.config.options.push(options);
+ slf
+ }
+ fn with_filter<'py>(
+ mut slf: PyRefMut<'py, Self>,
+ predicate: &Bound<'_, PyDict>,
+ ) -> PyResult<PyRefMut<'py, Self>> {
+ let predicate = dict_to_table_predicate(predicate, slf.table.schema(),
true)?;
+ slf.config.filters.push(predicate);
+ Ok(slf)
+ }
+ fn with_partition_filter<'py>(
+ mut slf: PyRefMut<'py, Self>,
+ predicate: &Bound<'_, PyDict>,
+ ) -> PyResult<PyRefMut<'py, Self>> {
+ let predicate = dict_to_table_predicate(predicate, slf.table.schema(),
true)?;
+ slf.config
+ .single(&slf.table)
+ .map_err(to_py_err)?
+ .with_partition_filter(predicate.clone())
+ .map_err(to_py_err)?;
+ slf.config.partition_filters.push(predicate);
+ Ok(slf)
+ }
+ fn new_vector_search_scan(&self) -> PyResult<PyVectorScan> {
+ let inner = self
+ .config
+ .single(&self.table)
+ .map_err(to_py_err)?
+ .new_scan()
+ .map_err(to_py_err)?;
+ Ok(PyVectorScan { inner })
+ }
+ fn new_vector_search_read(&self) -> PyResult<PyVectorRead> {
+ let mut builder = self.config.single(&self.table).map_err(to_py_err)?;
+ if let Some(query) = &self.query {
+ builder.with_query_vector(query.clone());
+ }
+ let inner = builder.new_read().map_err(to_py_err)?;
+ Ok(PyVectorRead { inner })
+ }
+ fn execute_local(&self, py: Python<'_>) -> PyResult<PySearchResult> {
+ let mut builder = self.config.single(&self.table).map_err(to_py_err)?;
+ if let Some(query) = &self.query {
+ builder.with_query_vector(query.clone());
+ }
+ let results = py
+ .detach(|| runtime().block_on(builder.execute()))
+ .map_err(to_py_err)?;
+ Ok(PySearchResult::new(results))
+ }
+}
+
+#[pyclass(name = "BatchVectorSearchBuilder", module =
"pypaimon_rust.datafusion")]
+pub struct PyBatchVectorSearchBuilder {
+ table: Arc<Table>,
+ config: SearchConfig,
+ query: Option<Vec<Vec<f32>>>,
+}
+
+impl PyBatchVectorSearchBuilder {
+ pub(crate) fn new(table: Arc<Table>) -> Self {
+ Self {
+ table,
+ config: SearchConfig::default(),
+ query: None,
+ }
+ }
+}
+
+#[pymethods]
+impl PyBatchVectorSearchBuilder {
+ fn with_vector_column(mut slf: PyRefMut<'_, Self>, name: String) ->
PyRefMut<'_, Self> {
+ slf.config.column = Some(name);
+ slf
+ }
+ fn with_limit(mut slf: PyRefMut<'_, Self>, limit: usize) -> PyRefMut<'_,
Self> {
+ slf.config.limit = Some(limit);
+ slf
+ }
+ fn with_query_vectors(
+ mut slf: PyRefMut<'_, Self>,
+ vectors: Vec<Vec<f32>>,
+ ) -> PyRefMut<'_, Self> {
+ slf.query = Some(vectors);
+ slf
+ }
+ fn with_option(mut slf: PyRefMut<'_, Self>, key: String, value: String) ->
PyRefMut<'_, Self> {
+ slf.config.options.push(HashMap::from([(key, value)]));
+ slf
+ }
+ fn with_options(
+ mut slf: PyRefMut<'_, Self>,
+ options: HashMap<String, String>,
+ ) -> PyRefMut<'_, Self> {
+ slf.config.options.push(options);
+ slf
+ }
+ fn with_filter<'py>(
+ mut slf: PyRefMut<'py, Self>,
+ predicate: &Bound<'_, PyDict>,
+ ) -> PyResult<PyRefMut<'py, Self>> {
+ let predicate = dict_to_table_predicate(predicate, slf.table.schema(),
true)?;
+ slf.config.filters.push(predicate);
+ Ok(slf)
+ }
+ fn with_partition_filter<'py>(
+ mut slf: PyRefMut<'py, Self>,
+ predicate: &Bound<'_, PyDict>,
+ ) -> PyResult<PyRefMut<'py, Self>> {
+ let predicate = dict_to_table_predicate(predicate, slf.table.schema(),
true)?;
+ slf.config
+ .batch(&slf.table)
+ .map_err(to_py_err)?
+ .with_partition_filter(predicate.clone())
+ .map_err(to_py_err)?;
+ slf.config.partition_filters.push(predicate);
+ Ok(slf)
+ }
+ fn new_vector_search_scan(&self) -> PyResult<PyVectorScan> {
+ let inner = self
+ .config
+ .batch(&self.table)
+ .map_err(to_py_err)?
+ .new_scan()
+ .map_err(to_py_err)?;
+ Ok(PyVectorScan { inner })
+ }
+ fn new_batch_vector_search_read(&self) -> PyResult<PyBatchVectorRead> {
+ let mut builder = self.config.batch(&self.table).map_err(to_py_err)?;
+ if let Some(query) = &self.query {
+ builder.with_query_vectors(query.clone());
+ }
+ let inner = builder.new_read().map_err(to_py_err)?;
+ Ok(PyBatchVectorRead { inner })
+ }
+ fn execute_batch_local(&self, py: Python<'_>) ->
PyResult<Vec<PySearchResult>> {
+ let mut builder = self.config.batch(&self.table).map_err(to_py_err)?;
+ if let Some(query) = &self.query {
+ builder.with_query_vectors(query.clone());
+ }
+ let results = py
+ .detach(|| runtime().block_on(builder.execute()))
+ .map_err(to_py_err)?;
+ Ok(results.into_iter().map(PySearchResult::new).collect())
+ }
+}
+
+#[pyclass(name = "VectorScan", module = "pypaimon_rust.datafusion")]
+pub struct PyVectorScan {
+ inner: VectorScan,
+}
+
+#[pymethods]
+impl PyVectorScan {
+ fn scan(&self, py: Python<'_>) -> PyResult<PyVectorScanPlan> {
+ let inner = py
+ .detach(|| runtime().block_on(self.inner.plan()))
+ .map_err(to_py_err)?;
+ Ok(PyVectorScanPlan { inner })
+ }
+}
+
+#[pyclass(name = "VectorScanPlan", module = "pypaimon_rust.datafusion")]
+pub struct PyVectorScanPlan {
+ inner: VectorScanPlan,
+}
+
+#[pymethods]
+impl PyVectorScanPlan {
+ fn snapshot_id(&self) -> Option<i64> {
+ self.inner.snapshot_id()
+ }
+}
+
+#[pyclass(name = "VectorRead", module = "pypaimon_rust.datafusion")]
+pub struct PyVectorRead {
+ inner: VectorRead,
+}
+
+#[pymethods]
+impl PyVectorRead {
+ fn read_plan(&self, py: Python<'_>, plan: &PyVectorScanPlan) ->
PyResult<PySearchResult> {
+ py.detach(|| runtime().block_on(self.inner.read(plan.inner.clone())))
+ .map(PySearchResult::new)
+ .map_err(to_py_err)
+ }
+}
+
+#[pyclass(name = "BatchVectorRead", module = "pypaimon_rust.datafusion")]
+pub struct PyBatchVectorRead {
+ inner: BatchVectorRead,
+}
+
+#[pymethods]
+impl PyBatchVectorRead {
+ fn read_batch_plan(
+ &self,
+ py: Python<'_>,
+ plan: &PyVectorScanPlan,
+ ) -> PyResult<Vec<PySearchResult>> {
+ py.detach(|| runtime().block_on(self.inner.read(plan.inner.clone())))
+ .map(|results|
results.into_iter().map(PySearchResult::new).collect())
+ .map_err(to_py_err)
+ }
+}
+
+#[pyclass(name = "SearchResult", module = "pypaimon_rust.datafusion")]
+pub struct PySearchResult {
+ inner: SearchResult,
+}
+
+impl PySearchResult {
+ fn new(inner: SearchResult) -> Self {
+ Self { inner }
+ }
+}
+
+type PySearchPosition = (Py<PyBytes>, i32, String, i64, f32);
+
+#[pymethods]
+impl PySearchResult {
+ fn snapshot_id(&self) -> Option<i64> {
+ self.inner.snapshot_id()
+ }
+ fn new_read_builder(&self) -> PySearchResultReadBuilder {
+ PySearchResultReadBuilder {
+ result: self.inner.clone(),
+ projection: None,
+ }
+ }
+ fn __len__(&self) -> usize {
+ self.inner.len()
+ }
+ fn row_ids(&self) -> PyResult<HashMap<u64, f32>> {
+ let hits = self.inner.row_ids().map_err(to_py_err)?;
+ Ok(hits
+ .row_ids
+ .iter()
+ .copied()
+ .zip(hits.scores.iter().copied())
+ .collect())
+ }
+ fn positions(&self, py: Python<'_>) -> PyResult<Vec<PySearchPosition>> {
+ Ok(self
+ .inner
+ .positions()
+ .map_err(to_py_err)?
+ .iter()
+ .map(|position| {
+ (
+ PyBytes::new(py,
&position.partition.to_serialized_bytes()).unbind(),
+ position.bucket,
+ position.data_file_name.clone(),
+ position.row_position,
+ position.score,
+ )
+ })
+ .collect())
+ }
+ fn splits(&self, py: Python<'_>) -> PyResult<Vec<Py<PyBytes>>> {
+ Ok(self
+ .inner
+ .serialize_primary_key_splits()
+ .map_err(to_py_err)?
+ .iter()
+ .map(|bytes| PyBytes::new(py, bytes).unbind())
+ .collect())
+ }
+}
+
+#[pyclass(name = "SearchResultReadBuilder", module =
"pypaimon_rust.datafusion")]
+pub struct PySearchResultReadBuilder {
+ result: SearchResult,
+ projection: Option<Vec<String>>,
+}
+
+#[pymethods]
+impl PySearchResultReadBuilder {
+ fn with_projection(mut slf: PyRefMut<'_, Self>, columns: Vec<String>) ->
PyRefMut<'_, Self> {
+ slf.projection = Some(columns);
+ slf
+ }
+ fn read(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
+ let batches = py
+ .detach(|| {
+ runtime().block_on(async {
+ let mut builder = self.result.new_read_builder();
+ if let Some(projection) = &self.projection {
+ let columns: Vec<&str> =
projection.iter().map(String::as_str).collect();
+ builder.with_projection(&columns);
+ }
+ builder.read().await?.try_collect::<Vec<_>>().await
+ })
+ })
+ .map_err(to_py_err)?;
+ batches
+ .iter()
+ .map(|batch| batch.to_pyarrow(py).map(Bound::unbind))
+ .collect()
+ }
+}
diff --git a/bindings/python/tests/test_vector_search.py
b/bindings/python/tests/test_vector_search.py
new file mode 100644
index 00000000..f167b6df
--- /dev/null
+++ b/bindings/python/tests/test_vector_search.py
@@ -0,0 +1,127 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pyarrow as pa
+import pytest
+
+from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
+
+
[email protected]
+def table(tmp_path):
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": str(tmp_path)})
+ ctx.sql("CREATE SCHEMA paimon.db")
+ ctx.sql("CREATE TABLE paimon.db.t (id INT, embedding FLOAT[], pt INT) "
+ "PARTITIONED BY (pt) WITH ('bucket'='-1',
'data-evolution.enabled'='true', "
+ "'row-tracking.enabled'='true', 'global-index.enabled'='true', "
+ "'vector-index.search-mode'='full')")
+ table = PaimonCatalog({"warehouse": str(tmp_path)}).get_table("db.t")
+ _write(table, [1, 2, 3, 4], [[1, 0], [0, 1], [1, 0], None], [0, 0, 1, 1])
+ return table
+
+
+def _write(table, ids, vectors, partitions):
+ builder = table.new_batch_write_builder()
+ writer = builder.new_write()
+ commit = builder.new_commit()
+ try:
+ writer.write_arrow(pa.record_batch([ids, vectors, partitions],
schema=pa.schema([
+ ('id', pa.int32()), ('embedding', pa.list_(pa.float32())), ('pt',
pa.int32())])))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+
+def _single(table):
+ return
table.new_vector_search_builder().with_vector_column('embedding').with_query_vector([1,
0]).with_limit(10)
+
+
+def test_filters_accumulate_and_partition_uses_table_schema(table):
+ result = (_single(table).with_filter({'method': 'greaterThan', 'field':
'id', 'literals': [1]})
+ .with_filter({'method': 'lessThan', 'field': 'id', 'literals':
[4]})
+ .with_partition_filter({'method': 'equal', 'field': 'pt',
'literals': [1]})
+ .execute_local())
+ assert len(result) == 1
+ assert list(result.row_ids().values()) == [1.0]
+ assert result.snapshot_id() == 1
+ with pytest.raises(Exception, match='global row IDs'):
+ result.positions()
+ with pytest.raises(Exception, match='primary-key indexed splits'):
+ result.splits()
+
+
[email protected]('batch', [False, True])
+def test_partition_filter_rejects_data_fields(table, batch):
+ builder = table.new_batch_vector_search_builder() if batch else
table.new_vector_search_builder()
+ with pytest.raises(Exception, match='Partition filter'):
+ builder.with_partition_filter({'method': 'equal', 'field': 'id',
'literals': [1]})
+ with pytest.raises(ValueError, match='missing'):
+ builder.with_filter({'method': 'equal', 'field': 'missing',
'literals': [1]})
+
+
+def test_scan_read_keeps_snapshot_and_is_reusable(table):
+ builder = _single(table)
+ plan = builder.new_vector_search_scan().scan()
+ _write(table, [5], [[1, 0]], [0])
+ reader = builder.new_vector_search_read()
+ result = reader.read_plan(plan)
+ assert len(result) == 3
+ rows =
pa.Table.from_batches(result.new_read_builder().with_projection(['id']).read())
+ assert rows.column_names == ['id', '__paimon_search_score']
+ ids = rows.column('id').to_pylist()
+ # Partition file publication determines global row IDs, so tied scores
+ # may order IDs 1 and 3 either way. Both must precede the weaker hit.
+ assert sorted(ids[:2]) == [1, 3]
+ assert ids[2] == 2
+ assert rows.column('__paimon_search_score').to_pylist() ==
pytest.approx([1, 1, 1 / 3])
+ assert result.snapshot_id() == plan.snapshot_id() == 1
+ assert reader.read_plan(plan).row_ids() == result.row_ids()
+ assert len(builder.execute_local()) == 4
+ filtered = _single(table).with_filter({'method': 'equal', 'field': 'id',
'literals': [1]})
+ with pytest.raises(Exception, match='plan.*reader|context|different'):
+ filtered.new_vector_search_read().read_plan(plan)
+
+
+def test_batch_order_empty_results_and_scan_reuse(table):
+ queries = [[1, 0], [0, 1], [1, 0]]
+ builder =
(table.new_batch_vector_search_builder().with_vector_column('embedding')
+ .with_query_vectors(queries).with_limit(1))
+ plan = builder.new_vector_search_scan().scan()
+ results = builder.new_batch_vector_search_read().read_batch_plan(plan)
+ assert [r.row_ids() for r in results] == [
+
_single(table).with_query_vector(q).with_limit(1).execute_local().row_ids() for
q in queries]
+ assert [r.row_ids() for r in builder.execute_batch_local()] ==
[r.row_ids() for r in results]
+ builder.with_filter({'method': 'equal', 'field': 'id', 'literals': [99]})
+ assert [len(r) for r in builder.execute_batch_local()] == [0, 0, 0]
+
+
[email protected]('query', [None, []])
+def test_invalid_query_is_reported_before_planning(table, query):
+ builder =
table.new_vector_search_builder().with_vector_column('embedding').with_limit(1)
+ if query is not None:
+ builder.with_query_vector(query)
+ with pytest.raises(Exception, match='vector|Vector'):
+ builder.new_vector_search_read()
+ with pytest.raises(Exception, match='Query vectors'):
+
table.new_batch_vector_search_builder().with_vector_column('embedding').with_limit(1).with_query_vectors([]).execute_batch_local()
+
+
+def test_single_vector_keyword_matches_stub(table):
+ builder = _single(table).with_query_vector(vector=[1, 0]).with_limit(1)
+ assert builder.execute_local().row_ids() ==
_single(table).with_limit(1).execute_local().row_ids()
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index d740b08e..b48208f2 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -25,6 +25,7 @@ pub(crate) const QUERY_AUTH_ENABLED_OPTION: &str =
"query-auth.enabled";
const DATA_EVOLUTION_ENABLED_OPTION: &str = "data-evolution.enabled";
const DATA_EVOLUTION_NESTED_FIELD_ENABLED_OPTION: &str =
"data-evolution.nested-field.enabled";
const FILE_INDEX_READ_ENABLED_OPTION: &str = "file-index.read.enabled";
+const GLOBAL_INDEX_FILTER_REFINE_FROM_DATA_OPTION: &str =
"global-index.filter.refine-from-data";
const GLOBAL_INDEX_ENABLED_OPTION: &str = "global-index.enabled";
const GLOBAL_INDEX_SEARCH_MODE_OPTION: &str = "global-index.search-mode";
const SCALAR_INDEX_SEARCH_MODE_OPTION: &str = "scalar-index.search-mode";
@@ -984,6 +985,15 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(true)
}
+ /// Verify candidate-only scalar index answers against data before vector
+ /// ranking (Java default: true). When false, those candidates are
excluded.
+ pub fn global_index_filter_refine_from_data(&self) -> bool {
+ self.options
+ .get(GLOBAL_INDEX_FILTER_REFINE_FROM_DATA_OPTION)
+ .map(|value| value.eq_ignore_ascii_case("true"))
+ .unwrap_or(true)
+ }
+
/// Whether index files are stored in the bucket data-file directory rather
/// than the table `index/` directory (option
`index-file-in-data-file-dir`,
/// default false).
diff --git a/crates/paimon/src/table/batch_vector_search_builder.rs
b/crates/paimon/src/table/batch_vector_search_builder.rs
index f80a7b24..68e83c5d 100644
--- a/crates/paimon/src/table/batch_vector_search_builder.rs
+++ b/crates/paimon/src/table/batch_vector_search_builder.rs
@@ -21,6 +21,7 @@ use crate::spec::{CoreOptions, Predicate};
use crate::table::de_vector_scan::PreparedVectorSearchFilter;
use crate::table::vector_read::BatchVectorRead;
use crate::table::vector_scan::{PlanContext, VectorScan};
+use crate::table::vector_search_common::{add_filter,
validate_partition_filter};
use crate::table::Table;
use crate::vector_search::SearchResult;
use roaring::RoaringTreemap;
@@ -68,7 +69,7 @@ impl<'a> BatchVectorSearchBuilder<'a> {
}
pub fn with_options(&mut self, options: HashMap<String, String>) -> &mut
Self {
- self.options = options;
+ self.options.extend(options);
self
}
@@ -76,12 +77,18 @@ impl<'a> BatchVectorSearchBuilder<'a> {
/// before vector Top-K. See
[`crate::table::VectorSearchBuilder::with_filter`] for the
/// primary-key and data-evolution execution semantics.
pub fn with_filter(&mut self, filter: Predicate) -> &mut Self {
- self.filter = Some(filter);
+ add_filter(&mut self.filter, filter);
self.include_row_ids = None;
self.prepared_filter = None;
self
}
+ /// Add a partition-only predicate expressed against the table schema.
+ pub fn with_partition_filter(&mut self, filter: Predicate) ->
crate::Result<&mut Self> {
+ validate_partition_filter(self.table, &filter)?;
+ Ok(self.with_filter(filter))
+ }
+
/// Attach a prepared scalar pre-filter together with the exact table
/// snapshot against which its row-ID allow-list was evaluated.
pub fn with_prepared_filter(
diff --git a/crates/paimon/src/table/batch_vector_search_builder/tests.rs
b/crates/paimon/src/table/batch_vector_search_builder/tests.rs
index 125e776f..22e91097 100644
--- a/crates/paimon/src/table/batch_vector_search_builder/tests.rs
+++ b/crates/paimon/src/table/batch_vector_search_builder/tests.rs
@@ -155,3 +155,86 @@ fn
batch_resolve_materialize_read_type_default_rejects_reserved_user_column() {
"batch default projection must reject reserved user column, got:
{err:?}"
);
}
+
+#[tokio::test]
+async fn repeated_filters_are_conjoined_for_single_and_batch_searches() {
+ use crate::spec::{Datum, PredicateBuilder};
+ let table = de_vector_table().await;
+ let predicates = PredicateBuilder::new(table.schema().fields());
+ let lower = predicates.greater_than("id", Datum::Int(1)).unwrap();
+ let upper = predicates.less_than("id", Datum::Int(3)).unwrap();
+ let single = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0, 0.0])
+ .with_limit(3)
+ .with_filter(lower.clone())
+ .with_filter(upper.clone())
+ .execute()
+ .await
+ .unwrap();
+ assert_eq!(single.row_ids().unwrap().row_ids, vec![1]);
+ let batch = table
+ .new_batch_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vectors(vec![vec![1.0, 0.0], vec![0.0, 1.0]])
+ .with_limit(3)
+ .with_filter(lower)
+ .with_filter(upper)
+ .execute()
+ .await
+ .unwrap();
+ assert_eq!(batch.len(), 2);
+ assert!(batch
+ .iter()
+ .all(|result| result.row_ids().unwrap().row_ids == vec![1]));
+}
+
+#[tokio::test]
+async fn repeated_options_keep_earlier_overrides() {
+ let table = de_vector_table().await.copy_with_options(HashMap::from([(
+ "fields.embedding.ivf.refine-factor".to_string(),
+ "invalid".to_string(),
+ )]));
+ let override_options = HashMap::from([("refine_factor".to_string(),
"1".to_string())]);
+ let later = HashMap::from([("nprobe".to_string(), "1".to_string())]);
+ let result = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![0.0, 1.0])
+ .with_limit(1)
+ .with_options(override_options.clone())
+ .with_options(later.clone())
+ .execute()
+ .await
+ .unwrap();
+ assert_eq!(result.row_ids().unwrap().row_ids, vec![1]);
+ let results = table
+ .new_batch_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vectors(vec![vec![0.0, 1.0]])
+ .with_limit(1)
+ .with_options(override_options)
+ .with_options(later)
+ .execute()
+ .await
+ .unwrap();
+ assert_eq!(results[0].row_ids().unwrap().row_ids, vec![1]);
+}
+
+#[test]
+fn explicit_partition_filters_reject_data_fields() {
+ use crate::spec::{Datum, PredicateBuilder};
+ let table = vector_test_table();
+ let filter = PredicateBuilder::new(table.schema().fields())
+ .equal("id", Datum::Int(1))
+ .unwrap();
+ assert!(table
+ .new_vector_search_builder()
+ .with_partition_filter(filter.clone())
+ .is_err());
+ assert!(table
+ .new_batch_vector_search_builder()
+ .with_partition_filter(filter)
+ .is_err());
+}
diff --git a/crates/paimon/src/table/de_vector_read.rs
b/crates/paimon/src/table/de_vector_read.rs
index e033f79f..665953bc 100644
--- a/crates/paimon/src/table/de_vector_read.rs
+++ b/crates/paimon/src/table/de_vector_read.rs
@@ -22,13 +22,14 @@ use crate::lumina::reader::LuminaVectorGlobalIndexReader;
use crate::lumina::{LuminaIndexMeta, LuminaVectorMetric};
use crate::spec::{
row_id_data_field, CoreOptions, DataField, DataType, FileKind,
GlobalIndexSearchMode,
- IndexFileMeta, IndexManifestEntry, ROW_ID_FIELD_NAME,
+ IndexFileMeta, IndexManifestEntry, Predicate, ROW_ID_FIELD_NAME,
};
-use crate::table::de_vector_scan::DeVectorScanPlan;
+use crate::table::de_vector_scan::{matching_row_ids_for_filter,
DeVectorScanPlan};
use crate::table::global_index_scanner::{
deleted_row_ranges_for_data_evolution_dvs, search_limit_with_deleted_rows,
- unindexed_ranges_for_global_index_entries, RowRangeIndex,
+ unindexed_ranges_for_global_index_entries, GlobalIndexScanner,
RowRangeIndex,
};
+use crate::table::global_index_types::normalize_queryable_global_index_type;
use crate::table::index_file_path::IndexFileLocation;
use crate::table::pk_vector_position_read::SEARCH_SCORE_COLUMN;
use crate::table::row_id_predicate::intersect_sorted_ranges;
@@ -188,6 +189,7 @@ impl Read for DeVectorRead {
table,
index_entries,
include_row_ids,
+ filter,
next_row_id,
timing,
skip_search,
@@ -231,6 +233,7 @@ impl Read for DeVectorRead {
table_options: pinned_table.schema().options(),
schema_fields: pinned_table.schema().fields(),
next_row_id,
+ filter: filter.as_ref(),
},
&index_entries,
&vector_searches,
@@ -278,7 +281,7 @@ async fn execute_vindex_searches<S: SeekRead + 'static, G:
Send + 'static>(
vector_searches: Vec<VectorSearch>,
source: S,
file_name: String,
- index_parallelism: usize,
+ context: VectorIndexSearchContext,
guard: G,
) -> crate::Result<Vec<Option<HashMap<u64, f32>>>> {
let panic_context = if vector_searches.len() > 1 {
@@ -288,9 +291,17 @@ async fn execute_vindex_searches<S: SeekRead + 'static, G:
Send + 'static>(
};
execute_global_index_with_guard(panic_context, guard, move || {
let mut reader = VindexVectorGlobalIndexReader::new(io_meta, options)
- .with_batch_index_parallelism(index_parallelism);
+ .with_batch_index_parallelism(context.parallelism);
reader
- .visit_batch_vector_search(&vector_searches, |_| Ok(source))
+ .visit_batch_vector_search_validated(
+ &vector_searches,
+ |_| Ok(source),
+ |metadata| {
+ context
+ .metrics
+ .check(RawVectorMetric::from_vindex(metadata.metric))
+ },
+ )
.map_err(|e| crate::Error::DataInvalid {
message: format!("Failed to read vindex index file '{}': {}",
file_name, e),
source: Some(Box::new(e)),
@@ -307,6 +318,46 @@ struct VectorSearchEvaluation<'a> {
table_options: &'a HashMap<String, String>,
schema_fields: &'a [DataField],
next_row_id: Option<i64>,
+ filter: Option<&'a Predicate>,
+}
+
+#[derive(Clone)]
+struct VectorMetricCheck {
+ persisted: Arc<std::sync::Mutex<Option<RawVectorMetric>>>,
+ requested: Option<RawVectorMetric>,
+}
+
+impl VectorMetricCheck {
+ fn check(&self, metric: RawVectorMetric) -> crate::Result<()> {
+ if self.requested.is_some_and(|requested| requested != metric) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Query vector metric {:?} does not match index metric
{metric:?}",
+ self.requested
+ ),
+ });
+ }
+ let mut persisted =
+ self.persisted
+ .lock()
+ .map_err(|error| crate::Error::UnexpectedError {
+ message: format!("Vector metric validation lock poisoned:
{error}"),
+ source: None,
+ })?;
+ if persisted.is_some_and(|previous| previous != metric) {
+ return Err(crate::Error::DataInvalid {
+ message: "Cannot merge vector indexes with different
metrics".to_string(),
+ source: None,
+ });
+ }
+ *persisted = Some(metric);
+ Ok(())
+ }
+}
+
+struct VectorIndexSearchContext {
+ parallelism: usize,
+ metrics: VectorMetricCheck,
}
#[derive(Default)]
@@ -330,6 +381,84 @@ async fn evaluate_vector_search(
crate::table::vector_search_common::take_only_result(results, "vector
search")?.to_row_ranges()
}
+/// None means the scalar index cannot evaluate the filter: Java routes all
+/// vector-index-covered ranges to raw scoring in that case, even in FAST mode.
+async fn scalar_matched_row_ids(
+ evaluation: VectorSearchEvaluation<'_>,
+ entries: &[IndexManifestEntry],
+ vector_ranges: &[RowRange],
+ filter: &Predicate,
+) -> crate::Result<Option<RoaringTreemap>> {
+ let core = CoreOptions::new(evaluation.table_options);
+ // Java search pre-filters use per-column coverage and exclude multi-field
+ // definitions, even though ordinary table scans can use composite indexes.
+ let scalar_entries: Vec<_> = entries
+ .iter()
+ .filter(|entry| {
+ entry
+ .index_file
+ .global_index_meta
+ .as_ref()
+ .is_some_and(|meta|
meta.extra_field_ids.as_ref().is_none_or(Vec::is_empty))
+ })
+ .cloned()
+ .collect();
+ let scanner = GlobalIndexScanner::create_with_fm_options(
+ evaluation.file_io,
+ evaluation.table_path,
+ core.global_index_thread_num()?,
+ core.btree_index_fallback_scan_max_size()?,
+ core.btree_index_data_block_cache_size()?,
+ core.bitmap_index_fallback_scan_max_size()?,
+ &scalar_entries,
+ evaluation.schema_fields,
+ if scalar_entries
+ .iter()
+ .any(|entry|
entry.index_file.index_type.eq_ignore_ascii_case("fm"))
+ {
+
crate::fm_index::FMOptions::from_options(evaluation.table_options)?.read
+ } else {
+ crate::fm_index::FMReadOptions::default()
+ },
+ )?;
+ let Some(scanner) = scanner else {
+ return Ok(None);
+ };
+ let Some((ranges, exact)) =
scanner.matching_ranges_with_exactness(filter).await? else {
+ return Ok(None);
+ };
+ let ranges = intersect_sorted_ranges(&ranges, vector_ranges);
+ if exact {
+ let mut row_ids = RoaringTreemap::new();
+ for range in ranges {
+ let start = u64::try_from(range.from()).map_err(|_|
crate::Error::DataInvalid {
+ message: "Scalar vector index returned a negative row
ID".to_string(),
+ source: None,
+ })?;
+ let end = u64::try_from(range.to()).map_err(|_|
crate::Error::DataInvalid {
+ message: "Scalar vector index returned a negative row
ID".to_string(),
+ source: None,
+ })?;
+ row_ids.insert_range(start..=end);
+ }
+ return Ok(Some(row_ids));
+ }
+ if !core.global_index_filter_refine_from_data() {
+ log::warn!("Scalar global index only provides candidates for
{filter:?}; global-index.filter.refine-from-data is false, so indexed
candidates are excluded");
+ return Ok(Some(RoaringTreemap::new()));
+ }
+ if ranges.is_empty() {
+ return Ok(Some(RoaringTreemap::new()));
+ }
+ let table = evaluation.table.ok_or_else(|| crate::Error::DataInvalid {
+ message: "Scalar vector filter refinement requires table
context".to_string(),
+ source: None,
+ })?;
+ Ok(Some(
+ matching_row_ids_for_filter(table, filter, Some(ranges)).await?,
+ ))
+}
+
async fn evaluate_batch_vector_search(
evaluation: VectorSearchEvaluation<'_>,
index_entries: &[IndexManifestEntry],
@@ -343,7 +472,6 @@ async fn evaluate_batch_vector_search(
let table_path = evaluation.table_path.trim_end_matches('/');
let core_options = CoreOptions::new(evaluation.table_options);
- let search_mode = core_options.vector_index_search_mode()?;
let field_name = &vector_searches[0].field_name;
if vector_searches
.iter()
@@ -367,6 +495,12 @@ async fn evaluate_batch_vector_search(
});
}
+ // Java's scan overlays query options for the vector/scalar search modes.
+ let mut effective_options = evaluation.table_options.clone();
+ effective_options.extend(search_options.clone());
+ let effective_core = CoreOptions::new(&effective_options);
+ let search_mode = effective_core.vector_index_search_mode()?;
+
let field_id = match find_field_id_by_name(evaluation.schema_fields,
field_name) {
Some(id) => id,
None => return Ok(vec![ScoredRowIds::empty(); vector_searches.len()]),
@@ -388,6 +522,53 @@ async fn evaluate_batch_vector_search(
return Ok(vec![ScoredRowIds::empty(); vector_searches.len()]);
}
+ let vector_ranges = merge_row_ranges(
+ vector_entries
+ .iter()
+ .filter_map(|entry| {
+ entry
+ .index_file
+ .global_index_meta
+ .as_ref()
+ .map(|meta| RowRange::new(meta.row_range_start,
meta.row_range_end))
+ })
+ .collect(),
+ );
+ let mut indexed_searches = Cow::Borrowed(vector_searches);
+ let mut route_indexed_to_raw = false;
+ // Partition predicates have already pruned manifest entries. They do not
+ // make an otherwise exact scalar lookup candidate-only.
+ let scalar_filter = evaluation.filter.and_then(|filter| match
evaluation.table {
+ Some(table) => {
+ let data =
crate::table::bucket_filter::split_partition_and_data_predicates(
+ filter.clone(),
+ table.schema().fields(),
+ table.schema().partition_keys(),
+ )
+ .1;
+ (!data.is_empty()).then(|| Predicate::and(data))
+ }
+ None => Some(filter.clone()),
+ });
+ let scalar_search_mode = scalar_filter
+ .as_ref()
+ .map(|_| effective_core.scalar_index_search_mode())
+ .transpose()?;
+ if let Some(filter) = scalar_filter
+ .as_ref()
+ .filter(|_| !vector_entries.is_empty())
+ {
+ match scalar_matched_row_ids(evaluation, index_entries,
&vector_ranges, filter).await? {
+ Some(row_ids) => {
+ let row_ids = Arc::new(row_ids);
+ for search in indexed_searches.to_mut() {
+ search.set_shared_include_row_ids(Arc::clone(&row_ids));
+ }
+ }
+ None => route_indexed_to_raw = true,
+ }
+ }
+
let deletion_vector_start = timing_enabled.then(Instant::now);
let deleted_row_index = if core_options.data_evolution_enabled() {
match evaluation.table {
@@ -420,9 +601,9 @@ async fn evaluate_batch_vector_search(
let index_search_limit = indexed_search_limit(max_limit, refine_factor)?;
let vector_entry_count = vector_entries.len();
- let vector_search_plans = if let Some(include_row_ids) =
- shared_batch_include_row_ids(vector_searches)
- {
+ let vector_search_plans = if route_indexed_to_raw {
+ Vec::new()
+ } else if let Some(include_row_ids) =
shared_batch_include_row_ids(&indexed_searches) {
let ranges = vector_entries
.iter()
.map(|entry| {
@@ -454,6 +635,10 @@ async fn evaluate_batch_vector_search(
.map(|entry| (entry, None))
.collect::<Vec<_>>()
};
+ let metric_check = VectorMetricCheck {
+ persisted: Arc::new(std::sync::Mutex::new(None)),
+ requested: configured_raw_vector_metric_override(&search_options,
field_name)?,
+ };
let mut permit_wait = Duration::ZERO;
let mut file_reader_open = Duration::ZERO;
let mut index_search = Duration::ZERO;
@@ -461,7 +646,7 @@ async fn evaluate_batch_vector_search(
let mut refine = Duration::ZERO;
let mut raw_fallback = Duration::ZERO;
let mut merged = vec![ScoredRowIds::empty(); vector_searches.len()];
- if !vector_entries.is_empty() {
+ if !vector_search_plans.is_empty() {
let index_search_start = timing_enabled.then(Instant::now);
let concurrency = core_options.global_index_thread_num()?;
if concurrency > tokio::sync::Semaphore::MAX_PERMITS {
@@ -509,13 +694,14 @@ async fn evaluate_batch_vector_search(
deleted_row_index.as_ref(),
)
.min(i32::MAX as usize);
- let mut vector_searches = vector_searches.to_vec();
+ let mut vector_searches = indexed_searches.to_vec();
for vector_search in &mut vector_searches {
vector_search.limit = index_limit;
}
let mut options = evaluation.table_options.clone();
options.extend(search_options.clone());
let input = evaluation.file_io.new_input(&path);
+ let metric_check = metric_check.clone();
async move {
if let Some(local_filter) = shared_local_filter {
let local_filter = Arc::new(local_filter);
@@ -575,6 +761,8 @@ async fn evaluate_batch_vector_search(
if let Some(start) = read_start {
full_file_read = Some((start.elapsed(),
data.len()));
}
+ let metric =
LuminaIndexMeta::deserialize(&io_meta.metadata)?.metric()?;
+
metric_check.check(RawVectorMetric::from_lumina(metric))?;
execute_global_index_with_guard(
"Lumina global-index batch search task failed",
permit,
@@ -618,7 +806,7 @@ async fn evaluate_batch_vector_search(
vector_searches,
source,
file_name.clone(),
- batch_index_parallelism,
+ VectorIndexSearchContext {
parallelism: batch_index_parallelism, metrics: metric_check },
permit,
)
.await?;
@@ -651,7 +839,7 @@ async fn evaluate_batch_vector_search(
vector_searches,
Cursor::new(data),
file_name.clone(),
- batch_index_parallelism,
+ VectorIndexSearchContext {
parallelism: batch_index_parallelism, metrics: metric_check },
permit,
)
.await?
@@ -727,7 +915,7 @@ async fn evaluate_batch_vector_search(
evaluation,
index_entries,
field_id,
- field_name,
+ &metric_check,
vector_searches,
merged,
index_search_limit,
@@ -736,9 +924,14 @@ async fn evaluate_batch_vector_search(
refine = refine_start.map_or(Duration::ZERO, |start| start.elapsed());
}
- if search_mode != GlobalIndexSearchMode::Fast {
+ if route_indexed_to_raw
+ || search_mode != GlobalIndexSearchMode::Fast
+ || scalar_search_mode.is_some_and(|mode| mode !=
GlobalIndexSearchMode::Fast)
+ {
let raw_fallback_start = timing_enabled.then(Instant::now);
- let detail_ranges = if search_mode == GlobalIndexSearchMode::Detail {
+ let detail_ranges = if search_mode == GlobalIndexSearchMode::Detail
+ || scalar_search_mode == Some(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,
@@ -748,7 +941,7 @@ async fn evaluate_batch_vector_search(
Vec::new()
};
let field_ids = HashSet::from([field_id]);
- let raw_ranges = unindexed_ranges_for_global_index_entries(
+ let mut raw_ranges = unindexed_ranges_for_global_index_entries(
index_entries,
&field_ids,
search_mode,
@@ -756,6 +949,25 @@ async fn evaluate_batch_vector_search(
&detail_ranges,
is_vector_global_index_file,
);
+ if let (Some(filter), Some(scalar_mode)) = (&scalar_filter,
scalar_search_mode) {
+ let mut scalar_ranges = scalar_unindexed_ranges(
+ evaluation,
+ index_entries,
+ filter,
+ scalar_mode,
+ &detail_ranges,
+ );
+ // Scalar FULL/DETAIL also recover gaps inside vector coverage. In
+ // vector FAST they must not expand beyond the indexed corpus.
+ if search_mode == GlobalIndexSearchMode::Fast {
+ scalar_ranges = intersect_sorted_ranges(&scalar_ranges,
&vector_ranges);
+ }
+ raw_ranges.extend(scalar_ranges);
+ }
+ if route_indexed_to_raw {
+ raw_ranges.extend(vector_ranges);
+ }
+ raw_ranges = merge_row_ranges(raw_ranges);
if !raw_ranges.is_empty() {
let table = evaluation.table.ok_or_else(||
crate::Error::DataInvalid {
message: "Vector raw search requires table
context".to_string(),
@@ -766,14 +978,22 @@ async fn evaluate_batch_vector_search(
evaluation.file_io,
table_path,
evaluation.table_options,
+ &vector_searches[0].options,
index_entries,
field_id,
field_name,
)
.await?;
let metric_resolve = metric_start.map_or(Duration::ZERO, |start|
start.elapsed());
- let (raw_results, raw_timing) =
- read_raw_batch_vector_search(table, vector_searches,
&raw_ranges, metric).await?;
+ metric_check.check(metric)?;
+ let (raw_results, raw_timing) = read_raw_batch_vector_search(
+ table,
+ vector_searches,
+ &raw_ranges,
+ metric,
+ evaluation.filter,
+ )
+ .await?;
if let Some(raw_timing) = raw_timing {
log::debug!(
target: "paimon::vector_search",
@@ -844,6 +1064,40 @@ async fn evaluate_batch_vector_search(
Ok(results)
}
+/// Predicate coverage requires every referenced field to be covered by a
+/// single-field scalar index. Reuse the table scanner's coverage accounting.
+fn scalar_unindexed_ranges(
+ evaluation: VectorSearchEvaluation<'_>,
+ entries: &[IndexManifestEntry],
+ filter: &Predicate,
+ search_mode: GlobalIndexSearchMode,
+ data_ranges: &[RowRange],
+) -> Vec<RowRange> {
+ if !evaluation.next_row_id.is_some_and(|next| next > 0) {
+ return Vec::new();
+ }
+ let mut leaf_refs = Vec::new();
+ crate::arrow::residual::collect_predicate_leaf_refs(filter, &mut
leaf_refs);
+ let field_ids = leaf_refs
+ .into_iter()
+ .filter_map(|(column, _)|
find_field_id_by_name(evaluation.schema_fields, column))
+ .collect();
+ unindexed_ranges_for_global_index_entries(
+ entries,
+ &field_ids,
+ search_mode,
+ evaluation.next_row_id,
+ data_ranges,
+ |file| {
+ normalize_queryable_global_index_type(&file.index_type).is_some()
+ && file
+ .global_index_meta
+ .as_ref()
+ .is_some_and(|meta|
meta.extra_field_ids.as_ref().is_none_or(Vec::is_empty))
+ },
+ )
+}
+
fn is_vector_global_index_file(index_file: &IndexFileMeta) -> bool {
VectorIndexBackend::from_index_type(&index_file.index_type).is_some()
}
@@ -965,7 +1219,7 @@ async fn maybe_rerank_indexed_batch_results(
evaluation: VectorSearchEvaluation<'_>,
index_entries: &[IndexManifestEntry],
field_id: i32,
- field_name: &str,
+ metric_check: &VectorMetricCheck,
vector_searches: &[VectorSearch],
results: Vec<ScoredRowIds>,
index_search_limit: usize,
@@ -1003,19 +1257,22 @@ async fn maybe_rerank_indexed_batch_results(
let unique_candidates = union_candidates.len();
let raw_ranges = sorted_row_ids_to_row_ranges(union_candidates.iter())?;
let metric_start = timing_enabled.then(Instant::now);
+ let field_name = &vector_searches[0].field_name;
let metric = resolve_raw_vector_metric(
evaluation.file_io,
evaluation.table_path.trim_end_matches('/'),
evaluation.table_options,
+ &vector_searches[0].options,
index_entries,
field_id,
field_name,
)
.await?;
+ metric_check.check(metric)?;
let metric_resolve = metric_start.map_or(Duration::ZERO, |start|
start.elapsed());
let (results, raw_timing) =
- read_raw_batch_vector_search(table, &candidate_searches, &raw_ranges,
metric).await?;
+ read_raw_batch_vector_search(table, &candidate_searches, &raw_ranges,
metric, None).await?;
if let (Some(total_start), Some(raw_timing)) = (total_start, raw_timing) {
log::debug!(
target: "paimon::vector_search",
@@ -1241,92 +1498,118 @@ async fn resolve_raw_vector_metric(
file_io: &FileIO,
table_path: &str,
table_options: &HashMap<String, String>,
+ search_options: &HashMap<String, String>,
index_entries: &[IndexManifestEntry],
field_id: i32,
field_name: &str,
) -> crate::Result<RawVectorMetric> {
+ let check = VectorMetricCheck {
+ persisted: Arc::new(std::sync::Mutex::new(None)),
+ requested: configured_raw_vector_metric_override(search_options,
field_name)?,
+ };
+ let mut persisted = None;
for entry in index_entries {
- if entry.kind != FileKind::Add {
+ if entry.kind != FileKind::Add
+ || !entry
+ .index_file
+ .global_index_meta
+ .as_ref()
+ .is_some_and(|meta| meta.index_field_id == field_id)
+ {
continue;
}
- let Some(global_meta) = entry.index_file.global_index_meta.as_ref()
else {
- continue;
- };
- if global_meta.index_field_id != field_id {
- continue;
+ if let Some(metric) = read_index_vector_metric(file_io, table_path,
entry).await? {
+ check.check(metric)?;
+ persisted = Some(metric);
}
- 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));
- }
+ }
+ if let Some(metric) = persisted {
+ return Ok(metric);
+ }
+ if let Some(metric) = check.requested {
+ return Ok(metric);
+ }
+ Ok(
+ configured_raw_vector_metric_override(table_options, field_name)?
+ .unwrap_or(RawVectorMetric::L2),
+ )
+}
+
+/// Rerouted raw searches still validate each shard's persisted metric.
+async fn read_index_vector_metric(
+ file_io: &FileIO,
+ table_path: &str,
+ entry: &IndexManifestEntry,
+) -> crate::Result<Option<RawVectorMetric>> {
+ let Some(global_meta) = entry.index_file.global_index_meta.as_ref() else {
+ return Ok(None);
+ };
+ let Some(backend) =
VectorIndexBackend::from_index_type(&entry.index_file.index_type) else {
+ return Ok(None);
+ };
+ 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(Some(RawVectorMetric::from_lumina(metric)));
}
}
- VectorIndexBackend::Vindex => {
- if let Some(index_meta) = global_meta.index_meta.as_ref() {
- if let Ok(options) =
- serde_json::from_slice::<HashMap<String,
String>>(index_meta)
- {
- if let Some(metric) = options.get("metric") {
- if let Some(metric) =
-
RawVectorMetric::parse_normalized(&normalize_metric(metric))
- {
- return Ok(metric);
- }
+ }
+ VectorIndexBackend::Vindex => {
+ if let Some(index_meta) = global_meta.index_meta.as_ref() {
+ if let Ok(options) = serde_json::from_slice::<HashMap<String,
String>>(index_meta) {
+ if let Some(metric) = options.get("metric") {
+ if let Some(metric) =
+
RawVectorMetric::parse_normalized(&normalize_metric(metric))
+ {
+ return Ok(Some(metric));
}
}
}
- let path = IndexFileLocation::Global { table_path }.resolve(
- &entry.index_file.file_name,
- entry.index_file.external_path.as_deref(),
- );
- let input = file_io.new_input(&path)?;
- let read_error = |e| crate::Error::DataInvalid {
+ }
+ let path = IndexFileLocation::Global { table_path }.resolve(
+ &entry.index_file.file_name,
+ entry.index_file.external_path.as_deref(),
+ );
+ let input = file_io.new_input(&path)?;
+ let read_error = |e| crate::Error::DataInvalid {
+ message: format!(
+ "Failed to read vindex index file '{}' for raw search
metric: {}",
+ entry.index_file.file_name, e
+ ),
+ source: Some(Box::new(e)),
+ };
+ let header_size = if entry.index_file.file_size > 0 {
+ (entry.index_file.file_size as u64).min(DISKANN_HEADER_SIZE as
u64)
+ } else {
+ input
+ .metadata()
+ .await
+ .map_err(&read_error)?
+ .size
+ .min(DISKANN_HEADER_SIZE as u64)
+ };
+ let file_reader = input.reader().await.map_err(&read_error)?;
+ let bytes =
file_reader.read(0..header_size).await.map_err(read_error)?;
+ let reader =
+ VIndexReader::open(Cursor::new(bytes)).map_err(|e|
crate::Error::DataInvalid {
message: format!(
- "Failed to read vindex index file '{}' for raw search
metric: {}",
- entry.index_file.file_name, e
+ "Failed to open paimon-vindex-core reader for raw
search metric: {}",
+ e
),
source: Some(Box::new(e)),
- };
- let header_size = if entry.index_file.file_size > 0 {
- (entry.index_file.file_size as
u64).min(DISKANN_HEADER_SIZE as u64)
- } else {
- input
- .metadata()
- .await
- .map_err(&read_error)?
- .size
- .min(DISKANN_HEADER_SIZE as u64)
- };
- let file_reader = input.reader().await.map_err(&read_error)?;
- let bytes =
file_reader.read(0..header_size).await.map_err(read_error)?;
- let reader =
VIndexReader::open(Cursor::new(bytes)).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)),
- }
})?;
- return
Ok(RawVectorMetric::from_vindex(reader.metadata().metric));
- }
+ return
Ok(Some(RawVectorMetric::from_vindex(reader.metadata().metric)));
}
}
-
- configured_raw_vector_metric(table_options, field_name)
+ Ok(None)
}
-fn configured_raw_vector_metric(
+fn configured_raw_vector_metric_override(
options: &HashMap<String, String>,
field_name: &str,
-) -> crate::Result<RawVectorMetric> {
+) -> crate::Result<Option<RawVectorMetric>> {
let direct_keys = [
format!("fields.{field_name}.distance.metric"),
format!("fields.{field_name}.metric"),
@@ -1337,7 +1620,7 @@ fn configured_raw_vector_metric(
];
for key in direct_keys {
if let Some(value) = options.get(&key) {
- return RawVectorMetric::parse(value);
+ return RawVectorMetric::parse(value).map(Some);
}
}
@@ -1352,13 +1635,13 @@ fn configured_raw_vector_metric(
};
if let Some(existing) = inferred {
if existing != metric {
- return Ok(RawVectorMetric::L2);
+ return Ok(None);
}
} else {
inferred = Some(metric);
}
}
- Ok(inferred.unwrap_or(RawVectorMetric::L2))
+ Ok(inferred)
}
#[derive(Default)]
@@ -1378,6 +1661,7 @@ async fn read_raw_batch_vector_search(
vector_searches: &[VectorSearch],
raw_ranges: &[RowRange],
metric: RawVectorMetric,
+ filter: Option<&Predicate>,
) -> crate::Result<(Vec<ScoredRowIds>, Option<RawVectorReadTiming>)> {
let timing_enabled = vector_search_timing_enabled();
let total_start = timing_enabled.then(Instant::now);
@@ -1409,6 +1693,9 @@ async fn read_raw_batch_vector_search(
read_builder
.with_projection(&[field_name.as_str(), ROW_ID_FIELD_NAME])?
.with_row_ranges(raw_ranges);
+ if let Some(filter) = filter {
+ read_builder.with_filter(filter.clone());
+ }
let plan = read_builder.new_scan().plan().await?;
let plan_elapsed = plan_start.map_or(Duration::ZERO, |start|
start.elapsed());
let split_count = plan.splits().len();
diff --git a/crates/paimon/src/table/de_vector_read/tests.rs
b/crates/paimon/src/table/de_vector_read/tests.rs
index 63e1ff80..ed64c2cb 100644
--- a/crates/paimon/src/table/de_vector_read/tests.rs
+++ b/crates/paimon/src/table/de_vector_read/tests.rs
@@ -61,6 +61,7 @@ fn eval_context<'a>(
table_options: options,
schema_fields: fields,
next_row_id,
+ filter: None,
}
}
@@ -432,16 +433,16 @@ fn
test_configured_raw_vector_metric_precedence_and_conflict_default() {
);
options.insert("metric".to_string(), "cosine".to_string());
assert_eq!(
- configured_raw_vector_metric(&options, "embedding").unwrap(),
- RawVectorMetric::InnerProduct
+ configured_raw_vector_metric_override(&options, "embedding").unwrap(),
+ Some(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
+ configured_raw_vector_metric_override(&options, "embedding").unwrap(),
+ None
);
}
@@ -465,6 +466,7 @@ async fn
test_resolve_raw_vector_metric_uses_vindex_manifest_metadata() {
&file_io,
"memory:///test_table",
&HashMap::new(),
+ &HashMap::new(),
&[entry],
2,
"embedding",
@@ -503,6 +505,7 @@ async fn
test_resolve_raw_vector_metric_falls_back_to_vindex_header() {
&file_io,
"memory:///test_table",
&HashMap::new(),
+ &HashMap::new(),
&[entry],
2,
"embedding",
@@ -1390,3 +1393,249 @@ async fn
de_result_read_applies_scalar_filter_before_top_k() {
assert_eq!(ids, vec![3, 2]);
}
+
+#[tokio::test]
+async fn raw_query_metric_options_precede_table_options() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_options = HashMap::from([(
+ "fields.embedding.distance.metric".to_string(),
+ "invalid".to_string(),
+ )]);
+ let options = HashMap::from([("ivf-flat.metric".to_string(),
"cosine".to_string())]);
+ let metric = resolve_raw_vector_metric(
+ &file_io,
+ "memory:/table",
+ &table_options,
+ &options,
+ &[],
+ 2,
+ "embedding",
+ )
+ .await
+ .unwrap();
+ assert_eq!(metric, RawVectorMetric::Cosine);
+ let table_options = HashMap::from([("metric".to_string(),
"inner_product".to_string())]);
+ let conflicting = HashMap::from([
+ ("a.metric".to_string(), "cosine".to_string()),
+ ("b.metric".to_string(), "l2".to_string()),
+ ]);
+ let metric = resolve_raw_vector_metric(
+ &file_io,
+ "memory:/table",
+ &table_options,
+ &conflicting,
+ &[],
+ 2,
+ "embedding",
+ )
+ .await
+ .unwrap();
+ assert_eq!(metric, RawVectorMetric::InnerProduct);
+}
+
+#[tokio::test]
+async fn indexed_search_rejects_mixed_metrics_and_conflicting_query_metric() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let mut entries = Vec::new();
+ for (ordinal, metric) in ["l2", "cosine"].into_iter().enumerate() {
+ let bytes = build_vindex_segment_bytes(metric);
+ let name = format!("metric-{ordinal}.idx");
+ let mut entry = make_lumina_entry(&name, IVF_FLAT_IDENTIFIER,
FileKind::Add, 2);
+ entry.index_file.file_size = bytes.len() as i64;
+ let meta = entry.index_file.global_index_meta.as_mut().unwrap();
+ meta.row_range_start = ordinal as i64 * 3;
+ meta.row_range_end = meta.row_range_start + 2;
+ file_io
+ .new_output(&format!("memory:///test_table/index/{name}"))
+ .unwrap()
+ .write(bytes::Bytes::from(bytes))
+ .await
+ .unwrap();
+ entries.push(entry);
+ }
+ let fields = vec![make_field(2, "embedding")];
+ let options = HashMap::new();
+ let query = VectorSearch::new(vec![1.0, 0.0], 10,
"embedding".to_string()).unwrap();
+ let error = evaluate_batch_vector_search(
+ eval_context(&file_io, &options, &fields, None),
+ &entries,
+ &[query.clone(), query.clone()],
+ )
+ .await
+ .unwrap_err();
+ assert!(
+ error
+ .to_string()
+ .contains("Cannot merge vector indexes with different metrics"),
+ "{error}"
+ );
+ let query = query.with_options(HashMap::from([(
+ "metric".to_string(),
+ "cosine".to_string(),
+ )]));
+ let error = evaluate_vector_search(
+ eval_context(&file_io, &options, &fields, None),
+ &entries[..1],
+ &query,
+ )
+ .await
+ .unwrap_err();
+ assert!(
+ error.to_string().contains("does not match index metric"),
+ "{error}"
+ );
+}
+
+#[tokio::test]
+async fn filtered_rerank_rejects_metric_from_a_skipped_shard() {
+ use crate::spec::IndexManifest;
+ use crate::table::{TableCommit, TableWrite};
+ use arrow_array::builder::ListBuilder;
+ let table = de_vector_table().await;
+ let element = Arc::new(ArrowField::new("item", ArrowDataType::Float32,
false));
+ let mut vectors =
ListBuilder::new(Float32Builder::new()).with_field(element.clone());
+ for values in [[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]] {
+ vectors.values().append_slice(&values);
+ vectors.append(true);
+ }
+ let batch = RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("embedding", ArrowDataType::List(element), true),
+ ])),
+ vec![
+ Arc::new(Int32Array::from(vec![4, 5, 6])),
+ Arc::new(vectors.finish()),
+ ],
+ )
+ .unwrap();
+ let mut writer = TableWrite::new(&table,
"metric-user".to_string()).unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ TableCommit::new(table.clone(), "metric-user".to_string())
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let count = table
+ .new_vindex_index_build_builder(IVF_FLAT_IDENTIFIER)
+ .with_index_column("embedding")
+ .with_options(HashMap::from([(
+ "ivf-flat.distance.metric".to_string(),
+ "cosine".to_string(),
+ )]))
+ .execute()
+ .await
+ .unwrap();
+ assert!(count > 0);
+ let manager = table.snapshot_manager();
+ let snapshot = manager.get_latest_snapshot().await.unwrap().unwrap();
+ let mut entries = IndexManifest::read(
+ table.file_io(),
+ &manager.manifest_path(snapshot.index_manifest().unwrap()),
+ )
+ .await
+ .unwrap();
+ entries.sort_by_key(|entry| {
+ entry
+ .index_file
+ .global_index_meta
+ .as_ref()
+ .map(|meta| meta.row_range_start)
+ });
+ let query = VectorSearch::new(vec![1.0, 0.0], 2, "embedding".to_string())
+ .unwrap()
+ .with_options(HashMap::from([(
+ "refine_factor".to_string(),
+ "2".to_string(),
+ )]))
+ .with_include_row_ids((3..6).collect());
+ let evaluation = VectorSearchEvaluation {
+ table: Some(&table),
+ file_io: table.file_io(),
+ table_path: table.location(),
+ table_options: table.schema().options(),
+ schema_fields: table.schema().fields(),
+ next_row_id: snapshot.next_row_id(),
+ filter: None,
+ };
+ let error = evaluate_vector_search(evaluation, &entries, &query)
+ .await
+ .unwrap_err();
+ assert!(
+ error
+ .to_string()
+ .contains("Cannot merge vector indexes with different metrics"),
+ "{error}"
+ );
+}
+
+#[test]
+fn scalar_unindexed_coverage_intersects_all_predicate_fields() {
+ let io = FileIOBuilder::new("memory").build().unwrap();
+ let options = HashMap::new();
+ let fields = vec![make_field(1, "id"), make_field(2, "other")];
+ let evaluation = eval_context(&io, &options, &fields, Some(20));
+ let mut id = make_lumina_entry("id.btree", "btree", FileKind::Add, 1);
+ let mut other = make_lumina_entry("other.btree", "btree", FileKind::Add,
2);
+ other
+ .index_file
+ .global_index_meta
+ .as_mut()
+ .unwrap()
+ .row_range_end = 4;
+ let mut composite = id.clone();
+ let meta = composite.index_file.global_index_meta.as_mut().unwrap();
+ meta.row_range_end = 19;
+ meta.extra_field_ids = Some(vec![2]);
+ let builder = crate::spec::PredicateBuilder::new(&fields);
+ let predicates = vec![
+ builder.equal("id", crate::spec::Datum::Int(7)).unwrap(),
+ builder.equal("other", crate::spec::Datum::Int(7)).unwrap(),
+ ];
+ for filter in [
+ Predicate::and(predicates.clone()),
+ Predicate::or(predicates),
+ ] {
+ let entries = vec![id.clone(), other.clone(), composite.clone()];
+ assert_eq!(
+ scalar_unindexed_ranges(
+ evaluation,
+ &entries,
+ &filter,
+ GlobalIndexSearchMode::Full,
+ &[]
+ ),
+ vec![RowRange::new(5, 19)]
+ );
+ assert_eq!(
+ scalar_unindexed_ranges(
+ evaluation,
+ &entries,
+ &filter,
+ GlobalIndexSearchMode::Detail,
+ &[RowRange::new(0, 9), RowRange::new(15, 17)]
+ ),
+ vec![RowRange::new(5, 9), RowRange::new(15, 17)]
+ );
+ assert!(scalar_unindexed_ranges(
+ evaluation,
+ &entries,
+ &filter,
+ GlobalIndexSearchMode::Fast,
+ &[]
+ )
+ .is_empty());
+ // An index from another family cannot fill scalar coverage.
+ id.index_file.index_type = "full-text".to_string();
+ assert_eq!(
+ scalar_unindexed_ranges(
+ evaluation,
+ &[id.clone(), other.clone()],
+ &filter,
+ GlobalIndexSearchMode::Full,
+ &[]
+ ),
+ vec![RowRange::new(0, 19)]
+ );
+ id.index_file.index_type = "btree".to_string();
+ }
+}
diff --git a/crates/paimon/src/table/de_vector_scan.rs
b/crates/paimon/src/table/de_vector_scan.rs
index c5830a65..ac154ef2 100644
--- a/crates/paimon/src/table/de_vector_scan.rs
+++ b/crates/paimon/src/table/de_vector_scan.rs
@@ -18,8 +18,10 @@
//! Plans global-index vector searches against one snapshot and resolves
scalar pre-filters.
use crate::spec::{CoreOptions, IndexManifest, IndexManifestEntry, Predicate,
ROW_ID_FIELD_NAME};
+use crate::table::bucket_filter::split_partition_and_data_predicates;
+use crate::table::partition_filter::PartitionFilter;
use crate::table::vector_scan::Scan;
-use crate::table::Table;
+use crate::table::{RowRange, Table};
use crate::vindex::vector_search_timing_enabled;
use arrow_array::{Array, Int64Array};
use futures::TryStreamExt;
@@ -52,14 +54,18 @@ fn same_vector_search_table(left: &Table, right: &Table) ->
bool {
&& left.branch() == right.branch()
}
-async fn matching_row_ids_for_filter(
+pub(super) async fn matching_row_ids_for_filter(
table: &Table,
filter: &Predicate,
+ ranges: Option<Vec<RowRange>>,
) -> crate::Result<RoaringTreemap> {
let mut read_builder = table.new_read_builder();
read_builder
.with_projection(&[ROW_ID_FIELD_NAME])?
.with_filter(filter.clone());
+ if let Some(ranges) = ranges {
+ read_builder.with_row_ranges(ranges);
+ }
let plan = read_builder.new_scan().plan().await?;
let read = read_builder.new_read()?;
let mut stream = read.to_arrow(plan.splits())?;
@@ -122,7 +128,7 @@ impl Table {
});
};
let table = self.copy_with_resolved_snapshot(&snapshot).await?;
- let include_row_ids = matching_row_ids_for_filter(&table,
&filter).await?;
+ let include_row_ids = matching_row_ids_for_filter(&table, &filter,
None).await?;
Ok(PreparedVectorSearchFilter {
table,
include_row_ids: Arc::new(include_row_ids),
@@ -160,6 +166,7 @@ pub(super) struct DeVectorScanPlan {
pub(super) table: Table,
pub(super) index_entries: Vec<IndexManifestEntry>,
pub(super) include_row_ids: Option<Arc<RoaringTreemap>>,
+ pub(super) filter: Option<Predicate>,
pub(super) timing: Option<DeVectorScanTiming>,
pub(super) next_row_id: Option<i64>,
pub(super) skip_search: bool,
@@ -172,6 +179,7 @@ impl DeVectorScanPlan {
skip_search: true,
index_entries: Vec::new(),
include_row_ids: None,
+ filter: None,
next_row_id: None,
timing,
}
@@ -254,14 +262,11 @@ impl Scan for DeVectorScan {
};
plan.next_row_id = snapshot.next_row_id();
+ plan.filter = self.filter.clone();
plan.include_row_ids = if let Some(prepared) =
self.prepared_filter.as_ref() {
Some(Arc::clone(prepared.include_row_ids()))
} else if let Some(include_row_ids) = self.include_row_ids.as_ref() {
Some(Arc::clone(include_row_ids))
- } else if let Some(filter) = self.filter.as_ref() {
- Some(Arc::new(
- matching_row_ids_for_filter(&plan.table, filter).await?,
- ))
} else {
None
};
@@ -281,6 +286,24 @@ impl Scan for DeVectorScan {
}
None => Vec::new(),
};
+ if let Some(filter) = &self.filter {
+ let (partition, _) = split_partition_and_data_predicates(
+ filter.clone(),
+ plan.table.schema().fields(),
+ plan.table.schema().partition_keys(),
+ );
+ if let Some(partition) = partition {
+ let fields = plan.table.schema().partition_fields();
+ let partition = PartitionFilter::from_predicate(partition,
&fields);
+ let mut entries = Vec::with_capacity(plan.index_entries.len());
+ for entry in plan.index_entries {
+ if partition.matches_entry(&entry.partition)? {
+ entries.push(entry);
+ }
+ }
+ plan.index_entries = entries;
+ }
+ }
if let Some(timing) = &mut plan.timing {
timing.manifest = manifest_start.map_or(Duration::ZERO, |start|
start.elapsed());
}
diff --git a/crates/paimon/src/table/de_vector_scan/tests.rs
b/crates/paimon/src/table/de_vector_scan/tests.rs
index 2016e697..aa1b14f9 100644
--- a/crates/paimon/src/table/de_vector_scan/tests.rs
+++ b/crates/paimon/src/table/de_vector_scan/tests.rs
@@ -86,7 +86,7 @@ async fn reader_reuses_queries_without_leaking_plan_filters()
{
let filter = id_gt_filter(&table, 1);
let empty_filter = id_gt_filter(&table, 99);
// One reader executes filtered, empty, and unfiltered plans. The plan owns
- // the allow-list; neither it nor the empty result can alter later queries.
+ // the predicate; neither it nor the empty result can alter later queries.
for (filter, expected) in [
(Some(&filter), [vec![2, 1], vec![1, 2]]),
(Some(&empty_filter), [vec![], vec![]]),
@@ -362,3 +362,211 @@ async fn
de_scalar_filter_applies_to_unindexed_raw_fallback() {
assert_eq!(result.row_ids().unwrap().row_ids, vec![3]);
}
+
+#[tokio::test]
+async fn
unevaluable_scalar_filter_routes_covered_ranges_to_raw_in_every_mode() {
+ let table = de_vector_table().await;
+ for mode in ["fast", "full", "detail"] {
+ let table = table.copy_with_options(HashMap::from([(
+ "vector-index.search-mode".to_string(),
+ mode.to_string(),
+ )]));
+ let filter = id_gt_filter(&table, 0);
+ let mut plan = DeVectorScan::new(&table, Some(&filter), None, None)
+ .plan()
+ .await
+ .unwrap();
+ // Keep the committed IVF shards but remove the scalar index from this
+ // plan. A data-derived ANN allow-list can lose hits with nprobe=1.
+ plan.index_entries
+ .retain(|entry| entry.index_file.index_type == "ivf-flat");
+ let read = DeVectorRead::new(
+ "embedding",
+ &[&[1.0, 0.0], &[0.0, 1.0]],
+ 3,
+ &HashMap::from([("ivf.nprobe".to_string(), "1".to_string())]),
+ )
+ .unwrap();
+ let results = read.read(plan).await.unwrap();
+ for result in results {
+ let mut ids = result.row_ids().unwrap().row_ids.clone();
+ ids.sort_unstable();
+ assert_eq!(ids, vec![0, 1, 2], "{mode}");
+ }
+ }
+}
+
+#[tokio::test]
+async fn exact_and_partial_scalar_indexes_obey_refinement_switch() {
+ let table = de_vector_table().await;
+ let predicate =
crate::spec::PredicateBuilder::new(table.schema().fields());
+ let exact = id_gt_filter(&table, 1);
+ let partial = Predicate::and(vec![
+ exact.clone(),
+ predicate.is_not_null("embedding").unwrap(),
+ ]);
+ let contradiction = Predicate::and(vec![exact.clone(),
Predicate::negate(exact.clone())]);
+ for mode in ["fast", "full", "detail"] {
+ for refine in [false, true] {
+ let table = table.copy_with_options(HashMap::from([
+ ("vector-index.search-mode".to_string(), mode.to_string()),
+ (
+ "global-index.filter.refine-from-data".to_string(),
+ refine.to_string(),
+ ),
+ ]));
+ for (filter, expected) in [
+ (&exact, 2),
+ (&partial, if refine { 2 } else { 0 }),
+ (&contradiction, 0),
+ ] {
+ let results = table
+ .new_batch_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vectors(vec![vec![1.0, 0.0], vec![0.0, 1.0]])
+ .with_limit(3)
+ .with_options(HashMap::from([("ivf.nprobe".to_string(),
"2".to_string())]))
+ .with_filter(filter.clone())
+ .execute()
+ .await
+ .unwrap();
+ assert_eq!(results.len(), 2);
+ for result in results {
+ assert_eq!(
+ result.row_ids().unwrap().len(),
+ expected,
+ "{mode}, refine={refine}"
+ );
+ }
+ }
+ }
+ }
+}
+
+#[tokio::test]
+async fn scalar_filter_ignores_composite_index_definitions() {
+ let table = de_vector_table().await.copy_with_options(HashMap::from([
+ ("vector-index.search-mode".to_string(), "fast".to_string()),
+ (
+ "global-index.filter.refine-from-data".to_string(),
+ "false".to_string(),
+ ),
+ ]));
+ let filter = id_gt_filter(&table, 0);
+ let mut plan = DeVectorScan::new(&table, Some(&filter), None, None)
+ .plan()
+ .await
+ .unwrap();
+ let embedding_id =
+ crate::table::find_field_id_by_name(table.schema().fields(),
"embedding").unwrap();
+ for entry in &mut plan.index_entries {
+ if entry.index_file.index_type == "btree" {
+ // A multi-field definition cannot supply Java's per-column scalar
+ // coverage, regardless of whether its leading field is indexed.
+ entry
+ .index_file
+ .global_index_meta
+ .as_mut()
+ .unwrap()
+ .extra_field_ids = Some(vec![embedding_id]);
+ }
+ }
+ let results = DeVectorRead::new("embedding", &[&[1.0, 0.0]], 3,
&HashMap::new())
+ .unwrap()
+ .read(plan)
+ .await
+ .unwrap();
+ assert_eq!(results[0].row_ids().unwrap().len(), 3);
+}
+
+#[tokio::test]
+async fn scalar_coverage_gaps_follow_scalar_and_vector_modes() {
+ for vector_covers_tail in [false, true] {
+ let table = de_vector_table().await;
+ let element = Arc::new(ArrowField::new("element",
ArrowDataType::Float32, true));
+ let mut vectors =
ListBuilder::new(Float32Builder::new()).with_field(element.clone());
+ vectors.values().append_slice(&[3.0, 3.0]);
+ vectors.append(true);
+ let batch = RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("embedding", ArrowDataType::List(element),
true),
+ ])),
+ vec![
+ Arc::new(Int32Array::from(vec![4])) as ArrayRef,
+ Arc::new(vectors.finish()) as ArrayRef,
+ ],
+ )
+ .unwrap();
+ let mut writer = TableWrite::new(&table,
"coverage-test".to_string()).unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ TableCommit::new(table.clone(), "coverage-test".to_string())
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ if vector_covers_tail {
+ table
+
.new_vindex_index_build_builder(crate::vindex::IVF_FLAT_IDENTIFIER)
+ .with_index_column("embedding")
+ .with_options(HashMap::from([(
+ "ivf-flat.nlist".to_string(),
+ "1".to_string(),
+ )]))
+ .execute()
+ .await
+ .unwrap();
+ }
+ let filter = id_gt_filter(&table, 3);
+ for scalar_mode in ["fast", "full", "detail"] {
+ for vector_mode in ["fast", "full", "detail"] {
+ for builder_options in [false, true] {
+ let modes = HashMap::from([
+ (
+ "vector-index.search-mode".to_string(),
+ vector_mode.to_string(),
+ ),
+ (
+ "scalar-index.search-mode".to_string(),
+ scalar_mode.to_string(),
+ ),
+ ]);
+ let mut table_options = if builder_options {
+ HashMap::from([
+ ("vector-index.search-mode".to_string(),
"fast".to_string()),
+ ("scalar-index.search-mode".to_string(),
"fast".to_string()),
+ ])
+ } else {
+ modes.clone()
+ };
+ table_options.insert(
+ "global-index.filter.refine-from-data".to_string(),
+ "false".to_string(),
+ );
+ let table = table.copy_with_options(table_options);
+ let mut builder = table.new_batch_vector_search_builder();
+ builder
+ .with_vector_column("embedding")
+ .with_query_vectors(vec![vec![0.0, 0.0], vec![1.0,
0.0]])
+ .with_limit(10)
+ .with_filter(filter.clone());
+ if builder_options {
+ builder.with_options(modes);
+ }
+ let expected = if (vector_covers_tail && scalar_mode !=
"fast")
+ || (!vector_covers_tail && vector_mode != "fast")
+ {
+ vec![3]
+ } else {
+ vec![]
+ };
+ let results = builder.execute().await.unwrap();
+ assert_eq!(results.len(), 2);
+ for result in results {
+ assert_eq!(result.row_ids().unwrap().row_ids, expected,
+ "scalar={scalar_mode}, vector={vector_mode},
vector_covers_tail={vector_covers_tail}, builder_options={builder_options}");
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/crates/paimon/src/table/global_index_scanner.rs
b/crates/paimon/src/table/global_index_scanner.rs
index b9f7f058..71dd09a0 100644
--- a/crates/paimon/src/table/global_index_scanner.rs
+++ b/crates/paimon/src/table/global_index_scanner.rs
@@ -366,6 +366,48 @@ impl GlobalIndexScanner {
)
}
+ /// Scalar candidates and Java's exactness contract for searches which rank
+ /// rows before applying the row predicate.
+ pub(super) async fn matching_ranges_with_exactness(
+ &self,
+ predicate: &Predicate,
+ ) -> Result<Option<(Vec<RowRange>, bool)>> {
+ let Some(result) = self.evaluate(predicate).await? else {
+ return Ok(None);
+ };
+ let exact = result.fully_evaluated
+ && self.is_exact_predicate(predicate, &result.evaluated_field_ids);
+ Ok(Some((result.row_ranges, exact)))
+ }
+
+ fn is_exact_predicate(
+ &self,
+ predicate: &Predicate,
+ evaluated_fields: &std::collections::HashSet<i32>,
+ ) -> bool {
+ use crate::spec::PredicateOperator;
+ match predicate {
+ Predicate::Leaf { column, op, .. } => {
+ predicates::is_sorted_global_index_supported_op(*op)
+ && !matches!(
+ op,
+ PredicateOperator::Contains
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Like
+ )
+ &&
crate::table::find_field_id_by_name(&self.schema_fields, column)
+ .is_some_and(|id| evaluated_fields.contains(&id))
+ }
+ Predicate::And(children) | Predicate::Or(children) => children
+ .iter()
+ .all(|child| self.is_exact_predicate(child, evaluated_fields)),
+ // The index evaluator declines NOT; its child's contributing
+ // field IDs cannot prove that this conjunct was evaluated.
+ Predicate::Not(_) | Predicate::AlwaysFalse => false,
+ Predicate::AlwaysTrue => true,
+ }
+ }
+
/// Matching positions without data-evolution's unindexed row-id extension.
/// Source-backed PK indexes cover a validated immutable source group.
pub(super) async fn matching_ranges(
diff --git a/crates/paimon/src/table/global_index_scanner/composite.rs
b/crates/paimon/src/table/global_index_scanner/composite.rs
index f61d0e4f..b84f6e31 100644
--- a/crates/paimon/src/table/global_index_scanner/composite.rs
+++ b/crates/paimon/src/table/global_index_scanner/composite.rs
@@ -195,6 +195,9 @@ impl GlobalIndexScanner {
.map(|field| field.id())
.collect(),
indexed_coverage,
+ // Tuple bounds can leave suffix constraints for data
reads.
+ // Search pre-filters exclude these definitions entirely.
+ fully_evaluated: false,
},
}));
}
diff --git a/crates/paimon/src/table/global_index_scanner/evaluator.rs
b/crates/paimon/src/table/global_index_scanner/evaluator.rs
index 3c50b5a4..cc3a6a37 100644
--- a/crates/paimon/src/table/global_index_scanner/evaluator.rs
+++ b/crates/paimon/src/table/global_index_scanner/evaluator.rs
@@ -59,6 +59,9 @@ pub(super) struct GlobalIndexScanResult {
pub(super) row_ranges: Vec<RowRange>,
pub(super) evaluated_field_ids: HashSet<i32>,
pub(super) indexed_coverage: Vec<RowRange>,
+ /// False when an AND conjunct was declined or only some constraints were
+ /// selected for a field. Contributing field IDs alone cannot prove
exactness.
+ pub(super) fully_evaluated: bool,
}
pub(super) async fn try_fold_bounded<T, Fut, Acc, Fold>(
@@ -149,10 +152,13 @@ impl GlobalIndexScanner {
self.evaluate_leaf(&selected_entries, &predicates)
.await
.map(|result| {
- result.map(|(row_ranges, indexed_coverage)|
GlobalIndexScanResult {
- row_ranges,
- evaluated_field_ids: HashSet::from([field_id]),
- indexed_coverage,
+ result.map(|(row_ranges, indexed_coverage,
fully_evaluated)| {
+ GlobalIndexScanResult {
+ row_ranges,
+ evaluated_field_ids:
HashSet::from([field_id]),
+ indexed_coverage,
+ fully_evaluated,
+ }
})
})
}
@@ -161,6 +167,7 @@ impl GlobalIndexScanner {
let mut row_ranges: Option<Vec<RowRange>> = None;
let mut indexed_coverage: Option<Vec<RowRange>> = None;
let mut evaluated_field_ids = HashSet::new();
+ let mut fully_evaluated = true;
loop {
let remaining_predicate =
Predicate::And(children.clone());
let Some(composite) =
self.evaluate_composite(&remaining_predicate).await?
@@ -168,6 +175,7 @@ impl GlobalIndexScanner {
break;
};
let result = composite.result;
+ fully_evaluated &= result.fully_evaluated;
row_ranges = Some(match row_ranges {
None => result.row_ranges,
Some(existing) => {
@@ -250,40 +258,48 @@ impl GlobalIndexScanner {
}
}
}
+ let fully_evaluated = selected_predicates.len() ==
predicates.len();
leaf_futures.push(async move {
let result = self
.evaluate_leaf(&selected_entries,
&selected_predicates)
.await?;
- Ok((field_id, result))
+ Ok((field_id, result, fully_evaluated))
});
}
}
let leaf_group_count = leaf_futures.len();
- let (mut row_ranges, mut indexed_coverage, mut
evaluated_field_ids) =
- try_fold_bounded(
- leaf_futures,
- leaf_group_count.max(1),
- (row_ranges, indexed_coverage,
evaluated_field_ids),
- |(row_ranges, indexed_coverage,
evaluated_field_ids),
- (field_id, result)| {
- if let Some((ranges, coverage)) = result {
- *row_ranges = Some(match row_ranges.take()
{
- None => ranges,
- Some(existing) => {
- intersect_sorted_ranges(&existing,
&ranges)
- }
- });
- *indexed_coverage = Some(match
indexed_coverage.take() {
- None => coverage,
- Some(existing) => {
- intersect_sorted_ranges(&existing,
&coverage)
- }
- });
- evaluated_field_ids.insert(field_id);
- }
- },
- )
- .await?;
+ let (
+ mut row_ranges,
+ mut indexed_coverage,
+ mut evaluated_field_ids,
+ mut fully_evaluated,
+ ) = try_fold_bounded(
+ leaf_futures,
+ leaf_group_count.max(1),
+ (
+ row_ranges,
+ indexed_coverage,
+ evaluated_field_ids,
+ fully_evaluated,
+ ),
+ |(row_ranges, indexed_coverage, evaluated_field_ids,
fully_evaluated),
+ (field_id, result, group_fully_evaluated)| {
+ *fully_evaluated &= group_fully_evaluated &&
result.is_some();
+ if let Some((ranges, coverage,
leaf_fully_evaluated)) = result {
+ *fully_evaluated &= leaf_fully_evaluated;
+ *row_ranges = Some(match row_ranges.take() {
+ None => ranges,
+ Some(existing) =>
intersect_sorted_ranges(&existing, &ranges),
+ });
+ *indexed_coverage = Some(match
indexed_coverage.take() {
+ None => coverage,
+ Some(existing) =>
intersect_sorted_ranges(&existing, &coverage),
+ });
+ evaluated_field_ids.insert(field_id);
+ }
+ },
+ )
+ .await?;
// Evaluate non-leaf children concurrently in predicate
order.
let stream =
futures::stream::iter(0..non_leaf_children.len())
@@ -292,6 +308,7 @@ impl GlobalIndexScanner {
futures::pin_mut!(stream);
while let Some(result) = stream.try_next().await? {
if let Some(child_result) = result {
+ fully_evaluated &= child_result.fully_evaluated;
row_ranges = Some(match row_ranges {
None => child_result.row_ranges,
Some(existing) => {
@@ -306,6 +323,8 @@ impl GlobalIndexScanner {
&child_result.indexed_coverage,
),
});
+ } else {
+ fully_evaluated = false;
}
}
@@ -313,10 +332,12 @@ impl GlobalIndexScanner {
row_ranges,
evaluated_field_ids,
indexed_coverage: indexed_coverage.unwrap_or_default(),
+ fully_evaluated,
}))
}
Predicate::Or(children) => {
let mut all_ranges: Vec<RowRange> = Vec::new();
+ let mut fully_evaluated = true;
let mut evaluated_field_ids = HashSet::new();
let mut indexed_coverage: Option<Vec<RowRange>> = None;
let stream = futures::stream::iter(0..children.len())
@@ -326,6 +347,7 @@ impl GlobalIndexScanner {
while let Some(result) = stream.try_next().await? {
match result {
Some(child_result) => {
+ fully_evaluated &=
child_result.fully_evaluated;
all_ranges.extend(child_result.row_ranges);
evaluated_field_ids.extend(child_result.evaluated_field_ids);
indexed_coverage = Some(match indexed_coverage
{
@@ -348,6 +370,7 @@ impl GlobalIndexScanner {
row_ranges,
evaluated_field_ids,
indexed_coverage: indexed_coverage.unwrap_or_default(),
+ fully_evaluated,
}))
}
_ => Ok(None),
@@ -362,7 +385,7 @@ impl GlobalIndexScanner {
&self,
entries: &[&GlobalIndexEntry],
predicates: &[(PredicateOperator, &[Datum], &DataType)],
- ) -> Result<Option<(Vec<RowRange>, Vec<RowRange>)>> {
+ ) -> Result<Option<(Vec<RowRange>, Vec<RowRange>, bool)>> {
let Some(normalized_predicates) = predicates
.iter()
.map(|(op, literals, data_type)| {
@@ -415,7 +438,7 @@ impl GlobalIndexScanner {
)
};
if between.as_ref().is_some_and(|range| range.is_empty()) {
- return Ok(Some((Vec::new(), coverage())));
+ return Ok(Some((Vec::new(), coverage(), true)));
}
let effective_predicates = if between.is_some() {
@@ -569,6 +592,7 @@ impl GlobalIndexScanner {
let mut query_plans = Vec::with_capacity(entries.len());
let mut all_matching_ranges = Vec::new();
+ let mut fully_evaluated = true;
for (entry_idx, entry) in entries.iter().enumerate() {
if (between.is_none() || between_all_matches[entry_idx])
&& predicate_all_matches
@@ -632,6 +656,7 @@ impl GlobalIndexScanner {
if file_cannot_match {
continue;
}
+ fully_evaluated &= !file_has_unsupported_match;
if !file_evaluated {
if file_has_unsupported_match {
return Ok(None);
@@ -722,6 +747,7 @@ impl GlobalIndexScanner {
Ok(Some((
crate::table::merge_row_ranges(all_ranges),
coverage(),
+ fully_evaluated,
)))
}
diff --git a/crates/paimon/src/table/global_index_scanner/tests.rs
b/crates/paimon/src/table/global_index_scanner/tests.rs
index 0a79be3a..c00c9967 100644
--- a/crates/paimon/src/table/global_index_scanner/tests.rs
+++ b/crates/paimon/src/table/global_index_scanner/tests.rs
@@ -3903,3 +3903,123 @@ async fn
test_between_unmatched_file_drops_remaining_match() {
whose key range is [0, 198] — got {ranges:?}"
);
}
+
+#[tokio::test]
+async fn
scalar_exactness_keeps_string_candidates_and_dropped_conjuncts_inexact() {
+ let io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let mut fields = string_schema_fields();
+ fields.push(DataField::new(
+ 2,
+ "other".to_string(),
+ fields[0].data_type().clone(),
+ ));
+ let mut entry = make_global_index_entry(
+ "unused.index",
+ 1,
+ 100,
+ 109,
+ &BTreeIndexMeta::new(Some(b"alpha".to_vec()), Some(b"zeta".to_vec()),
false),
+ );
+ entry.index_file.row_count = 10;
+ entry.index_file.file_size = 100;
+ let scanner = GlobalIndexScanner::create(&io, "memory:/absent", 1, 0, 0,
&[entry], &fields)
+ .unwrap()
+ .unwrap();
+ let builder = crate::spec::PredicateBuilder::new(&fields);
+ let exact = builder
+ .less_than("name", Datum::String("zz".to_string()))
+ .unwrap();
+ let (ranges, is_exact) = scanner
+ .matching_ranges_with_exactness(&exact)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(ranges, vec![RowRange::new(100, 109)]);
+ assert!(is_exact);
+ for candidate in [
+ builder
+ .contains("name", Datum::String("needle".to_string()))
+ .unwrap(),
+ builder
+ .ends_with("name", Datum::String("needle".to_string()))
+ .unwrap(),
+ builder
+ .like("name", Datum::String("%needle%".to_string()), None)
+ .unwrap(),
+ builder
+ .equal("other", Datum::String("needle".to_string()))
+ .unwrap(),
+ Predicate::negate(exact.clone()),
+ ] {
+ let filter = Predicate::and(vec![exact.clone(), candidate]);
+ let (ranges, is_exact) = scanner
+ .matching_ranges_with_exactness(&filter)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(ranges, vec![RowRange::new(100, 109)]);
+ assert!(!is_exact, "{filter:?}");
+ }
+}
+
+#[tokio::test]
+async fn scalar_exactness_detects_unsupported_conjunct_on_same_fm_field() {
+ let io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/fm_exactness";
+ let output = VecFileWrite::new();
+ let mut writer =
+ FMGlobalIndexWriter::new(Box::new(output.clone()),
FMWriteOptions::default()).unwrap();
+ writer.write(None, 0).await.unwrap();
+ writer.write(Some(b"alpha"), 1).await.unwrap();
+ let result = writer.finish().await.unwrap();
+ io.new_output(&format!("{path}/index/name.fm"))
+ .unwrap()
+ .write(bytes::Bytes::from(output.to_vec()))
+ .await
+ .unwrap();
+ let mut entry = make_global_index_entry_with_type(
+ "fm",
+ "name.fm",
+ 1,
+ 0,
+ 1,
+ &BTreeIndexMeta::new(None, None, true),
+ );
+ entry.index_file.row_count = result.row_count as i64;
+ entry.index_file.file_size = output.to_vec().len() as i64;
+ entry
+ .index_file
+ .global_index_meta
+ .as_mut()
+ .unwrap()
+ .index_meta = Some(result.index_meta);
+ let fields = string_schema_fields();
+ let scanner = GlobalIndexScanner::create(&io, path, 1, i64::MAX, i64::MAX,
&[entry], &fields)
+ .unwrap()
+ .unwrap();
+ let builder = crate::spec::PredicateBuilder::new(&fields);
+ let is_null = builder.is_null("name").unwrap();
+ let (ranges, exact) = scanner
+ .matching_ranges_with_exactness(&is_null)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(ranges, vec![RowRange::new(0, 0)]);
+ assert!(exact);
+ let filter = Predicate::and(vec![
+ is_null,
+ builder
+ .starts_with("name", Datum::String("a".to_string()))
+ .unwrap(),
+ ]);
+ let (ranges, exact) = scanner
+ .matching_ranges_with_exactness(&filter)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(ranges, vec![RowRange::new(0, 0)]);
+ assert!(
+ !exact,
+ "FM must not mark a dropped startsWith conjunct exact"
+ );
+}
diff --git a/crates/paimon/src/table/pk_vector_orchestrator.rs
b/crates/paimon/src/table/pk_vector_orchestrator.rs
index 5e37679b..2f750b73 100644
--- a/crates/paimon/src/table/pk_vector_orchestrator.rs
+++ b/crates/paimon/src/table/pk_vector_orchestrator.rs
@@ -289,7 +289,8 @@ pub(crate) fn build_indexed_splits(
.with_bucket(source.bucket())
.with_bucket_path(source.bucket_path().to_string())
.with_total_buckets(source.total_buckets())
- .with_data_files(vec![file_meta]);
+ .with_data_files(vec![file_meta])
+ .with_raw_convertible(false);
if let Some(df) = deletion_file {
builder = builder.with_data_deletion_files(vec![Some(df)]);
}
diff --git a/crates/paimon/src/table/source.rs
b/crates/paimon/src/table/source.rs
index d50980ab..d68ff2a3 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -943,7 +943,27 @@ impl DataSplit {
/// `IndexedSplit` (type 3) wrapping the DataSplit body plus the ranges.
Byte-compatible with
/// `compatibility/split-v1-data` / `split-v1-indexed`.
pub fn serialize_split_v1(&self) -> crate::Result<Vec<u8>> {
+ self.serialize_split_v1_with_scores(None)
+ }
+
+ /// Serialize aligned vector scores using Java's IndexedSplit wire format.
+ /// Scores belong to the selection, in ascending expanded-range order.
+ pub fn serialize_split_v1_with_scores(&self, scores: Option<&[f32]>) ->
crate::Result<Vec<u8>> {
self.ensure_serializable_without_grant()?;
+ if let Some(scores) = scores {
+ let count = self.row_ranges.as_ref().and_then(|ranges| {
+ ranges.iter().try_fold(0usize, |count, range| {
+ let len =
range.to().checked_sub(range.from())?.checked_add(1)?;
+ count.checked_add(usize::try_from(len).ok()?)
+ })
+ });
+ if count != Some(scores.len()) || scores.len() > i32::MAX as usize
{
+ return Err(crate::Error::DataInvalid {
+ message: "IndexedSplit scores must align with selected row
ranges".to_string(),
+ source: None,
+ });
+ }
+ }
let mut out = Vec::new();
out.extend_from_slice(&SPLIT_SER_MAGIC.to_be_bytes());
out.extend_from_slice(&SPLIT_SER_VERSION.to_be_bytes());
@@ -963,7 +983,16 @@ impl DataSplit {
out.extend_from_slice(&r.from().to_be_bytes());
out.extend_from_slice(&r.to().to_be_bytes());
}
- out.push(0); // scores = null (vector scores are not modeled
on the Rust split)
+ match scores {
+ None => out.push(0),
+ Some(scores) => {
+ out.push(1);
+ out.extend_from_slice(&(scores.len() as
i32).to_be_bytes());
+ for score in scores {
+ out.extend_from_slice(&score.to_be_bytes());
+ }
+ }
+ }
}
}
Ok(out)
@@ -2399,6 +2428,34 @@ mod tests {
assert_eq!(split.serialize_split_v1().unwrap(), expected);
}
+ #[test]
+ fn serialize_indexed_scores_match_java_wire_layout() {
+ let split = v1_data_split_builder()
+ .with_row_ranges(vec![RowRange::new(1, 4), RowRange::new(11, 13)])
+ .build()
+ .unwrap();
+ let scores = [1.0f32, 0.75, 0.5, 0.25, 0.0, -0.5, -1.0];
+ let mut expected = split.serialize_split_v1().unwrap();
+ assert_eq!(expected.pop(), Some(0));
+ expected.push(1);
+ expected.extend_from_slice(&7i32.to_be_bytes());
+ for score in scores {
+ expected.extend_from_slice(&score.to_be_bytes());
+ }
+ assert_eq!(
+ split.serialize_split_v1_with_scores(Some(&scores)).unwrap(),
+ expected
+ );
+ assert!(split
+ .serialize_split_v1_with_scores(Some(&scores[..6]))
+ .is_err());
+ assert!(v1_data_split_builder()
+ .build()
+ .unwrap()
+ .serialize_split_v1_with_scores(Some(&scores))
+ .is_err());
+ }
+
// The scores-stripped IndexedSplit frame the Rust serializer emits (see
// `serialize_indexed_split_v1_matches_golden`): the Java golden's
trailing vector scores are
// replaced by a `false` scores flag, which is the exact byte form
`deserialize_split_v1` supports.
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index 176d75f9..8614795b 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -20,6 +20,7 @@
use crate::spec::{CoreOptions, Predicate};
use crate::table::vector_read::{BatchVectorRead, VectorRead};
use crate::table::vector_scan::{PlanContext, VectorScan};
+use crate::table::vector_search_common::{add_filter,
validate_partition_filter};
use crate::table::Table;
use crate::vector_search::SearchResult;
use std::collections::HashMap;
@@ -61,7 +62,7 @@ impl<'a> VectorSearchBuilder<'a> {
}
pub fn with_options(&mut self, options: HashMap<String, String>) -> &mut
Self {
- self.options = options;
+ self.options.extend(options);
self
}
@@ -69,20 +70,24 @@ impl<'a> VectorSearchBuilder<'a> {
///
/// On the primary-key vector path this remains a residual allow-list over
/// physical positions, mirroring Java `PrimaryKeyVectorRead`. On the
- /// data-evolution/global-index path the predicate is evaluated through a
- /// snapshot-pinned table read (which can use scalar global indexes such as
- /// BTree), producing global row IDs that are localized for each
vector-index
- /// shard and passed to the vector backend as an include filter.
- ///
- /// The whole predicate is both pushed into the scan — where it prunes
whole
- /// data files by their column stats — and applied per row as a residual
over
- /// the surviving files, so results stay exact. Sub-file row-range
narrowing is
- /// not performed; a surviving file is re-read in full for the residual.
+ /// data-evolution/global-index path an exact scalar-index lookup supplies
+ /// the vector backend's row-ID include filter. Candidate-only answers are
+ /// refined against snapshot-pinned data when
+ /// `global-index.filter.refine-from-data=true`; otherwise those candidates
+ /// are excluded. If no scalar index can evaluate the predicate, indexed
+ /// ranges are scored from raw data even in FAST mode. Raw searches always
+ /// apply the row predicate before Top-K, matching Java.
pub fn with_filter(&mut self, filter: Predicate) -> &mut Self {
- self.filter = Some(filter);
+ add_filter(&mut self.filter, filter);
self
}
+ /// Add a partition-only predicate expressed against the table schema.
+ pub fn with_partition_filter(&mut self, filter: Predicate) ->
crate::Result<&mut Self> {
+ validate_partition_filter(self.table, &filter)?;
+ Ok(self.with_filter(filter))
+ }
+
/// Create a query-independent scan. Only the vector column must be
configured.
pub fn new_scan(&self) -> crate::Result<VectorScan> {
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
diff --git a/crates/paimon/src/table/vector_search_common.rs
b/crates/paimon/src/table/vector_search_common.rs
index 61535a8a..321bd914 100644
--- a/crates/paimon/src/table/vector_search_common.rs
+++ b/crates/paimon/src/table/vector_search_common.rs
@@ -18,7 +18,9 @@
//! Shared vector-search option validation, index I/O helpers, and result
ordering.
use crate::lumina::is_lumina_index_type;
-use crate::spec::{CoreOptions, DataField, ROW_ID_FIELD_NAME};
+use crate::spec::{
+ field_idx_to_partition_idx, CoreOptions, DataField, Predicate,
ROW_ID_FIELD_NAME,
+};
use crate::table::pk_vector_position_read::{PKEY_VECTOR_POSITION_COLUMN,
SEARCH_SCORE_COLUMN};
use crate::table::read_builder::resolve_projected_fields;
use crate::table::Table;
@@ -28,6 +30,28 @@ use arrow_array::{Int64Array, RecordBatch};
use arrow_select::interleave::interleave_record_batch;
use std::collections::HashMap;
+/// Explicit partition filters use table-field indices, like scalar filters.
+/// Reject mixed predicates rather than silently discarding their data
conjuncts.
+pub(super) fn validate_partition_filter(table: &Table, filter: &Predicate) ->
crate::Result<()> {
+ let mapping =
+ field_idx_to_partition_idx(table.schema().fields(),
table.schema().partition_keys());
+ if table.schema().partition_keys().is_empty() ||
!filter.references_only_mapped_fields(&mapping)
+ {
+ return Err(crate::Error::ConfigInvalid {
+ message: "Partition filter must reference only partition keys of a
partitioned table"
+ .to_string(),
+ });
+ }
+ Ok(())
+}
+
+pub(super) fn add_filter(current: &mut Option<Predicate>, filter: Predicate) {
+ *current = Some(match current.take() {
+ Some(previous) => Predicate::and(vec![previous, filter]),
+ None => filter,
+ });
+}
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum VectorIndexBackend {
Lumina,
diff --git a/crates/paimon/src/table/vector_search_result.rs
b/crates/paimon/src/table/vector_search_result.rs
index 0a69ba45..b2fb1f6b 100644
--- a/crates/paimon/src/table/vector_search_result.rs
+++ b/crates/paimon/src/table/vector_search_result.rs
@@ -156,6 +156,21 @@ impl SearchResult {
}
}
+ /// Snapshot-scoped PK selections in Java SplitSerializer v1 format.
+ /// The ranges are physical file positions and scores are aligned to them.
+ pub fn serialize_primary_key_splits(&self) -> crate::Result<Vec<Vec<u8>>> {
+ self.indexed_splits()?
+ .iter()
+ .map(|selected| {
+ selected
+ .split
+ .clone()
+ .with_selected_row_ranges(selected.row_ranges.clone())
+ .serialize_split_v1_with_scores(selected.scores.as_deref())
+ })
+ .collect()
+ }
+
/// Snapshot-scoped file metadata and selections, reused when hybrid fusion
/// builds its final selections without scanning the source table again.
pub(super) fn indexed_splits(&self) ->
crate::Result<&[PkVectorIndexedSplit]> {