انتقل إلى المحتوى الرئيسي

الوحدة 9 — نظام أسئلة وأجوبة محلّيّ على المستندات

بعد أن صار Ollama يعمل ويتّصل بالتطبيقات (الوحدات 1 إلى 8)، نبني الحالة الأكثر طلبًا في المكتب: مساعد يجيب عن أسئلة على أرشيف عقود ومذكّرات، دون أن يخرج أيّ سطر من المكتب. هذه هي RAG محلّيّة كاملة، من الفهرسة إلى الجواب.

معماريّة RAG بأربع طبقات

1. المستندات (PDF، docx، نصّ)

2. التقطيع (chunks بحجم مقبول للسياق)

3. التضمين (Ollama + nomic-embed-text)

4. الفهرسة (Chroma على القرص المحلّيّ)

عند الاستفسار:
سؤال → تضمين → بحث في Chroma → أعلى 5 قطع → قالب → chat-model → جواب مع مصادر

كلّ حلقة تعمل محلّيًّا. لا نداء خارجيّ في المسار كلّه. راجع الدورة 18 لتفاصيل RAG العامّة؛ هنا نُركّز على ما يخصّ Ollama.

اختيار نموذج التضمين

nomic-embed-text هو الافتراضيّ الجيّد في Ollama: 768 بُعدًا، متعدّد اللغات، سياق 8 K، رخصة Apache. البدائل:

  • bge-m3: متعدّد اللغات ممتاز في العربيّة، 1024 بُعدًا، أثقل.
  • mxbai-embed-large: جودة عالية بالإنجليزيّة، ضعيف في العربيّة.
  • snowflake-arctic-embed: سريع، مناسب للفهرسة الضخمة.

قاعدة: نموذج التضمين المستعمل للفهرسة هو نفسه المستعمل للاستفسار. تغييره لاحقًا يستوجب إعادة فهرسة كاملة.

بناء الفهرس

from pathlib import Path
import chromadb
import ollama
from pypdf import PdfReader

client = chromadb.PersistentClient(path="./index-amana")
collection = client.get_or_create_collection(
name="dossiers",
metadata={"hnsw:space": "cosine"},
)

def decouper(texte: str, taille: int = 800, chevauchement: int = 120):
"""تقطيع بسيط: قطع بطول ثابت مع تداخل."""
debut = 0
while debut < len(texte):
yield texte[debut:debut + taille]
debut += taille - chevauchement

def indexer_pdf(chemin: Path):
texte = "\n".join(p.extract_text() or "" for p in PdfReader(chemin).pages)
for i, morceau in enumerate(decouper(texte)):
vec = ollama.embeddings(model="nomic-embed-text", prompt=morceau)["embedding"]
collection.add(
ids=[f"{chemin.stem}-{i:04d}"],
embeddings=[vec],
documents=[morceau],
metadatas=[{"source": chemin.name, "chunk": i}],
)

for f in Path("./contrats").glob("*.pdf"):
indexer_pdf(f)

على المحطّة الرئيسيّة (RTX 4090)، فهرسة مئة عقد (5000 قطعة تقريبًا) تستغرق ≈ 3 دقائق. الملفّ يُخزَّن في ./index-amana بحجم بضع مئات من الميغابايتات.

الاستفسار مع الاسترجاع

def repondre(question: str, k: int = 5):
q_vec = ollama.embeddings(model="nomic-embed-text", prompt=question)["embedding"]
resultats = collection.query(query_embeddings=[q_vec], n_results=k)
passages = resultats["documents"][0]
sources = [m["source"] for m in resultats["metadatas"][0]]

contexte = "\n\n---\n\n".join(
f"[مصدر: {src}]\n{p}" for src, p in zip(sources, passages)
)
prompt = f"""أجب عن السؤال التالي بناءً حصريًّا على المقاطع المُعطاة.
إن لم يكن الجواب في المقاطع، قل ذلك صراحةً.
اذكر مصدر كلّ معلومة في نهاية الجواب.

المقاطع:
{contexte}

السؤال: {question}
"""
r = ollama.chat(
model="amana-assistant",
messages=[{"role": "user", "content": prompt}],
options={"temperature": 0.1, "num_ctx": 8192},
)
return r["message"]["content"], sources

النموذج يتلقّى المقاطع فقط، ويُنبَّه صراحةً على أن يعترف بجهله حين لا يجد الجواب. هذا يقلّل «الاختلاق» الشائع في RAG السيّئ.

عتبة اليقين

