Files
MathApp/rag/services/qdrant_store.py
Zareian 92dc2242f8 take the points back too when an upsert fails
بررسیِ collectionها بعد از ingestِ پایه نهم نشان داد Qdrant ۲۸۷ نقطه دارد ولی
دیتابیس ۲۷۹ ردیف. هشت نقطه‌ی اضافه، همه از صفحه‌ی ۱۲۵ — همان صفحه‌ای که دو بار
تایم‌اوت داد و بار سوم گرفت. سه مسئله سه نسخه داشتند و دو نقطه هم بی‌صاحب بود.

علت: `upsert` با `wait=True` ممکن است نوشته باشد و فقط جوابش تایم‌اوت شده
باشد. مسیرِ جبران فقط ردیف‌های DB را پس می‌گرفت، نه نقطه‌ها؛ و چون هر اجرای
دوباره `qdrant_point_id` تازه می‌سازد، هر retry یک نسخه‌ی تکراری اضافه می‌کرد.

اثرش در بازیابی دیده می‌شود نه در لاگ: top-k با سه نسخه از یک مسئله پر می‌شود
و بقیه‌ی متنِ مرتبط بیرون می‌ماند — یعنی دستیار بافتِ کمتری می‌بیند و هیچ‌کس
هم متوجه نمی‌شود.

حالا در صورتِ شکست، نقطه‌ها هم پاک می‌شوند. اگر خودِ پاک‌سازی هم شکست بخورد
لاگ می‌شود ولی خطای اصلی پوشانده نمی‌شود، چون صفحه باید ناتمام بماند تا اجرای
بعدی دوباره سراغش برود.

داده‌ی موجود هم تمیز شد: ۸ نقطه‌ی بی‌صاحب پاک شدند و حالا ۲۷۹ = ۲۷۹.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:00:09 +03:30

83 lines
3.5 KiB
Python

"""
لایه‌ی نازک روی Qdrant: ساختِ collection، upsert نقاط، و جستجوی برداری.
برداشتِ پاک‌سازی‌شده از subjects/services/qdrant.py مخزنِ Rag_backend.
هیچ کلاینتِ سطحِ ماژولی ساخته نمی‌شه؛ همه از get_qdrant_client() lazy.
"""
from django.conf import settings
from . import clients
def ensure_collection(name: str, vector_size: int | None = None) -> None:
client = clients.get_qdrant_client()
vector_size = vector_size or settings.OPENAI_EMBEDDING_DIM
existing = {c.name for c in client.get_collections().collections}
if name in existing:
return
from qdrant_client.models import Distance, VectorParams
client.create_collection(
collection_name=name,
vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE),
)
def upsert_points(collection: str, points: list[dict]) -> int:
"""points: [{id, vector, payload}, ...]"""
if not points:
return 0
client = clients.get_qdrant_client()
from qdrant_client.models import PointStruct
structs = [
PointStruct(id=str(p['id']), vector=p['vector'], payload=p['payload'])
for p in points
]
client.upsert(collection_name=collection, points=structs, wait=True)
return len(structs)
def delete_points(collection: str, point_ids: list[str]) -> int:
"""
پاک‌کردنِ نقاط با شناسه — برای جبرانِ upsertِ نیمه‌کاره.
`wait=True` در upsert یعنی تایم‌اوتِ کلاینت لزوماً به معنیِ ننوشتنِ Qdrant
نیست؛ ممکن است نوشته باشد و فقط جوابش دیر رسیده باشد. در آن حالت ردیف‌های
DB پس گرفته می‌شوند و نقطه‌ها بی‌صاحب می‌مانند. این تابع همان را جمع می‌کند.
"""
if not point_ids:
return 0
client = clients.get_qdrant_client()
client.delete(collection_name=collection,
points_selector=[str(i) for i in point_ids], wait=True)
return len(point_ids)
def search(collection: str, query_vector: list[float], *, lesson_filter_value=None,
lesson_filter_key: str | None = None, limit: int = 4) -> list[dict]:
"""
نزدیک‌ترین نقاط به بردارِ پرسش، در صورتِ نیاز محدود به یک درس.
خروجی: [{score, payload}, ...] — payload همون چیزیه که تیمِ دیگه ذخیره
کرده (معمولاً شاملِ summary / lesson_title / lesson_number).
"""
client = clients.get_qdrant_client()
query_filter = None
if lesson_filter_value is not None and lesson_filter_key:
from qdrant_client.models import FieldCondition, Filter, MatchValue
# دادهٔ تیمِ دیگه lesson_number/chapter را همیشه به‌صورتِ رشته ذخیره
# کرده ("5")، پس مقدار را به رشته تبدیل می‌کنیم تا فیلتر بخورد.
query_filter = Filter(should=[
FieldCondition(key=lesson_filter_key, match=MatchValue(value=str(lesson_filter_value))),
FieldCondition(key=lesson_filter_key, match=MatchValue(value=lesson_filter_value)),
])
response = client.query_points(
collection_name=collection,
query=query_vector,
limit=limit,
query_filter=query_filter,
)
return [{'score': p.score, 'payload': p.payload or {}} for p in response.points]