نظام RAG خاص على وحدة معالجة رسومات (GPU) واحدة بسعة 6-8 جيجابايت: نموذج Qwen صغير، وnomic-embed، وQdrant
RAG 7 min2025-10-20

نظام RAG خاص على وحدة معالجة رسومات (GPU) واحدة بسعة 6-8 جيجابايت: نموذج Qwen صغير، وnomic-embed، وQdrant

نظام سؤال وجواب خاص للمستندات مدعوم بالاستشهادات على وحدة معالجة رسومات (GPU) استهلاكية واحدة: ما الذي يتسع في 6 جيجابايت، وما الذي يحتاج 8 جيجابايت، كود برمجي جاهز للإدخال والبحث الهجين، وكيفية قياس مدى تحمله لطلبات المستخدمين.

إذا كانت مستنداتك لا يمكنها الخروج من المبنى، فإن استخدام واجهة برمجة تطبيقات (API) سحابية يعتبر خياراً مستبعداً قبل حتى أن يبدأ أي شخص في مقارنة النماذج. يشمل ذلك الملفات القانونية السرية، وسجلات المرضى، والكثير من المواد المالية الخاضعة للرقابة. الفكرة التالية المعتادة هي خادم GPU يكلف عشرات الآلاف، وغالباً ما يتعطل هذا القرار لربع سنة كامل.

هناك خطوة أولى أصغر. يمكن لوحدة معالجة رسومات (GPU) استهلاكية واحدة بذاكرة 6 إلى 8 جيجابايت تشغيل نظام سؤال وجواب للمستندات خاص لفريق واحد. يجيب من ملفاتك، ويستشهد بالصفحة، ولا يرسل أي شيء لطرف ثالث.

تتكون الحزمة من ثلاثة أجزاء. نموذج من عائلة Qwen بحوالي 3 مليارات معلمة (parameters) لكتابة الإجابات. وnomic-embed-text لتحويل النص إلى متجهات (vectors) قابلة للبحث. وQdrant لتخزينها والبحث فيها.

NOTE

اقرأ كلمة "إنتاج" (production) بحذر هنا. وحدة GPU واحدة تناسب فريقاً أو قسماً يُقاس بعشرات المستخدمين، وليس المئات. إذا كنت بحاجة إلى مئات المستخدمين المتزامنين، فاشترِ المزيد من وحدات GPU واستخدم محرك تقديم (serving engine) مناسب. يغطي دليل السرعة للأنظمة الداخلية هذا المسار.

متى تكون هذه الحزمة هي الخيار الصحيح

حالتكحزمة GPU واحدة؟السبب
فريق واحد، بضعة آلاف من المستندات، يجب أن تبقى البيانات في الموقعنعمتكلفة أجهزة منخفضة، ولا شيء يخرج من المبنى
تحتاج إلى مشروع تجريبي يعمل على بيانات حقيقية قبل رصد ميزانية للأجهزةنعميوضح ما إذا كانت جودة الاسترجاع (retrieval) متماسكة قبل إنفاق المزيد
الإجابات تحتاج إلى استنتاج (reasoning) متعدد الخطوات عبر مستندات عديدةلاالنماذج الصغيرة تفقد تسلسل الأفكار. استخدم نموذجاً أكبر
مئات المستخدمين المتزامنين في أوقات الذروةلاوحدة GPU واحدة تضع الطلبات في طابور ويرتفع زمن الاستجابة (latency) بسرعة
مجموعة مستندات مختلطة باللغتين العربية والإنجليزيةنعم، مع نموذج تضمين (embedding) مختلفراجع الأسئلة الشائعة أدناه

تفشل العديد من المشاريع التجريبية عند نفس النقطة. تعمل بشكل جيد على عشرة ملفات عينة، ثم لا يتحقق أحد مما إذا كانت تعمل على مجموعة المستندات الحقيقية. البناء بحجم صغير على بيانات حقيقية يتجنب هذا الفخ.

ما الذي يتسع في 6 جيجابايت، وما الذي يحتاج 8 جيجابايت

قدّر الذاكرة قبل شراء أي شيء. تستهلك أوزان النموذج تقريباً عدد المعلمات × البتات لكل وزن ÷ 8. نموذج بـ 3 مليارات معلمة بتكميم (quantization) Q4_K_M، والذي يبلغ متوسطه أقل بقليل من 5 بتات لكل وزن، يحتاج حوالي 1.7 إلى 2.0 جيجابايت. يخزن التكميم الأوزان بدقة أقل حتى يتسع النموذج في بطاقات أصغر. تُدرج Hugging Face نموذج Qwen الأساسي بحجم 3B بـ 3.09 مليار معلمة، مع سياق يبلغ 32,768 توكن (token).

