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 812bca965e [python] Support batch vector search for vindex (#8341)
812bca965e is described below
commit 812bca965e67e2eafc14f7dd3098d2c078a3a1d1
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jun 24 15:17:32 2026 +0800
[python] Support batch vector search for vindex (#8341)
Support batch vector search for the PyPaimon vindex global index reader
so batch vector reads can use the native paimon-vindex batch API instead
of falling back to per-query searches.
---
.../vindex/vindex_vector_global_index_reader.py | 121 +++++++++++++++++----
.../pypaimon/tests/vindex_vector_index_test.py | 118 +++++++++++++++++++-
2 files changed, 215 insertions(+), 24 deletions(-)
diff --git
a/paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py
b/paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py
index 5b5c93a1b3..4006c7d534 100644
---
a/paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py
+++
b/paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py
@@ -70,42 +70,80 @@ class VindexVectorGlobalIndexReader(GlobalIndexReader):
self._load_lock = threading.Lock()
def visit_vector_search(self, vector_search):
+ results = self._run_search(
+ [vector_search.vector],
+ vector_search.limit,
+ vector_search.include_row_ids,
+ vector_search.options,
+ )
+ return _completed_future(results[0])
+
+ def visit_batch_vector_search(self, batch_vector_search):
+ results = self._run_search(
+ batch_vector_search.vectors,
+ batch_vector_search.limit,
+ batch_vector_search.include_row_ids,
+ batch_vector_search.options,
+ )
+ return _completed_future(results)
+
+ def _run_search(self, vectors, limit, include_row_ids, query_options):
self._ensure_loaded()
- query = np.asarray(vector_search.vector, dtype=np.float32)
- if query.ndim != 1:
- raise ValueError("Query vector must be a one-dimensional float32
array")
- expected_dim = self._metadata.dimension
- if query.shape[0] != expected_dim:
- raise ValueError(
- "Query vector dimension mismatch: expected %d, got %d"
- % (expected_dim, query.shape[0]))
-
- effective_k = self._effective_k(vector_search)
+ queries = self._validate_queries(vectors)
+ n = len(queries)
+ effective_k = self._effective_k(limit, include_row_ids)
if effective_k <= 0:
- return _completed_future(None)
+ return [None] * n
- options = vector_search.options or {}
+ options = query_options or {}
nprobe = _int_parameter(options, NPROBE_PARAMETER, DEFAULT_NPROBE)
ef_search = _int_parameter(options, EF_SEARCH_PARAMETER,
DEFAULT_EF_SEARCH)
- filter_bytes = _filter_bytes(vector_search.include_row_ids)
-
- ids, distances = self._reader.search(
- query, effective_k, nprobe, ef_search, filter_bytes=filter_bytes)
- id_to_scores = _build_scores(ids, distances, self._metadata.metric)
- if not id_to_scores:
- return _completed_future(None)
- return _completed_future(DictBasedScoredIndexResult(id_to_scores))
+ filter_bytes = _filter_bytes(include_row_ids)
+
+ if n == 1:
+ ids, distances = self._reader.search(
+ queries[0], effective_k, nprobe, ef_search,
filter_bytes=filter_bytes)
+ return [_result_from_scores(ids, distances, self._metadata.metric)]
+
+ if hasattr(self._reader, "search_batch"):
+ batch_result = self._reader.search_batch(
+ np.ascontiguousarray(queries),
+ effective_k,
+ nprobe,
+ ef_search,
+ filter_bytes=filter_bytes,
+ )
+ return _batch_results(batch_result, n, effective_k,
self._metadata.metric)
+
+ results = []
+ for query in queries:
+ ids, distances = self._reader.search(
+ query, effective_k, nprobe, ef_search,
filter_bytes=filter_bytes)
+ results.append(_result_from_scores(ids, distances,
self._metadata.metric))
+ return results
+
+ def _validate_queries(self, vectors):
+ queries = []
+ expected_dim = self._metadata.dimension
+ for vector in vectors:
+ query = np.asarray(vector, dtype=np.float32)
+ if query.ndim != 1:
+ raise ValueError("Query vector must be a one-dimensional
float32 array")
+ if query.shape[0] != expected_dim:
+ raise ValueError(
+ "Query vector dimension mismatch: expected %d, got %d"
+ % (expected_dim, query.shape[0]))
+ queries.append(query)
+ return np.asarray(queries, dtype=np.float32)
def vector_metric(self):
self._ensure_loaded()
return self._metadata.metric
- def _effective_k(self, vector_search):
- limit = vector_search.limit
+ def _effective_k(self, limit, include_row_ids):
total_vectors = getattr(self._metadata, "total_vectors", limit)
effective_k = min(limit, int(total_vectors))
- include_row_ids = vector_search.include_row_ids
if include_row_ids is not None:
cardinality = include_row_ids.cardinality()
if cardinality == 0:
@@ -177,6 +215,43 @@ def _build_scores(ids, distances, metric):
return id_to_scores
+def _result_from_scores(ids, distances, metric):
+ id_to_scores = _build_scores(ids, distances, metric)
+ if not id_to_scores:
+ return None
+ return DictBasedScoredIndexResult(id_to_scores)
+
+
+def _batch_results(batch_result, vector_count, effective_k, metric):
+ if hasattr(batch_result, "ids_for_query"):
+ return [
+ _result_from_scores(
+ batch_result.ids_for_query(i),
+ batch_result.distances_for_query(i),
+ metric,
+ )
+ for i in range(vector_count)
+ ]
+
+ ids, distances = batch_result
+ return [
+ _result_from_scores(
+ _values_for_query(ids, i, effective_k),
+ _values_for_query(distances, i, effective_k),
+ metric,
+ )
+ for i in range(vector_count)
+ ]
+
+
+def _values_for_query(values, query_index, effective_k):
+ array = np.asarray(values)
+ if array.ndim >= 2:
+ return array[query_index]
+ start = query_index * effective_k
+ return array[start:start + effective_k]
+
+
def _convert_distance_to_score(distance, metric):
if metric == "l2":
return 1.0 / (1.0 + distance)
diff --git a/paimon-python/pypaimon/tests/vindex_vector_index_test.py
b/paimon-python/pypaimon/tests/vindex_vector_index_test.py
index eae983ca42..ee64512cb2 100644
--- a/paimon-python/pypaimon/tests/vindex_vector_index_test.py
+++ b/paimon-python/pypaimon/tests/vindex_vector_index_test.py
@@ -15,10 +15,74 @@
# specific language governing permissions and limitations
# under the License.
+import io
+import sys
+import types
import unittest
+from pypaimon.globalindex.batch_vector_search import BatchVectorSearch
+from pypaimon.globalindex.global_index_meta import GlobalIndexIOMeta
from pypaimon.globalindex.vector_search_result import
DictBasedScoredIndexResult
-from pypaimon.globalindex.vindex.vindex_vector_global_index_reader import
_build_scores
+from pypaimon.globalindex.vindex.vindex_vector_global_index_reader import (
+ VindexVectorGlobalIndexReader,
+ _build_scores,
+)
+from pypaimon.utils.roaring_bitmap import RoaringBitmap64
+
+
+class _FakeMetadata:
+ dimension = 2
+ metric = "l2"
+ total_vectors = 100
+
+
+class _FakeVectorIndexReader:
+ instances = []
+
+ def __init__(self, index_input):
+ self.index_input = index_input
+ self.search_calls = []
+ self.batch_calls = []
+ self.closed = False
+ _FakeVectorIndexReader.instances.append(self)
+
+ def metadata(self):
+ return _FakeMetadata()
+
+ def search(self, query, effective_k, nprobe, ef_search, filter_bytes=None):
+ self.search_calls.append(
+ (list(query), effective_k, nprobe, ef_search, filter_bytes))
+ return list(range(10, 10 + effective_k)), [float(i) for i in
range(effective_k)]
+
+ def search_batch(
+ self, queries, top_k, nprobe, ef_search=0, filter_bytes=None
+ ):
+ self.batch_calls.append(
+ {
+ "queries": queries,
+ "top_k": top_k,
+ "nprobe": nprobe,
+ "ef_search": ef_search,
+ "filter_bytes": filter_bytes,
+ }
+ )
+ ids = []
+ distances = []
+ query_count = queries.shape[0]
+ for query_index in range(query_count):
+ base_id = (query_index + 1) * 10
+ for rank in range(top_k):
+ ids.append(base_id + rank)
+ distances.append(float(rank))
+ return ids, distances
+
+ def close(self):
+ self.closed = True
+
+
+class _BytesFileIO:
+ def new_input_stream(self, path):
+ return io.BytesIO(b"fake-index")
class VindexVectorIndexTest(unittest.TestCase):
@@ -36,6 +100,58 @@ class VindexVectorIndexTest(unittest.TestCase):
top1 = DictBasedScoredIndexResult(id_to_scores).top_k(1)
self.assertEqual([10], top1.results().to_list())
+ def test_batch_search_uses_native_batch_api(self):
+ old_module = sys.modules.get("paimon_vindex")
+ sys.modules["paimon_vindex"] = types.SimpleNamespace(
+ VectorIndexReader=_FakeVectorIndexReader)
+ _FakeVectorIndexReader.instances = []
+
+ try:
+ reader = VindexVectorGlobalIndexReader(
+ file_io=_BytesFileIO(),
+ index_path="/tmp",
+ io_metas=[GlobalIndexIOMeta(file_name="index", file_size=1)],
+ )
+
+ include_ids = RoaringBitmap64()
+ include_ids.add(1)
+ include_ids.add(2)
+
+ results = reader.visit_batch_vector_search(
+ BatchVectorSearch(
+ vectors=[[1.0, 0.0], [0.0, 1.0], [0.5, 0.5]],
+ limit=3,
+ field_name="embedding",
+ options={"ivf.nprobe": "7", "hnsw.ef_search": "11"},
+ ).with_include_row_ids(include_ids)
+ ).result()
+
+ fake_reader = _FakeVectorIndexReader.instances[0]
+ self.assertEqual([], fake_reader.search_calls)
+ self.assertEqual(1, len(fake_reader.batch_calls))
+
+ call = fake_reader.batch_calls[0]
+ self.assertEqual((3, 2), call["queries"].shape)
+ self.assertEqual(2, call["top_k"])
+ self.assertEqual(7, call["nprobe"])
+ self.assertEqual(11, call["ef_search"])
+ self.assertIsNotNone(call["filter_bytes"])
+
+ self.assertEqual(3, len(results))
+ self.assertEqual([10, 11], results[0].results().to_list())
+ self.assertEqual([20, 21], results[1].results().to_list())
+ self.assertEqual([30, 31], results[2].results().to_list())
+ self.assertEqual(1.0, results[0].score_getter()(10))
+ self.assertEqual(0.5, results[0].score_getter()(11))
+
+ reader.close()
+ self.assertTrue(fake_reader.closed)
+ finally:
+ if old_module is None:
+ sys.modules.pop("paimon_vindex", None)
+ else:
+ sys.modules["paimon_vindex"] = old_module
+
if __name__ == '__main__':
unittest.main()