الاسترجاع بلا عتبة يُعيد دائمًا خمسة مقاطع، حتّى حين لا صلة لأيّها بالسؤال. النتيجة: النموذج يبني جوابًا على مقاطع بلا صلة. الحلّ: عتبة تشابه:

resultats = collection.query(query_embeddings=[q_vec], n_results=k)
distances = resultats["distances"][0]
seuil = 0.6 # مسافة جيبيّة قصوى
utiles = [
(doc, src)
for doc, src, dist in zip(
resultats["documents"][0],
[m["source"] for m in resultats["metadatas"][0]],
distances,
)
if dist < seuil
]
if not utiles:
return "لم أجد في أرشيف المكتب مقاطع ذات صلة بسؤالك.", []

قيمة العتبة تُعايَر على عيّنة من 30 سؤالًا. عتبة صارمة جدًّا: يرفض حتّى الأسئلة المشروعة. عتبة رخوة: يُجيب على ما لا يعرف.

جودة اللغة

النموذج اللغويّ يجيب باللغة التي يفهمها من المقاطع والسؤال. إن كانت المقاطع بالفرنسيّة والسؤال بالعربيّة، قد يخلط. الحلّ في نظام المكتب:

  • توحيد لغة المقاطع عند الفهرسة (كلّها مذكّرات بالفرنسيّة، أو كلّها عقود بالعربيّة).
  • تعليمة صريحة في القالب: «أجب بالعربيّة الفصحى مهما كانت لغة المقاطع».
  • استعمال نموذج ثنائيّ اللغة قويّ (qwen2.5:7b-instruct أو الأكبر منه).

الفخّ: عدم تقطيع الفواتير

المذكّرات والعقود تُقطَّع بالنصّ. الفواتير والجداول لا تُقطَّع بلا معنى: صفّ رقم مع صفّ آخر يشوّه المعلومة. البديل: استخراج مبنيّ قبل الفهرسة (تحويل كلّ فاتورة إلى JSON: التاريخ، المبلغ، الموكّل، رقم المرجع)، ثمّ فهرسة كائن JSON نصّيّ كامل كقطعة واحدة.

الأداء والحدود

على المحطّة الرئيسيّة، دورة استفسار كاملة (تضمين + استرجاع + توليد):

  • تضمين السؤال: ≈ 80 ms.
  • استفسار Chroma على 5000 مدخل: ≈ 15 ms.
  • توليد جواب 200 رمز على qwen2.5:7b Q4: ≈ 4 s.

المجموع 4 ثوانٍ لجواب مصدَّق بمراجعه. أسرع من أن يبحث المحامي يدويًّا في الأرشيف، وأدقّ من نموذج بلا مصادر.

الحدود:

  • الأرشيف أكبر من 100 K قطعة يستدعي فهرسًا أكفأ (FAISS، Qdrant محلّيّ).
  • الأسئلة العدديّة (كم عقدًا في 2024؟) تحتاج معالجة مسبقة، لا RAG وحده.
  • الأسئلة عبر مستندات كثيرة (مقارنة عشرة عقود) تحتاج مخطّطًا مركّبًا (agentic RAG).

الخيط الأحمر

للمكتب، النظام النهائيّ:

  • فهرس Chroma في D:\ollama\index-amana، محدَّث ليليّاً بسكربت يمشي على \\serveur\contrats\.
  • واجهة استفسار عبر Open WebUI (الوحدة 8): المستخدم يختار «نمط الاستشارة القانونيّة» فيستدعي repondre().
  • كلّ جواب يحوي قائمة مراجع؛ الجواب بدون مراجع يُعتبر إشارة إلى ثغرة في الأرشيف، لا كسل في النموذج.
قِس RAG على أزواج معروفة الجواب

اجمع من زميل قانونيّ 20 سؤالًا يعرف جوابها ومصدرها في الأرشيف. اختبر النظام: هل استرجع الوثيقة الصحيحة؟ هل استخرج المعلومة الصحيحة؟ هذا الجدول يمنع الانحدار عند كلّ تعديل.

الخلاصة

  • RAG محلّيّة كاملة: تضمين ب nomic-embed-text، فهرس Chroma، توليد بنموذج Ollama مخصّص.
  • نموذج التضمين ثابت بعد الفهرسة؛ تغييره يعني إعادة بناء كلّ شيء.
  • عتبة التشابه ضروريّة لتفادي الأجوبة المخترعة على مقاطع بلا صلة.
  • الأنواع الخاصّة (فواتير، جداول) تحتاج استخراجًا مسبقًا لا تقطيعًا نصّيًّا ساذجًا.