الباقي يذهب إلى ذاكرة التخزين المؤقت للسياق (context cache)، والتي تحتفظ بالسؤال والمقاطع (chunks) المسترجعة. وتنمو مع نافذة السياق ومع كل طلب متزامن. يضيف نموذج التضمين بضع مئات من الميجابايت. يعمل Qdrant في ذاكرة النظام (RAM)، وليس على GPU. يوثق Qdrant أن التكميم الخاص به يبقي المتجهات المضغوطة في RAM وينقل النسخ الأصلية بالدقة الكاملة إلى القرص.

المكونالحد الأدنىالموصى به
ذاكرة GPU6 جيجابايت (أي بطاقة NVIDIA حديثة)8 إلى 12 جيجابايت، مثل RTX 4060 8GB أو RTX 3060 12GB
ذاكرة النظام (RAM)16 جيجابايت32 جيجابايت
التخزين100 جيجابايت SSD500 جيجابايت NVMe
المعالج (CPU)8 أنوية16 نواة

على بطاقة 6 جيجابايت، خطط لاستخدام Q4_K_M، وسياق 8K، وطلب أو طلبين متزامنين. بطاقة 8 أو 12 جيجابايت تمنحك طلبات متزامنة أكثر أو تكميماً بدقة أعلى. لا تثق في تقديرنا على أجهزتك. قم بتشغيل nvidia-smi تحت الضغط واقرأ الرقم الحقيقي.

إعداد Ollama

يُشغل Ollama النماذج المكممة محلياً ويوفر واجهة برمجة تطبيقات (API) متوافقة مع OpenAI. تتغير وسوم (tags) النماذج في مكتبته بين الإصدارات. اختر نموذج تعليمات (instruct) من عائلة Qwen بحوالي 3 مليارات معلمة من المكتبة، وتحقق من الوسم الدقيق، وقم بتعيينه مرة واحدة.

</>View technical implementation · عرض التفاصيل التقنية
# Install Ollama (Linux)
curl -fsSL https://ollama.com/install.sh | sh

# Set the exact tag you picked from the Ollama library
export LLM_TAG="<qwen-3b-tag-from-library>"

ollama pull "$LLM_TAG"
ollama pull nomic-embed-text

# Confirm the model is on the GPU, not the CPU
ollama run "$LLM_TAG" "Hello" > /dev/null
ollama ps    # PROCESSOR column should read 100% GPU

إذا أظهر ollama ps انقساماً بين CPU/GPU، فهذا يعني أن النموذج لم يتسع. قم بتقليص السياق أو التكميم حتى يقرأ 100% GPU.

ملف تعريف النموذج لإجابات دقيقة ومستندة

</>View technical implementation · عرض التفاصيل التقنية
cat > Modelfile << EOF
FROM ${LLM_TAG}

SYSTEM """You are a precise knowledge assistant. Answer ONLY from the provided context.
If the answer is not in the context, say exactly: "This information is not in the available documents."
Cite sources using [Source N]. Be concise. Do not speculate."""

# Low temperature for factual answers
PARAMETER temperature 0.1
PARAMETER top_p 0.9
# Context window: the main VRAM lever after quantization
PARAMETER num_ctx 8192
# Cap answer length
PARAMETER num_predict 512
PARAMETER repeat_penalty 1.1
EOF

ollama create verel-rag -f Modelfile

تحتوي بعض نماذج Qwen الأحدث على وضع استنتاج (reasoning mode). هذا يضيف زمن الاستجابة (latency) ولا يقدم الكثير عندما تكون الإجابة موجودة في المقطع المسترجع. تحقق من بطاقة النموذج وقم بإيقاف تشغيله لهذا النوع من أعباء العمل.

استضافة Qdrant ذاتياً

</>View technical implementation · عرض التفاصيل التقنية
docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage:z \
  qdrant/qdrant:latest

curl http://localhost:6333/healthz

قم بتثبيت إصدار صورة (image) محدد في بيئة الإنتاج بدلاً من latest. الترقية غير المخطط لها عند إعادة التشغيل ليست شيئاً ترغب في تصحيح أخطائه في الساعة 9 صباحاً.

الإدخال (Ingestion)، مع بحث هجين مدمج منذ اليوم الأول

