Files
qdrant/tests/openapi/test_turbo4_storage.py
Andrey Vasnetsov 8a7325ad5d Remove deprecated search endpoints from OpenAPI, deprecate them in gRPC (#9982)
* Remove deprecated search/recommend/discover endpoints from OpenAPI

Remove deprecated REST API endpoint definitions from the OpenAPI
generator. These endpoints were deprecated in v1.13.3 (`f4ced2567`,
#5907, 2025-01-30) in favor of the universal `/points/query` endpoint:

- POST /points/search
- POST /points/search/batch
- POST /points/search/groups
- POST /points/recommend
- POST /points/recommend/batch
- POST /points/recommend/groups
- POST /points/discover
- POST /points/discover/batch

Also removes the corresponding request types from the schema generator
and updates the expected API count in the consistency check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Migrate OpenAPI integration tests to /points/query

The deprecated /points/search, /points/recommend and /points/discover
endpoints (along with their /batch and /groups variants) were removed
from the OpenAPI spec, which caused validation failures in the Python
integration test harness.

This commit migrates the affected tests to the universal /points/query
endpoint:

- Delete tests dedicated to the deprecated endpoints:
  test_recommend.py, test_discover.py, test_multicollection_reco.py,
  test_recommendation_multivector.py
- Refactor remaining tests to call /points/query (and /query/batch,
  /query/groups), translating request bodies (vector -> query / using,
  positive/negative -> query.recommend, target/context -> query.discover)
  and unwrapping the new result.points response shape.
- Drop equivalence assertions against the now-removed legacy endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Relax non-empty assertions in migrated recommend/discover tests

The previous migration added `len(...) > 0` assertions to tests that
previously only checked equivalence between the deprecated and new
API. These assertions are too strict because the parametrized
`query_filter` cases legitimately produce empty result sets.

Drop the `> 0` assertion and rely on `request_with_validation` to
verify the response is well-formed and HTTP OK.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Migrate remaining OpenAPI tests off deprecated search endpoints

Tests added to dev after the original migration was written still call
/points/search and /points/recommend/groups through
`request_with_validation`, which resolves the endpoint against the
OpenAPI spec and therefore breaks once the endpoint is not in the spec:

- test_turbo4_storage.py, test_sparse_idf_corpus.py, test_validation.py:
  translate /points/search to /points/query (vector{name,vector} ->
  query + using, result -> result.points).
- test_group.py: drop the /points/recommend/groups half of the
  lookup_from validation test in favour of the query equivalent.

test_sparse_idf_corpus.py's test_query_api_supports_idf_corpus goes
away: with the helper on /points/query every test in the file now
exercises what it asserted.

Also record why test_recommend_group cannot assert on its groups: it
uses every point in the collection as a recommend example, so all of
them are excluded and the result is legitimately empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Regenerate openapi.json without the deprecated search endpoints

Drops the 8 deprecated paths and the request schemas that only they
referenced: Search/Recommend/Discover request (+Batch, +Groups) types
and their exclusive dependencies (NamedVector, NamedSparseVector,
NamedVectorStruct, UsingVector, RecommendExample, ContextExamplePair).

Regenerated output is a strict subset of the previous spec, and every
remaining $ref still resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Deprecate the search/recommend/discover RPCs in gRPC

The REST counterparts have carried `deprecated: true` since v1.13.3 and
are now gone from the OpenAPI spec, while the gRPC RPCs never got any
deprecation annotation at all. Mark all 8 with `option deprecated = true`
so generated clients warn, and point each doc comment at its `Query`
replacement.

tonic puts `#[deprecated]` on the generated client methods only; the
server trait gets the doc comment alone, so our own `impl` is unaffected.
The RPCs keep serving traffic — this is annotation only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Restore the deleted recommend/discover suites on /points/query

The earlier migration deleted these four files outright, but the
query-side tests it left behind are all shallow smoke tests
(`len(result) > 0`, `"points" in result[0]`). The deleted ones carried
invariants with no query-API equivalent anywhere, so deleting them was a
real loss of coverage rather than de-duplication:

- test_recommend.py: default strategy equals average_vector; batch
  results identical to sequential singles across six request shapes;
  best_score with only negatives yields all-negative scores; best_score
  with a single positive orders identically to a nearest query; raw
  vectors as examples equal ids as examples.
- test_discover.py: context-only scores are all <= 0; target-only orders
  identically to a nearest query but scores differently; with a fixed
  context the integer part of the score is stable while the decimal part
  moves, and vice versa with a fixed target; batch equals singles;
  lookup_from by id equals by vector.
- test_multicollection_reco.py: cross-collection lookup_from, plus
  wrong-vector-size, unknown-collection and unknown-vector rejections.
- test_recommendation_multivector.py: the same recommend invariants over
  a max_sim multivector collection, which the query suite never covered.

Only test_recommend_missing_lookup_from_collection_with_raw_vector is
dropped as genuinely redundant — test_query.py's
test_query_missing_lookup_from_collection covers query, query/batch and
prefetch.

Two request-shape differences the translation had to absorb:

- Giving no examples at all is 422 (a RecommendInput validation rule),
  where the legacy API reported 400 from the query itself. A malformed
  example, such as an empty vector, is still 400.
- DiscoverInput requires the `context` key and accepts only an explicit
  null to mean "no context", so target-only discover must spell it out.
  The legacy API let it be omitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 18:09:43 +02:00

1113 lines
38 KiB
Python

import hashlib
import math
import random
import time
import pytest
from .helpers.collection_setup import drop_collection
from .helpers.helpers import request_with_validation
VECTOR_SIZE = 64
NUM_POINTS = 50
def _random_vector(rng, size=VECTOR_SIZE):
return [rng.uniform(-1.0, 1.0) for _ in range(size)]
def _make_points(rng, num_points, vector_names):
points = []
for point_id in range(1, num_points + 1):
points.append({
"id": point_id,
"vector": {name: _random_vector(rng) for name in vector_names},
})
return points
def turbo4_collection_setup(collection_name, on_disk_vectors, on_disk_payload):
drop_collection(collection_name=collection_name)
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': collection_name},
body={
"vectors": {
name: {
"size": VECTOR_SIZE,
"distance": distance,
"on_disk": on_disk_vectors,
"datatype": "turbo4",
}
for name, distance in
(("image", "Cosine"), ("audio", "Dot"), ("text", "Euclid"))
},
"on_disk_payload": on_disk_payload,
}
)
assert response.ok
rng = random.Random(42)
points = _make_points(rng, NUM_POINTS, ["image", "audio", "text"])
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
body={"points": points}
)
assert response.ok
@pytest.fixture(autouse=True, scope="module")
def setup(on_disk_vectors, on_disk_payload, collection_name):
turbo4_collection_setup(
collection_name=collection_name,
on_disk_vectors=on_disk_vectors,
on_disk_payload=on_disk_payload,
)
yield
drop_collection(collection_name=collection_name)
def test_turbo4_config_persists(collection_name):
response = request_with_validation(
api='/collections/{collection_name}',
method="GET",
path_params={'collection_name': collection_name},
)
assert response.ok
vectors = response.json()['result']['config']['params']['vectors']
for name in ("image", "audio", "text"):
assert vectors[name]['datatype'] == "turbo4"
# (vector_name, descending) — Euclid orders ascending (lower = closer).
@pytest.mark.parametrize("vector_name,descending", [
("image", True),
("audio", True),
("text", False),
])
def test_turbo4_search(collection_name, vector_name, descending):
rng = random.Random(123)
query_vector = _random_vector(rng)
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': collection_name},
body={
"query": query_vector,
"using": vector_name,
"limit": 10,
}
)
assert response.ok
result = response.json()['result']['points']
assert len(result) == 10
scores = [hit['score'] for hit in result]
assert scores == sorted(scores, reverse=descending)
###############################################################################
# Quantization on top of Turbo4 storage.
#
# A Turbo4 source is dequantized to `f32` before re-quantization (see
# `quantized_scorer_builder.rs`), so any quantization config may be layered
# on top of the turbo4 datatype.
###############################################################################
QUANT_NUM_POINTS = 100
QUANT_CONFIGS = {
# (distance, quantization_config)
# No scalar here: int8 over a 4-bit source doubles memory for nothing,
# so it is not a practical combination (unit tests still cover it).
"binary": ("Dot", {"binary": {"always_ram": True}}),
"turbo": ("Cosine", {"turbo": {"bits": "bits2", "always_ram": True}}),
}
def _wait_collection_green(collection_name, timeout=30):
start = time.time()
while time.time() - start < timeout:
response = request_with_validation(
api='/collections/{collection_name}',
method="GET",
path_params={'collection_name': collection_name},
)
assert response.ok
if response.json()['result']['status'] == 'green':
return
time.sleep(0.2)
raise Exception(f"Collection {collection_name} did not turn green in {timeout}s")
@pytest.fixture(scope="module")
def quant_collection_name(on_disk_vectors, collection_name):
name = f"{collection_name}_quantized"
drop_collection(collection_name=name)
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': name},
body={
"vectors": {
vector_name: {
"size": VECTOR_SIZE,
"distance": distance,
"on_disk": on_disk_vectors,
"datatype": "turbo4",
"quantization_config": quantization,
}
for vector_name, (distance, quantization) in QUANT_CONFIGS.items()
},
# Force the optimizer to build index + quantized data so searches
# actually exercise the quantized-over-turbo4 path.
"optimizers_config": {
"default_segment_number": 1,
"indexing_threshold": 1,
},
}
)
assert response.ok
rng = random.Random(4)
points = _make_points(rng, QUANT_NUM_POINTS, list(QUANT_CONFIGS))
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': name},
query_params={'wait': 'true'},
body={"points": points}
)
assert response.ok
_wait_collection_green(name)
yield name
drop_collection(collection_name=name)
def test_quantized_turbo4_config_persists(quant_collection_name):
response = request_with_validation(
api='/collections/{collection_name}',
method="GET",
path_params={'collection_name': quant_collection_name},
)
assert response.ok
vectors = response.json()['result']['config']['params']['vectors']
for name, (_, quantization) in QUANT_CONFIGS.items():
assert vectors[name]['datatype'] == "turbo4"
quant_kind = next(iter(quantization))
assert quant_kind in vectors[name]['quantization_config']
@pytest.mark.parametrize("vector_name", list(QUANT_CONFIGS))
@pytest.mark.parametrize("rescore", [True, False])
def test_quantized_turbo4_search(quant_collection_name, vector_name, rescore):
# rescore=True re-scores candidates against the (dequantized) turbo4
# storage after the quantized first pass.
rng = random.Random(2024)
query_vector = _random_vector(rng)
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': quant_collection_name},
body={
"query": query_vector,
"using": vector_name,
"params": {
"quantization": {
"ignore": False,
"rescore": rescore,
"oversampling": 2.0,
}
},
"limit": 10,
}
)
assert response.ok
result = response.json()['result']['points']
assert len(result) == 10
scores = [hit['score'] for hit in result]
assert scores == sorted(scores, reverse=True)
###############################################################################
# Vector roundtrip & mutation.
#
# Vectors come back dequantized (and normalized for Cosine), so checks
# against the written input are cosine-similarity based, never exact.
# Re-reads of the same stored bytes, however, must match exactly — turbo4
# quantizes once at ingest, so any drift between reads means an extra
# dequantize→requantize pass slipped in. These tests run after the search
# tests above (pytest definition order) and mutate disjoint point ids, so the
# shared collection stays valid for everything else.
###############################################################################
def _cosine_sim(a, b):
dot = sum(x * y for x, y in zip(a, b))
norm_a = sum(x * x for x in a) ** 0.5
norm_b = sum(x * x for x in b) ** 0.5
return dot / (norm_a * norm_b)
def _norm(vector):
return sum(x * x for x in vector) ** 0.5
def _original_vectors(point_id):
# The module setup fills the collection from a fixed seed, so the
# pre-mutation vectors of any point can be regenerated exactly.
points = _make_points(random.Random(42), NUM_POINTS, ["image", "audio", "text"])
return points[point_id - 1]["vector"]
def _retrieve_point(collection_name, point_id):
response = request_with_validation(
api='/collections/{collection_name}/points/{id}',
method="GET",
path_params={'collection_name': collection_name, 'id': point_id},
)
assert response.ok
return response.json()['result']
def test_turbo4_retrieve_with_vector(collection_name):
point = _retrieve_point(collection_name, 10)
original = _original_vectors(10)
for name in ("image", "audio", "text"):
retrieved = point['vector'][name]
assert len(retrieved) == VECTOR_SIZE
# Dequantized f32 values — raw 4-bit codes would all be integers.
assert any(v != int(v) for v in retrieved)
# Rotated back into the input space: still near-parallel to what was
# written; without the inverse rotation the direction would be random.
assert _cosine_sim(retrieved, original[name]) > 0.9
response = request_with_validation(
api='/collections/{collection_name}/points/scroll',
method="POST",
path_params={'collection_name': collection_name},
body={"limit": 3, "with_vector": True},
)
assert response.ok
points = response.json()['result']['points']
assert len(points) == 3
# Scroll reads vectors through the batched storage path (the single-point
# GET above reads one by one), so it gets its own fidelity check.
for point in points:
original = _original_vectors(point['id'])
for name in ("image", "audio", "text"):
retrieved = point['vector'][name]
assert len(retrieved) == VECTOR_SIZE
assert _cosine_sim(retrieved, original[name]) > 0.9
def test_turbo4_overwrite_point(collection_name):
old = _original_vectors(1)
rng = random.Random(1001)
new = {name: _random_vector(rng) for name in ("image", "audio", "text")}
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
body={"points": [{"id": 1, "vector": new}]},
)
assert response.ok
point = _retrieve_point(collection_name, 1)
for name in ("image", "audio", "text"):
retrieved = point['vector'][name]
assert _cosine_sim(retrieved, new[name]) > 0.9
assert _cosine_sim(retrieved, new[name]) > _cosine_sim(retrieved, old[name])
# Cosine ("image") is normalized at write time, so magnitude is only
# preserved for the Dot and Euclid vectors.
for name in ("audio", "text"):
assert 0.8 < _norm(point['vector'][name]) / _norm(new[name]) < 1.25
def test_turbo4_update_single_vector(collection_name):
# Partial update through PUT /points/vectors: only the named vector is
# replaced. The untouched vectors' storages see no write at all, so
# their dequantized values must come back identical — near-identical
# would mean they were re-quantized along the way.
old = _original_vectors(2)
before = _retrieve_point(collection_name, 2)['vector']
new_audio = _random_vector(random.Random(2002))
response = request_with_validation(
api='/collections/{collection_name}/points/vectors',
method="PUT",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
body={"points": [{"id": 2, "vector": {"audio": new_audio}}]},
)
assert response.ok
point = _retrieve_point(collection_name, 2)
audio = point['vector']['audio']
assert _cosine_sim(audio, new_audio) > 0.9
assert _cosine_sim(audio, new_audio) > _cosine_sim(audio, old['audio'])
assert 0.8 < _norm(audio) / _norm(new_audio) < 1.25
for untouched in ("image", "text"):
assert point['vector'][untouched] == pytest.approx(
before[untouched], abs=1e-6)
assert _cosine_sim(point['vector'][untouched], old[untouched]) > 0.9
def test_turbo4_delete_vectors_and_points(collection_name):
# Deletion flags are turbo4-storage-owned state (each named storage
# persists its own): deleting one named vector must hide the point from
# searches on that name only, while the other storages keep serving it.
response = request_with_validation(
api='/collections/{collection_name}/points/vectors/delete',
method="POST",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
body={"points": [3], "vector": ["audio"]},
)
assert response.ok
point = _retrieve_point(collection_name, 3)
assert 'audio' not in point['vector']
original = _original_vectors(3)
for name in ("image", "text"):
assert _cosine_sim(point['vector'][name], original[name]) > 0.9
response = request_with_validation(
api='/collections/{collection_name}/points/delete',
method="POST",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
body={"points": [4]},
)
assert response.ok
response = request_with_validation(
api='/collections/{collection_name}/points/{id}',
method="GET",
path_params={'collection_name': collection_name, 'id': 4},
)
assert response.status_code == 404
# Point 3 is gone from "audio" searches but still found via "image";
# the deleted point 4 appears in neither.
for vector_name, absent, present in (
("audio", [3, 4], []),
("image", [4], [3]),
):
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': collection_name},
body={
"query": original[vector_name],
"using": vector_name,
"limit": NUM_POINTS,
},
)
assert response.ok
ids = [hit['id'] for hit in response.json()['result']['points']]
for point_id in absent:
assert point_id not in ids
for point_id in present:
assert point_id in ids
###############################################################################
# Multivector over turbo4 storage.
###############################################################################
def test_turbo4_multivector(on_disk_vectors, collection_name):
name = f"{collection_name}_multivec"
drop_collection(collection_name=name)
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': name},
body={
"vectors": {
"multi": {
"size": VECTOR_SIZE,
"distance": "Cosine",
"on_disk": on_disk_vectors,
"datatype": "turbo4",
"multivector_config": {"comparator": "max_sim"},
}
},
},
)
assert response.ok
rng = random.Random(7)
points = [
{"id": i, "vector": {"multi": [_random_vector(rng) for _ in range(3)]}}
for i in range(1, 21)
]
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': name},
query_params={'wait': 'true'},
body={"points": points},
)
assert response.ok
response = request_with_validation(
api='/collections/{collection_name}',
method="GET",
path_params={'collection_name': name},
)
assert response.ok
config = response.json()['result']['config']['params']['vectors']['multi']
assert config['datatype'] == "turbo4"
assert config['multivector_config']['comparator'] == "max_sim"
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': name},
body={
"query": [_random_vector(rng) for _ in range(2)],
"using": "multi",
"limit": 10,
},
)
assert response.ok
hits = response.json()['result']['points']
assert len(hits) == 10
scores = [hit['score'] for hit in hits]
assert scores == sorted(scores, reverse=True)
multi = _retrieve_point(name, 1)['vector']['multi']
assert len(multi) == 3
for sub, original in zip(multi, points[0]["vector"]["multi"]):
assert len(sub) == VECTOR_SIZE
assert _cosine_sim(sub, original) > 0.9
drop_collection(collection_name=name)
###############################################################################
# HNSW-indexed search over plain turbo4 (no secondary quantization).
#
# The shared collection above stays below the default indexing threshold, so
# its searches run unindexed; this section forces index builds. Graph
# construction scores stored vectors against each other in the quantized
# domain — a path no unindexed search exercises.
###############################################################################
def _wait_indexed_vectors(collection_name, timeout=30):
start = time.time()
while time.time() - start < timeout:
response = request_with_validation(
api='/collections/{collection_name}',
method="GET",
path_params={'collection_name': collection_name},
)
assert response.ok
if response.json()['result']['indexed_vectors_count'] > 0:
return
time.sleep(0.2)
raise Exception(f"Collection {collection_name} built no index in {timeout}s")
def test_turbo4_indexed_search(on_disk_vectors, collection_name):
name = f"{collection_name}_indexed"
drop_collection(collection_name=name)
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': name},
body={
# Unnamed vector also covers the default-vector path over turbo4.
"vectors": {
"size": VECTOR_SIZE,
"distance": "Cosine",
"on_disk": on_disk_vectors,
"datatype": "turbo4",
},
"optimizers_config": {
"default_segment_number": 1,
"indexing_threshold": 1,
},
},
)
assert response.ok
rng = random.Random(11)
points = [{"id": i, "vector": _random_vector(rng)} for i in range(1, 101)]
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': name},
query_params={'wait': 'true'},
body={"points": points},
)
assert response.ok
_wait_indexed_vectors(name)
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': name},
body={"query": _random_vector(random.Random(12)), "limit": 10},
)
assert response.ok
result = response.json()['result']['points']
assert len(result) == 10
scores = [hit['score'] for hit in result]
assert scores == sorted(scores, reverse=True)
# Recall through the graph: a stored point's own (pre-quantization)
# vector must come back as the top hit, near-perfectly similar.
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': name},
body={"query": points[16]["vector"], "limit": 1},
)
assert response.ok
top = response.json()['result']['points'][0]
assert top['id'] == 17
assert top['score'] > 0.95
drop_collection(collection_name=name)
def test_turbo4_optimizer_rebuild_preserves_vectors(on_disk_vectors, collection_name):
# Segment rebuilds move turbo4 vectors as raw encoded bytes: quantization
# happens once at ingest and never again. Forcing the optimizer over the
# data afterwards must reproduce identical dequantized values — any drift
# means a dequantize→requantize snuck into the rebuild (the roundtrip is
# not idempotent, so vectors would degrade a little on every rebuild).
name = f"{collection_name}_rebuild"
drop_collection(collection_name=name)
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': name},
body={
"vectors": {
"size": VECTOR_SIZE,
"distance": "Dot",
"on_disk": on_disk_vectors,
"datatype": "turbo4",
},
},
)
assert response.ok
rng = random.Random(61)
points = [{"id": i, "vector": _random_vector(rng)} for i in range(1, 101)]
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': name},
query_params={'wait': 'true'},
body={"points": points},
)
assert response.ok
sample_ids = [1, 42, 100]
before = {pid: _retrieve_point(name, pid)['vector'] for pid in sample_ids}
# Drop the indexing threshold so the optimizer rebuilds the segment (and
# builds an HNSW index) from the already-quantized data.
response = request_with_validation(
api='/collections/{collection_name}',
method="PATCH",
path_params={'collection_name': name},
body={
"optimizers_config": {
"default_segment_number": 1,
"indexing_threshold": 1,
},
},
)
assert response.ok
_wait_indexed_vectors(name)
for pid in sample_ids:
after = _retrieve_point(name, pid)['vector']
assert after == pytest.approx(before[pid], abs=1e-6)
# The rebuilt, indexed segment still finds the right point.
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': name},
body={"query": points[41]["vector"], "limit": 1},
)
assert response.ok
assert response.json()['result']['points'][0]['id'] == 42
drop_collection(collection_name=name)
###############################################################################
# Manhattan distance over turbo4.
#
# The fourth supported distance; kept out of the shared three-distance
# collection to leave its layout (and every `_original_vectors` consumer)
# untouched. Like Euclid it is a true distance: scores order ascending.
###############################################################################
def test_turbo4_manhattan(on_disk_vectors, collection_name):
name = f"{collection_name}_manhattan"
drop_collection(collection_name=name)
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': name},
body={
"vectors": {
"size": VECTOR_SIZE,
"distance": "Manhattan",
"on_disk": on_disk_vectors,
"datatype": "turbo4",
},
},
)
assert response.ok
rng = random.Random(71)
points = [{"id": i, "vector": _random_vector(rng)} for i in range(1, 21)]
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': name},
query_params={'wait': 'true'},
body={"points": points},
)
assert response.ok
retrieved = _retrieve_point(name, 5)['vector']
assert _cosine_sim(retrieved, points[4]["vector"]) > 0.9
assert 0.8 < _norm(retrieved) / _norm(points[4]["vector"]) < 1.25
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': name},
body={"query": points[4]["vector"], "limit": 20},
)
assert response.ok
result = response.json()['result']['points']
assert len(result) == 20
scores = [hit['score'] for hit in result]
assert scores == sorted(scores)
assert result[0]['id'] == 5
drop_collection(collection_name=name)
###############################################################################
# Snapshot roundtrip: no vector degradation allowed.
#
# A snapshot must carry the turbo4 storages, the HNSW index and the
# secondary quantized data intact: snapshot, drop the collection, recover
# from the downloaded snapshot alone, and verify config, exact vector
# values, and search. The API mechanics themselves (checksums, non-wait,
# security) are covered by test_snapshot.py.
###############################################################################
def _snapshot_and_recover(collection_name, srv_dir, srv_url):
# Snapshot the collection, download the file (verifying its checksum),
# drop everything server-side, then recover from the download alone.
response = request_with_validation(
api='/collections/{collection_name}/snapshots',
method="POST",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
)
assert response.ok
snapshot_name = response.json()['result']['name']
snapshot_checksum = response.json()['result']['checksum']
response = request_with_validation(
api='/collections/{collection_name}/snapshots/{snapshot_name}',
method="GET",
path_params={'collection_name': collection_name,
'snapshot_name': snapshot_name},
)
assert response.ok
assert hashlib.sha256(response.content).hexdigest() == snapshot_checksum
with open(srv_dir / f"{collection_name}.tar", 'wb') as f:
f.write(response.content)
response = request_with_validation(
api='/collections/{collection_name}/snapshots/{snapshot_name}',
method="DELETE",
path_params={'collection_name': collection_name,
'snapshot_name': snapshot_name},
query_params={'wait': 'true'},
)
assert response.ok
response = request_with_validation(
api='/collections/{collection_name}',
method="DELETE",
path_params={'collection_name': collection_name},
)
assert response.ok
response = request_with_validation(
api='/collections/{collection_name}/snapshots/recover',
method="PUT",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
body={
"location": f"{srv_url}/{collection_name}.tar",
"checksum": snapshot_checksum,
},
)
assert response.ok
def test_turbo4_snapshot_roundtrip(http_server, on_disk_vectors, collection_name):
(srv_dir, srv_url) = http_server
name = f"{collection_name}_snapshot"
drop_collection(collection_name=name)
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': name},
body={
"vectors": {
# One vector keeps a secondary quantization so the snapshot
# carries quantized data next to the turbo4 storages.
"image": {
"size": VECTOR_SIZE,
"distance": "Cosine",
"on_disk": on_disk_vectors,
"datatype": "turbo4",
"quantization_config": {
"turbo": {"bits": "bits2", "always_ram": True},
},
},
"audio": {
"size": VECTOR_SIZE,
"distance": "Dot",
"on_disk": on_disk_vectors,
"datatype": "turbo4",
},
"text": {
"size": VECTOR_SIZE,
"distance": "Euclid",
"on_disk": on_disk_vectors,
"datatype": "turbo4",
},
},
# Index so the snapshot carries the HNSW graphs too.
"optimizers_config": {
"default_segment_number": 1,
"indexing_threshold": 1,
},
},
)
assert response.ok
points = _make_points(random.Random(31), NUM_POINTS, ["image", "audio", "text"])
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': name},
query_params={'wait': 'true'},
body={"points": points},
)
assert response.ok
_wait_indexed_vectors(name)
vectors_before = _retrieve_point(name, 7)['vector']
_snapshot_and_recover(name, srv_dir, srv_url)
# config survived
response = request_with_validation(
api='/collections/{collection_name}',
method="GET",
path_params={'collection_name': name},
)
assert response.ok
vectors = response.json()['result']['config']['params']['vectors']
for vector_name in ("image", "audio", "text"):
assert vectors[vector_name]['datatype'] == "turbo4"
assert "turbo" in vectors["image"]['quantization_config']
# the index survived (restored, not rebuilt from scratch: the count is
# there as soon as the collection loads)
_wait_indexed_vectors(name)
# all points survived
response = request_with_validation(
api='/collections/{collection_name}/points/scroll',
method="POST",
path_params={'collection_name': name},
body={"limit": NUM_POINTS + 1},
)
assert response.ok
assert len(response.json()['result']['points']) == NUM_POINTS
# The stored turbo4 vectors survived intact: recovery must reproduce the
# same dequantized values to float precision. The epsilon only absorbs
# serialization noise — a lossy re-encode would move values by whole
# quantization steps, orders of magnitude past it.
point = _retrieve_point(name, 7)
original = points[6]["vector"]
for vector_name in ("image", "audio", "text"):
retrieved = point['vector'][vector_name]
assert retrieved == pytest.approx(vectors_before[vector_name], abs=1e-6)
assert len(retrieved) == VECTOR_SIZE
assert any(v != int(v) for v in retrieved)
assert _cosine_sim(retrieved, original[vector_name]) > 0.9
# and quantized search over the recovered index still works
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': name},
body={
"query": _random_vector(random.Random(32)),
"using": "image",
"params": {
"quantization": {
"ignore": False,
"rescore": True,
"oversampling": 2.0,
}
},
"limit": 10,
},
)
assert response.ok
result = response.json()['result']['points']
assert len(result) == 10
scores = [hit['score'] for hit in result]
assert scores == sorted(scores, reverse=True)
drop_collection(collection_name=name)
###############################################################################
# Datatype boundary semantics.
#
# The uint8 datatype has targeted tests for its conversion semantics
# (truncation and saturation, test_multi_vector_uint8.py). The turbo4
# analogue is per-vector scaling and rotation: degenerate and extreme
# inputs must quantize without NaN/Inf, and dimensions that are not a
# power of two make the rotation decompose into power-of-two blocks and
# leave a padding tail in the packed codes that the API must hide.
###############################################################################
def _assert_finite(vector):
# NaN/Inf serialize to JSON null, so a non-number element is the first
# sign of a poisoned value.
for v in vector:
assert isinstance(v, (int, float))
assert math.isfinite(v)
def test_turbo4_degenerate_vectors(on_disk_vectors, collection_name):
# Zero and constant vectors collapse the per-vector scale. Dot distance
# keeps raw values end to end: a zero vector is stored with length 0
# and must come back as exact zeros, not NaN from a divide-by-range.
name = f"{collection_name}_degenerate"
drop_collection(collection_name=name)
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': name},
body={
"vectors": {
"size": VECTOR_SIZE,
"distance": "Dot",
"on_disk": on_disk_vectors,
"datatype": "turbo4",
},
},
)
assert response.ok
constant = [0.5] * VECTOR_SIZE
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': name},
query_params={'wait': 'true'},
body={"points": [
{"id": 1, "vector": [0.0] * VECTOR_SIZE},
{"id": 2, "vector": constant},
]},
)
assert response.ok
zero = _retrieve_point(name, 1)['vector']
assert len(zero) == VECTOR_SIZE
_assert_finite(zero)
assert zero == pytest.approx([0.0] * VECTOR_SIZE, abs=1e-6)
const_back = _retrieve_point(name, 2)['vector']
assert len(const_back) == VECTOR_SIZE
_assert_finite(const_back)
assert _cosine_sim(const_back, constant) > 0.9
assert 0.8 < _norm(const_back) / _norm(constant) < 1.25
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': name},
body={"query": _random_vector(random.Random(51)), "limit": 2},
)
assert response.ok
result = response.json()['result']['points']
assert len(result) == 2
for hit in result:
assert math.isfinite(hit['score'])
drop_collection(collection_name=name)
def test_turbo4_extreme_magnitudes(on_disk_vectors, collection_name):
# The per-vector scale must absorb any input magnitude: huge, tiny and
# outlier-dominated vectors have to come back finite and direction-true.
name = f"{collection_name}_extreme"
drop_collection(collection_name=name)
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': name},
body={
"vectors": {
"size": VECTOR_SIZE,
"distance": "Dot",
"on_disk": on_disk_vectors,
"datatype": "turbo4",
},
},
)
assert response.ok
rng = random.Random(52)
vectors = {
1: [x * 1e30 for x in _random_vector(rng)],
2: [x * 1e-30 for x in _random_vector(rng)],
3: [1e6] + _random_vector(rng, VECTOR_SIZE - 1),
}
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': name},
query_params={'wait': 'true'},
body={"points": [
{"id": point_id, "vector": vector}
for point_id, vector in vectors.items()
]},
)
assert response.ok
for point_id, original in vectors.items():
retrieved = _retrieve_point(name, point_id)['vector']
assert len(retrieved) == VECTOR_SIZE
_assert_finite(retrieved)
assert _cosine_sim(retrieved, original) > 0.9
# Direction alone is scale-invariant — the magnitude must survive too.
assert 0.8 < _norm(retrieved) / _norm(original) < 1.25
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': name},
body={"query": _random_vector(random.Random(53)), "limit": 3},
)
assert response.ok
result = response.json()['result']['points']
assert len(result) == 3
for hit in result:
assert math.isfinite(hit['score'])
drop_collection(collection_name=name)
def test_turbo4_odd_dimension(on_disk_vectors, collection_name):
# A size that is not a power of two; 63 decomposes into the maximum
# number of rotation blocks (32+16+8+4+2+1). The quantizer unit tests
# sweep many sizes — at the API level one representative checks that
# the storage still returns exactly `size` values, padding hidden.
size = 63
name = f"{collection_name}_dim_{size}"
drop_collection(collection_name=name)
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': name},
body={
"vectors": {
"size": size,
"distance": "Cosine",
"on_disk": on_disk_vectors,
"datatype": "turbo4",
},
},
)
assert response.ok
rng = random.Random(54)
points = [{"id": i, "vector": _random_vector(rng, size)} for i in range(1, 11)]
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': name},
query_params={'wait': 'true'},
body={"points": points},
)
assert response.ok
retrieved = _retrieve_point(name, 3)['vector']
assert len(retrieved) == size
assert any(v != int(v) for v in retrieved)
assert _cosine_sim(retrieved, points[2]["vector"]) > 0.9
response = request_with_validation(
api='/collections/{collection_name}/points/query',
method="POST",
path_params={'collection_name': name},
body={"query": _random_vector(random.Random(55), size), "limit": 5},
)
assert response.ok
result = response.json()['result']['points']
assert len(result) == 5
scores = [hit['score'] for hit in result]
assert scores == sorted(scores, reverse=True)
drop_collection(collection_name=name)