Files
alibaba--zvec/python/tests/test_vamana.py
T
2026-07-13 12:47:42 +08:00

502 lines
18 KiB
Python

# Copyright 2025-present the zvec project
#
# Licensed 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.
"""
Tests for the Python entry point of the Vamana (DiskANN) dense vector index.
Mirrors the structure of ``test_hnsw_contiguous_memory.py`` (the closest
hnsw dense reference), and is split into two parts:
1. **Surface tests** — verify that ``VamanaIndexParam`` / ``VamanaQueryParam``
are correctly bound: construction defaults, readonly properties,
``to_dict``, ``__repr__``, pickle round-trip, and that they appear in the
public ``zvec`` namespace with the expected ``IndexType.VAMANA`` value.
2. **End-to-end tests** — build a collection that uses Vamana on a dense
FP32 column, insert deterministic documents, then run a top-k query
through ``VamanaQueryParam`` on both the writer segment and the
persisted (post-``optimize()``) segment.
"""
from __future__ import annotations
import pickle
import sys
import numpy as np
import pytest
import zvec
from zvec import (
Collection,
CollectionOption,
CollectionSchema,
Doc,
FieldSchema,
InvertIndexParam,
VamanaIndexParam,
VamanaQueryParam,
Query,
VectorSchema,
)
from zvec.typing import DataType, IndexType, MetricType, QuantizeType
DIMENSION = 32
NUM_DOCS = 128
TOPK = 5
# Defaults pulled from src/include/zvec/core/interface/constants.h. Keep
# in sync with kDefaultVamana* if the engine defaults ever change.
DEFAULT_MAX_DEGREE = 64
DEFAULT_SEARCH_LIST_SIZE = 100
DEFAULT_ALPHA = 1.2
DEFAULT_EF_SEARCH = 200
DEFAULT_SATURATE_GRAPH = False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_schema(
name: str,
*,
metric_type: MetricType = MetricType.IP,
max_degree: int = 32,
search_list_size: int = 64,
alpha: float = 1.2,
use_contiguous_memory: bool = False,
) -> CollectionSchema:
"""Create a simple schema with a single FP32 Vamana vector column."""
return CollectionSchema(
name=name,
fields=[
FieldSchema(
"id",
DataType.INT64,
nullable=False,
index_param=InvertIndexParam(enable_range_optimization=True),
),
],
vectors=[
VectorSchema(
"dense",
DataType.VECTOR_FP32,
dimension=DIMENSION,
index_param=VamanaIndexParam(
metric_type=metric_type,
max_degree=max_degree,
search_list_size=search_list_size,
alpha=alpha,
use_contiguous_memory=use_contiguous_memory,
),
),
],
)
def _generate_docs(rng: np.random.Generator, num: int = NUM_DOCS) -> list[Doc]:
"""Produce deterministic documents for insertion."""
docs: list[Doc] = []
for i in range(num):
vec = rng.standard_normal(DIMENSION).astype(np.float32)
docs.append(
Doc(
id=str(i),
fields={"id": i},
vectors={"dense": vec.tolist()},
)
)
return docs
def _query_topk(
coll: Collection, query_vec: list[float], *, ef_search: int = 64
) -> list[str]:
"""Run a top-k vector query and return the returned ids in order."""
vector_query = Query(
field_name="dense",
vector=query_vec,
param=VamanaQueryParam(ef_search=ef_search),
)
hits = coll.query(vector_query, topk=TOPK)
assert hits is not None, "query returned None"
assert len(hits) >= 1, f"expected at least one hit, got {hits!r}"
return [doc.id for doc in hits]
# ---------------------------------------------------------------------------
# 1) Surface: construction / property / to_dict / repr / pickle / namespace
# ---------------------------------------------------------------------------
class TestVamanaIndexParamSurface:
"""Verify the Python binding for ``VamanaIndexParam``."""
def test_defaults(self):
param = VamanaIndexParam()
assert param.type == IndexType.VAMANA
assert param.metric_type == MetricType.IP
assert param.max_degree == DEFAULT_MAX_DEGREE
assert param.search_list_size == DEFAULT_SEARCH_LIST_SIZE
assert param.alpha == pytest.approx(DEFAULT_ALPHA)
assert param.saturate_graph is DEFAULT_SATURATE_GRAPH
assert param.use_contiguous_memory is False
assert param.use_id_map is False
assert param.quantize_type == QuantizeType.UNDEFINED
def test_custom_construction(self):
param = VamanaIndexParam(
metric_type=MetricType.COSINE,
max_degree=48,
search_list_size=128,
alpha=1.5,
saturate_graph=True,
use_contiguous_memory=True,
use_id_map=False,
quantize_type=QuantizeType.INT8,
)
assert param.type == IndexType.VAMANA
assert param.metric_type == MetricType.COSINE
assert param.max_degree == 48
assert param.search_list_size == 128
assert param.alpha == pytest.approx(1.5)
assert param.saturate_graph is True
assert param.use_contiguous_memory is True
assert param.use_id_map is False
assert param.quantize_type == QuantizeType.INT8
def test_to_dict_includes_all_fields(self):
param = VamanaIndexParam(
metric_type=MetricType.L2,
max_degree=32,
search_list_size=80,
alpha=1.3,
saturate_graph=True,
use_contiguous_memory=True,
use_id_map=False,
quantize_type=QuantizeType.FP16,
)
data = param.to_dict()
assert data["type"] == "VAMANA"
assert data["metric_type"] == "L2"
assert data["max_degree"] == 32
assert data["search_list_size"] == 80
assert data["alpha"] == pytest.approx(1.3)
assert data["saturate_graph"] is True
assert data["use_contiguous_memory"] is True
assert data["use_id_map"] is False
assert data["quantize_type"] == "FP16"
def test_repr_contains_key_fields(self):
text = repr(
VamanaIndexParam(
metric_type=MetricType.COSINE,
max_degree=24,
search_list_size=72,
alpha=1.4,
saturate_graph=True,
use_contiguous_memory=True,
)
)
# Spot-check the most diagnostic fields are rendered.
assert "VAMANA" in text
assert "COSINE" in text
assert "max_degree" in text and "24" in text
assert "search_list_size" in text and "72" in text
assert "alpha" in text
assert "saturate_graph" in text and "true" in text
assert "use_contiguous_memory" in text and "true" in text
@pytest.mark.parametrize(
"field, kwargs",
[
("max_degree", dict(max_degree=99)),
("search_list_size", dict(search_list_size=99)),
("alpha", dict(alpha=1.7)),
("saturate_graph", dict(saturate_graph=True)),
("use_contiguous_memory", dict(use_contiguous_memory=True)),
("use_id_map", dict(use_id_map=True)),
],
)
def test_readonly_properties(self, field, kwargs):
param = VamanaIndexParam(**kwargs)
if sys.version_info >= (3, 11):
match_pattern = r"(can't set attribute|has no setter|readonly attribute)"
else:
match_pattern = r"can't set attribute"
with pytest.raises(AttributeError, match=match_pattern):
setattr(param, field, getattr(param, field))
def test_pickle_roundtrip(self):
original = VamanaIndexParam(
metric_type=MetricType.COSINE,
max_degree=48,
search_list_size=120,
alpha=1.4,
saturate_graph=True,
use_contiguous_memory=True,
use_id_map=False,
quantize_type=QuantizeType.INT8,
)
restored = pickle.loads(pickle.dumps(original))
assert restored.type == IndexType.VAMANA
assert restored.metric_type == MetricType.COSINE
assert restored.max_degree == 48
assert restored.search_list_size == 120
assert restored.alpha == pytest.approx(1.4)
assert restored.saturate_graph is True
assert restored.use_contiguous_memory is True
assert restored.use_id_map is False
assert restored.quantize_type == QuantizeType.INT8
# to_dict equality is the strongest end-to-end equivalence we have.
assert restored.to_dict() == original.to_dict()
class TestVamanaQueryParamSurface:
"""Verify the Python binding for ``VamanaQueryParam``."""
def test_defaults(self):
q = VamanaQueryParam()
assert q.type == IndexType.VAMANA
assert q.ef_search == DEFAULT_EF_SEARCH
assert q.radius == pytest.approx(0.0)
assert q.is_linear is False
assert q.is_using_refiner is False
assert q.prefetch_offset == 8
assert q.prefetch_lines == 0
def test_custom_construction(self):
q = VamanaQueryParam(
ef_search=300,
radius=0.5,
is_linear=True,
is_using_refiner=True,
extra_params={
"prefetch_offset": 8,
"prefetch_lines": 2,
},
)
assert q.type == IndexType.VAMANA
assert q.ef_search == 300
assert q.radius == pytest.approx(0.5)
assert q.is_linear is True
assert q.is_using_refiner is True
assert q.prefetch_offset == 8
assert q.prefetch_lines == 2
def test_repr_contains_key_fields(self):
text = repr(VamanaQueryParam(ef_search=128, radius=0.25))
assert "VAMANA" in text
assert "ef_search" in text and "128" in text
assert "radius" in text
def test_readonly_ef_search(self):
q = VamanaQueryParam(ef_search=100)
if sys.version_info >= (3, 11):
match_pattern = r"(can't set attribute|has no setter|readonly attribute)"
else:
match_pattern = r"can't set attribute"
with pytest.raises(AttributeError, match=match_pattern):
q.ef_search = 200 # type: ignore[misc]
def test_pickle_roundtrip(self):
original = VamanaQueryParam(
ef_search=256,
radius=0.3,
is_linear=False,
is_using_refiner=True,
extra_params={
"prefetch_offset": 4,
"prefetch_lines": 3,
},
)
restored = pickle.loads(pickle.dumps(original))
assert restored.type == IndexType.VAMANA
assert restored.ef_search == 256
assert restored.radius == pytest.approx(0.3)
assert restored.is_linear is False
assert restored.is_using_refiner is True
assert restored.prefetch_offset == 4
assert restored.prefetch_lines == 3
class TestVamanaPublicNamespace:
"""The Vamana entry points must be importable from the top-level ``zvec``."""
def test_top_level_exports(self):
assert zvec.VamanaIndexParam is VamanaIndexParam
assert zvec.VamanaQueryParam is VamanaQueryParam
assert "VamanaIndexParam" in zvec.__all__
assert "VamanaQueryParam" in zvec.__all__
def test_index_type_enum_member(self):
# Sanity: the IndexType enum exposes VAMANA and it is what the
# bound params advertise.
assert IndexType.VAMANA is not None
assert VamanaIndexParam().type == IndexType.VAMANA
assert VamanaQueryParam().type == IndexType.VAMANA
# ---------------------------------------------------------------------------
# 2) End-to-end: create collection, insert, query through the writer segment
# ---------------------------------------------------------------------------
@pytest.fixture
def rng() -> np.random.Generator:
return np.random.default_rng(seed=42)
# Mirror the hnsw dense test fixture: only the mmap-backed variant is
# currently usable for vector index construction. BufferPool (enable_mmap=
# False) is intentionally omitted because the same write-path guard in
# ``SegmentImpl::merge_vector_indexer`` rejects that combination.
@pytest.fixture(params=[True], ids=["mmap_on"])
def collection_option(request) -> CollectionOption:
return CollectionOption(read_only=False, enable_mmap=request.param)
class TestVamanaEndToEnd:
"""End-to-end: schema -> create_and_open -> insert -> query works."""
def test_schema_round_trip(self, tmp_path_factory, collection_option):
"""The Vamana index params survive the schema persist path."""
schema = _build_schema(
"vamana_schema_rt",
metric_type=MetricType.COSINE,
max_degree=32,
search_list_size=80,
alpha=1.3,
use_contiguous_memory=True,
)
path = tmp_path_factory.mktemp("zvec") / "vamana_schema_rt"
coll = zvec.create_and_open(
path=str(path), schema=schema, option=collection_option
)
try:
vec_schema = coll.schema.vectors[0]
ip = vec_schema.index_param
assert ip.type == IndexType.VAMANA
assert ip.metric_type == MetricType.COSINE
assert ip.max_degree == 32
assert ip.search_list_size == 80
assert ip.alpha == pytest.approx(1.3)
assert ip.use_contiguous_memory is True
finally:
coll.destroy()
def test_insert_and_query_self_recall(
self, tmp_path_factory, collection_option, rng
):
"""Top-1 of a query equal to an inserted vector must be that vector.
Exercises the writer-segment Vamana streamer end-to-end through the
Python entry point: ``VamanaIndexParam`` for build and
``VamanaQueryParam`` for search.
"""
schema = _build_schema("vamana_e2e_recall")
path = tmp_path_factory.mktemp("zvec") / "vamana_e2e_recall"
coll = zvec.create_and_open(
path=str(path), schema=schema, option=collection_option
)
try:
docs = _generate_docs(rng)
for r in coll.insert(docs=docs):
assert r.ok(), f"insert failed: code={r.code()}"
assert coll.stats.doc_count == NUM_DOCS
# Self-recall: query with the i-th inserted vector, expect id i
# to be the top result.
for probe in (0, 7, 42, NUM_DOCS - 1):
query_vec = docs[probe].vector("dense")
ids = _query_topk(coll, query_vec)
assert ids[0] == str(probe), (
f"expected self-recall at probe={probe}, got top-1 id={ids[0]} "
f"(top-{TOPK}={ids})"
)
finally:
coll.destroy()
def test_query_param_ef_search_affects_only_quality(
self, tmp_path_factory, collection_option, rng
):
"""``ef_search`` is a search-time knob and must not crash for any
sensible value. Larger ``ef_search`` should be at least as good as
smaller for self-recall."""
schema = _build_schema("vamana_e2e_ef")
path = tmp_path_factory.mktemp("zvec") / "vamana_e2e_ef"
coll = zvec.create_and_open(
path=str(path), schema=schema, option=collection_option
)
try:
docs = _generate_docs(rng)
for r in coll.insert(docs=docs):
assert r.ok()
query_vec = docs[3].vector("dense")
ids_small = _query_topk(coll, query_vec, ef_search=16)
ids_large = _query_topk(coll, query_vec, ef_search=256)
# Both should self-recall the probe vector at top-1.
assert ids_small[0] == "3"
assert ids_large[0] == "3"
assert len(ids_small) == TOPK
assert len(ids_large) == TOPK
finally:
coll.destroy()
def test_optimize_then_query(self, tmp_path_factory, collection_option, rng):
"""The persisted Vamana segment built by ``optimize()`` must serve
queries correctly.
Until the cmake fix to force-load ``core_knn_vamana_static`` into the
``_zvec`` pybind module, this path failed at ``VamanaStreamer``
creation because the global factory registration in
``vamana_streamer.cc`` was never linked in. This test pins down the
regression.
"""
schema = _build_schema("vamana_e2e_optimize")
path = tmp_path_factory.mktemp("zvec") / "vamana_e2e_optimize"
coll = zvec.create_and_open(
path=str(path), schema=schema, option=collection_option
)
try:
docs = _generate_docs(rng)
for r in coll.insert(docs=docs):
assert r.ok()
assert coll.stats.doc_count == NUM_DOCS
# Snapshot the writer-segment top-k for a probe vector.
query_vec = docs[5].vector("dense")
ids_pre = _query_topk(coll, query_vec)
assert ids_pre[0] == "5"
# Trigger persisted segment build. Pre-fix this raised
# RuntimeError("Failed to create index").
coll.optimize()
# Persisted segment must still serve queries with the same
# top-1 self-recall guarantee. We do not assert full top-k
# equality with the writer segment because the persisted
# streamer may visit nodes in a different order; top-1 self-
# recall is the strong invariant.
ids_post = _query_topk(coll, query_vec)
assert ids_post[0] == "5", (
f"post-optimize top-1 should still be probe id, got {ids_post}"
)
assert len(ids_post) == TOPK
finally:
coll.destroy()