البحث الدلالي (Semantic search) يجد المقاطع ذات المعنى المشابه. لكنه ضعيف في السلاسل النصية الدقيقة: رموز المنتجات، أرقام البنود، أسماء الأدوية. البحث بالكلمات المفتاحية (BM25) يلتقطها. تشغيل كليهما ودمج النتائج يُسمى البحث الهجين (hybrid search). إعداده أثناء الإدخال أرخص من تعديله لاحقاً، لذا سنقوم بإنشاء كلا النوعين من المتجهات الآن.

هناك تفصيلان يوقعان الناس في الخطأ. الأول، يتوقع nomic-embed-text بادئات للمهام: search_document: على النص المخزن و search_query: على الأسئلة. تجاهلها وسيؤدي ذلك إلى انخفاض جودة الاسترجاع بهدوء. الثاني، أداة التقسيم في LangChain تحسب الأحرف، وليس التوكنز. تُدرج بطاقة نموذج nomic-embed-text على Hugging Face طول سياق يبلغ 8192 توكن.

</>View technical implementation · عرض التفاصيل التقنية
import uuid
import ollama
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import DirectoryLoader, PyPDFLoader
from fastembed import SparseTextEmbedding
from qdrant_client import QdrantClient, models

COLLECTION  = "enterprise_docs"
EMBED_MODEL = "nomic-embed-text"
EMBED_DIM   = 768

qdrant = QdrantClient(host="localhost", port=6333)
bm25   = SparseTextEmbedding(model_name="Qdrant/bm25")

if not qdrant.collection_exists(COLLECTION):
    qdrant.create_collection(
        collection_name=COLLECTION,
        vectors_config={
            "dense": models.VectorParams(size=EMBED_DIM, distance=models.Distance.COSINE),
        },
        sparse_vectors_config={
            "bm25": models.SparseVectorParams(modifier=models.Modifier.IDF),
        },
    )
    # Index the tenant field so filtered searches stay fast
    qdrant.create_payload_index(COLLECTION, "namespace", models.PayloadSchemaType.KEYWORD)

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1500,      # characters, roughly 350-400 English tokens
    chunk_overlap=200,
    separators=["\n\n", "\n", ". ", " ", ""],
)

def embed_docs(texts: list[str]) -> list[list[float]]:
    prefixed = [f"search_document: {t}" for t in texts]
    return ollama.embed(model=EMBED_MODEL, input=prefixed)["embeddings"]

def embed_query(text: str) -> list[float]:
    return ollama.embed(model=EMBED_MODEL, input=[f"search_query: {text}"])["embeddings"][0]

def ingest_directory(path: str, namespace: str = "default", batch_size: int = 32):
    docs   = DirectoryLoader(path, glob="**/*.pdf", loader_cls=PyPDFLoader).load()
    chunks = splitter.split_documents(docs)

    for i in range(0, len(chunks), batch_size):
        batch  = chunks[i : i + batch_size]
        texts  = [c.page_content for c in batch]
        dense  = embed_docs(texts)
        sparse = list(bm25.embed(texts))

        points = [
            models.PointStruct(
                id=str(uuid.uuid4()),
                vector={
                    "dense": d,
                    "bm25": models.SparseVector(
                        indices=s.indices.tolist(), values=s.values.tolist()
                    ),
                },
                payload={
                    "text": c.page_content,
                    "source": c.metadata.get("source", "unknown"),
                    "page": c.metadata.get("page", 0),
                    "namespace": namespace,
                },
            )
            for c, d, s in zip(batch, dense, sparse)
        ]
        # Upsert per batch so a failure mid-run doesn't lose everything
        qdrant.upsert(collection_name=COLLECTION, points=points)

قم بقياس وقت عملية إدخال كاملة على مجموعة مستنداتك الخاصة. ملفات PDF الممسوحة ضوئياً تحتاج إلى التعرف البصري على الحروف (OCR) أولاً، وهذه الخطوة عادة ما تكون أبطأ من كل ما سبق مجتمعاً.

لاحظ أين تتوقف هذه الحزمة. الفلتر الوحيد هو حقل المستأجر (tenant). لا توجد تواريخ، ولا حالة مسودة أو معتمد، ولا صلاحيات لكل مجموعة على مقطع معين. يمكن لمشروع تجريبي لفريق واحد التعايش مع ذلك. أي شيء تتم مشاركته عبر الأقسام يحتاج إلى مجموعات وصول، وتواريخ، وحالة على كل مقطع قبل أن يتوسع نطاقه.

