سه کارِ بههمپیوسته که از یک کشف شروع شدند: راهحلهایی که vision از کتاب
درآورده گاهی غلطاند، و این خطا در بازیابی دیده نمیشود — فقط وقتی کسی به
`solution` اعتماد کند خودش را نشان میدهد.
۱) بازبینیِ راهحلها (rag_verify_solutions)
هر chunk دوباره حل میشود و با کتاب مقایسه. نتیجه سهحالته است، نه بله/خیر:
اجرای اول با بولی ۱۹٪ خطا گزارش کرد؛ خواندنِ دستیِ نمونهها نشان داد ۳۵٪ از
آنها مسئلههایی بودند که به شکلِ کتاب ارجاع میدادند و اصلاً قابلِ قضاوت
نبودند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ ۳٫۱۰۴. با دو
حالت، «نمیدانم» ناچار به «غلط است» ترجمه میشد.
با سه حالت و پرامپتی که این دو تله را میشناسد: ۲٬۱۱۹ chunk بررسی شد،
۱٬۱۱۲ درست، ۱۲۳ غلط (۵٫۸٪)، ۸۸۱ غیرقابلِ قضاوت. آن ۸۸۱ رایگان برچسب خوردند
چون پیشفیلتر قبل از هر فراخوانی کنارشان گذاشت.
`solution` هرگز بازنویسی نمیشود — کتاب باید کتاب بماند. برچسب در payloadِ
Qdrant هم مینشیند، ولی با نامی که `retrieval._payload_text` نمیخواند: یک
نویسه هم به متنی که به مدل میرسد اضافه نشد.
نمونهی خطاهای واقعیِ کتاب که پیدا شد:
BMI = ۱۰۰/(۲٫۷۴)² کتاب: ۳۳٫۲ درست: ۱۳٫۳
۱۶ آبی + ۲۴ صورتی کتاب: «کل ۵۰» درست: ۴۰
بزرگترین عددِ ۴رقمیِ بیتکرار کتاب: ۸۵۳۲ درست: ۹۸۷۶
g(x) = x³ − ۲ کتاب: «نمایی» درست: چندجملهای
۲) سوالها فقط از راهحلهای تأییدشده
۱٬۱۹۹ سوال در content/seed/questions.json، همه از chunkهایی که برچسبِ
«درست» گرفتهاند. هر شش پایه برنامهی درسی دارند (۱٬۹۲۸ درس) — هشتم و دهم
تازه ساخته شدند.
۳) GET /api/nodes/{id}/quiz
واحدِ بازی در اپ «مبحث» است ولی سوالها به «درس» بستهاند، و درس اینجا
ریزترین دانه است: یک نوعِ مسئله، نه یک جلسه. نتیجهاش این بود که اپ برای یک
آزمونِ چهارسوالی باید دهها درخواست میزد. این اندپوینت از همهی درسهای مبحث
نمونه برمیدارد، حداکثر یکی از هر درس تا آزمون روی یک مهارت گیر نکند.
درهمریزی با بذرِ nodeId:userId پایدار است: رفرشِ صفحه همان آزمون را میدهد،
نه مجموعهی تازهای که پیشرفت را بیمعنا کند. `questionCount` جدا از
`requested` میآید تا مبحثِ کمسوال وانمود نکند کامل است.
──────────
اصلاحاتِ جانبی که سرِ راه لازم شدند:
• QDRANT_TIMEOUT_SECONDS: کلاینت بدونِ timeout ساخته میشد و پیشفرضِ ۵
ثانیهایِ کتابخانه ingestِ کتابِ هشتم را روی اولین فراخوانی کشت.
• پاکسازیِ نقاطِ نیمهنوشته: upsert با wait=True ممکن است نوشته باشد و فقط
جوابش تایماوت شده باشد؛ مسیرِ جبران فقط ردیفهای DB را پس میگرفت و هر
retry یک نسخهی تکراری در Qdrant جا میگذاشت (۸ مورد در پایه نهم، پاک شد).
• تشخیصِ سقفِ حساب: «insufficient user quota» با فاصله میآید نه زیرخط، و
محافظِ اول نمیگرفتش — یک اجرا ۷۷۰ فراخوانیِ محکومبهشکست زد.
• پرامپتِ vision حالا میخواهد محتوای شکل داخلِ متنِ مسئله نوشته شود. ۴۲٪ از
محتوای موجود به شکلی ارجاع میدهد که در متن نیست؛ مدل آن را دیده بود ولی
از او خواسته نشده بود بنویسدش. کتابهای موجود قبل از این قاعده وارد شدهاند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
102 lines
4.2 KiB
Python
102 lines
4.2 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 set_payload_fields(collection: str, point_ids: list[str], fields: dict) -> int:
|
|
"""
|
|
فقط چند کلیدِ payload را روی نقاطِ موجود مینویسد، بدونِ دست زدن به بردار.
|
|
|
|
برای همگامسازیِ برچسبها همین کافی است و از upsertِ کامل خیلی ارزانتر:
|
|
نه بردار دوباره فرستاده میشود نه embedding دوباره خریده میشود.
|
|
"""
|
|
if not point_ids or not fields:
|
|
return 0
|
|
client = clients.get_qdrant_client()
|
|
client.set_payload(
|
|
collection_name=collection,
|
|
payload=fields,
|
|
points=[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]
|