| # 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 ctypes |
| import io |
| import threading |
| |
| import numpy as np |
| import pytest |
| |
| from paimon_vindex import ( |
| SearchParams, |
| VectorIndexReader, |
| VectorIndexTrainer, |
| VectorIndexWriter, |
| ) |
| |
| |
| class VectorIndexInput: |
| def __init__(self, data): |
| self.data = data |
| |
| def pread_many(self, ranges): |
| return [self.data[pos : pos + length] for pos, length in ranges] |
| |
| |
| def clustered_data(n, d, clusters): |
| data = np.zeros((n, d), dtype=np.float32) |
| for i in range(n): |
| cluster = i % clusters |
| for j in range(d): |
| data[i, j] = cluster * 20.0 + j * 0.01 + i * 0.0001 |
| return data |
| |
| |
| def build_index(options, d, n=512): |
| data = clustered_data(n, d, int(options.get("nlist", "4"))) |
| ids = np.arange(n, dtype=np.int64) |
| output = io.BytesIO() |
| training = VectorIndexTrainer.train(options, data) |
| with VectorIndexWriter(training) as writer: |
| writer.add_vectors(ids, data) |
| writer.write(output) |
| return output.getvalue(), data |
| |
| |
| def reader_from_bytes(data): |
| return VectorIndexReader(VectorIndexInput(data)) |
| |
| |
| def test_python_search_parameters_remain_algorithm_specific(): |
| params = SearchParams.diskann(top_k=10, l_search=200).to_ffi() |
| |
| assert params.search_width == 2 |
| assert params.width == 200 |
| automatic = SearchParams.automatic(top_k=10).to_ffi() |
| assert automatic.search_width == 0 |
| assert automatic.width == 0 |
| |
| |
| @pytest.mark.parametrize( |
| "factory", |
| [ |
| lambda: SearchParams.automatic(top_k=0), |
| lambda: SearchParams.ivf(top_k=5, nprobe=-1), |
| lambda: SearchParams.diskann(top_k=5, l_search=-1), |
| lambda: SearchParams.ivf( |
| top_k=5, nprobe=ctypes.c_size_t(-1).value + 1 |
| ), |
| ], |
| ) |
| def test_python_search_parameters_reject_values_that_ctypes_would_wrap(factory): |
| with pytest.raises(ValueError): |
| factory() |
| |
| |
| def test_python_high_level_training_infers_dimension_and_ivf_shape(): |
| data = clustered_data(512, 16, 8) |
| options = {"index.type": "ivf_sq", "metric": "l2"} |
| ids = np.arange(data.shape[0], dtype=np.int64) |
| output = io.BytesIO() |
| |
| training = VectorIndexTrainer.train(options, data) |
| with VectorIndexWriter(training) as writer: |
| writer.add_vectors(ids, data) |
| writer.write(output) |
| |
| with reader_from_bytes(output.getvalue()) as reader: |
| metadata = reader.metadata() |
| assert metadata.dimension == 16 |
| assert metadata.nlist == 8 |
| result_ids, _ = reader.search(data[0], SearchParams.automatic(top_k=5)) |
| assert result_ids[0] == 0 |
| |
| |
| def test_python_high_level_training_preserves_explicit_expected_count(): |
| data = clustered_data(512, 16, 8) |
| options = { |
| "index.type": "ivf_sq", |
| "metric": "l2", |
| "expected-vector-count": "1000000", |
| } |
| ids = np.arange(data.shape[0], dtype=np.int64) |
| output = io.BytesIO() |
| |
| training = VectorIndexTrainer.train(options, data) |
| with VectorIndexWriter(training) as writer: |
| writer.add_vectors(ids, data) |
| writer.write(output) |
| |
| with reader_from_bytes(output.getvalue()) as reader: |
| assert reader.metadata().nlist == 1024 |
| |
| |
| def test_python_read_callback_forwards_ranges_in_one_batch(): |
| from paimon_vindex import _make_read_ranges_callback |
| from paimon_vindex import _ffi |
| |
| class RecordingInput(VectorIndexInput): |
| def __init__(self, data): |
| super().__init__(data) |
| self.calls = [] |
| |
| def pread_many(self, ranges): |
| self.calls.append(list(ranges)) |
| return super().pread_many(ranges) |
| |
| source = RecordingInput(bytes(range(32))) |
| callback = _make_read_ranges_callback(source) |
| first = (ctypes.c_uint8 * 3)() |
| second = (ctypes.c_uint8 * 4)() |
| requests = (_ffi.PaimonVindexReadRequest * 2)( |
| _ffi.PaimonVindexReadRequest(2, first, 3), |
| _ffi.PaimonVindexReadRequest(11, second, 4), |
| ) |
| |
| assert callback(None, requests, 2) == 0 |
| assert source.calls == [[(2, 3), (11, 4)]] |
| assert bytes(first) == bytes([2, 3, 4]) |
| assert bytes(second) == bytes([11, 12, 13, 14]) |
| |
| |
| def test_python_handle_lock_rejects_worker_callback_reentry(): |
| from paimon_vindex import _NativeHandleLock |
| |
| native_handle_lock = _NativeHandleLock() |
| rejected = [] |
| |
| def callback_worker(): |
| native_handle_lock._enter_callback() |
| try: |
| with native_handle_lock: |
| pass |
| except RuntimeError as error: |
| rejected.append("reentrant native-handle operation" in str(error)) |
| finally: |
| native_handle_lock._exit_callback() |
| |
| with native_handle_lock: |
| worker = threading.Thread(target=callback_worker) |
| worker.start() |
| worker.join(timeout=5) |
| assert not worker.is_alive() |
| |
| assert rejected == [True] |
| |
| |
| def test_python_reader_close_waits_for_an_inflight_native_search(): |
| index_bytes, data = build_index( |
| { |
| "index.type": "ivf_flat", |
| "dimension": "2", |
| "nlist": "2", |
| "metric": "l2", |
| }, |
| 2, |
| n=64, |
| ) |
| |
| class BlockingInput(VectorIndexInput): |
| def __init__(self, payload): |
| super().__init__(payload) |
| self.block_reads = False |
| self.read_entered = threading.Event() |
| self.release_read = threading.Event() |
| |
| def pread_many(self, ranges): |
| if self.block_reads: |
| self.read_entered.set() |
| assert self.release_read.wait(timeout=5) |
| return super().pread_many(ranges) |
| |
| source = BlockingInput(index_bytes) |
| reader = VectorIndexReader(source) |
| source.block_reads = True |
| search_done = threading.Event() |
| close_done = threading.Event() |
| errors = [] |
| |
| def search(): |
| try: |
| reader.search(data[0], SearchParams.ivf(top_k=5, nprobe=2)) |
| except Exception as exc: |
| errors.append(exc) |
| finally: |
| search_done.set() |
| |
| def close(): |
| reader.close() |
| close_done.set() |
| |
| search_thread = threading.Thread(target=search) |
| close_thread = threading.Thread(target=close) |
| search_thread.start() |
| assert source.read_entered.wait(timeout=5) |
| close_thread.start() |
| assert not close_done.wait(timeout=0.1) |
| source.release_read.set() |
| search_thread.join(timeout=5) |
| close_thread.join(timeout=5) |
| |
| assert search_done.is_set() |
| assert close_done.is_set() |
| assert errors == [] |
| |
| |
| def test_python_ffi_roundtrips_supported_indexes(): |
| configs = [ |
| ( |
| { |
| "index.type": "ivf_flat", |
| "dimension": "16", |
| "nlist": "4", |
| "metric": "l2", |
| }, |
| 16, |
| ), |
| ( |
| { |
| "index.type": "ivf_pq", |
| "dimension": "16", |
| "nlist": "4", |
| "metric": "l2", |
| "use-opq": "false", |
| }, |
| 16, |
| ), |
| ( |
| { |
| "index.type": "ivf_rq", |
| "dimension": "16", |
| "nlist": "4", |
| "metric": "l2", |
| }, |
| 16, |
| ), |
| ( |
| { |
| "index.type": "ivf_sq", |
| "dimension": "16", |
| "nlist": "4", |
| "metric": "l2", |
| }, |
| 16, |
| ), |
| ( |
| { |
| "index.type": "diskann", |
| "dimension": "16", |
| "pq.m": "4", |
| "pq.bits": "4", |
| "metric": "l2", |
| "diskann.max-degree": "8", |
| "diskann.build-search-list-size": "16", |
| }, |
| 16, |
| ), |
| ] |
| |
| for options, d in configs: |
| index_bytes, data = build_index(options, d) |
| with reader_from_bytes(index_bytes) as reader: |
| metadata = reader.metadata() |
| assert reader.index_type == options["index.type"] |
| assert metadata.index_type == options["index.type"] |
| assert reader.dimension == d |
| assert metadata.total_vectors == 512 |
| if options["index.type"] == "ivf_pq": |
| assert metadata.pq_m == 4 |
| assert metadata.pq_bits == 8 |
| elif options["index.type"] == "ivf_sq": |
| assert metadata.pq_m is None |
| assert metadata.pq_bits == 8 |
| elif options["index.type"] == "diskann": |
| assert metadata.pq_m == 4 |
| assert metadata.pq_bits == 4 |
| assert metadata.diskann_max_degree == 8 |
| assert metadata.diskann_build_search_list_size == 16 |
| assert metadata.diskann_alpha == pytest.approx(1.2) |
| |
| params = ( |
| SearchParams.diskann(top_k=5, l_search=32) |
| if options["index.type"] == "diskann" |
| else SearchParams.ivf(top_k=5, nprobe=4) |
| ) |
| ids, distances = reader.search(data[0], params) |
| reader.optimize_for_search() |
| if options["index.type"] == "diskann": |
| reader.warmup_queries(np.vstack([data[0], data[1]]), l_search=32) |
| optimized_ids, optimized_distances = reader.search(data[0], params) |
| assert ids.shape == (5,) |
| assert distances.shape == (5,) |
| if options["index.type"] == "diskann": |
| assert ids[0] >= 0 |
| else: |
| assert ids[0] == 0 |
| np.testing.assert_array_equal(optimized_ids, ids) |
| np.testing.assert_allclose(optimized_distances, distances, rtol=0, atol=1e-4) |
| |
| |
| def test_python_ffi_batch_search(): |
| index_bytes, data = build_index( |
| { |
| "index.type": "ivf_flat", |
| "dimension": "2", |
| "nlist": "2", |
| "metric": "l2", |
| }, |
| 2, |
| n=64, |
| ) |
| |
| with reader_from_bytes(index_bytes) as reader: |
| ids, distances = reader.search_batch( |
| np.vstack([data[0], data[1]]), |
| SearchParams.ivf(top_k=2, nprobe=2), |
| ) |
| assert ids.shape == (2, 2) |
| assert distances.shape == (2, 2) |
| assert ids[0, 0] == 0 |
| assert ids[1, 0] == 1 |
| |
| |
| def test_python_diskann_latency_hint_selects_coalesced_read_plan(): |
| index_bytes, data = build_index( |
| { |
| "index.type": "diskann", |
| "dimension": "16", |
| "pq.m": "4", |
| "metric": "l2", |
| "diskann.max-degree": "8", |
| "diskann.build-search-list-size": "16", |
| }, |
| 16, |
| ) |
| source = VectorIndexInput(index_bytes) |
| source.estimated_random_read_latency_nanos = 20_000_000 |
| |
| with VectorIndexReader(source) as reader: |
| plan = reader.read_plan() |
| assert plan.random_read_latency_nanos == 20_000_000 |
| assert plan.window_bytes == 64 * 1024 |
| ids, distances = reader.search(data[0], SearchParams.diskann(top_k=5, l_search=100)) |
| |
| assert ids.shape == (5,) |
| assert distances.shape == (5,) |
| assert ids[0] >= 0 |
| |
| |
| def test_python_diskann_automatic_cache_reuses_reads(): |
| class RecordingInput(VectorIndexInput): |
| def __init__(self, data): |
| super().__init__(data) |
| self.calls = [] |
| |
| def pread_many(self, ranges): |
| self.calls.append(list(ranges)) |
| return super().pread_many(ranges) |
| |
| index_bytes, data = build_index( |
| { |
| "index.type": "diskann", |
| "dimension": "16", |
| "pq.m": "4", |
| "metric": "l2", |
| "diskann.max-degree": "8", |
| "diskann.build-search-list-size": "16", |
| }, |
| 16, |
| ) |
| source = RecordingInput(index_bytes) |
| |
| with VectorIndexReader(source) as reader: |
| reader.optimize_for_search() |
| reader.search(data[0], SearchParams.diskann(top_k=5, l_search=100)) |
| first_query_calls = len(source.calls) |
| source.calls.clear() |
| reader.search(data[0], SearchParams.diskann(top_k=5, l_search=100)) |
| |
| assert len(source.calls) <= first_query_calls |
| |
| |
| def test_python_diskann_calibrates_automatic_search_width(): |
| index_bytes, data = build_index( |
| { |
| "index.type": "diskann", |
| "dimension": "16", |
| "pq.m": "4", |
| "metric": "l2", |
| "diskann.max-degree": "8", |
| "diskann.build-search-list-size": "16", |
| }, |
| 16, |
| ) |
| |
| with reader_from_bytes(index_bytes) as reader: |
| resolved = reader.calibrate_search_width(data[:4], top_k=5) |
| assert resolved in {100, 200, 400} |
| ids, distances = reader.search(data[0], SearchParams.automatic(top_k=5)) |
| assert ids.shape == (5,) |
| assert distances.shape == (5,) |
| |
| |
| @pytest.mark.parametrize( |
| ("metric", "expected_id", "expected_distance"), |
| [ |
| ("inner_product", 100, -10.0), |
| ("cosine", 100, 0.0), |
| ], |
| ) |
| def test_python_diskann_supports_ip_and_cosine(metric, expected_id, expected_distance): |
| data = np.asarray( |
| [ |
| [10.0, 0.0], |
| [1.0, 1.0], |
| [0.0, 8.0], |
| [0.0, 1.0], |
| [-1.0, 0.0], |
| [0.0, -1.0], |
| [-2.0, 1.0], |
| [1.0, -2.0], |
| [-3.0, -1.0], |
| [-1.0, -3.0], |
| [-4.0, 0.5], |
| [0.5, -4.0], |
| [-5.0, -2.0], |
| [-2.0, -5.0], |
| [-6.0, -1.0], |
| [-2.0, 0.0], |
| ], |
| dtype=np.float32, |
| ) |
| options = { |
| "index.type": "diskann", |
| "dimension": "2", |
| "metric": metric, |
| "pq.m": "1", |
| "pq.bits": "4", |
| "diskann.max-degree": "8", |
| "diskann.build-search-list-size": "16", |
| "diskann.raw-vector-encoding": "f32", |
| } |
| output = io.BytesIO() |
| training = VectorIndexTrainer.train(options, data) |
| with VectorIndexWriter(training) as writer: |
| writer.add_vectors(np.arange(100, 116, dtype=np.int64), data) |
| writer.write(output) |
| |
| with reader_from_bytes(output.getvalue()) as reader: |
| assert reader.metadata().metric == metric |
| ids, distances = reader.search( |
| np.asarray([1.0, 0.0], dtype=np.float32), |
| SearchParams.diskann(top_k=1, l_search=16), |
| ) |
| assert ids[0] == expected_id |
| assert distances[0] == pytest.approx(expected_distance) |
| assert ids[0] >= 0 |
| |
| |
| def test_python_diskann_read_plan_resolves_during_open(): |
| index_bytes, data = build_index( |
| { |
| "index.type": "diskann", |
| "dimension": "16", |
| "pq.m": "4", |
| "metric": "l2", |
| "diskann.max-degree": "8", |
| "diskann.build-search-list-size": "16", |
| }, |
| 16, |
| ) |
| |
| with VectorIndexReader(VectorIndexInput(index_bytes)) as reader: |
| plan = reader.read_plan() |
| assert plan.random_read_latency_nanos > 0 |
| assert plan.window_bytes > 0 |
| reader.search(data[0], SearchParams.diskann(top_k=5, l_search=100)) |
| |
| |
| def test_python_reader_rejects_negative_memory_budget(): |
| with pytest.raises(ValueError, match="memory_budget_bytes"): |
| VectorIndexReader(VectorIndexInput(b""), memory_budget_bytes=-1) |
| |
| |
| def test_python_reader_rejects_negative_latency_hint(): |
| source = VectorIndexInput(b"") |
| source.estimated_random_read_latency_nanos = -1 |
| with pytest.raises(ValueError, match="estimated_random_read_latency_nanos"): |
| VectorIndexReader(source) |
| |
| |
| def test_python_size_t_arguments_reject_platform_overflow(): |
| oversized = ctypes.c_size_t(-1).value + 1 |
| with pytest.raises(ValueError, match="top_k"): |
| SearchParams.automatic(oversized) |
| with pytest.raises(ValueError, match="nprobe"): |
| SearchParams.ivf(5, oversized) |
| with pytest.raises(ValueError, match="memory_budget_bytes"): |
| VectorIndexReader(VectorIndexInput(b""), memory_budget_bytes=oversized) |
| |
| source = VectorIndexInput(b"") |
| source.max_ranges_per_read = oversized |
| with pytest.raises(ValueError, match="max_ranges_per_read"): |
| VectorIndexReader(source) |
| |
| source = VectorIndexInput(b"") |
| source.estimated_random_read_latency_nanos = ctypes.c_uint64(-1).value + 1 |
| with pytest.raises(ValueError, match="estimated_random_read_latency_nanos"): |
| VectorIndexReader(source) |
| |
| index_bytes, data = build_index( |
| { |
| "index.type": "diskann", |
| "dimension": "16", |
| "pq.m": "4", |
| "metric": "l2", |
| "diskann.max-degree": "8", |
| "diskann.build-search-list-size": "16", |
| }, |
| 16, |
| ) |
| with reader_from_bytes(index_bytes) as reader: |
| with pytest.raises(ValueError, match="l_search"): |
| reader.warmup_queries(data[:1], l_search=oversized) |
| with pytest.raises(ValueError, match="top_k"): |
| reader.calibrate_search_width(data[:1], top_k=oversized) |
| |
| |
| def test_python_reader_rejects_reentrant_callback_operations(): |
| index_bytes, data = build_index( |
| { |
| "index.type": "ivf_flat", |
| "dimension": "16", |
| "nlist": "4", |
| "metric": "l2", |
| }, |
| 16, |
| ) |
| |
| class ReentrantInput(VectorIndexInput): |
| reader = None |
| attempted = False |
| |
| def pread_many(self, ranges): |
| if self.reader is not None and not self.attempted: |
| self.attempted = True |
| self.reader.close() |
| return super().pread_many(ranges) |
| |
| source = ReentrantInput(index_bytes) |
| reader = VectorIndexReader(source) |
| source.reader = reader |
| try: |
| with pytest.raises(RuntimeError): |
| reader.search(data[0], SearchParams.ivf(top_k=5, nprobe=2)) |
| assert source.attempted |
| assert reader.metadata().index_type == "ivf_flat" |
| finally: |
| reader.close() |
| |
| |
| def test_python_writer_rejects_reentrant_output_callback_operations(): |
| data = np.arange(128 * 16, dtype=np.float32).reshape(128, 16) |
| training = VectorIndexTrainer.train( |
| { |
| "index.type": "ivf_flat", |
| "dimension": "16", |
| "nlist": "4", |
| "metric": "l2", |
| }, |
| data, |
| ) |
| |
| class ReentrantOutput(io.BytesIO): |
| writer = None |
| attempted = False |
| |
| def write(self, payload): |
| if self.writer is not None and not self.attempted: |
| self.attempted = True |
| self.writer.close() |
| return super().write(payload) |
| |
| output = ReentrantOutput() |
| writer = VectorIndexWriter(training) |
| output.writer = writer |
| try: |
| with pytest.raises(RuntimeError): |
| writer.write(output) |
| assert output.attempted |
| assert writer.dimension == 16 |
| finally: |
| writer.close() |
| |
| |
| def test_python_reader_enforces_configured_resident_memory_budget(): |
| index_bytes, _ = build_index( |
| { |
| "index.type": "diskann", |
| "dimension": "16", |
| "pq.m": "4", |
| "metric": "l2", |
| "diskann.max-degree": "8", |
| "diskann.build-search-list-size": "16", |
| }, |
| 16, |
| ) |
| |
| with VectorIndexReader( |
| VectorIndexInput(index_bytes), memory_budget_bytes=1 |
| ) as reader: |
| with pytest.raises(RuntimeError, match="reader budget"): |
| reader.optimize_for_search() |
| |
| |
| def test_python_ffi_ivfrq_build_bits(): |
| index_bytes, data = build_index( |
| { |
| "index.type": "ivf_rq", |
| "dimension": "16", |
| "nlist": "4", |
| "rq.bits": "5", |
| "metric": "l2", |
| }, |
| 16, |
| n=128, |
| ) |
| |
| with reader_from_bytes(index_bytes) as reader: |
| assert reader.metadata().rq_bits == 5 |
| ids, distances = reader.search(data[7], SearchParams.ivf(top_k=5, nprobe=4)) |
| assert ids.shape == (5,) |
| assert distances.shape == (5,) |
| assert ids[0] % 4 == 7 % 4 |
| |
| ids, distances = reader.search_batch( |
| np.vstack([data[4], data[7]]), SearchParams.ivf(top_k=5, nprobe=4) |
| ) |
| assert ids[0, 0] % 4 == 4 % 4 |
| assert ids[1, 0] % 4 == 7 % 4 |
| |
| |
| def test_python_ffi_delegates_validation(): |
| options = { |
| "index.type": "ivf_pq", |
| "dimension": "16", |
| "nlist": "4", |
| "pq.m": "4", |
| "metric": "l2", |
| } |
| with VectorIndexTrainer.create(options) as trainer: |
| with pytest.raises(RuntimeError, match="training data length 17"): |
| trainer.add_training_vectors(np.zeros((1, 17), dtype=np.float32)) |
| |
| data = np.zeros((1, 16), dtype=np.float32) |
| ids = np.array([1, 2], dtype=np.int64) |
| training = VectorIndexTrainer.train(options, data) |
| with VectorIndexWriter(training) as writer: |
| with pytest.raises(RuntimeError, match="ids length 2 does not match vector count 1"): |
| writer.add_vectors(ids, data) |
| |
| index_bytes, data = build_index(options, 16) |
| with reader_from_bytes(index_bytes) as reader: |
| with pytest.raises(RuntimeError, match="query length 15"): |
| reader.search(np.zeros(15, dtype=np.float32), SearchParams.ivf(top_k=5, nprobe=2)) |
| with pytest.raises(ValueError, match="top_k must be"): |
| reader.search(data[0], SearchParams.ivf(top_k=0, nprobe=2)) |
| with pytest.raises(RuntimeError, match="queries length 15"): |
| reader.search_batch( |
| np.zeros((1, 15), dtype=np.float32), SearchParams.ivf(top_k=5, nprobe=2) |
| ) |