الاسترجاع (Retrieval) والتوليد (Generation)

تُشغل واجهة برمجة تطبيقات الاستعلام في Qdrant كلا البحثين كـ "جلب مسبق" (prefetches) وتدمجهما باستخدام Reciprocal Rank Fusion. تدمج هذه الطريقة القائمتين المُرتبتين، لذا لا حاجة لمعايرة الدرجات. ضع فلتر المستأجر على كل جلب مسبق، حتى لا يصل أي مقطع خاص بمستأجر آخر إلى عملية الدمج.

</>View technical implementation · عرض التفاصيل التقنية
from openai import OpenAI

llm = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

def retrieve(query: str, namespace: str = "default", top_k: int = 6) -> list[dict]:
    tenant = models.Filter(must=[
        models.FieldCondition(key="namespace", match=models.MatchValue(value=namespace))
    ])
    s = list(bm25.query_embed(query))[0]

    result = qdrant.query_points(
        collection_name=COLLECTION,
        prefetch=[
            models.Prefetch(query=embed_query(query), using="dense", filter=tenant, limit=20),
            models.Prefetch(
                query=models.SparseVector(indices=s.indices.tolist(), values=s.values.tolist()),
                using="bm25", filter=tenant, limit=20,
            ),
        ],
        query=models.FusionQuery(fusion=models.Fusion.RRF),
        limit=top_k,
        with_payload=True,
    )
    return [
        {"text": p.payload["text"], "source": p.payload["source"], "page": p.payload["page"]}
        for p in result.points
    ]

def answer(query: str, namespace: str = "default") -> dict:
    chunks = retrieve(query, namespace)
    if not chunks:
        return {"answer": "This information is not in the available documents.", "sources": []}

    context = "\n\n---\n\n".join(
        f"[Source {i}: {c['source']}, p.{c['page']}]\n{c['text']}"
        for i, c in enumerate(chunks, 1)
    )
    prompt = f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer with [Source N] citations:"

    reply = llm.chat.completions.create(
        model="verel-rag",
        messages=[{"role": "user", "content": prompt}],
    ).choices[0].message.content

    cited = [
        {"index": i, "file": c["source"], "page": c["page"]}
        for i, c in enumerate(chunks, 1) if f"[Source {i}]" in reply
    ]
    return {"answer": reply, "sources": cited}

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

تقدير زمن الاستجابة قبل أن يفعله المستخدمون نيابة عنك

نحن لا ننشر أرقام قياس الأداء (benchmarks) لهذه الحزمة. فهي تختلف كثيراً باختلاف البطاقة، والتعريف (driver)، ومجموعة المستندات، وطول الإجابة. قس أداءك الخاص باستخدام هذه المعادلة:

زمن الاستجابة ≈ تضمين الاستعلام + البحث + (توكنز الموجّه ÷ سرعة التعبئة المسبقة) + (توكنز الإجابة ÷ سرعة التوليد) + انتظار الطابور

إليك مثال عملي باستخدام متغيراتك الخاصة. ستة مقاطع بحوالي 375 توكن تعطي حمولة سياق تبلغ 2,250 توكن. مع التعليمات، يكون الموجّه (prompt) أكبر قليلاً. إجابة مكونة من 200 توكن بسرعة التوليد المقاسة G تستغرق 200 ÷ G ثانية. يطبع Ollama معدلات التعبئة المسبقة (prefill) والتوليد عندما تقوم بتشغيل ollama run --verbose.

انتظار الطابور هو العامل المؤثر سلباً. يتحكم إعداد OLLAMA_NUM_PARALLEL في Ollama في عدد الطلبات التي تعمل في وقت واحد. كل خانة متزامنة تحجز ذاكرة السياق الخاصة بها، لذا فإن بطاقة 6 جيجابايت تدعم خانات قليلة. عندما يتجاوز المستخدمون الخانات المتاحة، فإنهم ينتظرون في الطابور. قم باختبار التحميل (Load-test) باستخدام ذروة عدد المستخدمين الحقيقيين، وليس المتوسط.

