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 f23855a8ad [python] Support native batch vector search in Lumina
reader (#8280)
f23855a8ad is described below
commit f23855a8ade27d77ffdb27c42932c1878685680e
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Jun 23 13:26:27 2026 +0800
[python] Support native batch vector search in Lumina reader (#8280)
---
.../pypaimon/globalindex/batch_vector_search.py | 95 ++++++++++++++++
.../pypaimon/globalindex/global_index_reader.py | 12 ++
.../lumina/lumina_vector_global_index_reader.py | 91 +++++++++------
.../globalindex/offset_global_index_reader.py | 11 ++
.../pypaimon/table/source/vector_search_read.py | 112 +++++++++++-------
.../pypaimon/tests/lumina_vector_index_test.py | 89 +++++++++++++++
.../pypaimon/tests/vector_search_filter_test.py | 125 +++++++++++++++++++--
7 files changed, 454 insertions(+), 81 deletions(-)
diff --git a/paimon-python/pypaimon/globalindex/batch_vector_search.py
b/paimon-python/pypaimon/globalindex/batch_vector_search.py
new file mode 100644
index 0000000000..c5aae32ce9
--- /dev/null
+++ b/paimon-python/pypaimon/globalindex/batch_vector_search.py
@@ -0,0 +1,95 @@
+# 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.
+
+"""BatchVectorSearch for performing batch vector similarity search."""
+
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional, Union
+
+import numpy as np
+
+from pypaimon.globalindex.vector_search import VectorSearch
+
+
+@dataclass
+class BatchVectorSearch:
+ """Batch vector search over multiple query vectors; result ``i`` maps to
``vectors[i]``."""
+
+ vectors: List[Union[List[float], np.ndarray]]
+ limit: int
+ field_name: str
+ include_row_ids: Optional['RoaringBitmap64'] = field(default=None)
+ options: Optional[Dict[str, str]] = field(default=None)
+
+ def __post_init__(self):
+ if not self.vectors:
+ raise ValueError("Search vectors cannot be empty")
+ if self.limit <= 0:
+ raise ValueError(f"Limit must be positive, got: {self.limit}")
+ if not self.field_name:
+ raise ValueError("Field name cannot be null or empty")
+ # Match VectorSearch: list vectors -> float32.
+ self.vectors = [
+ np.array(v, dtype=np.float32) if isinstance(v, list) else v
+ for v in self.vectors
+ ]
+ self.options = {} if self.options is None else dict(self.options)
+
+ @property
+ def vector_count(self) -> int:
+ return len(self.vectors)
+
+ def for_index(self, i: int) -> VectorSearch:
+ """Return the single VectorSearch for query vector ``i``."""
+ return VectorSearch(
+ vector=self.vectors[i],
+ limit=self.limit,
+ field_name=self.field_name,
+ include_row_ids=self.include_row_ids,
+ options=self.options,
+ )
+
+ def with_include_row_ids(self, include_row_ids: 'RoaringBitmap64') ->
'BatchVectorSearch':
+ return BatchVectorSearch(
+ vectors=self.vectors,
+ limit=self.limit,
+ field_name=self.field_name,
+ include_row_ids=include_row_ids,
+ options=self.options,
+ )
+
+ def offset_range(self, from_: int, to: int) -> 'BatchVectorSearch':
+ """Offset include_row_ids into the given range; vectors are shared by
all queries."""
+ if self.include_row_ids is None:
+ return self
+ from pypaimon.utils.roaring_bitmap import RoaringBitmap64
+
+ range_bitmap = RoaringBitmap64()
+ range_bitmap.add_range(from_, to)
+ and_result = RoaringBitmap64.and_(range_bitmap, self.include_row_ids)
+ offset_bitmap = RoaringBitmap64()
+ # Per-element shift (RoaringBitmap64 has no bulk translate yet).
+ for row_id in and_result:
+ offset_bitmap.add(row_id - from_)
+ return self.with_include_row_ids(offset_bitmap)
+
+ def visit(self, visitor: 'GlobalIndexReader') ->
'Future[List[Optional[GlobalIndexResult]]]':
+ return visitor.visit_batch_vector_search(self)
+
+ def __repr__(self) -> str:
+ return (f"BatchVectorSearch(field_name={self.field_name}, "
+ f"limit={self.limit}, vector_count={self.vector_count})")
diff --git a/paimon-python/pypaimon/globalindex/global_index_reader.py
b/paimon-python/pypaimon/globalindex/global_index_reader.py
index 1ac0905bb6..d3915583ca 100644
--- a/paimon-python/pypaimon/globalindex/global_index_reader.py
+++ b/paimon-python/pypaimon/globalindex/global_index_reader.py
@@ -58,6 +58,18 @@ class GlobalIndexReader(ABC):
def visit_vector_search(self, vector_search: 'VectorSearch') ->
'Future[Optional[GlobalIndexResult]]':
raise NotImplementedError("Vector search not supported by this reader")
+ def visit_batch_vector_search(
+ self, batch_vector_search: 'BatchVectorSearch'
+ ) -> 'Future[List[Optional[GlobalIndexResult]]]':
+ """Default: fan out to single-vector search; result ``i`` maps to
``vectors[i]``.
+
+ Blocks per future (fine while readers return completed futures); an
+ async reader should override.
+ """
+ singles = [self.visit_vector_search(batch_vector_search.for_index(i))
+ for i in range(batch_vector_search.vector_count)]
+ return _completed_future([f.result() for f in singles])
+
def visit_full_text_search(self, full_text_search: 'FullTextSearch') ->
'Future[Optional[GlobalIndexResult]]':
raise NotImplementedError("Full-text search not supported by this
reader")
diff --git
a/paimon-python/pypaimon/globalindex/lumina/lumina_vector_global_index_reader.py
b/paimon-python/pypaimon/globalindex/lumina/lumina_vector_global_index_reader.py
index cabd491911..2abba80617 100644
---
a/paimon-python/pypaimon/globalindex/lumina/lumina_vector_global_index_reader.py
+++
b/paimon-python/pypaimon/globalindex/lumina/lumina_vector_global_index_reader.py
@@ -50,6 +50,21 @@ def _merge_options(base_options, index_options,
query_options):
return options
+def _collect_scored_result(distances, labels, base, k, index_metric):
+ """Convert one query's [base, base+k) slice of distances/labels into a
result."""
+ from lumina_data import MetricType
+
+ SENTINEL = 0xFFFFFFFFFFFFFFFF
+ id_to_scores = {}
+ for i in range(k):
+ row_id = labels[base + i]
+ if row_id == SENTINEL:
+ continue
+ id_to_scores[int(row_id)] = MetricType.convert_distance_to_score(
+ float(distances[base + i]), index_metric)
+ return DictBasedScoredIndexResult(id_to_scores)
+
+
class LuminaVectorGlobalIndexReader(GlobalIndexReader):
"""Vector global index reader using Lumina."""
@@ -66,57 +81,69 @@ class LuminaVectorGlobalIndexReader(GlobalIndexReader):
self._load_lock = threading.Lock()
def visit_vector_search(self, vector_search):
+ # Single-vector search is just the n == 1 case of the batch path.
+ 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):
+ """Run one native batch search; result ``i`` maps to ``vectors[i]``
(``None`` if
+ no hits). Single search is the n == 1 case, shared by both visit paths.
+ """
self._ensure_loaded()
- from lumina_data import MetricType
- query_flat = [float(v) for v in
np.asarray(vector_search.vector).tolist()]
+ n = len(vectors)
expected_dim = self._index_meta.dim
- if len(query_flat) != expected_dim:
- raise ValueError(
- "Query vector dimension mismatch: expected %d, got %d"
- % (expected_dim, len(query_flat)))
+ query_flat = []
+ for vector in vectors:
+ flat = [float(v) for v in np.asarray(vector).tolist()]
+ if len(flat) != expected_dim:
+ raise ValueError(
+ "Query vector dimension mismatch: expected %d, got %d"
+ % (expected_dim, len(flat)))
+ query_flat.extend(flat)
- limit = vector_search.limit
index_metric = self._index_meta.metric
-
count = self._searcher.get_count()
effective_k = min(limit, count)
if effective_k <= 0:
- return _completed_future(None)
-
- include_row_ids = vector_search.include_row_ids
- query_options = vector_search.options
+ return [None] * n
if include_row_ids is not None:
filter_id_list = list(include_row_ids)
if len(filter_id_list) == 0:
- return _completed_future(None)
+ return [None] * n
effective_k = min(effective_k, len(filter_id_list))
- search_opts = _merge_options(
- self._options, {}, query_options)
+ search_opts = _merge_options(self._options, {}, query_options)
search_opts["search.thread_safe_filter"] = "true"
_ensure_search_list_size(search_opts, effective_k)
distances, labels = self._searcher.search_with_filter_list(
- query_flat, 1, effective_k, filter_id_list, search_opts)
+ query_flat, n, effective_k, filter_id_list, search_opts)
else:
- search_opts = _merge_options(
- self._options, {}, query_options)
+ search_opts = _merge_options(self._options, {}, query_options)
_ensure_search_list_size(search_opts, effective_k)
distances, labels = self._searcher.search_list(
- query_flat, 1, effective_k, search_opts)
-
- # Collect results with score conversion (same as Java collectResults)
- SENTINEL = 0xFFFFFFFFFFFFFFFF
- id_to_scores = {}
- for i in range(effective_k):
- row_id = labels[i]
- if row_id == SENTINEL:
- continue
- score = MetricType.convert_distance_to_score(
- float(distances[i]), index_metric)
- id_to_scores[int(row_id)] = score
-
- return _completed_future(DictBasedScoredIndexResult(id_to_scores))
+ query_flat, n, effective_k, search_opts)
+
+ # Each query's results occupy a contiguous [q * k, q * k + k) slice.
+ return [
+ _collect_scored_result(
+ distances, labels, q * effective_k, effective_k, index_metric)
+ for q in range(n)
+ ]
def _ensure_loaded(self):
if self._searcher is not None:
diff --git a/paimon-python/pypaimon/globalindex/offset_global_index_reader.py
b/paimon-python/pypaimon/globalindex/offset_global_index_reader.py
index a0965ed205..f13145ff50 100644
--- a/paimon-python/pypaimon/globalindex/offset_global_index_reader.py
+++ b/paimon-python/pypaimon/globalindex/offset_global_index_reader.py
@@ -48,6 +48,17 @@ class OffsetGlobalIndexReader(GlobalIndexReader):
self._wrapped.visit_vector_search(
vector_search.offset_range(self._offset, self._to)))
+ def visit_batch_vector_search(
+ self, batch_vector_search) ->
'Future[List[Optional[GlobalIndexResult]]]':
+ source = self._wrapped.visit_batch_vector_search(
+ batch_vector_search.offset_range(self._offset, self._to))
+
+ def transform(results):
+ return [r.offset(self._offset) if r is not None else None
+ for r in results]
+
+ return _map_future(source, transform)
+
def visit_full_text_search(self, full_text_search) ->
'Future[Optional[GlobalIndexResult]]':
return self._apply_offset_future(
self._wrapped.visit_full_text_search(full_text_search))
diff --git a/paimon-python/pypaimon/table/source/vector_search_read.py
b/paimon-python/pypaimon/table/source/vector_search_read.py
index 9656308071..faca1b71b9 100644
--- a/paimon-python/pypaimon/table/source/vector_search_read.py
+++ b/paimon-python/pypaimon/table/source/vector_search_read.py
@@ -20,6 +20,7 @@
from abc import ABC, abstractmethod
from concurrent.futures import wait
+from pypaimon.globalindex.batch_vector_search import BatchVectorSearch
from pypaimon.globalindex.global_index_meta import GlobalIndexIOMeta
from pypaimon.globalindex.global_index_result import GlobalIndexResult
from pypaimon.globalindex.offset_global_index_reader import
OffsetGlobalIndexReader
@@ -168,12 +169,11 @@ class AbstractVectorSearchReadImpl:
finally:
scanner.close()
- def _eval(self, row_range_start, row_range_end, vector_index_files,
- query_vector, include_row_ids):
- from pypaimon.globalindex.global_index_reader import _completed_future
+ def _open_offset_reader(self, vector_index_files, row_range_start,
row_range_end):
+ """Open a vector index reader for the split, wrapped with the row-id
offset.
- if not vector_index_files:
- return _completed_future(None)
+ The caller must close the returned reader once its future completes.
+ """
index_io_meta_list = []
for index_file in vector_index_files:
meta = index_file.global_index_meta
@@ -187,10 +187,21 @@ class AbstractVectorSearchReadImpl:
)
)
- index_type = vector_index_files[0].index_type
- index_path =
self._table.path_factory().global_index_path_factory().index_path()
- file_io = self._table.file_io
- options = self._table.table_schema.options
+ reader = _create_vector_reader(
+ vector_index_files[0].index_type,
+ self._table.file_io,
+
self._table.path_factory().global_index_path_factory().index_path(),
+ index_io_meta_list,
+ self._table.table_schema.options,
+ )
+ return reader, OffsetGlobalIndexReader(reader, row_range_start,
row_range_end)
+
+ def _eval(self, row_range_start, row_range_end, vector_index_files,
+ query_vector, include_row_ids):
+ from pypaimon.globalindex.global_index_reader import _completed_future
+
+ if not vector_index_files:
+ return _completed_future(None)
vector_search = VectorSearch(
vector=query_vector,
@@ -201,11 +212,8 @@ class AbstractVectorSearchReadImpl:
if include_row_ids is not None:
vector_search = vector_search.with_include_row_ids(include_row_ids)
- reader = _create_vector_reader(
- index_type, file_io, index_path,
- index_io_meta_list, options
- )
- offset_reader = OffsetGlobalIndexReader(reader, row_range_start,
row_range_end)
+ reader, offset_reader = self._open_offset_reader(
+ vector_index_files, row_range_start, row_range_end)
future = offset_reader.visit_vector_search(vector_search)
future.add_done_callback(lambda _: reader.close())
return future
@@ -253,6 +261,28 @@ class AbstractVectorSearchReadImpl:
scores[row_id] = _compute_score(query_vector, stored_vector,
metric)
return DictBasedScoredIndexResult(scores).top_k(self._limit)
+ def _eval_batch(self, row_range_start, row_range_end, vector_index_files,
+ query_vectors, include_row_ids):
+ from pypaimon.globalindex.global_index_reader import _completed_future
+
+ if not vector_index_files:
+ return _completed_future([None] * len(query_vectors))
+
+ batch_vector_search = BatchVectorSearch(
+ vectors=query_vectors,
+ limit=self._limit,
+ field_name=self._vector_column.name,
+ options=self._options,
+ )
+ if include_row_ids is not None:
+ batch_vector_search =
batch_vector_search.with_include_row_ids(include_row_ids)
+
+ reader, offset_reader = self._open_offset_reader(
+ vector_index_files, row_range_start, row_range_end)
+ future = offset_reader.visit_batch_vector_search(batch_vector_search)
+ future.add_done_callback(lambda _: reader.close())
+ return future
+
class VectorSearchReadImpl(AbstractVectorSearchReadImpl, VectorSearchRead):
"""Implementation for VectorSearchRead."""
@@ -329,40 +359,42 @@ class
BatchVectorSearchReadImpl(AbstractVectorSearchReadImpl,
if not index_splits and not raw_splits:
return [GlobalIndexResult.create_empty() for _ in range(n)]
+ # One native batch call per INDEX split (all query vectors at once),
+ # passing that split's pre-filter. Each future returns n per-query
results.
pre_filters = self._pre_filters(index_splits)
- futures_by_vector = [
- [
- self._eval(
- split.row_range_start, split.row_range_end,
- split.vector_index_files,
- vector,
- None if not pre_filters else pre_filters[i]
- )
- for i, split in enumerate(index_splits)
- ]
- for vector in self._query_vectors
+ futures = [
+ self._eval_batch(
+ split.row_range_start, split.row_range_end,
+ split.vector_index_files, self._query_vectors,
+ None if not pre_filters else pre_filters[i],
+ )
+ for i, split in enumerate(index_splits)
]
- for futures in futures_by_vector:
- wait(futures)
+ wait(futures)
- results = []
+ # Merge each query vector's indexed results across index splits.
+ merged_scores = [{} for _ in range(n)]
+ for future in futures:
+ split_results = future.result()
+ for i in range(n):
+ split_result = split_results[i]
+ if split_result is None:
+ continue
+ score_getter = split_result.score_getter()
+ for row_id in split_result.results():
+ if row_id not in merged_scores[i]:
+ merged_scores[i][row_id] = score_getter(row_id)
+
+ # Each query: merge indexed results with the raw (brute-force)
fallback.
raw_pre_filter = self._raw_pre_filter(raw_splits)
raw_ranges = _raw_row_ranges(raw_splits)
raw_index_type = _raw_search_index_type(raw_splits)
- for futures in futures_by_vector:
- merged_scores = {}
- for future in futures:
- split_result = future.result()
- if split_result is not None:
- score_getter = split_result.score_getter()
- for row_id in split_result.results():
- if row_id not in merged_scores:
- merged_scores[row_id] = score_getter(row_id)
- indexed = DictBasedScoredIndexResult(merged_scores)
- vector = self._query_vectors[len(results)]
+ results = []
+ for i in range(n):
+ indexed = DictBasedScoredIndexResult(merged_scores[i])
raw = self._read_raw_search(
- raw_ranges, raw_pre_filter, vector, raw_index_type)
+ raw_ranges, raw_pre_filter, self._query_vectors[i],
raw_index_type)
results.append(indexed.or_(raw).top_k(self._limit))
return results
diff --git a/paimon-python/pypaimon/tests/lumina_vector_index_test.py
b/paimon-python/pypaimon/tests/lumina_vector_index_test.py
index 5a27af20c8..3c2ae7eec2 100644
--- a/paimon-python/pypaimon/tests/lumina_vector_index_test.py
+++ b/paimon-python/pypaimon/tests/lumina_vector_index_test.py
@@ -27,6 +27,7 @@ import pytest
lumina_data = pytest.importorskip("lumina_data")
from lumina_data import LuminaBuilder
+from pypaimon.globalindex.batch_vector_search import BatchVectorSearch
from pypaimon.globalindex.global_index_meta import GlobalIndexIOMeta
from pypaimon.globalindex.lumina.lumina_index_meta import LuminaIndexMeta
from pypaimon.globalindex.lumina.lumina_vector_global_index_reader import (
@@ -165,3 +166,91 @@ class LuminaVectorIndexTest(unittest.TestCase):
reader.close()
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
+
+ def test_batch_matches_single(self):
+ """Native batch search must return, per query, the same result as a
single search."""
+ dim, n = 8, 200
+
+ paimon_options = {
+ "lumina.index.dimension": str(dim),
+ "lumina.index.type": "diskann",
+ "lumina.distance.metric": "l2",
+ "lumina.encoding.type": "rawf32",
+ "lumina.diskann.build.ef_construction": "64",
+ "lumina.diskann.build.neighbor_count": "32",
+ "lumina.diskann.build.thread_count": "2",
+ }
+
+ build_options = strip_lumina_options(paimon_options)
+ vectors, ids, raw = _make_vectors(n, dim, seed=123)
+
+ tmp_dir = tempfile.mkdtemp(prefix="paimon_lumina_test_")
+ file_name = "lumina-batch-0.index"
+ index_file = os.path.join(tmp_dir, file_name)
+
+ def scores(result):
+ if result is None:
+ return {}
+ getter = result.score_getter()
+ return {row_id: getter(row_id) for row_id in result.results()}
+
+ query_vectors = [raw[i * dim:(i + 1) * dim] for i in (0, 3, 7, 50,
123)]
+ limit = 5
+
+ try:
+ with LuminaBuilder(build_options) as builder:
+ builder.pretrain(vectors, n, dim)
+ builder.insert(vectors, ids, n, dim)
+ builder.dump(index_file)
+
+ meta = LuminaIndexMeta(build_options)
+ io_meta = GlobalIndexIOMeta(
+ file_name=file_name,
+ file_size=os.path.getsize(index_file),
+ metadata=meta.serialize(),
+ )
+
+ include_ids = RoaringBitmap64()
+ for i in (0, 3, 7, 50, 123, 10, 11, 12):
+ include_ids.add(i)
+
+ def assert_batch_matches_single(reader, batch_results,
include_row_ids):
+ for i, query_vector in enumerate(query_vectors):
+ vs = VectorSearch(
+ vector=query_vector, limit=limit,
field_name="embedding")
+ if include_row_ids is not None:
+ vs = vs.with_include_row_ids(include_row_ids)
+ single = scores(reader.visit_vector_search(vs).result())
+ batch = scores(batch_results[i])
+ self.assertEqual(set(batch), set(single))
+ for row_id in batch:
+ self.assertAlmostEqual(
+ batch[row_id], single[row_id], places=5)
+
+ with LuminaVectorGlobalIndexReader(
+ file_io=_SimpleFileIO(),
+ index_path=tmp_dir,
+ io_metas=[io_meta],
+ options=paimon_options,
+ ) as reader:
+ # Unfiltered: each query's batch slice must equal its single
search.
+ unfiltered = reader.visit_batch_vector_search(
+ BatchVectorSearch(
+ vectors=query_vectors, limit=limit,
field_name="embedding")
+ ).result()
+ assert_batch_matches_single(reader, unfiltered, None)
+
+ # Distinct queries must not collapse to one identical result
set,
+ # which would hide a wrong per-query slice.
+ top_sets = {tuple(sorted(scores(r))) for r in unfiltered}
+ self.assertGreater(len(top_sets), 1)
+
+ # Filtered: a single include_row_ids bitmap shared by all
queries.
+ filtered = reader.visit_batch_vector_search(
+ BatchVectorSearch(
+ vectors=query_vectors, limit=limit,
field_name="embedding")
+ .with_include_row_ids(include_ids)
+ ).result()
+ assert_batch_matches_single(reader, filtered, include_ids)
+ finally:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py
b/paimon-python/pypaimon/tests/vector_search_filter_test.py
index 9b1aae3261..d176d4f091 100644
--- a/paimon-python/pypaimon/tests/vector_search_filter_test.py
+++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py
@@ -2143,6 +2143,60 @@ class VectorSearchManySplitsTest(unittest.TestCase):
raw_read.assert_called_once()
self.assertEqual([1, 8], sorted(list(result.results())))
+ def test_batch_merges_raw_search_results(self):
+ from pypaimon.globalindex.global_index_reader import GlobalIndexReader
+ from pypaimon.globalindex.vector_search_result import (
+ DictBasedScoredIndexResult,
+ )
+ from pypaimon.table.source.vector_search_read import (
+ BatchVectorSearchReadImpl,
+ )
+ from pypaimon.table.source.vector_search_split import (
+ IndexVectorSearchSplit,
+ RawVectorSearchSplit,
+ )
+
+ embedding_field = _field(1, "embedding", "FLOAT")
+ entry = _entry(None, field_id=1, index_type="lumina-vector-ann",
+ file_name="vec.index",
+ row_range_start=0, row_range_end=4)
+ table = _StubTable(fields=[embedding_field], entries=[entry])
+
+ def _fake_create(index_type, file_io, index_path,
+ index_io_meta_list, options=None):
+ class _FakeReader(GlobalIndexReader):
+ def visit_vector_search(self_inner, vs):
+ row_id = int(vs.vector[0])
+ return _completed_future(
+ DictBasedScoredIndexResult({row_id: 0.5}))
+
+ def close(self_inner):
+ pass
+ return _FakeReader()
+
+ split = IndexVectorSearchSplit(
+ row_range_start=0,
+ row_range_end=4,
+ vector_index_files=[entry.index_file],
+ )
+ raw = RawVectorSearchSplit([Range(5, 9)], [], "lumina-vector-ann")
+
+ with mock.patch(
+
"pypaimon.table.source.vector_search_read._create_vector_reader",
+ side_effect=_fake_create):
+ reader = BatchVectorSearchReadImpl(
+ table, limit=5, vector_column=embedding_field,
+ query_vectors=[[1.0], [2.0]], filter_=None)
+ with mock.patch.object(
+ reader, "_read_raw_search",
+ return_value=DictBasedScoredIndexResult({8: 0.9})) as
raw_read:
+ results = reader.read_batch([split, raw])
+
+ # The raw fallback must be merged into EACH query, not dropped.
+ self.assertEqual(2, raw_read.call_count)
+ self.assertEqual([1, 8], sorted(list(results[0].results())))
+ self.assertEqual([2, 8], sorted(list(results[1].results())))
+
def test_read_uses_empty_index_prefilter_when_scalar_index_missing(self):
from pypaimon.table.source.vector_search_read import
VectorSearchReadImpl
from pypaimon.table.source.vector_search_split import
IndexVectorSearchSplit
@@ -2358,6 +2412,7 @@ class BatchVectorSearchTest(unittest.TestCase):
"""Batch vector search returns one result per query vector, in input
order."""
def test_batch_returns_per_query_results_in_order(self):
+ from pypaimon.globalindex.global_index_reader import GlobalIndexReader
from pypaimon.globalindex.vector_search_result import (
DictBasedScoredIndexResult,
)
@@ -2372,11 +2427,12 @@ class BatchVectorSearchTest(unittest.TestCase):
table = _StubTable(fields=[embedding_field], entries=[entry])
_patch_snapshot(self, [entry])
- # The fake reader routes each query vector to a distinct row id derived
- # from the vector itself, so result i must reflect query_vectors[i].
+ # A reader implementing only single search exercises the default batch
+ # fan-out; it routes each query vector to a row id derived from the
+ # vector itself, so result i must reflect query_vectors[i].
def _fake_create(index_type, file_io, index_path,
index_io_meta_list, options=None):
- class _FakeReader:
+ class _FakeReader(GlobalIndexReader):
def visit_vector_search(self_inner, vs):
row_id = int(vs.vector[0])
return _completed_future(
@@ -2384,12 +2440,6 @@ class BatchVectorSearchTest(unittest.TestCase):
def close(self_inner):
pass
-
- def __enter__(self_inner):
- return self_inner
-
- def __exit__(self_inner, *a):
- return False
return _FakeReader()
query_vectors = [[10.0], [20.0], [30.0]]
@@ -2413,6 +2463,63 @@ class BatchVectorSearchTest(unittest.TestCase):
self.assertNotEqual(
list(results[0].results()), list(results[1].results()))
+ def test_batch_uses_reader_native_batch_when_available(self):
+ from pypaimon.globalindex.global_index_reader import GlobalIndexReader
+ from pypaimon.globalindex.vector_search_result import (
+ DictBasedScoredIndexResult,
+ )
+ from pypaimon.table.source.batch_vector_search_builder import (
+ BatchVectorSearchBuilderImpl,
+ )
+
+ embedding_field = _field(1, "embedding", "FLOAT")
+ entry = _entry(None, field_id=1, index_type="lumina-vector-ann",
+ file_name="vec.index",
+ row_range_start=0, row_range_end=99)
+ table = _StubTable(fields=[embedding_field], entries=[entry])
+ _patch_snapshot(self, [entry])
+
+ calls = {"single": 0, "batch": 0}
+
+ # A reader that implements native batch must be driven through one
+ # batch call per split, not per-vector single calls.
+ def _fake_create(index_type, file_io, index_path,
+ index_io_meta_list, options=None):
+ class _FakeReader(GlobalIndexReader):
+ def visit_vector_search(self_inner, vs):
+ calls["single"] += 1
+ return _completed_future(
+ DictBasedScoredIndexResult({int(vs.vector[0]): 1.0}))
+
+ def visit_batch_vector_search(self_inner, bvs):
+ calls["batch"] += 1
+ return _completed_future([
+ DictBasedScoredIndexResult({int(bvs.vectors[i][0]):
1.0})
+ for i in range(bvs.vector_count)
+ ])
+
+ def close(self_inner):
+ pass
+ return _FakeReader()
+
+ query_vectors = [[10.0], [20.0], [30.0]]
+ with mock.patch(
+
"pypaimon.table.source.vector_search_read._create_vector_reader",
+ side_effect=_fake_create):
+ results = (
+ BatchVectorSearchBuilderImpl(table)
+ .with_vector_column("embedding")
+ .with_query_vectors(query_vectors)
+ .with_limit(5)
+ .execute_batch_local()
+ )
+
+ self.assertEqual(calls["batch"], 1)
+ self.assertEqual(calls["single"], 0)
+ self.assertEqual(len(results), len(query_vectors))
+ for i, query_vector in enumerate(query_vectors):
+
self.assertTrue(results[i].results().contains(int(query_vector[0])))
+
def test_batch_empty_splits_returns_empty_per_query(self):
from pypaimon.table.source.batch_vector_search_builder import (
BatchVectorSearchBuilderImpl,