This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new ae2ea66a43 [python] Add regression coverage for sparse IVF-PQ searches
(#10726)
ae2ea66a43 is described below
commit ae2ea66a433630ff0dec8253594ee2980546aa3a
Author: QuakeWang <[email protected]>
AuthorDate: Mon Oct 26 12:20:15 2026 +0800
[python] Add regression coverage for sparse IVF-PQ searches (#10726)
---
.../pypaimon/tests/vector_index_dv_build_test.py | 26 ++++++++
.../pypaimon/tests/vindex_sparse_filter_test.py | 76 ++++++++++++++++++++++
2 files changed, 102 insertions(+)
diff --git a/paimon-python/pypaimon/tests/vector_index_dv_build_test.py
b/paimon-python/pypaimon/tests/vector_index_dv_build_test.py
index da06652e56..f8766fbc9f 100644
--- a/paimon-python/pypaimon/tests/vector_index_dv_build_test.py
+++ b/paimon-python/pypaimon/tests/vector_index_dv_build_test.py
@@ -114,6 +114,32 @@ def
test_native_search_excludes_deletions_before_and_after_build(table, metric,
assert build(table, metric) == 0
[email protected]_plan
[email protected]_write
[email protected]_commit
[email protected]('batch', [False, True])
+def test_python_ivfpq_search_keeps_live_null_vectors_out_of_results(tmp_path,
batch):
+ pytest.importorskip('paimon_vindex')
+ schema = pa.schema([('id', pa.int64()), ('embedding',
pa.list_(pa.float32()))])
+ table = pm.connect(options={'warehouse': str(tmp_path)}).create_table(
+ 'vectors', schema=schema, options={
+ 'file.format': 'parquet', 'vector.file.format': 'parquet',
'vector-index.search-mode': 'fast'})
+ table.add(pa.table({
+ 'id': list(range(5)), 'embedding': [[1., 0.], [0., 1.], None, [1.,
1.], [2., 2.]]}, schema=schema))
+ table.delete('id = 0 OR id = 3')
+ assert table.create_index('embedding', 'ivf-pq', options={
+ 'ivf-pq.dimension': '2', 'ivf-pq.nlist': '1', 'ivf-pq.pq.m': '1',
+ 'ivf-pq.distance.metric': 'l2'}) == 1
+ raw_table = table.raw_table
+ query =
(raw_table.new_batch_vector_search_builder().with_query_vectors([[1., 1.], [1.,
1.]]) if batch
+ else raw_table.new_vector_search_builder().with_query_vector([1.,
1.]))
+
query.with_vector_column('embedding').with_limit(64).with_option('ivf.nprobe',
'1')
+ results = query.execute_batch_local() if batch else [query.execute_local()]
+ assert len(results) == (2 if batch else 1)
+ for result in results:
+ assert sorted(result.results()) == [1, 4]
+
+
def test_native_build_and_query_keep_snapshot_semantics(table):
table.delete('id = 1 OR id = 6')
assert build(table) == 2
diff --git a/paimon-python/pypaimon/tests/vindex_sparse_filter_test.py
b/paimon-python/pypaimon/tests/vindex_sparse_filter_test.py
new file mode 100644
index 0000000000..1a47407d14
--- /dev/null
+++ b/paimon-python/pypaimon/tests/vindex_sparse_filter_test.py
@@ -0,0 +1,76 @@
+# 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.
+
+"""Python Vindex searches allow fewer valid hits than the requested limit."""
+
+import io
+
+import numpy as np
+import pytest
+
+from pypaimon.execution.python.read.search.vector_search_read import
_create_vector_reader
+from pypaimon.globalindex.batch_vector_search import BatchVectorSearch
+from pypaimon.globalindex.global_index_meta import GlobalIndexIOMeta
+from pypaimon.globalindex.vector_search import VectorSearch
+from pypaimon.utils.roaring_bitmap import RoaringBitmap64
+
+
[email protected](params=['l2', 'cosine', 'inner_product'])
+def index_bytes(request):
+ vindex = pytest.importorskip('paimon_vindex')
+ vectors = np.array([[1., 0.], [0., 1.], [1., 1.], [2., 2.]],
dtype=np.float32)
+ options = {'index.type': 'ivf_pq', 'dimension': '2', 'nlist': '1',
+ 'pq.m': '1', 'metric': request.param}
+ output = io.BytesIO()
+ with vindex.VectorIndexTrainer.train(options, vectors) as training:
+ with vindex.VectorIndexWriter(training) as writer:
+ writer.add_vectors(np.array([0, 1, 3, 4], dtype=np.int64), vectors)
+ writer.write(output)
+ return output.getvalue()
+
+
[email protected]('batch_size', [None, 1, 2], ids=['single',
'batch-one', 'batch-two'])
[email protected]('selected, expected', [([1, 2, 4], [1, 4]), ([2],
[]), ([], [])],
+ ids=['live-null', 'null-only', 'empty'])
+def test_python_ivfpq_sparse_filter(index_bytes, batch_size, selected,
expected):
+ class FileIO:
+ def new_input_stream(self, path):
+ self.stream = io.BytesIO(index_bytes)
+ return self.stream
+
+ file_io = FileIO()
+ include = RoaringBitmap64()
+ for row_id in selected:
+ include.add(row_id)
+ # Row 2 is a live NULL vector; rows 0 and 3 are deleted by the query
filter.
+ query_type = BatchVectorSearch if batch_size else VectorSearch
+ vectors = [[1., 1.], [0., 1.]][:batch_size] if batch_size else [1., 1.]
+ query = query_type(vectors, 64, 'embedding', options={'ivf.nprobe': '1'})
+ reader = _create_vector_reader('ivf-pq', file_io, '/indexes',
[GlobalIndexIOMeta('file', len(index_bytes))])
+ with reader:
+ search = reader.visit_batch_vector_search if batch_size else
reader.visit_vector_search
+ reference = search(query).result()
+ results = search(query.with_include_row_ids(include)).result()
+ if not batch_size:
+ reference, results = [reference], [results]
+ assert len(results) == len(reference) == (batch_size or 1)
+ for result, unfiltered in zip(results, reference):
+ assert ([] if result is None else sorted(result.results())) ==
expected
+ if result is not None:
+ assert {row_id: result.score_getter()(row_id) for row_id in
expected} == pytest.approx(
+ {row_id: unfiltered.score_getter()(row_id) for row_id in
expected})
+ assert file_io.stream.closed