blob: e293f68424eac782ea89da68d2e84ddc61665bb5 [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 unittest
from unittest import mock
import numpy as np
import pyarrow as pa
from pypaimon.execution.python.read.search.vector_search_read import (
DataEvolutionVectorRead,
_compute_score,
_compute_scores,
_iter_arrow_batch_scores,
_iter_arrow_scores,
_score_block_size,
_score_rows,
)
from pypaimon.table.special_fields import SpecialFields
from pypaimon.tests.vector_search_filter_test import _StubTable, _field
from pypaimon.utils.range import Range
from pypaimon.utils.roaring_bitmap import RoaringBitmap64
class VectorScoringTest(unittest.TestCase):
def test_scores_exactly_match_scalar_accumulation(self):
rng = np.random.default_rng(42)
for dimension in (1, 3, 128, 384):
vectors = rng.standard_normal((37, dimension)).astype(np.float32)
query = rng.standard_normal(dimension).tolist()
for metric in ("l2", "cosine", "inner_product"):
with self.subTest(dimension=dimension, metric=metric):
expected = [_compute_score(query, row, metric) for row in vectors.tolist()]
actual = _compute_scores(query, vectors.astype(np.float64), metric)
self.assertEqual(expected, actual)
self.assertEqual(np.array(expected).tobytes(), np.array(actual).tobytes())
def test_cancellation_zero_norms_and_close_scores(self):
cases = [
([1e16, 1.0, -1e16], [[1, 1, 1], [1, 0, 1], [0, 0, 0]]),
([0, 0, 0], [[1, 2, 3], [0, 0, 0], [-0.0, -0.0, -0.0]]),
([1, 1e-8, 1e-8], [[1, 1e-8, 0], [1, 0, 1e-8], [1, 0, 0]]),
([1e150, 1e-150, -1e150], [[1e30, 1e-30, 1e30], [0, 1e-30, 0]]),
]
for query, values in cases:
vectors = np.array(values, dtype=np.float32)
for metric in ("l2", "cosine", "inner_product"):
expected = [_compute_score(query, row, metric) for row in vectors.tolist()]
self.assertEqual(expected, _compute_scores(query, vectors.astype(np.float64), metric))
def test_arrow_slices_chunks_and_list_layouts(self):
values = np.random.default_rng(7).standard_normal((1031, 7)).astype(np.float32).tolist()
query = [1.0] * 7
for dtype in (pa.list_(pa.float32()), pa.large_list(pa.float32()), pa.list_(pa.float32(), 7)):
array = pa.array(values, type=dtype)
sliced = array.slice(2, 1027)
chunked = pa.chunked_array([sliced.slice(0, 511), sliced.slice(511)])
for data in (sliced, chunked):
for metric in ("l2", "cosine", "inner_product"):
expected = [_compute_score(query, row, metric) for row in values[2:1029]]
self.assertEqual(expected, list(_iter_arrow_scores(data, query, metric)))
def test_batch_arrow_scores_exactly_match_scalar_accumulation(self):
rng = np.random.default_rng(17)
values = rng.standard_normal((2051, 128)).astype(np.float32)
vectors = pa.array(values.tolist(), type=pa.list_(pa.float32(), 128))
queries = rng.standard_normal((5, 128)).astype(np.float32).tolist()
for metric in ("l2", "cosine", "inner_product"):
with self.subTest(metric=metric):
actual = [[] for unused in queries]
for start, query_index, scores in _iter_arrow_batch_scores(
vectors, queries, metric
):
self.assertEqual(start, len(actual[query_index]))
actual[query_index].extend(scores)
for query, scores in zip(queries, actual):
expected = [
_compute_score(query, row, metric)
for row in values.tolist()
]
self.assertEqual(expected, scores)
self.assertEqual(
np.array(expected).tobytes(), np.array(scores).tobytes())
def test_null_and_unsupported_data_preserve_scalar_behavior(self):
for dtype in (pa.list_(pa.float32()), pa.list_(pa.float64()), pa.list_(pa.int64())):
array = pa.array([[1, 2], None, [0, 0]], type=dtype)
self.assertEqual([1.0, None, 1.0 / 6.0], list(_iter_arrow_scores(array, [1, 2], "l2")))
scores = _iter_arrow_scores(pa.array([None, [1.0]], type=pa.list_(pa.float32())), [1, 2], "l2")
self.assertIsNone(next(scores))
with self.assertRaisesRegex(ValueError, "dimension mismatch"):
next(scores)
with self.assertRaisesRegex(ValueError, "dimension mismatch"):
list(_iter_arrow_scores(pa.array([[1.0], [1.0, 2.0]]), [1, 2], "l2"))
with self.assertRaises(TypeError):
list(_iter_arrow_scores(pa.array([[1.0, None]], type=pa.list_(pa.float32())), [1, 2], "l2"))
values = [[float("nan"), 0], [1, 0]]
scores = list(_iter_arrow_scores(pa.array(values, type=pa.list_(pa.float32())), [1, 0], "l2"))
self.assertTrue(np.isnan(scores[0]))
self.assertEqual(1.0, scores[1])
self.assertEqual([1.0], list(_iter_arrow_scores(pa.array([[]], type=pa.list_(pa.float32())), [], "l2")))
def test_batch_null_vectors_preserve_scores_without_scalar_fallback(self):
rng = np.random.default_rng(37)
values = rng.standard_normal((2053, 7)).astype(np.float32).tolist()
for i in (0, 2, 511, 1025, 2051, 2052):
values[i] = None
queries = [[1.0] * 7, [0.0] * 7, [-0.5] * 7]
for dtype in (pa.list_(pa.float32()), pa.large_list(pa.float32()), pa.list_(pa.float32(), 7)):
sliced = pa.array(values, type=dtype).slice(2, 2050)
chunked = pa.chunked_array([sliced.slice(0, 513), sliced.slice(513)])
for vectors in (sliced, chunked):
for metric in ("l2", "cosine", "inner_product"):
with self.subTest(dtype=dtype, chunked=isinstance(vectors, pa.ChunkedArray), metric=metric):
expected = [[None if row is None else _compute_score(query, row, metric)
for row in values[2:2052]] for query in queries]
actual = [[] for _ in queries]
with mock.patch("pypaimon.execution.python.read.search.vector_search_read._compute_score",
side_effect=AssertionError("valid rows must use vectorized scoring")):
for start, query_index, scores in _iter_arrow_batch_scores(vectors, queries, metric):
self.assertEqual(start, len(actual[query_index]))
actual[query_index].extend(scores)
self.assertEqual(expected, actual)
def test_batch_all_null_vectors_and_invalid_children_keep_existing_behavior(self):
queries = [[1.0, 0.0], [0.0, 0.0]]
for dtype in (pa.list_(pa.float32()), pa.large_list(pa.float32()), pa.list_(pa.float32(), 2)):
for metric in ("l2", "cosine", "inner_product"):
result = list(_iter_arrow_batch_scores(pa.array([None, None], type=dtype), queries, metric))
self.assertEqual([(0, 0, [None, None]), (0, 1, [None, None])], result)
for rows, exception, message in (
([None, [1.0]], ValueError, "dimension mismatch"),
([None, [1.0, None]], TypeError, "NoneType")):
with self.subTest(rows=rows):
vectors = pa.array(rows, type=pa.list_(pa.float32()))
with self.assertRaisesRegex(exception, message):
list(_iter_arrow_batch_scores(vectors, queries, "l2"))
vectors = pa.array([None, [float("nan"), 0.0], [1.0, 0.0]], type=pa.list_(pa.float32()))
scores = list(_iter_arrow_batch_scores(vectors, queries, "l2"))[0][2]
self.assertIsNone(scores[0])
self.assertTrue(np.isnan(scores[1]))
self.assertEqual(1.0, scores[2])
def test_batch_null_parent_ignores_invalid_child_values(self):
vectors = pa.Array.from_buffers(
pa.list_(pa.float32(), 2), 2, [pa.py_buffer(b"\x02")],
children=[pa.array([float("nan"), None, 1.0, 2.0], type=pa.float32())])
with mock.patch("pypaimon.execution.python.read.search.vector_search_read._compute_score",
side_effect=AssertionError("null children must not disable the fast path")):
result = list(_iter_arrow_batch_scores(vectors, [[1.0, 2.0]], "l2"))
self.assertEqual([(0, 0, [None, 1.0])], result)
def test_raw_search_filters_before_scoring_and_preserves_ties(self):
column = _field(1, "embedding", "FLOAT")
reader = DataEvolutionVectorRead(_StubTable([column], []), 2, column, [1, 0])
table = pa.table({SpecialFields.ROW_ID.name: [9, 3, 1, 2],
"embedding": pa.array([[1], [1, 0], [1, 0], None],
type=pa.list_(pa.float32()))})
candidates = RoaringBitmap64()
for row_id in (1, 2, 3):
candidates.add(row_id)
with mock.patch.object(reader, "_read_raw_arrow", return_value=table):
result = reader._read_raw_search([Range(0, 9)], None, [1, 0], score_candidates=candidates)
self.assertEqual([1, 3], result.results().to_list())
reader._limit = 1
with mock.patch.object(reader, "_read_raw_arrow", return_value=table):
result = reader._read_raw_search([Range(0, 9)], None, [1, 0], score_candidates=candidates)
self.assertEqual([1], result.results().to_list())
def test_refinement_and_block_memory_bound(self):
column = _field(1, "embedding", "FLOAT")
reader = DataEvolutionVectorRead(_StubTable([column], []), 10, column, [1, 0])
values = {i: [float(i % 7), 0] for i in range(2051)}
original = {row_id: vector[:] for row_id, vector in values.items()}
candidates = RoaringBitmap64()
for row_id in values:
candidates.add(row_id)
result = reader._score_raw_vectors(candidates, values, [1, 0], "inner_product", 10)
expected = sorted(values, key=lambda i: (-values[i][0], i))[:10]
self.assertEqual(sorted(expected), result.results().to_list())
self.assertEqual(original, values)
self.assertLessEqual(_score_block_size([0] * 4096) * 4096, 1 << 20)
self.assertEqual([0.0], _score_rows([[0, 0]], [1, 0], "cosine"))