قائمة التحقق قبل الإطلاق

  • ▸ يُظهر ollama ps استخدام 100% لـ GPU تحت الحمل الحقيقي، بحجم السياق الذي اخترته
  • ▸ يُفعل nvidia-smi -pm 1 وضع الاستمرار (persistence mode)، بحيث يبقى التعريف مهيأً بين الطلبات
  • ▸ تعيين OLLAMA_KEEP_ALIVE حتى لا يتم تفريغ النموذج خلال فترات الهدوء
  • ▸ تشغيل Ollama و Qdrant كخدمات يُعاد تشغيلها عند التعطل
  • ▸ تثبيت إصدار صورة Qdrant، والبيانات على وحدة تخزين مستمرة (persistent volume)
  • ▸ لقطات (snapshots) يومية لـ Qdrant عبر (POST /collections/{name}/snapshots)، ونسخها خارج الجهاز
  • ▸ تطبيق فلتر المستأجر على كل مسار بحث، واختباره باستعلام عبر المستأجرين يجب ألا يُرجع شيئاً
  • ▸ حد معدل (Rate limit) لكل مستخدم في طبقة API، حتى لا يتمكن شخص واحد من حظر الطابور
  • ▸ مجموعة اختبار من الأسئلة الحقيقية بإجابات معروفة، يتم تشغيلها قبل كل تغيير في النموذج أو التقسيم (chunking)
  • ▸ مراقبة ذاكرة GPU، وعمق الطابور، ونسبة الإجابات التي لا تحتوي على استشهاد

الأسئلة الشائعة

هل يمكن تشغيل هذا على خادم يعتمد على CPU فقط؟ نعم، لكن التوليد يصبح أبطأ بعدة مرات، وتستغرق الإجابات ثوانٍ عديدة. يناسب ذلك المهام المجمعة (batch jobs) الليلية، مثل تلخيص تراكم المستندات. لكنه لا يناسب شخصاً ينتظر إجابة على مكتبه.

ماذا لو كانت مستنداتنا باللغة العربية؟ قم بتغيير نموذج التضمين (embedding) قبل أي شيء آخر. يُعد multilingual-e5-large خياراً مُثبتاً للاسترجاع باللغتين العربية والإنجليزية، وقواعد التطبيع (normalization) التي يجب تطبيقها قبل فهرسة اللغة العربية لا تقل أهمية عن النموذج. يستخدم بادئاته الخاصة (query: و passage: ) ويُخرج 1,024 بُعداً، لذا قم بتحديث حجم المجموعة (collection size). تتعامل نماذج عائلة Qwen مع اللغة العربية الفصحى الحديثة بشكل جيد معقول. اختبر المستندات باللهجات أو بالنصوص المختلطة على مجموعة أسئلتك الخاصة. يغطي مقالنا حول الذكاء الاصطناعي العربي في بيئة الإنتاج عملية التقسيم إلى توكنز (tokenization)، واللهجات، والبحث الذي يجد المستند الصحيح.

هل التكميم (quantization) بـ 4 بت دقيق بما يكفي للعمل الخاضع للرقابة؟ في نظام RAG، تعود معظم الإجابات الخاطئة إلى الاسترجاع، وليس إلى دقة النموذج. إذا لم يصل المقطع الصحيح إلى النموذج أبداً، فلن ينقذ أي إعداد للدقة الإجابة. لا تجادل في ذلك نظرياً. قم بتشغيل مجموعة الاختبار الخاصة بك على Q4_K_M وعلى تكميم أعلى، ثم قارن. احتفظ بـ 4 بت فقط إذا تطابقت الدرجات على مستنداتك.

أنظمة معرفة الشركات →
بحث خاص عبر مستنداتك مع استشهادات على مستوى الصفحة، داخلياً (on-premise) إذا كانت متطلبات الإقامة تفرض ذلك. تكلفة ثابتة من 8 آلاف إلى 45 ألف دولار، من 6 إلى 8 أسابيع.

ابدأ بمجموعة الاختبار، وليس بطلب الأجهزة. اكتب خمسين سؤالاً حقيقياً مع الإجابة الصحيحة والصفحة لكل منها، مستمدة مما يسأله فريقك في أسبوع عادي. مررها عبر هذه الحزمة على بطاقة واحدة. ستخبرك النتيجة ما إذا كان يجب نشر النظام، أو ترقية GPU، أو إصلاح الاسترجاع أولاً.

→ لماذا يعتبر نموذج LLM المحلي لديك بطيئاً: أدوات تحسين حزمة التقديم بالترتيب → تقييم نظام RAG قبل الإطلاق: مجموعة اختبار من 100 سؤال وأربعة مقاييس للقبول → مقارنة بين Qdrant و pgvector و Pinecone عند أكثر من 10 ملايين متجه: الذاكرة، الفلترة، ومتى يجب الانتقال

الخدمات ذات الصلة