mirror of
https://github.com/alibaba/zvec.git
synced 2026-09-29 02:02:56 +08:00
530 lines
18 KiB
Python
530 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.
|
|
|
|
from __future__ import annotations
|
|
|
|
import gc
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
import zvec
|
|
from zvec import (
|
|
CollectionOption,
|
|
CollectionSchema,
|
|
Doc,
|
|
FieldSchema,
|
|
HnswIndexParam,
|
|
HnswQueryParam,
|
|
InvertIndexParam,
|
|
Query,
|
|
VamanaIndexParam,
|
|
VamanaQueryParam,
|
|
VectorSchema,
|
|
)
|
|
from zvec.typing import DataType, MetricType, QuantizeType
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"quantize_type",
|
|
[QuantizeType.FP16, QuantizeType.INT8, QuantizeType.INT4],
|
|
)
|
|
@pytest.mark.parametrize("index_kind", ["hnsw", "vamana"])
|
|
def test_non_training_quantizer_uses_insert_time_vectors(
|
|
tmp_path, quantize_type, index_kind
|
|
):
|
|
"""Final Flat precision must not change insert-time quantized payloads."""
|
|
dimension = 64
|
|
doc_count = 96
|
|
vectors = np.asarray(
|
|
[
|
|
[
|
|
(i * 7 + d * 13) % 197 / 211.0 + ((i * 17 + d * 5) % 29) / 10000.0
|
|
for d in range(dimension)
|
|
]
|
|
for i in range(doc_count)
|
|
],
|
|
dtype=np.float32,
|
|
)
|
|
query_vector = np.asarray(
|
|
[(d * 19 % 181) / 193.0 + 0.00031 * (d % 7) for d in range(dimension)],
|
|
dtype=np.float32,
|
|
)
|
|
|
|
def build_and_search(label, flat_data_type):
|
|
if index_kind == "hnsw":
|
|
index_param = HnswIndexParam(
|
|
metric_type=MetricType.L2,
|
|
m=16,
|
|
ef_construction=64,
|
|
quantize_type=quantize_type,
|
|
use_flat_contiguous_memory=True,
|
|
flat_data_type=flat_data_type,
|
|
)
|
|
query_param = HnswQueryParam(ef=doc_count, is_linear=True)
|
|
else:
|
|
index_param = VamanaIndexParam(
|
|
metric_type=MetricType.L2,
|
|
max_degree=16,
|
|
search_list_size=64,
|
|
quantize_type=quantize_type,
|
|
use_contiguous_memory=True,
|
|
use_flat_contiguous_memory=True,
|
|
flat_data_type=flat_data_type,
|
|
)
|
|
query_param = VamanaQueryParam(
|
|
ef_search=doc_count,
|
|
is_linear=True,
|
|
)
|
|
schema = CollectionSchema(
|
|
name="insert_time_quantizer_source_precision",
|
|
vectors=[
|
|
VectorSchema(
|
|
"dense",
|
|
DataType.VECTOR_FP32,
|
|
dimension=dimension,
|
|
index_param=index_param,
|
|
)
|
|
],
|
|
)
|
|
collection = zvec.create_and_open(
|
|
path=str(tmp_path / label),
|
|
schema=schema,
|
|
option=CollectionOption(read_only=False, enable_mmap=True),
|
|
)
|
|
try:
|
|
docs = [
|
|
Doc(id=str(i), vectors={"dense": vector.tolist()})
|
|
for i, vector in enumerate(vectors)
|
|
]
|
|
assert all(status.ok() for status in collection.insert(docs))
|
|
collection = None
|
|
gc.collect()
|
|
collection = zvec.open(
|
|
path=str(tmp_path / label),
|
|
option=CollectionOption(read_only=False, enable_mmap=True),
|
|
)
|
|
collection.optimize()
|
|
hits = collection.query(
|
|
Query(
|
|
field_name="dense",
|
|
vector=query_vector.tolist(),
|
|
param=query_param,
|
|
),
|
|
topk=doc_count,
|
|
)
|
|
return [hit.id for hit in hits], np.asarray(
|
|
[hit.score for hit in hits], dtype=np.float32
|
|
)
|
|
finally:
|
|
if collection is not None:
|
|
collection.destroy()
|
|
|
|
name = quantize_type.name.lower()
|
|
fp16_ids, fp16_scores = build_and_search(
|
|
f"{index_kind}_{name}_flat_fp16", DataType.VECTOR_FP16
|
|
)
|
|
fp32_ids, fp32_scores = build_and_search(
|
|
f"{index_kind}_{name}_flat_fp32", DataType.VECTOR_FP32
|
|
)
|
|
|
|
assert fp16_ids == fp32_ids
|
|
np.testing.assert_array_equal(fp16_scores, fp32_scores)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("configured_flat_data_type", "effective_flat_data_type"),
|
|
[
|
|
(None, DataType.VECTOR_FP32),
|
|
(DataType.VECTOR_FP16, DataType.VECTOR_FP16),
|
|
(DataType.VECTOR_UINT8, DataType.VECTOR_UINT8),
|
|
],
|
|
ids=["default_fp32", "fp16", "uint8"],
|
|
)
|
|
@pytest.mark.parametrize("index_kind", ["hnsw", "vamana"])
|
|
@pytest.mark.parametrize("use_flat_contiguous_memory", [False, True])
|
|
@pytest.mark.parametrize(
|
|
"quantize_type",
|
|
[
|
|
QuantizeType.INT8,
|
|
QuantizeType.UNIFORM_UINT7,
|
|
QuantizeType.UNIFORM_UINT8,
|
|
QuantizeType.UNIFORM_UINT4,
|
|
],
|
|
ids=["record_int8", "uniform_uint7", "uniform_uint8", "uniform_uint4"],
|
|
)
|
|
def test_refine_flat_native_storage_roundtrip(
|
|
tmp_path,
|
|
configured_flat_data_type,
|
|
effective_flat_data_type,
|
|
index_kind,
|
|
use_flat_contiguous_memory,
|
|
quantize_type,
|
|
):
|
|
dimension = 17
|
|
initial_doc_count = 96
|
|
doc_count = 112
|
|
collection_path = tmp_path / (
|
|
f"refine_{index_kind}_{effective_flat_data_type.name.lower()}_"
|
|
f"contiguous_{use_flat_contiguous_memory}"
|
|
)
|
|
flat_data_type_option = (
|
|
{}
|
|
if configured_flat_data_type is None
|
|
else {"flat_data_type": configured_flat_data_type}
|
|
)
|
|
if index_kind == "hnsw":
|
|
index_param = HnswIndexParam(
|
|
metric_type=MetricType.L2,
|
|
m=16,
|
|
ef_construction=100,
|
|
quantize_type=quantize_type,
|
|
use_flat_contiguous_memory=use_flat_contiguous_memory,
|
|
**flat_data_type_option,
|
|
)
|
|
else:
|
|
index_param = VamanaIndexParam(
|
|
metric_type=MetricType.L2,
|
|
max_degree=16,
|
|
search_list_size=64,
|
|
quantize_type=quantize_type,
|
|
use_flat_contiguous_memory=use_flat_contiguous_memory,
|
|
**flat_data_type_option,
|
|
)
|
|
schema = CollectionSchema(
|
|
name="refine_flat_native_storage",
|
|
fields=[
|
|
FieldSchema(
|
|
"ordinal",
|
|
DataType.INT64,
|
|
nullable=False,
|
|
index_param=InvertIndexParam(),
|
|
)
|
|
],
|
|
vectors=[
|
|
VectorSchema(
|
|
"dense",
|
|
DataType.VECTOR_FP32,
|
|
dimension=dimension,
|
|
index_param=index_param,
|
|
)
|
|
],
|
|
)
|
|
option = CollectionOption(read_only=False, enable_mmap=True)
|
|
docs = []
|
|
for i in range(doc_count):
|
|
vector = np.asarray(
|
|
[
|
|
(i * 7 + d * 3) % 239 + ((i * 11 + d * 5) % 17) / 23.0
|
|
for d in range(dimension)
|
|
],
|
|
dtype=np.float32,
|
|
)
|
|
docs.append(
|
|
Doc(
|
|
id=str(i),
|
|
fields={"ordinal": i},
|
|
vectors={"dense": vector.tolist()},
|
|
)
|
|
)
|
|
|
|
def refined_query(collection, doc_index, *, include_vector=False):
|
|
if index_kind == "hnsw":
|
|
query_param = HnswQueryParam(ef=128, is_using_refiner=True)
|
|
else:
|
|
query_param = VamanaQueryParam(ef_search=128, is_using_refiner=True)
|
|
query = Query(
|
|
field_name="dense",
|
|
vector=docs[doc_index].vector("dense"),
|
|
param=query_param,
|
|
)
|
|
hits = collection.query(query, topk=5, include_vector=include_vector)
|
|
assert hits
|
|
return hits
|
|
|
|
def native_vector(values):
|
|
vector = np.asarray(values, dtype=np.float32)
|
|
if effective_flat_data_type == DataType.VECTOR_FP16:
|
|
return vector.astype(np.float16).astype(np.float32)
|
|
if effective_flat_data_type == DataType.VECTOR_UINT8:
|
|
return vector.astype(np.uint8).astype(np.float32)
|
|
return vector
|
|
|
|
def assert_native_fetch(collection, doc_index):
|
|
fetched = collection.fetch(ids=[str(doc_index)])[str(doc_index)]
|
|
np.testing.assert_array_equal(
|
|
np.asarray(fetched.vector("dense"), dtype=np.float32),
|
|
native_vector(docs[doc_index].vector("dense")),
|
|
)
|
|
|
|
def assert_native_refine_scores(collection, doc_index):
|
|
query = np.asarray(docs[doc_index].vector("dense"), dtype=np.float32)
|
|
hits = refined_query(collection, doc_index, include_vector=True)
|
|
observed_native_difference = False
|
|
for hit in hits:
|
|
stored = np.asarray(docs[int(hit.id)].vector("dense"), dtype=np.float32)
|
|
if effective_flat_data_type == DataType.VECTOR_FP16:
|
|
query_native = query.astype(np.float16).astype(np.float32)
|
|
stored_native = stored.astype(np.float16).astype(np.float32)
|
|
elif effective_flat_data_type == DataType.VECTOR_UINT8:
|
|
query_native = query.astype(np.uint8).astype(np.int32)
|
|
stored_native = stored.astype(np.uint8).astype(np.int32)
|
|
else:
|
|
query_native = query
|
|
stored_native = stored
|
|
expected = float(np.sum((stored_native - query_native) ** 2))
|
|
fp32_expected = float(np.sum((stored - query) ** 2))
|
|
observed_native_difference |= not np.isclose(
|
|
expected, fp32_expected, rtol=1e-7, atol=1e-5
|
|
)
|
|
assert hit.score == pytest.approx(expected, rel=1e-5, abs=1e-5)
|
|
np.testing.assert_array_equal(
|
|
np.asarray(hit.vector("dense"), dtype=np.float32),
|
|
native_vector(stored),
|
|
)
|
|
assert observed_native_difference is (
|
|
effective_flat_data_type != DataType.VECTOR_FP32
|
|
)
|
|
assert_native_fetch(collection, doc_index)
|
|
return hits
|
|
|
|
collection = zvec.create_and_open(
|
|
path=str(collection_path), schema=schema, option=option
|
|
)
|
|
reopened = None
|
|
try:
|
|
for status in collection.insert(docs[:initial_doc_count]):
|
|
assert status.ok()
|
|
assert refined_query(collection, 23)[0].id == "23"
|
|
assert_native_fetch(collection, 23)
|
|
|
|
collection.optimize()
|
|
assert_native_refine_scores(collection, 23)
|
|
restored_param = collection.schema.vectors[0].index_param
|
|
assert restored_param.use_flat_contiguous_memory is use_flat_contiguous_memory
|
|
assert restored_param.flat_data_type == effective_flat_data_type
|
|
|
|
for status in collection.insert(docs[initial_doc_count:]):
|
|
assert status.ok()
|
|
assert refined_query(collection, 103)[0].id == "103"
|
|
collection.optimize()
|
|
assert_native_refine_scores(collection, 103)
|
|
|
|
collection = None
|
|
gc.collect()
|
|
reopened = zvec.open(path=str(collection_path), option=option)
|
|
restored_param = reopened.schema.vectors[0].index_param
|
|
assert restored_param.use_flat_contiguous_memory is use_flat_contiguous_memory
|
|
assert restored_param.flat_data_type == effective_flat_data_type
|
|
assert_native_refine_scores(reopened, 23)
|
|
assert_native_refine_scores(reopened, 103)
|
|
finally:
|
|
if reopened is not None:
|
|
reopened.destroy()
|
|
elif collection is not None:
|
|
collection.destroy()
|
|
|
|
|
|
@pytest.mark.parametrize("index_kind", ["hnsw", "vamana"])
|
|
def test_pooled_graph_context_refreshes_metric(tmp_path, index_kind):
|
|
"""A pooled graph context must not retain a prior index's query metric."""
|
|
dimension = 16
|
|
doc_count = 48
|
|
query_id = 11
|
|
docs = [
|
|
Doc(
|
|
id=str(i),
|
|
vectors={
|
|
"dense": np.asarray(
|
|
[(i * 17 + d * 7) % 101 for d in range(dimension)],
|
|
dtype=np.float32,
|
|
).tolist()
|
|
},
|
|
)
|
|
for i in range(doc_count)
|
|
]
|
|
option = CollectionOption(read_only=False, enable_mmap=True)
|
|
|
|
def create_collection(name, quantize_type):
|
|
if index_kind == "hnsw":
|
|
index_param = HnswIndexParam(
|
|
metric_type=MetricType.L2,
|
|
m=12,
|
|
ef_construction=64,
|
|
quantize_type=quantize_type,
|
|
)
|
|
else:
|
|
index_param = VamanaIndexParam(
|
|
metric_type=MetricType.L2,
|
|
max_degree=12,
|
|
search_list_size=64,
|
|
quantize_type=quantize_type,
|
|
)
|
|
schema = CollectionSchema(
|
|
name=name,
|
|
vectors=[
|
|
VectorSchema(
|
|
"dense",
|
|
DataType.VECTOR_FP32,
|
|
dimension=dimension,
|
|
index_param=index_param,
|
|
)
|
|
],
|
|
)
|
|
collection = zvec.create_and_open(
|
|
path=str(tmp_path / name), schema=schema, option=option
|
|
)
|
|
for status in collection.insert(docs):
|
|
assert status.ok()
|
|
collection.optimize()
|
|
return collection
|
|
|
|
def linear_query(collection):
|
|
if index_kind == "hnsw":
|
|
query_param = HnswQueryParam(ef=128, is_linear=True)
|
|
else:
|
|
query_param = VamanaQueryParam(ef_search=128, is_linear=True)
|
|
hits = collection.query(
|
|
Query(
|
|
field_name="dense",
|
|
vector=docs[query_id].vector("dense"),
|
|
param=query_param,
|
|
),
|
|
topk=5,
|
|
)
|
|
assert hits
|
|
return [hit.id for hit in hits]
|
|
|
|
quantized = create_collection(f"{index_kind}_pooled_quantized", QuantizeType.INT8)
|
|
raw = None
|
|
try:
|
|
assert linear_query(quantized)[0] == str(query_id)
|
|
|
|
# HNSW/Vamana contexts are reused per thread. Searching a raw FP32
|
|
# index after an INT8 index must replace the old query preprocess.
|
|
raw = create_collection(f"{index_kind}_pooled_raw", QuantizeType.UNDEFINED)
|
|
assert linear_query(raw)[0] == str(query_id)
|
|
finally:
|
|
if raw is not None:
|
|
raw.destroy()
|
|
quantized.destroy()
|
|
|
|
|
|
@pytest.mark.parametrize("index_kind", ["hnsw", "vamana"])
|
|
def test_fp16_cosine_refine_uses_native_flat_pipeline(tmp_path, index_kind):
|
|
dimension = 17
|
|
doc_count = 80
|
|
collection_path = tmp_path / f"fp16_cosine_refine_{index_kind}"
|
|
if index_kind == "hnsw":
|
|
index_param = HnswIndexParam(
|
|
metric_type=MetricType.COSINE,
|
|
m=16,
|
|
ef_construction=100,
|
|
quantize_type=QuantizeType.INT8,
|
|
flat_data_type=DataType.VECTOR_FP16,
|
|
use_flat_contiguous_memory=True,
|
|
)
|
|
query_param = HnswQueryParam(ef=128, is_using_refiner=True)
|
|
else:
|
|
index_param = VamanaIndexParam(
|
|
metric_type=MetricType.COSINE,
|
|
max_degree=16,
|
|
search_list_size=64,
|
|
quantize_type=QuantizeType.INT8,
|
|
flat_data_type=DataType.VECTOR_FP16,
|
|
use_flat_contiguous_memory=True,
|
|
)
|
|
query_param = VamanaQueryParam(ef_search=128, is_using_refiner=True)
|
|
|
|
schema = CollectionSchema(
|
|
name="fp16_cosine_refine",
|
|
vectors=[
|
|
VectorSchema(
|
|
"dense",
|
|
DataType.VECTOR_FP32,
|
|
dimension=dimension,
|
|
index_param=index_param,
|
|
)
|
|
],
|
|
)
|
|
docs = []
|
|
for i in range(doc_count):
|
|
vector = np.asarray(
|
|
[
|
|
# Separate directions so INT8 coarse search reliably retains
|
|
# the self match. Keep non-FP16-exact values to test casting.
|
|
(((i * 37 + d * 19 + i * d * 7) % 97) - 48) * 0.013
|
|
for d in range(dimension)
|
|
],
|
|
dtype=np.float32,
|
|
)
|
|
docs.append(Doc(id=str(i), vectors={"dense": vector.tolist()}))
|
|
|
|
collection = zvec.create_and_open(
|
|
path=str(collection_path),
|
|
schema=schema,
|
|
option=CollectionOption(read_only=False, enable_mmap=True),
|
|
)
|
|
try:
|
|
for status in collection.insert(docs):
|
|
assert status.ok()
|
|
collection.optimize()
|
|
|
|
query_id = 37
|
|
query = Query(
|
|
field_name="dense",
|
|
vector=docs[query_id].vector("dense"),
|
|
param=query_param,
|
|
)
|
|
hits = collection.query(query, topk=10, include_vector=True)
|
|
assert hits
|
|
assert hits[0].id == str(query_id)
|
|
|
|
query_native = np.asarray(docs[query_id].vector("dense"), dtype=np.float16)
|
|
query_norm = np.linalg.norm(query_native.astype(np.float32))
|
|
query_normalized = (query_native.astype(np.float32) / query_norm).astype(
|
|
np.float16
|
|
)
|
|
for hit in hits:
|
|
stored_native = np.asarray(
|
|
docs[int(hit.id)].vector("dense"), dtype=np.float16
|
|
)
|
|
stored_norm = np.linalg.norm(stored_native.astype(np.float32))
|
|
stored_normalized = (stored_native.astype(np.float32) / stored_norm).astype(
|
|
np.float16
|
|
)
|
|
expected = 1.0 - float(
|
|
np.dot(
|
|
query_normalized.astype(np.float32),
|
|
stored_normalized.astype(np.float32),
|
|
)
|
|
)
|
|
assert hit.score == pytest.approx(expected, abs=3e-4)
|
|
np.testing.assert_allclose(
|
|
np.asarray(hit.vector("dense"), dtype=np.float32),
|
|
stored_native.astype(np.float32),
|
|
rtol=0,
|
|
atol=3e-4,
|
|
)
|
|
|
|
fetched = collection.fetch(ids=[str(query_id)])[str(query_id)]
|
|
np.testing.assert_allclose(
|
|
np.asarray(fetched.vector("dense"), dtype=np.float32),
|
|
query_native.astype(np.float32),
|
|
rtol=0,
|
|
atol=3e-4,
|
|
)
|
|
finally:
|
|
collection.destroy()
|