blob: 46a83ae2839da9b54df2e277cc8f55d733a9b629 [file]
# 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 io
import sys
import types
import unittest
from unittest.mock import Mock, patch
import numpy as np
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.vector_search import VectorSearch
from pypaimon.globalindex.vindex_reader import (
VindexVectorGlobalIndexReader,
)
from pypaimon.execution.python.globalindex.vindex.vindex_vector_global_index_reader import (
_build_scores,
_search_params,
PythonVindexVectorGlobalIndexReader,
)
from pypaimon.utils.roaring_bitmap import RoaringBitmap64
class _FakeMetadata:
dimension = 2
metric = "l2"
total_vectors = 100
class _FakeSearchParams:
def __init__(self, kind, top_k, width):
self.kind = kind
self.top_k = top_k
self.width = width
@classmethod
def automatic(cls, top_k):
return cls("automatic", top_k, 0)
@classmethod
def ivf(cls, top_k, nprobe):
return cls("ivf", top_k, nprobe)
@classmethod
def diskann(cls, top_k, l_search):
return cls("diskann", top_k, l_search)
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 optimize_for_search(self):
pass
def search(self, query, params, filter_bytes=None):
self.search_calls.append(
(list(query), params, filter_bytes))
return (
list(range(10, 10 + params.top_k)),
[float(i) for i in range(params.top_k)],
)
def search_batch(self, queries, params, filter_bytes=None):
self.batch_calls.append(
{
"queries": queries,
"params": params,
"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(params.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):
def test_single_query_preserves_short_filtered_results(self):
reader = PythonVindexVectorGlobalIndexReader(
_BytesFileIO(), '/tmp', [GlobalIndexIOMeta('index', 1)])
reader._metadata = _FakeMetadata()
reader._search_params_type = _FakeSearchParams
reader._reader = Mock()
reader._reader.search.side_effect = RuntimeError('scalar ABI requires exactly K results')
reader._reader.search_batch.return_value = (
np.array([[10, 11, -1]]), np.array([[0., 1., np.nan]]))
with patch.object(reader, '_ensure_loaded'):
result = reader.visit_vector_search(VectorSearch([1., 0.], 3, 'embedding')).result()
self.assertEqual([10, 11], result.results().to_list())
self.assertEqual(1., result.score_getter()(10))
self.assertEqual(.5, result.score_getter()(11))
reader._reader.search.assert_not_called()
reader.close()
def test_query_widths_match_java_integer_text_and_bounds(self):
for key in ('ivf.nprobe', 'diskann.l_search'):
for value, expected in (('+12', 12), ('12', 12), ('٢', 2), ('2147483647', 2147483647), (12, 12)):
with self.subTest(key=key, value=value):
self.assertEqual(expected, _search_params(_FakeSearchParams, {key: value}, 4).width)
for value in ('2147483648', '-2147483649', ' 2', '2 ', '2.0', '1_0', '𝟙', '+', '', 1.5, True):
with self.subTest(key=key, value=value):
with self.assertRaisesRegex(ValueError, 'integer'):
_search_params(_FakeSearchParams, {key: value}, 4)
def test_inner_product_distance_converted_to_higher_is_better_score(self):
id_to_scores = _build_scores(
[10, 20, 30],
[-1.0, -0.5, -0.1],
"inner_product")
self.assertEqual(1.0, id_to_scores[10])
self.assertEqual(0.5, id_to_scores[20])
self.assertEqual(0.1, id_to_scores[30])
top1 = DictBasedScoredIndexResult(id_to_scores).top_k(1)
self.assertEqual([10], top1.results().to_list())
def test_top_k_breaks_boundary_ties_by_row_id(self):
scores = {1: 0.5, 2: 0.5, 3: 0.5, 4: 0.9}
self.assertEqual(
[1, 4],
DictBasedScoredIndexResult(scores).top_k(2).results().to_list(),
)
@patch('pypaimon.execution.native.read.vindex_reader._native_reader_type',
return_value=None)
def test_batch_search_uses_native_batch_api(self, _native_reader):
old_module = sys.modules.get("paimon_vindex")
sys.modules["paimon_vindex"] = types.SimpleNamespace(
SearchParams=_FakeSearchParams,
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"},
).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["params"].top_k)
self.assertEqual("ivf", call["params"].kind)
self.assertEqual(7, call["params"].width)
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()