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
+31 -26
View File
@@ -8,7 +8,7 @@ Provides async vector operations with:
- Similarity queries
"""
from qdrant_client import QdrantClient
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import (
Distance, VectorParams, PointStruct,
Filter, FieldCondition, MatchValue, Range
@@ -30,17 +30,22 @@ class QdrantClientWrapper:
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.
Uses AsyncQdrantClient so vector calls never block the FastAPI
event loop, with an explicit timeout so a hung Qdrant cannot
stall requests indefinitely.
Args:
url: Qdrant server URL (e.g., "http://qdrant:6333")
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
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:
"""
@@ -62,12 +67,12 @@ class QdrantClientWrapper:
collection_name: Collection name
"""
try:
collections = self.client.get_collections()
collections = await self.client.get_collections()
existing = [c.name for c in collections.collections]
if collection_name not in existing:
logger.info(f"Creating Qdrant collection: {collection_name}")
self.client.create_collection(
await self.client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(
size=self.embedding_dim,
@@ -90,7 +95,7 @@ class QdrantClientWrapper:
True if collection exists
"""
try:
collections = self.client.get_collections()
collections = await self.client.get_collections()
existing = [c.name for c in collections.collections]
return collection_name in existing
except Exception as e:
@@ -161,7 +166,7 @@ class QdrantClientWrapper:
))
try:
self.client.upsert(
await self.client.upsert(
collection_name=collection_name,
points=points
)
@@ -213,7 +218,7 @@ class QdrantClientWrapper:
query_filter = Filter(must=conditions)
try:
response = self.client.query_points(
response = await self.client.query_points(
collection_name=collection_name,
query=query_vector,
limit=limit,
@@ -251,7 +256,7 @@ class QdrantClientWrapper:
collection_name = self.get_collection_name(user)
try:
self.client.delete(
await self.client.delete(
collection_name=collection_name,
points_selector=Filter(
must=[
@@ -287,7 +292,7 @@ class QdrantClientWrapper:
try:
# Scroll through points with doc_id filter
points, _ = self.client.scroll(
points, _ = await self.client.scroll(
collection_name=collection_name,
scroll_filter=Filter(
must=[
@@ -329,7 +334,7 @@ class QdrantClientWrapper:
try:
# 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
# For unique docs, would need to aggregate by doc_id
return collection_info.points_count
@@ -352,7 +357,7 @@ class QdrantClientWrapper:
collection_name = self.get_collection_name(user)
try:
self.client.delete_collection(collection_name)
await self.client.delete_collection(collection_name)
logger.warning(f"Deleted collection: {collection_name}")
return True
except Exception as e:
@@ -382,7 +387,7 @@ class QdrantClientWrapper:
try:
# Get source document chunks with vectors
source_points, _ = self.client.scroll(
source_points, _ = await self.client.scroll(
collection_name=collection_name,
scroll_filter=Filter(
must=[
@@ -404,7 +409,7 @@ class QdrantClientWrapper:
# (could aggregate multiple chunks for better results)
first_vector = source_points[0].vector
response = self.client.query_points(
response = await self.client.query_points(
collection_name=collection_name,
query=first_vector,
limit=limit * 2, # Get more to filter out same doc
@@ -449,7 +454,7 @@ class QdrantClientWrapper:
True if successful
"""
try:
self.client.upsert(
await self.client.upsert(
collection_name=collection_name,
points=[PointStruct(
id=vector_id,
@@ -487,7 +492,7 @@ class QdrantClientWrapper:
query_filter = Filter(must=conditions)
# Delete points
result = self.client.delete(
result = await self.client.delete(
collection_name=collection_name,
points_selector=query_filter
)
@@ -532,7 +537,7 @@ class QdrantClientWrapper:
query_filter = Filter(must=conditions)
try:
response = self.client.query_points(
response = await self.client.query_points(
collection_name=collection_name,
query=query_vector,
limit=limit,
@@ -589,7 +594,7 @@ class QdrantClientWrapper:
try:
while True:
points, next_offset = self.client.scroll(
points, next_offset = await self.client.scroll(
collection_name=collection_name,
scroll_filter=scroll_filter,
limit=batch_size,
@@ -636,7 +641,7 @@ class QdrantClientWrapper:
return 0
try:
self.client.delete(
await self.client.delete(
collection_name=collection_name,
points_selector=point_ids
)
@@ -655,13 +660,13 @@ class QdrantClientWrapper:
List of collection info dictionaries
"""
try:
collections = self.client.get_collections()
collections = await self.client.get_collections()
result = []
for coll in collections.collections:
# Get detailed collection info
try:
info = self.client.get_collection(coll.name)
info = await self.client.get_collection(coll.name)
result.append({
"name": coll.name,
"vectors_count": info.vectors_count or 0,
@@ -717,7 +722,7 @@ class QdrantClientWrapper:
)
try:
response = self.client.query_points(
response = await self.client.query_points(
collection_name=collection_name,
query=query_vector,
limit=limit,
@@ -768,7 +773,7 @@ class QdrantClientWrapper:
count = 0
offset = None
while True:
points, next_offset = self.client.scroll(
points, next_offset = await self.client.scroll(
collection_name=collection_name,
scroll_filter=expiry_filter,
limit=100,
@@ -784,7 +789,7 @@ class QdrantClientWrapper:
return 0
# Delete expired points
self.client.delete(
await self.client.delete(
collection_name=collection_name,
points_selector=expiry_filter
)
@@ -804,7 +809,7 @@ class QdrantClientWrapper:
List of volatile collection names
"""
try:
collections = self.client.get_collections()
collections = await self.client.get_collections()
return [
c.name for c in collections.collections
if c.name.startswith("volatile_")
+1
View File
@@ -40,6 +40,7 @@ class Settings(BaseSettings):
# Qdrant Configuration
qdrant_host: str = Field(default="qdrant", description="Qdrant host")
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
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()
client = QdrantClientWrapper(
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")
return client
@@ -591,7 +592,7 @@ async def check_service_health() -> dict:
try:
qdrant = get_qdrant_client()
# Check if we can list collections
collections = qdrant.client.get_collections()
await qdrant.client.get_collections()
health["qdrant"] = True
except Exception as 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
try:
self.qdrant.client.delete(
await self.qdrant.client.delete(
collection_name=collection,
points_selector={
"filter": {
@@ -242,7 +242,7 @@ class DocumentSyncService:
# Upsert to Qdrant
if points:
self.qdrant.client.upsert(
await self.qdrant.client.upsert(
collection_name=collection,
points=points
)
+6 -14
View File
@@ -485,34 +485,26 @@ JSON:"""
return [], (time.time() - start) * 1000, None
# 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
from qdrant_client.models import Filter, FieldCondition, MatchValue
search_results = self.vector.qdrant.client.search(
# Search via the async wrapper with doc_type=document filter
search_results = await self.vector.qdrant.search_vectors(
collection_name=collection_name,
query_vector=query_embedding,
limit=config.document_limit,
score_threshold=config.document_threshold,
query_filter=Filter(
must=[
FieldCondition(
key="doc_type",
match=MatchValue(value="document")
)
]
)
filter_conditions={"doc_type": "document"}
)
# Format results
formatted = []
for r in search_results:
payload = r.payload or {}
payload = r.get("payload") or {}
formatted.append({
"paperless_id": payload.get("paperless_id"),
"title": payload.get("title", "Untitled Document"),
"content": payload.get("chunk_text", ""),
"score": r.score,
"score": r["score"],
"correspondent": payload.get("correspondent"),
"document_type": payload.get("document_type"),
"tags": payload.get("tags", []),