feat(qdrant): add tenant-scoped point storage for ingestion
Why: - Ingested chunks need to become searchable Qdrant points before the upload response returns, with tenant/domain isolation and a safe re-ingestion story per ADR-0001/0017. Changes: - src/application/points/: index_chunks() is the sole entry point, owning payload construction, batched/bounded-concurrency upserts (upsert_concurrency semaphore), and a soft-delete sweep for points a shorter re-ingestion leaves behind. The sweep runs only after every upsert in the attempt succeeds, so a failed attempt can leave a stale prefix but never removes content from a working index. - PointStorage port (application/ports/) + QdrantPointStorage adapter (infrastructure/qdrant/points.py), keeping the qdrant_client SDK out of application code per ADR-0015. - FakePointStorage test double for exercising the ordering/idempotency guarantees without a real Qdrant.
This commit is contained in:
97
src/infrastructure/qdrant/points.py
Normal file
97
src/infrastructure/qdrant/points.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Qdrant adapter for the `PointStorage` port (ADR-0001, ADR-0002, ADR-0015).
|
||||
|
||||
The `qdrant_client` SDK appears here and nowhere in `application/`. This module
|
||||
translates the SDK-free `ChunkPoint` into `PointStruct`s and builds every
|
||||
filter — routers and application services never construct Qdrant filters.
|
||||
|
||||
`AsyncQdrantClient` is genuinely async, so unlike the `minio` adapter nothing
|
||||
here needs a thread offload.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
|
||||
from qdrant_client import AsyncQdrantClient, models
|
||||
|
||||
from src.application.points.models import ChunkPoint
|
||||
from src.infrastructure.qdrant.collection import SPARSE_VECTOR
|
||||
|
||||
|
||||
def _tenant_file_filter(
|
||||
tenant_id: uuid.UUID, file_id: uuid.UUID, *, from_chunk_index: int
|
||||
) -> models.Filter:
|
||||
"""Points of one file, at or past `from_chunk_index`, within one tenant.
|
||||
|
||||
`tenant_id` is always a condition, never optional: a `file_id` alone is not
|
||||
authority to mutate anything (ADR-0002's isolation rule applies to every
|
||||
code path, not just reads).
|
||||
"""
|
||||
return models.Filter(
|
||||
must=[
|
||||
models.FieldCondition(key="tenant_id", match=models.MatchValue(value=str(tenant_id))),
|
||||
models.FieldCondition(key="file_id", match=models.MatchValue(value=str(file_id))),
|
||||
models.FieldCondition(key="chunk_index", range=models.Range(gte=from_chunk_index)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class QdrantPointStorage:
|
||||
"""A `PointStorage` (see `src/application/ports/point_storage.py`)."""
|
||||
|
||||
def __init__(self, client: AsyncQdrantClient, *, collection: str) -> None:
|
||||
self._client = client
|
||||
self._collection = collection
|
||||
|
||||
async def upsert_points(self, points: Sequence[ChunkPoint]) -> None:
|
||||
if not points:
|
||||
return
|
||||
await self._client.upsert(
|
||||
collection_name=self._collection,
|
||||
points=[
|
||||
models.PointStruct(
|
||||
id=str(point.point_id),
|
||||
vector={
|
||||
**point.dense,
|
||||
SPARSE_VECTOR: models.SparseVector(
|
||||
indices=point.sparse.indices, values=point.sparse.values
|
||||
),
|
||||
},
|
||||
payload=point.payload,
|
||||
)
|
||||
for point in points
|
||||
],
|
||||
)
|
||||
|
||||
async def deactivate_points_from_index(
|
||||
self,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
from_chunk_index: int,
|
||||
deleted_at: datetime,
|
||||
updated_by: str,
|
||||
) -> int:
|
||||
"""Soft-delete via `set_payload` — the points stay for audit (ADR-0002).
|
||||
|
||||
Counts first so the caller can report how many points the sweep
|
||||
touched; `set_payload` itself reports only an operation status.
|
||||
"""
|
||||
point_filter = _tenant_file_filter(tenant_id, file_id, from_chunk_index=from_chunk_index)
|
||||
stale = await self._client.count(
|
||||
collection_name=self._collection, count_filter=point_filter, exact=True
|
||||
)
|
||||
if stale.count == 0:
|
||||
return 0
|
||||
|
||||
await self._client.set_payload(
|
||||
collection_name=self._collection,
|
||||
payload={
|
||||
"is_active": False,
|
||||
"deleted_at": deleted_at.isoformat(),
|
||||
"updated_at": deleted_at.isoformat(),
|
||||
"updated_by": updated_by,
|
||||
},
|
||||
points=models.FilterSelector(filter=point_filter),
|
||||
)
|
||||
return stale.count
|
||||
Reference in New Issue
Block a user