Files
chatbot_v3/tests/integration/qdrant/test_collection.py
Ali Zarinkolah 5e935e5895 feat(qdrant): index content, is_active, and chunk_index on the chunks collection
Why:
- ADR-0002's keyword search needs a full-text index on content, which
  collection.py deliberately deferred to plan 002. is_active and chunk_index
  were unindexed while ingestion was the only reader; every /v1/points read path
  filters on them.

Changes:
- content gets a TEXT index with the multilingual tokenizer, which segments
  Persian correctly where the word tokenizer mishandles ZWNJ-joined compounds.
  No stemmer or stopword list: content is already letter-folded by
  normalize_persian_text at ingest, and the ranked Farsi lexical path is the
  benchmarked BM25 sparse vector, not this index.
- Tests assert content is TEXT rather than KEYWORD -- a keyword index would only
  match an entire chunk verbatim, which never happens and fails silently.
- Adds a test that a missing index is added to an already-live collection.

Impact:
- Requires re-running `python -m src.cli.qdrant_bootstrap`. Payload indexes are
  additive, so no collection rebuild and no re-embedding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:09:26 +03:30

147 lines
6.2 KiB
Python

"""`ensure_chunks_collection` against a real Qdrant (ADR-0001).
The assertions that matter most here are the ones for schema properties that
fail *silently* in production: the sparse `modifier=idf` and the pinned dense
dimensions.
"""
import pytest
from qdrant_client import AsyncQdrantClient, models
from src.config import QdrantSettings
from src.infrastructure.qdrant.collection import (
CollectionSchemaMismatchError,
ensure_chunks_collection,
)
pytestmark = [
pytest.mark.integration,
pytest.mark.qdrant,
pytest.mark.asyncio(loop_scope="session"),
]
async def test_ensure_chunks_collection_creates_all_four_named_vectors(
qdrant_client: AsyncQdrantClient, qdrant_settings: QdrantSettings
) -> None:
created = await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection)
assert created is True
info = await qdrant_client.get_collection(qdrant_settings.collection)
vectors = info.config.params.vectors
assert isinstance(vectors, dict)
assert vectors["dense_nomic"].size == 768
assert vectors["dense_openai"].size == 3072
assert vectors["late_interaction"].size == 128
assert vectors["late_interaction"].multivector_config is not None
assert vectors["late_interaction"].on_disk is True
async def test_ensure_chunks_collection_sets_the_sparse_idf_modifier(
qdrant_client: AsyncQdrantClient, qdrant_settings: QdrantSettings
) -> None:
"""Without this, Qdrant applies no IDF and lexical retrieval silently
degrades -- no error, no warning (ADR-0005).
"""
await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection)
info = await qdrant_client.get_collection(qdrant_settings.collection)
sparse = info.config.params.sparse_vectors
assert sparse is not None
assert sparse["sparse"].modifier == models.Modifier.IDF
async def test_ensure_chunks_collection_creates_the_payload_indexes(
qdrant_client: AsyncQdrantClient, qdrant_settings: QdrantSettings
) -> None:
await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection)
info = await qdrant_client.get_collection(qdrant_settings.collection)
schema = info.payload_schema
assert set(schema) >= {
"tenant_id",
"domain",
"file_id",
"order_id",
"previous_chunk_id",
"next_chunk_id",
"content",
"is_active",
"chunk_index",
}
# order_id must be numeric: Qdrant's Range/order_by reject keyword payloads.
assert schema["order_id"].data_type == models.PayloadSchemaType.FLOAT
assert schema["tenant_id"].data_type == models.PayloadSchemaType.KEYWORD
# `content` must be TEXT, not KEYWORD: ADR-0002's keyword search is a
# full-text match on it, and a keyword index would only match the entire
# chunk verbatim -- which never happens and would fail silently.
assert schema["content"].data_type == models.PayloadSchemaType.TEXT
assert schema["is_active"].data_type == models.PayloadSchemaType.BOOL
assert schema["chunk_index"].data_type == models.PayloadSchemaType.INTEGER
async def test_ensure_chunks_collection_adds_a_missing_index_to_a_live_collection(
qdrant_client: AsyncQdrantClient, qdrant_settings: QdrantSettings
) -> None:
"""Payload indexes are additive, unlike vector config.
This is the operational claim the runbook makes when a new index ships:
re-running the bootstrap against an existing collection adds it in place, so
plan 002's `content`/`is_active`/`chunk_index` indexes do not require
recreating a collection that already holds a tenant's points.
"""
await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection)
await qdrant_client.delete_payload_index(
collection_name=qdrant_settings.collection, field_name="content"
)
info = await qdrant_client.get_collection(qdrant_settings.collection)
assert "content" not in info.payload_schema
assert not await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection)
info = await qdrant_client.get_collection(qdrant_settings.collection)
assert info.payload_schema["content"].data_type == models.PayloadSchemaType.TEXT
async def test_ensure_chunks_collection_is_idempotent(
qdrant_client: AsyncQdrantClient, qdrant_settings: QdrantSettings
) -> None:
assert await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection)
assert not await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection)
async def test_ensure_chunks_collection_rejects_a_mismatched_existing_collection(
qdrant_client: AsyncQdrantClient, qdrant_settings: QdrantSettings
) -> None:
"""A wrong-dimension collection must fail loudly, not be silently accepted."""
await qdrant_client.create_collection(
collection_name=qdrant_settings.collection,
vectors_config={
"dense_nomic": models.VectorParams(size=384, distance=models.Distance.COSINE),
"dense_openai": models.VectorParams(size=3072, distance=models.Distance.COSINE),
"late_interaction": models.VectorParams(size=128, distance=models.Distance.COSINE),
},
sparse_vectors_config={"sparse": models.SparseVectorParams(modifier=models.Modifier.IDF)},
)
with pytest.raises(CollectionSchemaMismatchError, match="768"):
await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection)
async def test_ensure_chunks_collection_rejects_a_collection_without_the_idf_modifier(
qdrant_client: AsyncQdrantClient, qdrant_settings: QdrantSettings
) -> None:
await qdrant_client.create_collection(
collection_name=qdrant_settings.collection,
vectors_config={
"dense_nomic": models.VectorParams(size=768, distance=models.Distance.COSINE),
"dense_openai": models.VectorParams(size=3072, distance=models.Distance.COSINE),
"late_interaction": models.VectorParams(size=128, distance=models.Distance.COSINE),
},
sparse_vectors_config={"sparse": models.SparseVectorParams()},
)
with pytest.raises(CollectionSchemaMismatchError, match="idf"):
await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection)