192 lines
6.1 KiB
Python
192 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
import inspect
|
|
import os
|
|
from collections.abc import AsyncIterator
|
|
from dataclasses import replace
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from qdrant_client import AsyncQdrantClient
|
|
|
|
from ragcore.infrastructure.qdrant import (
|
|
CollectionKind,
|
|
CollectionProfile,
|
|
CollectionProfileMismatchError,
|
|
IndexPayload,
|
|
IndexPoint,
|
|
QdrantIndexAdapter,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def profile() -> CollectionProfile:
|
|
return CollectionProfile(
|
|
kind=CollectionKind.DENSE,
|
|
profile="test",
|
|
version=uuid4().hex[:12],
|
|
dimensions=4,
|
|
model_digest="a" * 64,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def adapter(
|
|
profile: CollectionProfile,
|
|
) -> AsyncIterator[tuple[QdrantIndexAdapter, CollectionProfile]]:
|
|
client = AsyncQdrantClient(
|
|
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
|
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
|
timeout=60,
|
|
)
|
|
index = QdrantIndexAdapter(client)
|
|
try:
|
|
yield index, profile
|
|
finally:
|
|
await client.delete_collection(profile.collection_name)
|
|
await client.close()
|
|
|
|
|
|
def make_point(profile: CollectionProfile) -> IndexPoint:
|
|
document_id = uuid4()
|
|
version_id = uuid4()
|
|
chunk_id = uuid4()
|
|
return IndexPoint(
|
|
point_id=chunk_id,
|
|
dense_vector=(0.1, 0.2, 0.3, 0.4),
|
|
sparse_indices=(2, 9),
|
|
sparse_values=(0.8, 0.3),
|
|
payload=IndexPayload(
|
|
workspace_id=uuid4(),
|
|
space_id=uuid4(),
|
|
document_id=document_id,
|
|
document_version_id=version_id,
|
|
chunk_id=chunk_id,
|
|
parent_chunk_id=None,
|
|
source_id=uuid4(),
|
|
status="published",
|
|
content_type="text/markdown",
|
|
language="en",
|
|
trust_level="source",
|
|
location={"page": 1, "block": "heading"},
|
|
parser_version="text-v1",
|
|
chunk_profile_version=profile.version,
|
|
embedding_model_digest=profile.model_digest,
|
|
embedding_space_id=profile.embedding_space_id,
|
|
text="A safe indexed passage.",
|
|
excerpt="A safe indexed passage.",
|
|
),
|
|
)
|
|
|
|
|
|
def test_profile_rejects_invalid_dimensions_and_digest() -> None:
|
|
with pytest.raises(ValueError, match="dimensions"):
|
|
CollectionProfile(CollectionKind.DENSE, "test", "v1", 0, "a" * 64)
|
|
with pytest.raises(ValueError, match="16384"):
|
|
CollectionProfile(CollectionKind.DENSE, "test", "v1", 16_385, "a" * 64)
|
|
with pytest.raises(ValueError, match="SHA-256"):
|
|
CollectionProfile(CollectionKind.DENSE, "test", "v1", 4, "not-a-digest")
|
|
|
|
|
|
def test_point_payload_rejects_unbounded_or_nonfinite_values() -> None:
|
|
profile = CollectionProfile(CollectionKind.DENSE, "test", "v1", 4, "a" * 64)
|
|
point = make_point(profile)
|
|
with pytest.raises(ValueError, match="payload limit"):
|
|
replace(point.payload, text="x" * (1_048_576 + 1))
|
|
with pytest.raises(ValueError, match="finite"):
|
|
IndexPoint(point_id=point.point_id, dense_vector=(float("nan"),), payload=point.payload)
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_collection_profile_indexes_alias_and_idempotent_setup(
|
|
adapter: tuple[QdrantIndexAdapter, CollectionProfile],
|
|
) -> None:
|
|
index, profile = adapter
|
|
await index.ensure_collection(profile)
|
|
await index.ensure_collection(profile)
|
|
|
|
client = index._client
|
|
info = await client.get_collection(profile.collection_name)
|
|
assert info.config.params.vectors["dense"].size == profile.dimensions
|
|
assert "sparse" in info.config.params.sparse_vectors
|
|
assert set(
|
|
(
|
|
"workspace_id",
|
|
"space_id",
|
|
"document_id",
|
|
"document_version_id",
|
|
"status",
|
|
"content_type",
|
|
"language",
|
|
"trust_level",
|
|
)
|
|
).issubset(info.payload_schema)
|
|
aliases = await client.get_aliases()
|
|
assert any(
|
|
alias.alias_name == profile.alias_name and alias.collection_name == profile.collection_name
|
|
for alias in aliases.aliases
|
|
)
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_point_upsert_and_explicit_delete_are_idempotent(
|
|
adapter: tuple[QdrantIndexAdapter, CollectionProfile],
|
|
) -> None:
|
|
index, profile = adapter
|
|
await index.ensure_collection(profile)
|
|
point = make_point(profile)
|
|
|
|
await index.upsert_points(profile, (point,))
|
|
await index.upsert_points(profile, (point,))
|
|
stored = await index._client.retrieve(
|
|
collection_name=profile.collection_name,
|
|
ids=[str(point.point_id)],
|
|
)
|
|
assert len(stored) == 1
|
|
with pytest.raises(CollectionProfileMismatchError):
|
|
await index.upsert_points(
|
|
profile,
|
|
(
|
|
IndexPoint(
|
|
point_id=point.point_id,
|
|
dense_vector=(0.1, 0.2),
|
|
payload=point.payload,
|
|
),
|
|
),
|
|
)
|
|
|
|
await index.delete_points(profile, (point.point_id,))
|
|
await index.delete_points(profile, (point.point_id,))
|
|
assert (
|
|
await index._client.retrieve(
|
|
collection_name=profile.collection_name,
|
|
ids=[str(point.point_id)],
|
|
)
|
|
== []
|
|
)
|
|
|
|
|
|
def test_public_point_operations_do_not_accept_raw_filters() -> None:
|
|
for method_name in ("upsert_points", "delete_points"):
|
|
parameters = inspect.signature(getattr(QdrantIndexAdapter, method_name)).parameters
|
|
assert "filter" not in parameters
|
|
assert "raw_filter" not in parameters
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_point_upsert_rejects_a_different_embedding_space_before_qdrant_io() -> None:
|
|
profile = CollectionProfile(
|
|
kind=CollectionKind.DENSE,
|
|
profile="test",
|
|
version="v2",
|
|
dimensions=4,
|
|
model_digest="a" * 64,
|
|
embedding_space_id=uuid4(),
|
|
)
|
|
point = make_point(profile)
|
|
mismatched_point = replace(point, payload=replace(point.payload, embedding_space_id=uuid4()))
|
|
adapter = QdrantIndexAdapter(None) # type: ignore[arg-type]
|
|
|
|
with pytest.raises(CollectionProfileMismatchError, match="embedding space"):
|
|
await adapter.upsert_points(profile, (mismatched_point,))
|