ابنِ مسار RAG على خادم VPS خاص بك
قسّم النصوص، وأنشئ embeddings، وخزّنها واسترجعها على VPS واحد. تعرّف إلى مخطط pgvector، وضبط HNSW، ونموذج محلي، واستعلام SQL يثبت نجاح الاسترجاع.
كيف تبدو منظومة RAG مستضافة ذاتياً
تتكوّن منظومة RAG (التوليد المعزّز بالاسترجاع) من خمس مراحل: تقسيم المستندات إلى مقاطع، وإنشاء embeddings لهذه المقاطع، وتخزين المتجهات، واسترجاع أقرب المتجهات إلى السؤال، ثم إرسال تلك المقاطع إلى نموذج لغوي يكتب الإجابة. على VPS تستأجره مسبقاً، تُنفَّذ المراحل الأربع الأولى على الخادم نفسه. يخزّن PostgreSQL مع إضافة pgvector المتجهات، ويحوّل نموذج embedding صغير يقدّمه Ollama النص إلى متجهات. أما المرحلة الأخيرة فقط، فيجب أن تُنفَّذ خارج الخادم.
هذا التقسيم هو أساس هذا الدليل. تقسيم النصوص إلى مقاطع عملية تعتمد على CPU فقط. أما إنشاء embeddings، فيستخدم نموذجاً يحتوي على 137 مليون معلَمة ويشغل بضع مئات من ميغابايت من RAM. والتخزين عبارة عن جدول PostgreSQL يمكنك حساب حجمه باستخدام العمليات الحسابية قبل كتابة صف واحد. بالنسبة إلى مجموعة نصوص تحتوي على مئات الآلاف من المقاطع، يمكن تنفيذ كل ذلك على VPS عادي. أما التوليد فمختلف، لأنه يفرض تكلفة مع كل سؤال، إلى أجل غير محدد.
ما الأجزاء التي تكلّف مالاً فعلياً في مسار RAG
يلجأ البرنامج التعليمي الشامل لمسار RAG من DigitalOcean إلى قاعدة بيانات متجهات مُدارة ونموذج تضمين مستضاف، بينما يتناول قسم التكاليف الجوانب النوعية: خزّن الاستعلامات المتكررة مؤقتاً، وأبقِ عدد المقاطع المسترجعة صغيراً، وأعد ترتيبها قبل التوليد. هذه النصائح صحيحة. لكنها تتجاهل الخيار الذي يغيّر الحسابات، وهو تشغيل نموذج التضمين على الخادم الذي تدفع تكلفته بالفعل.
احسب عدد الرموز بدلاً من الدولارات، لأن أعداد الرموز لا تصبح قديمة عند تغيّر قائمة الأسعار. افترض مجموعة نصوص تتكون من 100,000 مقطع، يحتوي كل منها على 400 رمز، و10,000 سؤال مطروحاً عليها، وإرسال 8 مقاطع إلى النموذج لكل إجابة، وسؤال وكتلة تعليمات من 100 رمز، وإجابات من 400 رمز.
The data behind this chart
[
{
"label": "Embed the corpus (once)",
"tokens_millions": 40,
"tokens_per_question": "4,000"
},
{
"label": "Embed each question",
"tokens_millions": 0.2,
"tokens_per_question": "20"
},
{
"label": "Generation input",
"tokens_millions": 33,
"tokens_per_question": "3,300"
},
{
"label": "Generation output",
"tokens_millions": 4,
"tokens_per_question": "400"
}
]يتطلب تضمين مجموعة النصوص كاملة 40 مليون رمز، ويحدث ذلك مرة واحدة. وعند توزيعه على 10,000 سؤال، يصبح 4,000 رمزاً لكل سؤال. وإذا طرحت مئة ألف سؤال، ينخفض إلى 400. أما التوليد فلا ينخفض أبداً. فهو يكلّف 3,300 رمزاً داخلاً و400 رمزاً خارجاً مع كل سؤال ستجيب عنه مستقبلاً.
لذلك تتبع التكلفة المالية المرحلة التي تتكرر. شغّل خطوة التضمين بنفسك، لأنك تدفع تكلفتها مرة واحدة، ولأن VPS يعمل على أي حال. واشترِ خطوة التوليد، لأن النموذج الأفضل يستحق إنفاقاً فعلياً في هذه المرحلة. وينطبق السبب نفسه على التخزين المؤقت: فتطابق ذاكرة التخزين المؤقت يتجاوز المرحلة الوحيدة التي لا تنخفض تكلفتها بمرور الوقت. يحدد الفرق بين KV cache وprompt cache أي نصف من ذلك يمكنك إعادة استخدامه، كما أن مطالبة RAG تتكون من كتلة تعليمات ثابتة تتبعها كتلة مقاطع متغيرة، وهي البنية التي تستفيد أكثر من ذلك.
التقسيم إلى أجزاء: لماذا يُعد الحجم الثابت مع التداخل الخيار الافتراضي الصحيح
الجزء هو الوحدة التي تسترجعها، لذلك يحدد حجمه كل ما يأتي بعد ذلك. يجب أن يكون صغيراً بما يكفي ليعبّر التضمين الخاص به عن موضوع واحد، لأن التضمين نقطة واحدة في الفضاء. لذلك، إذا غطّى الجزء أربعة موضوعات، فسيستقر بينها ويصبح قريباً من لا شيء منها. ويجب أن يكون كبيراً بما يكفي للإجابة عن سؤال بمفرده، لأن النموذج اللغوي يرى الجزء ولا يرى المستند المحيط به.
ابدأ بـ300 كلمة مع تداخل قدره 50 كلمة. يحتوي النص الإنجليزي تقريباً على 1.3 رمز لكل كلمة، لذا تعادل 300 كلمة نحو 400 رمز. يوجد التداخل لأن الجملة التي تقع عند حد فاصل ستُنصف وإلا، ولن يجيب أي من نصفَيها عن السؤال.
قسّم وفق البنية أولاً عندما تكون للمستندات بنية واضحة. ابدأ بالعناوين، ثم بالفقرات، وطبّق قاعدة الحجم الثابت فقط داخل قسم لا يزال طويلاً جداً. يبدو الجزء الذي يبدأ من منتصف جملة سيئاً في الإجابة النهائية، لأن النموذج يعيد اقتباس ما قدّمته له.
لا تضبط التقسيم قبل أن تتمكن من قياسه. الحجم الثابت مع التداخل حتمي ورخيص التنفيذ مرة أخرى، لذلك يشكّل خط أساس يمكنك تحسينه. أنشئ استعلام التقييم الوارد لاحقاً أولاً، ثم غيّر شيئاً واحداً في كل مرة.
التضمين على الخادم نفسه، وتكلفته من الذاكرة وزمن الاستجابة
curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-textيتكوّن nomic-embed-text من 137 million parameters، ويبلغ حجم تنزيله 274 MB اعتباراً من August 2026. تحقّق من القيمة التي يعيدها قبل أن تصمّم جدولاً اعتماداً عليها.
curl -s http://127.0.0.1:11434/api/embed \
-d '{"model": "nomic-embed-text", "input": "search_document: hello"}' |
python3 -c 'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'يطبع ذلك 768. يجب أن يطابق نوع العمود هذه القيمة تماماً.
هناك إعدادان في هذا النموذج يسببان أخطاء شائعة.
بادئة المهمة إلزامية. توضح بطاقة نموذج Nomic أن الإدخال «يجب أن يتضمن بادئة لتعليمات المهمة». تُضمَّن المستندات مع search_document: في المقدمة، وتُضمَّن الأسئلة مع search_query: . إذا حذفتها فلن يفشل شيء: ستحصل على متجهات، لكن جودة الاسترجاع ستنخفض، ولن يخبرك أي سطر في السجل بالسبب.
يُقتطع الإدخال الطويل بصمت. يتلقى endpoint /api/embed حقلاً باسم truncate، وتكون قيمته الافتراضية true، بينما يعلن النموذج المعبأ بواسطة Ollama عن سياق بحجم 2K. إذا تجاوزت القطعة هذا الحد، تُقتطع عنده ثم تُضمَّن رغم ذلك، ولذلك يصبح ذيلها غير قابل للبحث. أرسل "truncate": false أثناء الاختبار، لكي تفشل القطعة الزائدة بدلاً من تمريرها.
أرسل الطلبات على دفعات، وأبقِ النموذج محمّلاً في الذاكرة.
curl -s http://127.0.0.1:11434/api/embed -d '{
"model": "nomic-embed-text",
"input": ["search_document: first chunk", "search_document: second chunk"],
"keep_alive": "30m"
}' > /dev/nullيقبل input قائمة، ويتفوق طلب واحد يحمل 32 قطعة على 32 طلباً منفصلاً، لأن رحلة HTTP ذهاباً وإياباً والبحث عن النموذج يحدثان مرة واحدة بدلاً من 32 مرة. يتحكم keep_alive في مدة بقاء النموذج في الذاكرة بعد الطلب، والقيمة الافتراضية هي 5 minutes. عند انتهاء هذه المدة، يدفع الطلب التالي تكلفة التحميل مرة أخرى.
قِس الرقمين المهمين على خادمك بنفسك. فهما يعتمدان على عدد vCPU لديك، ولذلك لن تطابقك أي قيمة منشورة تماماً.
ollama ps
time curl -s http://127.0.0.1:11434/api/embed \
-d '{"model":"nomic-embed-text","input":"search_document: ... one real chunk ..."}' > /dev/nullيطبع ollama ps الحجم المقيم للنموذج المحمّل، أي مقدار RAM الذي تلتزم به ما دام keep_alive يحتفظ به. ناتج time مقسوماً على حجم الدفعة هو عدد الثواني لكل قطعة. اضربه في عدد القطع لتحصل على تكلفة الفهرسة لمرة واحدة. في خطة تعتمد على CPU فقط، توقّع أن تستغرق مجموعة من 100,000 قطعة ساعات لا دقائق. هذا مقبول لأنها تحدث مرة واحدة ويمكن تشغيلها طوال الليل تحت nice -n 19. إذا لم تكن الساعات مقبولة، فالسؤال الحقيقي هو ما إذا كان استئجار GPU سيغطي تكلفته، وهذا حساب نقطة تعادل مقابل رموز API وليس مسألة تفضيل.
إذا كان الخادم يقدّم نموذج محادثة بالفعل، فسيكون نموذج التضمين نموذجاً مقيماً ثانياً، وتتراكم متطلبات RAM. يشرح تشغيل Ollama على VPS كيفية تحديد حجم جانب التوليد، بينما يشرح ما يحدث للنموذج المستضاف ذاتياً عند استخدام عدة مستخدمين متزامنين ما يحدث عندما يطرح عدة أشخاص أسئلة في الوقت نفسه. نموذج التضمين صغير بما يكفي ليعمل بجانب أيٍّ منهما.
سكربت الفهرسة من البداية إلى النهاية
على Ubuntu 24.04، يتوقف تشغيل pip install العادي خارج البيئة الافتراضية مع error: externally-managed-environment، لأن Python الخاص بالنظام مملوك لـ apt.
python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvectorimport json, urllib.request
import psycopg
from pgvector.psycopg import register_vector
from pgvector import Vector
OLLAMA = "http://127.0.0.1:11434/api/embed"
MODEL = "nomic-embed-text"
def embed(texts, prefix="search_document: "):
payload = {"model": MODEL,
"input": [prefix + t for t in texts],
"truncate": False,
"keep_alive": "30m"}
req = urllib.request.Request(OLLAMA, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req) as resp:
return json.load(resp)["embeddings"]
def split(text, size=300, overlap=50):
words = text.split()
step = size - overlap
return [" ".join(words[i:i + size]) for i in range(0, len(words), step)]
with psycopg.connect("dbname=rag user=rag") as conn:
register_vector(conn)
for doc_id, text in documents(): # your loader
pieces = split(text)
for start in range(0, len(pieces), 32):
batch = pieces[start:start + 32]
vectors = embed(batch)
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO chunks (doc_id, seq, body, embedding)"
" VALUES (%s, %s, %s, %s)",
[(doc_id, start + i, body, Vector(vec))
for i, (body, vec) in enumerate(zip(batch, vectors))])
conn.commit()documents() هو الجزء الذي تكتبه أنت: أي كود يمر على ملفاتك أو صفوفك ويُرجع معرّف مستند ونصه. أما كل شيء آخر فهو خط المعالجة.
التخزين: مخطط pgvector وحجمه
يأتي Ubuntu 24.04 مع postgresql-16-pgvector بالإصدار 0.6.0، وهو أقدم من نوع halfvec. استخدم مستودع مشروع PostgreSQL نفسه للحصول على إصدار حديث.
sudo apt update && sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17 postgresql-17-pgvectorيجب أن يطابق الرقم في اسم الحزمة الإصدار الرئيسي لخادمك. بعد ذلك، أنشئ الدور وقاعدة البيانات والامتداد.
sudo -u postgres createuser --pwprompt rag
sudo -u postgres createdb --owner rag rag
sudo -u postgres psql -d rag -c 'CREATE EXTENSION vector;'CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id text NOT NULL,
seq int NOT NULL,
body text NOT NULL,
embedding vector(768) NOT NULL,
fts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);
CREATE INDEX chunks_fts ON chunks USING gin (fts);يجب أن يطابق vector(768) مخرجات النموذج. إذا أدخلت متجهًا ذا 1024 بُعدًا في ذلك العمود، فسيرفضه Postgres بالخطأ expected 768 dimensions, not 1024، وهي أوضح رسالة خطأ في مسار المعالجة هذا بأكمله. لا يضيف العمود fts المُنشأ أي تكلفة صيانة، ويتيح لك البحث بالكلمات المفتاحية لاحقاً.
حساب مساحة التخزين مباشر. توثّق pgvector أن vector يستهلك 4 * dimensions + 8 بايت، وأن halfvec يستهلك 2 * dimensions + 8. أعداد الأبعاد أدناه هي أحجام المخرجات المنشورة لكل نموذج.
The data behind this chart
[
{
"label": "384 (all-minilm)",
"bytes_per_vector": "1,544",
"vector_mib_per_100k": 147,
"halfvec_mib_per_100k": 74
},
{
"label": "768 (nomic-embed-text)",
"bytes_per_vector": "3,080",
"vector_mib_per_100k": 294,
"halfvec_mib_per_100k": 147
},
{
"label": "1024 (mxbai-embed-large)",
"bytes_per_vector": "4,104",
"vector_mib_per_100k": 391,
"halfvec_mib_per_100k": 196
},
{
"label": "1536 (hosted API model)",
"bytes_per_vector": "6,152",
"vector_mib_per_100k": 587,
"halfvec_mib_per_100k": 294
}
]عند استخدام 768 بُعدًا، يستهلك كل متجه 3,080 بايت، ولذلك تخزّن 100,000 قطعة 294 MiB من بيانات المتجهات. أما المجموعة نفسها بعد تضمينها باستخدام نموذج مستضاف ذي 1536 بُعدًا، فتحتاج إلى 587 MiB، وينمو الفهرس عليها بالتناسب. وتخفض الدقة النصفية كلا الحجمين إلى النصف: يخزّن halfvec(768) تلك المجموعة في 147 MiB. يجيب استعلام التقييم أدناه عن تأثير ذلك في الاسترجاع، إن وُجد، في تشغيل واحد.
تغطي هذه الأرقام عمود المتجهات وحده. يضاف إليها النص ومصاريف الصفوف والفهارس، لذلك قِس حجم الجدول الفعلي.
SELECT pg_size_pretty(pg_total_relation_size('chunks')) AS total,
pg_size_pretty(pg_relation_size('chunks')) AS heap,
count(*) AS n_rows
FROM chunks;إذا كنت تفضّل استخدام الامتداد نفسه مع API وحسابات مستخدمين حوله، فإن حزمة Supabase مستضافة ذاتياً هي PostgreSQL مع تفعيل pgvector مسبقاً، وتعمل معها كل استعلامات هذا الدليل دون تغيير.
الفهرسة: إعدادات HNSW المهمة
إذا كان عدد الصفوف أقل من بضعة آلاف، فتجاوز الفهرس. يقرأ البحث الدقيق كل صف، ويكون سريعاً بما يكفي عند هذا الحجم، كما أن استدعاءه كاملاً. أضف الفهرس عندما لا يعود الفحص التسلسلي سريعاً بما يكفي، وافهم المفاضلة: يعيد الفهرس التقريبي جيراناً صحيحة تقريباً.
SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 3;
CREATE INDEX chunks_embedding ON chunks
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);m = 16 وef_construction = 64 هما القيمتان الافتراضيتان في pgvector. تؤدي زيادتهما إلى تحسين الاستدعاء، لكنها تزيد زمن البناء وحجم الفهرس. استخدم vector_cosine_ops مع العامل <=>، إلا إذا كنت تعرف أن النموذج يُصدر متجهات ذات طول وحدي، لأن مسافة cosine تتجاهل طول المتجه، بينما لا يفعل inner product ذلك.
راقب عملية البناء. عندما تتجاوز الخريطة maintenance_work_mem، يوضح pgvector ذلك:
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL: Building will take significantly more time.هذا ليس خطأ، وتكتمل عملية البناء، لكنها تنتقل إلى مسار أبطأ بكثير. ارفع maintenance_work_mem في الجلسة التي تبني الفهرس، واترك القيمة الافتراضية للخادم كما هي، لأن هذا الإعداد يخص عملية صيانة واحدة، ولأن رفع قيمته على مستوى الخادم قد يؤدي إلى نفاد ذاكرة الخادم. تابع عملية البناء الطويلة من جلسة ثانية.
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;ثم قارن الفهرس المكتمل بالذاكرة المتاحة في الخادم.
SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;يستعرض بحث HNSW خريطة بيانية، لذلك يصل إلى صفحات متفرقة داخل الفهرس بدلاً من قراءة نطاق متصل. إذا لم يتسع الفهرس في الذاكرة، تحوّل كل استعلام إلى قراءات من القرص، ويظهر البطء في ذيل زمن الاستجابة الذي يلاحظه المستخدمون. هذه هي قاعدة التحجيم الأساسية للخادم: يجب أن يتسع الفهرس والصفوف التي تقدّمها فعلياً في RAM. free -m والحجم المذكور أعلاه هما الرقمان اللذان يجب مقارنتهما.
أثناء تنفيذ الاستعلام، يكون hnsw.ef_search هو مفتاح ضبط الاستدعاء، وقيمته الافتراضية 40.
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;تبحث القيمة الأعلى في جزء أكبر من الخريطة، وتعثر على جيران أفضل، لكنها تزيد زمن الاستجابة. هذا إعداد خاص بالجلسة، لذلك يمكنك رفعه لاستعلام واحد دون تعديل الفهرس.
إذا لم يستخدم الاستعلام الفهرس إطلاقاً، فستعرض الخطة ذلك.
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;غالباً ما يعود استخدام الفحص التسلسلي هنا إلى التخزين. يبلغ حجم متجه ذي 768 بُعداً 3,080 بايت، وهو أكبر مما يحتفظ به Postgres داخل الصف، لذلك تنتقل القيمة إلى جدول TOAST، وهو مخزن القيم الكبيرة خارج الصف. يوضح تنبيه pgvector نفسه أن المخطط لا يحتسب التخزين خارج الصف ضمن تقديرات التكلفة، ما قد يجعل الفحص التسلسلي يبدو أقل تكلفة مما هو عليه فعلياً. يحافظ ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; على المتجهات داخل الصف. ينطبق ذلك على الصفوف التي تُكتب بعد التغيير، لذلك تحتاج الصفوف الموجودة إلى إعادة كتابة الجدول.
الاسترجاع: استعلام واحد وإشارتان
يعثر البحث المتجهي على نص يحمل المعنى نفسه للسؤال. لكنه ضعيف في التعامل مع السلاسل النصية المطابقة تماماً، مثل رقم الجزء أو رمز الخطأ أو اسم العائلة. أما البحث بالكلمات المفتاحية فعلى العكس من ذلك، ويدعمه Postgres أصلاً. ادمجهما في استعلام واحد بدلاً من تشغيل نظام ثانٍ.
يُعد دمج الرتب التبادلي أبسط آلية دمج فعّالة. يحصل كل نتيجة على 1 / (60 + rank) من كل قائمة تظهر فيها، ثم تُجمع النتيجتان. ولا يحتاج ذلك إلى تطبيع الدرجات، لأنه يعتمد على المواضع بدلاً من المسافات.
WITH semantic AS (
SELECT id, row_number() OVER (ORDER BY distance) AS rank
FROM (SELECT id, embedding <=> $1 AS distance
FROM chunks ORDER BY embedding <=> $1 LIMIT 40) s
),
keyword AS (
SELECT id, row_number() OVER (ORDER BY score DESC) AS rank
FROM (SELECT c.id, ts_rank_cd(c.fts, q) AS score
FROM chunks c, websearch_to_tsquery('english', $2) q
WHERE c.fts @@ q
ORDER BY score DESC LIMIT 40) k
)
SELECT c.id, c.body,
coalesce(1.0 / (60 + s.rank), 0) + coalesce(1.0 / (60 + k.rank), 0) AS rrf
FROM (SELECT id FROM semantic UNION SELECT id FROM keyword) u
JOIN chunks c ON c.id = u.id
LEFT JOIN semantic s ON s.id = u.id
LEFT JOIN keyword k ON k.id = u.id
ORDER BY rrf DESC
LIMIT 8;$1 هو تمثيل السؤال المتجهي الناتج عن النموذج نفسه، والمُنشأ باستخدام البادئة search_query: . و$2 هو السؤال بصيغته النصية. ويُمرَّر كلاهما من تطبيقك. يقبل websearch_to_tsquery سؤالاً حقيقياً من المستخدم دون أن يتعثر بسبب علامات الترقيم، بخلاف to_tsquery. وهناك نقطة أخرى يجب معرفتها: يمكن أن يؤدي إضافة عامل تصفية WHERE فوق فحص HNSW إلى إرجاع عدد صفوف أقل من العدد المطلوب، لأن البحث في الفهرس يحدث أولاً، ثم يُطبَّق عامل التصفية. ويجعل SET hnsw.iterative_scan = relaxed_order; pgvector يواصل الفحص حتى يحصل على عدد كافٍ من الصفوف.
كيف تعرف ما إذا كان الاسترجاع جيداً؟
هذه هي الخطوة التي تتجاوزها معظم أدلة RAG، وهي الخطوة الوحيدة التي تخبرك بما إذا كانت الخيارات الأخرى قد حسّنت النتيجة. لا تحتاج إلى إطار تقييم. تحتاج إلى 30 سؤالاً ومعرّف المقطع الذي يجيب عن كل سؤال.
اكتب الأسئلة يدوياً. اختر أسئلة يطرحها المستخدمون فعلاً حول هذه المجموعة، وشغّل كل سؤال، واقرأ النتيجة، وسجّل معرّف المقطع الذي كان ينبغي أن يأتي أولاً. لن تكشف 30 سؤالاً الفروق الصغيرة. لكنها ستكشف الفروق المهمة، لأن هذه الفروق كبيرة.
CREATE TABLE gold (
id bigserial PRIMARY KEY,
question text NOT NULL,
chunk_id bigint NOT NULL REFERENCES chunks(id),
embedding vector(768) NOT NULL
);حوّل كل سؤال إلى embedding باستخدام البادئة search_query: ، وخزّنه، ثم قيّم المجموعة كاملة في استعلام واحد.
WITH hits AS (
SELECT g.id,
min(r.rank) FILTER (WHERE r.id = g.chunk_id) AS hit_rank
FROM gold g
CROSS JOIN LATERAL (
SELECT top.id, row_number() OVER (ORDER BY top.distance) AS rank
FROM (SELECT c.id, c.embedding <=> g.embedding AS distance
FROM chunks c
ORDER BY c.embedding <=> g.embedding
LIMIT 10) top
) r
GROUP BY g.id
)
SELECT count(*) AS questions,
count(hit_rank) AS found_in_top_10,
round(avg(coalesce(1.0 / hit_rank, 0)), 3) AS mrr
FROM hits;قيمة found_in_top_10 مقسومة على questions هي recall عند 10: أي عدد المرات التي كانت فيها الإجابة داخل النافذة التي ترسلها إلى النموذج. يحسب MRR، أي متوسط الرتبة التبادلية، متوسط 1 مقسوماً على موضع المقطع الصحيح، ويحسب الإخفاق على أنه صفر. لذلك يكافئ ظهور الإجابة أولاً بدلاً من ظهورها في المرتبة الثامنة. يتغير الرقمان عند تغيير حجم المقطع، أو تبديل نموذج embedding، أو إضافة البحث بالكلمات المفتاحية. وهكذا يمكنك معرفة اتجاه التغيير.
أعطِ recall عند 10 الأولوية على كل شيء آخر، لأن المولّد لا يستطيع استخدام مقطع لم يتلقّه. عندما تكون قيمة recall عند 10 هي 0.9 وتظل الإجابات خاطئة، فالمشكلة في prompt أو في النموذج، وليست في الاسترجاع. هذا الفصل وحده يوفر أياماً من التخمين.
افحص الفهرس بشكل منفصل. يكلّف البحث التقريبي جزءاً من recall، ويبيّن لك pgvector مقدار ذلك: شغّل الاستعلام نفسه باستخدام البحث الدقيق، ثم قارن المعرّفات.
BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;إذا تطابقت 9 معرّفات من أصل 10، فهذا يعني أن ef_search مضبوط جيداً. أما إذا تطابقت 4 من أصل 10، فارفع قيمته.
إعادة الترتيب والتوليد: حيث تحقق واجهة API عائدها
نموذج إعادة الترتيب هو نوع مختلف من النماذج. يقرأ السؤال ومقطعاً واحداً معاً، ويقيّم هذا الزوج. وهذا أفضل من مقارنة تمثيلين متجهيين حُسبا بشكل مستقل. لكنه بطيء جداً بحيث لا يمكن تشغيله على مجموعة نصوص كاملة. ولهذا تحديداً يكون استخدامه مناسباً هنا. فهو يعالج المرشحين الـ40 الذين أعادتهم عملية الاسترجاع، لا المقاطع الـ100,000 الموجودة في الجدول. لذلك تفرض واجهة API مستضافة لإعادة الترتيب رسوماً مقابل 40 زوجاً قصيراً لكل سؤال، وتستبعد النتائج الإيجابية الكاذبة الأسوأ قبل وصولها إلى المرحلة المكلفة.
التوليد هو التكلفة المتكررة، وهناك عاملان يؤثران فيها. أرسل عدداً أقل من المقاطع، واستخدم الاستدعاء عند 10 لمعرفة أقل عدد يمكنك إرساله من دون فقدان الإجابات. حافظ على بداية الطلب ثابتة بايتاً مقابل بايت حتى تتمكن ذاكرة التخزين المؤقت للطلبات لدى موفر الخدمة من استخدامها، وضع المقاطع المسترجعة بعد ذلك الجزء الثابت. خزّن الإجابات المكتملة مؤقتاً حسب السؤال أيضاً، لأن أرخص رمز مولَّد هو الرمز الذي ولّدته الأسبوع الماضي.
حجم الخادم، ومتى لا يعود هذا كافياً
كل قاعدة لتحديد الحجم هنا تعتمد على ما تقيسه، لا على ما تقدّره.
- الذاكرة RAM هي القيد الأساسي: حجم النموذج المقيم من
ollama ps، مضافاً إليه حجم فهرس HNSW، ثمshared_buffers، مع ترك مساحة احتياطية للاتصالات وذاكرة الصفحات. - يحتاج القرص إلى ضعف
pg_total_relation_size('chunks')، لأن إعادة بناء الفهرس تحتفظ بالنسختين في الوقت نفسه. - يحدد CPU مدة إعادة الفهرسة، وفق عدد الثواني المقاس لكل chunk مضروباً في عدد chunks.
- تحدث إعادة الفهرسة مرات أكثر مما تتوقع، لأن تغيير نموذج embedding يبطل صلاحية كل vector مخزَّن سابقاً.
يتوقف هذا التصميم عن أن يكون كافياً عند نقطة يمكنك توقعها. عندما لا يعود فهرس HNSW يتسع في RAM التي يمكنك شراؤها، تتحول زمن استجابة الاستعلامات إلى عمليات بحث على القرص، ولا يعالجها أي إعداد. عندما يخدم جدول واحد عدة مستأجرين وتصفّي كل استعلاماتك النتائج حسب المستأجر، تصبح تجزئة الجدول هي الحل، وهذا يتطلب عملاً فعلياً. عندما تتنافس عمليات الكتابة أثناء الفهرسة مع استعلامات المستخدمين على الخادم نفسه، انقل عامل embedding إلى خادم ثانٍ قبل نقل قاعدة البيانات. ما لم يحدث أحد هذه الأمور، فإن Postgres مع pgvector على VPS الذي تستأجره بالفعل يظل حلاً صالحاً لبيئة الإنتاج، وتوضح لك الأرقام أعلاه مدى بُعدك عن الحد.
FAQ
هل يمكنني تشغيل خط أنابيب RAG على VPS واحد، أم أحتاج إلى قاعدة بيانات متجهات؟
يكفي VPS واحد للمجموعات التي تضم مئات الآلاف من المقاطع. عند استخدام 768 بُعداً، يشغل 100,000 مقطع 294 MiB من بيانات المتجهات، إضافةً إلى النص وفهرس HNSW، وهذا يتسع له مقدار RAM في خطة عادية. القيد هنا هو الذاكرة وليس عدد الصفوف، لأن بحث HNSW يتنقل بين أجزاء الفهرس، لذلك يزداد زمن الاستجابة عندما يتوقف الفهرس عن الملاءمة في RAM. قارن pg_relation_size في الفهرس مع free -m، وستعرف وضعك.
هل أحتاج إلى GPU لإنشاء embeddings لمستنداتي؟
لا، إذا أنشأت embeddings مرة واحدة ثم أجريت الاستعلامات لاحقاً. يعمل نموذج يضم 137 مليون معلَمة، مثل nomic-embed-text، على CPU، ويستغرق تمرير كامل على مجموعة كبيرة ساعات يمكنك استغلالها أثناء الليل. تصبح GPU مهمة عندما تصل المستندات باستمرار، أو عندما تريد تشغيل التوليد على الخادم نفسه. قِس زمناً دفعة واحدة باستخدام /api/embed على خادمك، ثم اضربه في عدد المقاطع، لأن عدد vCPU يختلف بدرجة كبيرة ولا يفيد رقم منشور في هذا السياق.
لماذا يستخدم استعلام المتجهات لدي فحصاً تسلسلياً بدلاً من فهرس HNSW؟
اقرأ خطة التنفيذ باستخدام EXPLAIN (ANALYZE, BUFFERS). السبب الشائع هو التخزين: يذكر pgvector أن مخطِّط الاستعلام لا يحتسب التخزين خارج السطر في تقديرات التكلفة، ما يجعل الفحص التسلسلي يبدو أرخص مما هو عليه، كما أن المتجه ذي 768 بُعداً يشغل 3,080 بايت، ولذلك يُخزَّن افتراضياً في جدول TOAST. يحافظ ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; على بقاء الصفوف الجديدة داخل السطر. والسببان الآخران هما استخدام عامل لا يطابق الفهرس، إذ لا يُستخدم الفهرس المبني باستخدام vector_cosine_ops إلا مع <=>، ووجود استعلام بلا ORDER BY ... LIMIT، لأن الفهرس التقريبي يخدم فقط استعلامات أقرب جار مرتبة.
كيف أعرف ما إذا كانت عملية الاسترجاع لدي جيدة؟
أنشئ مجموعة مرجعية من 30 سؤالاً، واربط كل سؤال منها بمعرّف المقطع الذي يجيب عنه، ثم خزّن embeddings الأسئلة إلى جانبها. بعد ذلك، قِس recall عند 10، أي عدد المرات التي يظهر فيها المقطع الصحيح ضمن أول 10 نتائج، وقِس MRR، الذي يكافئ وضعه في المرتبة الأولى. يوضح لك هذان الرقمان ما إذا كان تغيير حجم المقاطع أو نموذج embeddings أو دمج الترتيب قد حسّن النتائج. من دونهما، ستغيّر الإعدادات وتعتمد على انطباعك عن عدد قليل من الإجابات.