blob: 8c638f473710e48ac2ed7424b64d47fa285a75db [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 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)
)