perf: switch Qdrant to AsyncQdrantClient with explicit timeout

Every vector call ran on the sync QdrantClient inside async wrapper
methods, blocking the FastAPI event loop per Qdrant round-trip. The
wrapper now holds an AsyncQdrantClient (timeout via QDRANT_TIMEOUT,
default 30s) and awaits all client calls; the wrapper API is unchanged.

Call sites off the wrapper were fixed too: the HybridRAG document leg
now uses the async search_vectors wrapper instead of the deprecated raw
client.search (also fixing its call to the nonexistent ollama.embed_text
which made the leg permanently report 'failed'), the health check awaits
get_collections, and document_sync's raw delete/upsert calls are awaited
(routed through wrappers in the next commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
This commit is contained in:
2026-07-14 12:58:01 +02:00
co-authored by Claude Fable 5
parent 0b346d3a57
commit 406143e7ae
8 changed files with 53 additions and 48 deletions
+4
View File
@@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Weekly quality report** — `POST /maintenance/quality-report {user}` runs the duplicate scan, flags stale pages (not updated in N days AND ≤ M SearchQuery hits from the graph data), lists pages missing tags/description, folds in the latest integrity-check results (Redis-cached or run inline), and writes a dated report page to `users/{user}/system/quality-reports/YYYY-MM-DD` (same-day reruns update the same page — the page id is remembered in Redis because the Wiki.js listing lags page creation). Response returns the full report content + page path. Verified end-to-end against the local dev server as `llm_tester`. - **Weekly quality report** — `POST /maintenance/quality-report {user}` runs the duplicate scan, flags stale pages (not updated in N days AND ≤ M SearchQuery hits from the graph data), lists pages missing tags/description, folds in the latest integrity-check results (Redis-cached or run inline), and writes a dated report page to `users/{user}/system/quality-reports/YYYY-MM-DD` (same-day reruns update the same page — the page id is remembered in Redis because the Wiki.js listing lags page creation). Response returns the full report content + page path. Verified end-to-end against the local dev server as `llm_tester`.
- **Nightly integrity check** — `POST /maintenance/integrity-check {user}` (read-only: reports, never auto-fixes) reports per tenant: wiki pages with ZERO vectors in Qdrant (silent-skip reindex victims), orphaned vectors whose wiki page no longer exists, unexpected Qdrant collections (test-tenant residue and unknown namespaces flagged; other services' collections counted as foreign), Neo4j Document nodes without wiki counterparts, plus counts and duration. The latest report is cached in Redis (30 days) so the weekly quality report can fold it in. - **Nightly integrity check** — `POST /maintenance/integrity-check {user}` (read-only: reports, never auto-fixes) reports per tenant: wiki pages with ZERO vectors in Qdrant (silent-skip reindex victims), orphaned vectors whose wiki page no longer exists, unexpected Qdrant collections (test-tenant residue and unknown namespaces flagged; other services' collections counted as foreign), Neo4j Document nodes without wiki counterparts, plus counts and duration. The latest report is cached in Redis (30 days) so the weekly quality report can fold it in.
### Changed (performance)
- **Async Qdrant client** - `QdrantClientWrapper` now uses `AsyncQdrantClient` with an explicit timeout (`QDRANT_TIMEOUT`, default 30s). Every vector call previously ran on the synchronous client inside async wrapper methods, blocking the FastAPI event loop for the duration of each Qdrant round-trip. The wrapper API is unchanged (all methods were already `async`), so call sites only gained real awaits. The HybridRAG document leg was moved off the deprecated raw `client.search` onto the wrapper's `search_vectors` (fixing a latent `AttributeError`: it called the nonexistent `ollama.embed_text`, so the leg always reported `failed`), and the health check awaits `get_collections`.
### Changed ### Changed
- **BREAKING: `user` is now required on every tenant-data endpoint** - The implicit `jpmschweitzer` default tenant (`DEFAULT_USER`) has been removed everywhere. All endpoints that read or write tenant data (`/query/*`, `/wiki/*`, `/vector/*`, `/graph/*`, `/ingest/*`, `/volatile/*`, `/documents/*`, `/stats`, `/rag/search`) now reject requests without an explicit, non-empty, non-whitespace `user` (HTTP 422), matching the existing `/maintenance/*` pattern. A shared validator (`require_user` dependency / `RequiredUser` model type) also rejects blank users. The Wiki.js change listener now skips changes whose notification email yields no user instead of attributing them to the production tenant. **Caller coordination required:** tatlock and the Scheduler ingest/prefetch/consolidation tasks must send an explicit `user` on every call — see the deploy checklist. - **BREAKING: `user` is now required on every tenant-data endpoint** - The implicit `jpmschweitzer` default tenant (`DEFAULT_USER`) has been removed everywhere. All endpoints that read or write tenant data (`/query/*`, `/wiki/*`, `/vector/*`, `/graph/*`, `/ingest/*`, `/volatile/*`, `/documents/*`, `/stats`, `/rag/search`) now reject requests without an explicit, non-empty, non-whitespace `user` (HTTP 422), matching the existing `/maintenance/*` pattern. A shared validator (`require_user` dependency / `RequiredUser` model type) also rejects blank users. The Wiki.js change listener now skips changes whose notification email yields no user instead of attributing them to the production tenant. **Caller coordination required:** tatlock and the Scheduler ingest/prefetch/consolidation tasks must send an explicit `user` on every call — see the deploy checklist.
+31 -26
View File
@@ -8,7 +8,7 @@ Provides async vector operations with:
- Similarity queries - Similarity queries
""" """
from qdrant_client import QdrantClient from qdrant_client import AsyncQdrantClient
from qdrant_client.models import ( from qdrant_client.models import (
Distance, VectorParams, PointStruct, Distance, VectorParams, PointStruct,
Filter, FieldCondition, MatchValue, Range Filter, FieldCondition, MatchValue, Range
@@ -30,17 +30,22 @@ class QdrantClientWrapper:
Each user has isolated vector collection for their documents. Each user has isolated vector collection for their documents.
""" """
def __init__(self, url: str, embedding_dim: int = 768): def __init__(self, url: str, embedding_dim: int = 768, timeout: float = 30.0):
""" """
Initialize Qdrant client. Initialize Qdrant client.
Uses AsyncQdrantClient so vector calls never block the FastAPI
event loop, with an explicit timeout so a hung Qdrant cannot
stall requests indefinitely.
Args: Args:
url: Qdrant server URL (e.g., "http://qdrant:6333") url: Qdrant server URL (e.g., "http://qdrant:6333")
embedding_dim: Vector embedding dimension (default 768 for nomic-embed-text) embedding_dim: Vector embedding dimension (default 768 for nomic-embed-text)
timeout: Per-request timeout in seconds (default 30.0)
""" """
self.client = QdrantClient(url=url) self.client = AsyncQdrantClient(url=url, timeout=timeout)
self.embedding_dim = embedding_dim self.embedding_dim = embedding_dim
logger.info(f"Initialized Qdrant client: {url}") logger.info(f"Initialized async Qdrant client: {url} (timeout={timeout}s)")
def get_collection_name(self, user: str) -> str: def get_collection_name(self, user: str) -> str:
""" """
@@ -62,12 +67,12 @@ class QdrantClientWrapper:
collection_name: Collection name collection_name: Collection name
""" """
try: try:
collections = self.client.get_collections() collections = await self.client.get_collections()
existing = [c.name for c in collections.collections] existing = [c.name for c in collections.collections]
if collection_name not in existing: if collection_name not in existing:
logger.info(f"Creating Qdrant collection: {collection_name}") logger.info(f"Creating Qdrant collection: {collection_name}")
self.client.create_collection( await self.client.create_collection(
collection_name=collection_name, collection_name=collection_name,
vectors_config=VectorParams( vectors_config=VectorParams(
size=self.embedding_dim, size=self.embedding_dim,
@@ -90,7 +95,7 @@ class QdrantClientWrapper:
True if collection exists True if collection exists
""" """
try: try:
collections = self.client.get_collections() collections = await self.client.get_collections()
existing = [c.name for c in collections.collections] existing = [c.name for c in collections.collections]
return collection_name in existing return collection_name in existing
except Exception as e: except Exception as e:
@@ -161,7 +166,7 @@ class QdrantClientWrapper:
)) ))
try: try:
self.client.upsert( await self.client.upsert(
collection_name=collection_name, collection_name=collection_name,
points=points points=points
) )
@@ -213,7 +218,7 @@ class QdrantClientWrapper:
query_filter = Filter(must=conditions) query_filter = Filter(must=conditions)
try: try:
response = self.client.query_points( response = await self.client.query_points(
collection_name=collection_name, collection_name=collection_name,
query=query_vector, query=query_vector,
limit=limit, limit=limit,
@@ -251,7 +256,7 @@ class QdrantClientWrapper:
collection_name = self.get_collection_name(user) collection_name = self.get_collection_name(user)
try: try:
self.client.delete( await self.client.delete(
collection_name=collection_name, collection_name=collection_name,
points_selector=Filter( points_selector=Filter(
must=[ must=[
@@ -287,7 +292,7 @@ class QdrantClientWrapper:
try: try:
# Scroll through points with doc_id filter # Scroll through points with doc_id filter
points, _ = self.client.scroll( points, _ = await self.client.scroll(
collection_name=collection_name, collection_name=collection_name,
scroll_filter=Filter( scroll_filter=Filter(
must=[ must=[
@@ -329,7 +334,7 @@ class QdrantClientWrapper:
try: try:
# Get collection info # Get collection info
collection_info = self.client.get_collection(collection_name) collection_info = await self.client.get_collection(collection_name)
# This gives total points, not unique docs # This gives total points, not unique docs
# For unique docs, would need to aggregate by doc_id # For unique docs, would need to aggregate by doc_id
return collection_info.points_count return collection_info.points_count
@@ -352,7 +357,7 @@ class QdrantClientWrapper:
collection_name = self.get_collection_name(user) collection_name = self.get_collection_name(user)
try: try:
self.client.delete_collection(collection_name) await self.client.delete_collection(collection_name)
logger.warning(f"Deleted collection: {collection_name}") logger.warning(f"Deleted collection: {collection_name}")
return True return True
except Exception as e: except Exception as e:
@@ -382,7 +387,7 @@ class QdrantClientWrapper:
try: try:
# Get source document chunks with vectors # Get source document chunks with vectors
source_points, _ = self.client.scroll( source_points, _ = await self.client.scroll(
collection_name=collection_name, collection_name=collection_name,
scroll_filter=Filter( scroll_filter=Filter(
must=[ must=[
@@ -404,7 +409,7 @@ class QdrantClientWrapper:
# (could aggregate multiple chunks for better results) # (could aggregate multiple chunks for better results)
first_vector = source_points[0].vector first_vector = source_points[0].vector
response = self.client.query_points( response = await self.client.query_points(
collection_name=collection_name, collection_name=collection_name,
query=first_vector, query=first_vector,
limit=limit * 2, # Get more to filter out same doc limit=limit * 2, # Get more to filter out same doc
@@ -449,7 +454,7 @@ class QdrantClientWrapper:
True if successful True if successful
""" """
try: try:
self.client.upsert( await self.client.upsert(
collection_name=collection_name, collection_name=collection_name,
points=[PointStruct( points=[PointStruct(
id=vector_id, id=vector_id,
@@ -487,7 +492,7 @@ class QdrantClientWrapper:
query_filter = Filter(must=conditions) query_filter = Filter(must=conditions)
# Delete points # Delete points
result = self.client.delete( result = await self.client.delete(
collection_name=collection_name, collection_name=collection_name,
points_selector=query_filter points_selector=query_filter
) )
@@ -532,7 +537,7 @@ class QdrantClientWrapper:
query_filter = Filter(must=conditions) query_filter = Filter(must=conditions)
try: try:
response = self.client.query_points( response = await self.client.query_points(
collection_name=collection_name, collection_name=collection_name,
query=query_vector, query=query_vector,
limit=limit, limit=limit,
@@ -589,7 +594,7 @@ class QdrantClientWrapper:
try: try:
while True: while True:
points, next_offset = self.client.scroll( points, next_offset = await self.client.scroll(
collection_name=collection_name, collection_name=collection_name,
scroll_filter=scroll_filter, scroll_filter=scroll_filter,
limit=batch_size, limit=batch_size,
@@ -636,7 +641,7 @@ class QdrantClientWrapper:
return 0 return 0
try: try:
self.client.delete( await self.client.delete(
collection_name=collection_name, collection_name=collection_name,
points_selector=point_ids points_selector=point_ids
) )
@@ -655,13 +660,13 @@ class QdrantClientWrapper:
List of collection info dictionaries List of collection info dictionaries
""" """
try: try:
collections = self.client.get_collections() collections = await self.client.get_collections()
result = [] result = []
for coll in collections.collections: for coll in collections.collections:
# Get detailed collection info # Get detailed collection info
try: try:
info = self.client.get_collection(coll.name) info = await self.client.get_collection(coll.name)
result.append({ result.append({
"name": coll.name, "name": coll.name,
"vectors_count": info.vectors_count or 0, "vectors_count": info.vectors_count or 0,
@@ -717,7 +722,7 @@ class QdrantClientWrapper:
) )
try: try:
response = self.client.query_points( response = await self.client.query_points(
collection_name=collection_name, collection_name=collection_name,
query=query_vector, query=query_vector,
limit=limit, limit=limit,
@@ -768,7 +773,7 @@ class QdrantClientWrapper:
count = 0 count = 0
offset = None offset = None
while True: while True:
points, next_offset = self.client.scroll( points, next_offset = await self.client.scroll(
collection_name=collection_name, collection_name=collection_name,
scroll_filter=expiry_filter, scroll_filter=expiry_filter,
limit=100, limit=100,
@@ -784,7 +789,7 @@ class QdrantClientWrapper:
return 0 return 0
# Delete expired points # Delete expired points
self.client.delete( await self.client.delete(
collection_name=collection_name, collection_name=collection_name,
points_selector=expiry_filter points_selector=expiry_filter
) )
@@ -804,7 +809,7 @@ class QdrantClientWrapper:
List of volatile collection names List of volatile collection names
""" """
try: try:
collections = self.client.get_collections() collections = await self.client.get_collections()
return [ return [
c.name for c in collections.collections c.name for c in collections.collections
if c.name.startswith("volatile_") if c.name.startswith("volatile_")
+1
View File
@@ -40,6 +40,7 @@ class Settings(BaseSettings):
# Qdrant Configuration # Qdrant Configuration
qdrant_host: str = Field(default="qdrant", description="Qdrant host") qdrant_host: str = Field(default="qdrant", description="Qdrant host")
qdrant_port: int = Field(default=6333, description="Qdrant port") qdrant_port: int = Field(default=6333, description="Qdrant port")
qdrant_timeout: int = Field(default=30, ge=1, le=300, description="Qdrant client timeout in seconds")
# Wiki.js Configuration # Wiki.js Configuration
wikijs_url: str = Field(default="http://wiki:3000", description="Wiki.js URL") wikijs_url: str = Field(default="http://wiki:3000", description="Wiki.js URL")
+3 -2
View File
@@ -101,7 +101,8 @@ def get_qdrant_client() -> QdrantClientWrapper:
settings = get_settings() settings = get_settings()
client = QdrantClientWrapper( client = QdrantClientWrapper(
url=settings.qdrant_url, url=settings.qdrant_url,
embedding_dim=768 # nomic-embed-text default embedding_dim=768, # nomic-embed-text default
timeout=settings.qdrant_timeout
) )
logger.debug("Created Qdrant client instance") logger.debug("Created Qdrant client instance")
return client return client
@@ -591,7 +592,7 @@ async def check_service_health() -> dict:
try: try:
qdrant = get_qdrant_client() qdrant = get_qdrant_client()
# Check if we can list collections # Check if we can list collections
collections = qdrant.client.get_collections() await qdrant.client.get_collections()
health["qdrant"] = True health["qdrant"] = True
except Exception as e: except Exception as e:
logger.error(f"Qdrant health check failed: {e}") logger.error(f"Qdrant health check failed: {e}")
+2 -2
View File
@@ -198,7 +198,7 @@ class DocumentSyncService:
# Delete existing chunks for this document # Delete existing chunks for this document
try: try:
self.qdrant.client.delete( await self.qdrant.client.delete(
collection_name=collection, collection_name=collection,
points_selector={ points_selector={
"filter": { "filter": {
@@ -242,7 +242,7 @@ class DocumentSyncService:
# Upsert to Qdrant # Upsert to Qdrant
if points: if points:
self.qdrant.client.upsert( await self.qdrant.client.upsert(
collection_name=collection, collection_name=collection,
points=points points=points
) )
+6 -14
View File
@@ -485,34 +485,26 @@ JSON:"""
return [], (time.time() - start) * 1000, None return [], (time.time() - start) * 1000, None
# Get query embedding # Get query embedding
query_embedding = await self.vector.ollama.embed_text(query) query_embedding = await self.vector.ollama.embed(query)
# Search with filter for doc_type=document # Search via the async wrapper with doc_type=document filter
from qdrant_client.models import Filter, FieldCondition, MatchValue search_results = await self.vector.qdrant.search_vectors(
search_results = self.vector.qdrant.client.search(
collection_name=collection_name, collection_name=collection_name,
query_vector=query_embedding, query_vector=query_embedding,
limit=config.document_limit, limit=config.document_limit,
score_threshold=config.document_threshold, score_threshold=config.document_threshold,
query_filter=Filter( filter_conditions={"doc_type": "document"}
must=[
FieldCondition(
key="doc_type",
match=MatchValue(value="document")
)
]
)
) )
# Format results # Format results
formatted = [] formatted = []
for r in search_results: for r in search_results:
payload = r.payload or {} payload = r.get("payload") or {}
formatted.append({ formatted.append({
"paperless_id": payload.get("paperless_id"), "paperless_id": payload.get("paperless_id"),
"title": payload.get("title", "Untitled Document"), "title": payload.get("title", "Untitled Document"),
"content": payload.get("chunk_text", ""), "content": payload.get("chunk_text", ""),
"score": r.score, "score": r["score"],
"correspondent": payload.get("correspondent"), "correspondent": payload.get("correspondent"),
"document_type": payload.get("document_type"), "document_type": payload.get("document_type"),
"tags": payload.get("tags", []), "tags": payload.get("tags", []),
+1 -1
View File
@@ -148,7 +148,7 @@ class TestQdrantIntegration:
collection_name = qdrant_client.get_collection_name(test_user) collection_name = qdrant_client.get_collection_name(test_user)
await qdrant_client.ensure_collection(collection_name) await qdrant_client.ensure_collection(collection_name)
collections = qdrant_client.client.get_collections() collections = await qdrant_client.client.get_collections()
collection_names = [c.name for c in collections.collections] collection_names = [c.name for c in collections.collections]
assert collection_name in collection_names assert collection_name in collection_names
+5 -3
View File
@@ -42,8 +42,6 @@ def mock_qdrant():
qdrant.search_with_expiry_filter = AsyncMock(return_value=[]) qdrant.search_with_expiry_filter = AsyncMock(return_value=[])
qdrant.upsert_vector = AsyncMock(return_value=True) qdrant.upsert_vector = AsyncMock(return_value=True)
qdrant.delete_by_filter = AsyncMock(return_value=0) qdrant.delete_by_filter = AsyncMock(return_value=0)
qdrant.client = MagicMock()
qdrant.client.search = MagicMock(return_value=[])
return qdrant return qdrant
@@ -307,9 +305,13 @@ class TestDocumentLegScoping:
await hybrid_service._retrieve_parallel("q", TENANT, config, {}) await hybrid_service._retrieve_parallel("q", TENANT, config, {})
assert ( assert (
mock_qdrant.client.search.call_args.kwargs["collection_name"] mock_qdrant.search_vectors.await_args.kwargs["collection_name"]
== TENANT_COLLECTION == TENANT_COLLECTION
) )
assert (
mock_qdrant.search_vectors.await_args.kwargs["filter_conditions"]
== {"doc_type": "document"}
)
# ============================================================================= # =============================================================================