تشغيل قاعدة بيانات متجهات على VPS: pgvector أم Qdrant؟
تطبيقك وفهارسك على VPS واحد، لذا ليست الكمون المشكلة. قارن pgvector وQdrant وChroma والبحث المباشر، واحسب RAM وفق الأبعاد وعدد المتجهات.
ما الذي تكلّفك إياه قاعدة بيانات المتجهات فعلياً على VPS
يُلغي تشغيل قاعدة بيانات المتجهات على VPS (خادم خاص افتراضي) المشكلة التي يبيع كل مزوّد مُدار حلاً لها. يكون تطبيقك وفهرسك على الجهاز نفسه، لذلك يعبر طلب البحث مقبس loopback بدلاً من الشبكة. وما يتبقى هو التكلفة التي كانت دائماً التكلفة الفعلية: تحويل النص إلى متجهات. وينتج عن ذلك عاملان إضافيان: الوقت اللازم لإنشاء الفهرس، وذاكرة RAM التي يحتفظ بها الفهرس طوال فترة تقديمه للخدمة.
وهذا يغيّر القرارات المهمة. لا تعود المنطقة وزمن الرحلة ذهاباً وإياباً إلى نقطة النهاية من مسؤولياتك. أما حاصل ضرب عدد المتجهات في الأبعاد وفي أربعة بايتات، فيصبح من مسؤولياتك، لأنه يحدد ما إذا كان الفهرس سيتسع في الذاكرة التي تستأجرها كل شهر.
أين تذهب الملليثواني فعلياً على خادم واحد
تتبّع استعلام تشابه واحداً عبر حزمة خدمات مستضافة ذاتياً.
- يحوّل نموذج التضمين نص الاستعلام إلى متجه. يستغرق ذلك على CPU عشرات إلى مئات الملليثواني للنص القصير. وعلى GPU يستغرق بضع ملليثوانٍ فقط.
- يُرسل المتجه إلى مخزن البيانات. يستغرق ذلك عبر TCP على loopback أو عبر Unix domain socket جزءاً من الملليثانية.
- يفحص المخزن فهرسه ويعيد الصفوف الأقرب.
- يقرأ الكود النص المطابق ويُنشئ prompt.
تكون الخطوة 1 عادةً صاحبة الزمن الأكبر في هذه القائمة. أما الخطوة 2 فهي المرحلة التي تتنافس عليها الخدمات المستضافة، لكنها تكاد لا تُذكر على خادم واحد. لا تفترض توزيع الزمن. قِس طرفي العملية على خادمك.
curl http://127.0.0.1:11434/api/embed -s -o /dev/null \
-w 'embed: %{time_total}s\n' \
-d '{"model": "nomic-embed-text", "input": "how do I rotate the api key"}'ثم شغّل \timing on في psql قبل استعلام البحث. إذا طبع الأمر الأول embed: 0.184312s وأجاب psql بـ Time: 4.201 ms، فإن ضبط الفهرس ليس المهمة الصحيحة: زمن الاستجابة لديك ناتج عن نموذج التضمين. يضع تشغيل نموذج التضمين محلياً باستخدام Ollama الخطوة 1 على CPU نفسه الذي ينفّذ الخطوتين 2 و3، لذلك يتنافس النصفان على الأنوية نفسها وذاكرة RAM نفسها. يشرح دليل خط أنابيب RAG المستضاف ذاتياً حلقة الإدخال والاسترجاع المحيطة بهذا المخزن. RAG تعني التوليد المعزّز بالاسترجاع: تبحث في مستنداتك أنت، ثم تلصق أفضل النتائج المطابقة في prompt.
عند أقل من نحو مئة ألف متجه، افحصها جميعاً
تقارن عملية الفحص الشامل الاستعلام بكل متجه مخزّن. يكون الاسترجاع مثالياً بحكم التعريف. ولا تحتاج العملية إلى فهرس أو خطوة بناء، ولا يمكن أن يصبح الفهرس قديماً مقارنة ببياناتك.
توضح الحسابات متى لا يعود ذلك مناسباً. يقرأ الفحص n * d * 4 بايت لكل استعلام، حيث يمثّل n عدد المتجهات وتمثّل d الأبعاد. عند وجود 100,000 متجه بأبعاد 768، يكون الحجم 307 MB لكل استعلام، ويمكن لوحدة CPU حديثة قراءته تسلسلياً خلال بضع عشرات من المللي ثانية. عند 5 مليون متجه، يصبح الحجم 15 GB لكل استعلام، وعندها لا يعود ذلك استعلاماً عملياً.
لذلك خزّن المتجهات في SQLite وأجرِ المقارنة باستخدام NumPy.
import sqlite3, numpy as np
db = sqlite3.connect("docs.db")
db.execute("CREATE TABLE IF NOT EXISTS docs (id INTEGER PRIMARY KEY, body TEXT, vec BLOB)")
def add(body, vec):
v = np.asarray(vec, dtype=np.float32)
v /= np.linalg.norm(v)
db.execute("INSERT INTO docs (body, vec) VALUES (?, ?)", (body, v.tobytes()))
db.commit()
def search(query_vec, k=5):
rows = db.execute("SELECT id, body, vec FROM docs").fetchall()
mat = np.frombuffer(b"".join(r[2] for r in rows), dtype=np.float32).reshape(len(rows), -1)
q = np.asarray(query_vec, dtype=np.float32)
q /= np.linalg.norm(q)
scores = mat @ q
return [(rows[i][0], rows[i][1], float(scores[i])) for i in np.argsort(-scores)[:k]]تُضبط القيمتان إلى طول وحدة، لذلك فإن الضرب النقطي هو تشابه جيب التمام، وتعني الدرجة الأعلى تطابقاً أقرب. حمّل mat مرة واحدة عند بدء التشغيل بدلاً من تحميله مرة لكل استعلام، وبذلك تُزال قراءة SQLite تماماً من المسار الحرج.
قِس الأداء على خادمك قبل رفض هذا الخيار.
import time
t = time.perf_counter()
search(q)
print(f"{(time.perf_counter() - t) * 1000:.1f} ms")الحدود الفعلية واضحة: هذه عملية واحدة تحتفظ بالمصفوفة بأكملها في RAM، ولا توفّر تصفية للبيانات الوصفية أو آلية واضحة للكتابة المتزامنة. عندما يكون أحد هذه القيود هو سبب عدم رضاك، انتقل إلى حل آخر. تُعد SQLite نفسها مخزناً جاداً من جهة الخادم، ويتناول ذلك دليل SQLite في بيئات الإنتاج، وإذا كان حملك الفعلي يفحص الأعمدة بدلاً من تقديم الصفوف، فستكون مقارنة DuckDB وSQLite القراءة الأكثر فائدة.
pgvector عند تشغيل Postgres مسبقاً
إذا كانت لدى تطبيقك قاعدة بيانات Postgres مسبقاً، فإن pgvector يضيف أقل قدر من المكونات الجديدة. فهو امتداد وليس خدمة. تُخزَّن المتجهات في جدول عادي بجوار الصف الذي تصفه، لذلك يصبح البحث المصفّى عبارة عن جملة WHERE بدلاً من نظام ثانٍ يجب إبقاؤه متزامناً.
يتوفر في Ubuntu 24.04 ضمن المكوّن universe.
sudo apt update
sudo apt install -y postgresql-16-pgvector
sudo -u postgres psql -d yourdb -c 'CREATE EXTENSION vector;'هذه الحزمة هي pgvector 0.6.0 اعتباراً من August 2026، وهي أقدم بكثير من الإصدار upstream. تحتاج عمليات مسح الفهارس التكرارية خصوصاً إلى الإصدار 0.8، لذلك احصل عليها من مستودع مشروع PostgreSQL نفسه.
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17-pgvectorاستبدل 17 بالإصدار الرئيسي لخادمك، وتعرضه sudo -u postgres psql -tAc 'SHOW server_version'. إذا ثبّتَّ حزمة الامتداد المبنية لإصدار رئيسي خاطئ، يفشل CREATE EXTENSION لأن Postgres يبحث فقط في مجلد share الخاص بالإصدار قيد التشغيل:
ERROR: could not open extension control file "/usr/share/postgresql/16/extension/vector.control": No such file or directoryالمخطط عبارة عن SQL عادي مع نوع جديد واحد.
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id bigint NOT NULL,
body text NOT NULL,
embedding vector(768)
);
SELECT id, body FROM chunks
ORDER BY embedding <=> '[0.013, -0.021, 0.004]'
LIMIT 5;<=> هي مسافة cosine، و<-> هي مسافة L2 (Euclidean)، و<#> هي الجداء الداخلي السالب. استخدم المقياس الذي دُرِّب عليه نموذج embedding لديك. إذا اخترت المقياس الخطأ، فلن يظهر أي خطأ، لكن نتائجك ستصبح أسوأ بصمت.
من دون فهرس، ينفّذ هذا الاستعلام بحثاً دقيقاً في كل صف. وهذه هي نسخة Postgres من البحث brute force السابق، ولها الاسترجاع الكامل نفسه. يؤدي رفع max_parallel_workers_per_gather إلى استخدام عدد أكبر من الأنوية. نفّذ ذلك أولاً، ثم أنشئ الفهرس لاحقاً، لأنك ستحصل الآن على خط أساس للاسترجاع تقيس الفهرس مقارنةً به.
Qdrant، عندما يتجاوز الفهرس سعة قاعدة البيانات
Qdrant هو مخزن متجهات مخصص مكتوب بلغة Rust. يصبح استخدامه مناسباً عندما يكبر الفهرس إلى حد تفضّل معه ألا يتنافس بناؤه مع Postgres الخاص بتطبيقك، أو عندما تحتاج إلى تصفية الحمولة والتكميم اللذين لا يوفّرهما pgvector.
docker run -d --name qdrant \
-p 127.0.0.1:6333:6333 -p 127.0.0.1:6334:6334 \
-e QDRANT__SERVICE__API_KEY="$(openssl rand -hex 32)" \
-v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
qdrant/qdrantيخدم المنفذ 6333 واجهة REST (نقل الحالة التمثيلي) ولوحة تحكم على /dashboard، بينما يقدّم المنفذ 6334 خدمة gRPC. هناك تفصيلان مهمان على VPS عام. توضح وثائق Qdrant أن الخدمة تعمل افتراضياً «من دون تشفير أو مصادقة»، كما أن -p 6333:6333 من دليل البدء السريع يرتبط بكل الواجهات، ما يجعل Docker ينشره متجاوزاً قاعدة ufw لأنه يكتب قواعد إعادة التوجيه الخاصة به. اربط الخدمة بـ127.0.0.1 واضبط مفتاح API. إن كان مثيل Qdrant قابلاً للوصول عبر عنوان IP عام من دون مفتاح، فستكون مستنداتك متاحة للعموم.
curl -s http://127.0.0.1:6333/collections -H "api-key: $QDRANT_API_KEY"تكون الاستجابة السليمة على النحو {"result":{"collections":[]},"status":"ok","time":0.00002}. إذا ظهرت {"status":{"error":"Unauthorized"}} فهذا يعني أن اسم الرأس أو المفتاح غير صحيح، أما عدم ظهور أي استجابة فيعني أن الحاوية لا تعمل أو أنها مرتبطة بمكان آخر. إن كان شكل هذه الحاوية مناسباً لخادمك فهو سؤال اعتيادي يتعلق بالخدمات ذات الحالة، ولذلك ينطبق مقارنة Docker بقاعدة البيانات على المضيف هنا من دون تغيير.
Chroma وما الغرض منه
Chroma هو أقصر طريق للانتقال من لا شيء إلى عرض توضيحي عملي للاسترجاع.
pip install chromadb
chroma run --path /srv/chromaيعمل ذلك على المنفذ 8000، ويتصل به chromadb.HttpClient(host="localhost", port=8000). يوفّر Chroma دالة تضمين افتراضية، لذلك لا يحتاج النموذج الأولي إلى خادم نماذج منفصل على الإطلاق.
كن واضحاً بشأن المقايضة. يبدو Chroma مريحاً لأنه يخفي القرارات التي يتناولها هذا الدليل: مقياس المسافة المستخدم، ومقدار RAM الذي ستشغله النتيجة. هذا مناسب للنموذج الأولي، لكنه غير مناسب للنظام الذي ستتلقى تنبيهاً بشأنه. إذا كانت بياناتك موجودة بالفعل في Postgres، فإن نقلها إلى Chroma يضيف عملية ومشكلة مزامنة لحل مشكلة لا يعاني منها pgvector.
ما مقدار الذاكرة RAM التي سيحتاج إليها الفهرس
ابدأ بالمتجهات الخام، لأنها تمثل الحد الأدنى، ولا يمكن لأي ضبط تحسين أن يغيّرها.
bytes = number_of_vectors * dimensions * 4تشغل كل بُعد 4 بايتات من نوع 32-bit float. تضيف وثائق تخطيط السعة في Qdrant معامل 1.5 للبيانات الوصفية والمقاطع المؤقتة التي تُنشأ أثناء التحسين:
memory_size = number_of_vectors * vector_dimension * 4 bytes * 1.5إليك تطبيق هذه المعادلة على مليون متجه، باستخدام أبعاد تنتجها نماذج التضمين الفعلية.
The data behind this chart
[
{
"label": "384 dims",
"raw_gib": 1.43,
"with_overhead_gib": 2.15
},
{
"label": "768 dims",
"raw_gib": 2.86,
"with_overhead_gib": 4.29
},
{
"label": "1024 dims",
"raw_gib": 3.81,
"with_overhead_gib": 5.72
},
{
"label": "1536 dims",
"raw_gib": 5.72,
"with_overhead_gib": 8.58
},
{
"label": "3072 dims",
"raw_gib": 11.44,
"with_overhead_gib": 17.17
}
]هذه نتائج المعادلة بوحدة gibibyte (GiB)، وليست قياساً فعلياً. تعامل معها على أنها حجم المساحة التي يجب تركها في الذاكرة. نموذج بأبعاد 768، مثل nomic-embed-text، يحتاج مع مليون مقطع إلى نحو 4.29 GiB، وهذا يناسب خطة بسعة 8 GB مع ترك مساحة لـPostgres. أما مجموعة البيانات نفسها بأبعاد 3072 فتحتاج إلى 17.17 GiB، ولذلك لا تناسبها.
العامل المؤثر هو العمود الأول في ذلك الجدول، وليس الأخير. يؤدي خفض عدد الأبعاد إلى النصف إلى خفض كل بايت يعتمد عليه إلى النصف، بشكل دائم. وغالباً ما يكون نموذج بأبعاد 768، حتى إذا حقق نتيجة أقل قليلاً على لوحة ترتيب عامة، خياراً هندسياً أفضل على VPS. بعد ذلك يخزّن نوع halfvec في pgvector قيم 16-bit float، فيخفض عدد البايتات إلى النصف مرة أخرى، ويفهرسها عبر تعبير:
CREATE INDEX ON chunks USING hnsw ((embedding::halfvec(768)) halfvec_cosine_ops);هناك حد يجب معرفته قبل اختيار النموذج. يقبل نوع vector في pgvector ما يصل إلى 16,000 بُعد، لكن فهارس HNSW وIVFFlat فيه تغطي 2,000 بُعد فقط. عند تجاوز ذلك، تفهرس قيمة محوّلة إلى halfvec، ما يرفع الحد إلى 4,000 بُعد، أو لا تنشئ فهرساً على الإطلاق.
تكلفة m وef_construction أثناء الإنشاء
HNSW (العالم الصغير الهرمي القابل للتنقل) هو الفهرس الذي يستخدمه كل من pgvector وQdrant عادةً. وهو رسم بياني متعدد الطبقات. كل متجه هو عقدة لها روابط مع العقد القريبة، وينتقل البحث عبر هذه الروابط نحو الاستعلام بدلاً من قراءة كل شيء.
m هو عدد الروابط التي تحتفظ بها كل عقدة. توثيق Faiss يقدّر ذاكرة HNSW بمقدار (d * 4 + m * 2 * 4) بايت لكل متجه، ويوصي بإبقاء m بين 4 و64. طبّق ذلك على 768 بُعداً ومليون متجه.
The data behind this chart
[
{
"label": "m = 8",
"link_bytes_per_vector": 64,
"total_gib": 2.92
},
{
"label": "m = 16 (default)",
"link_bytes_per_vector": 128,
"total_gib": 2.98
},
{
"label": "m = 32",
"link_bytes_per_vector": 256,
"total_gib": 3.1
},
{
"label": "m = 64",
"link_bytes_per_vector": 512,
"total_gib": 3.34
}
]المفاجأة المفيدة هي حجم هذا الفرق. الانتقال من القيمة الافتراضية m = 16 إلى m = 64 يضيف 512 بايت من الروابط لكل متجه، مقابل 3072 بايت لبيانات المتجه، لذلك ينتقل الإجمالي من 2.98 GiB إلى 3.34 GiB. أي ما يقارب 12 بالمئة. عند استخدام هذه الأبعاد، لا تمثل m الجزء الأكبر من استهلاك الذاكرة. المتجهات هي التي تستهلكها.
التكلفة الفعلية لـm تظهر في وقت الإنشاء ووقت الإدراج، لأن وضع العقدة يتطلب العثور على ذلك العدد من الجيران وربطها. ef_construction هو حجم قائمة المرشحين التي يفحصها المنشئ أثناء وضع كل عقدة. تؤدي زيادته إلى رسم بياني أفضل وإنشاء أبطأ، لكنها لا تغيّر حجم الفهرس النهائي إطلاقاً.
SET maintenance_work_mem = '4GB';
SET max_parallel_maintenance_workers = 7;
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);maintenance_work_mem هو الإعداد الذي يحدد ما إذا كان الإنشاء سيستغرق دقائق أو ساعات، لأن pgvector ينشئ الرسم البياني في الذاكرة عندما يتسع لها. وعندما لا تعود الذاكرة كافية، يعرض pgvector الرسالة التالية:
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL: Building will take significantly more time.
HINT: Increase maintenance_work_mem to speed up builds.هذا الإشعار هو السطر الأكثر فائدة الذي يطبعه pgvector. ويعني أن الإنشاء انتقل إلى مسار أبطأ بكثير. ألغِ العملية، وارفع الإعداد إلى قيمة تتجاوز مقدار RAM الذي حسبته أعلاه، ثم ابدأ من جديد. راقب التقدم من جلسة ثانية:
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS pct
FROM pg_stat_progress_create_index;يعرض HNSW initializing ثم loading tuples. إذا ظل الإنشاء وقتاً طويلاً عند نسبة منخفضة، بينما القرص غير مشغول، فهذه مشكلة maintenance_work_mem وليست استعلاماً عالقاً.
هناك حقيقتان ينبغي أخذهما في الحسبان أثناء التخطيط. يذكر الملف README الخاص بـpgvector أن HNSW «يستغرق وقتاً أطول في الإنشاء ويستخدم ذاكرة أكبر» من IVFFlat، لكنه يوفّر مقابلاً أفضل بين السرعة والاستدعاء. ويمكن إنشاء HNSW على جدول فارغ، بينما يجب أن ينفّذ IVFFlat خوارزمية k-means على بيانات تمثيلية أولاً، لذلك يؤدي إنشاؤه على جدول فارغ إلى استدعاء ضعيف. عند إعداد مخطط جديد، يكون HNSW هو الفهرس الذي يمكنك إنشاؤه مسبقاً.
ef_search: الإعداد الذي تضبطه بعد إنشاء الفهرس
يُثبَّت كل من m وef_construction داخل الفهرس. أما ef_search فلا يُثبَّت. فهو يحدد عدد المرشحين الذين يحتفظ بهم البحث أثناء اجتيازه للرسم البياني، ويمكنك تغييره لكل جلسة أو لكل استعلام.
SET hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 5;القيمة الافتراضية هي 40. ارفعها، فتزداد الاستعادة وتزداد معها زمن الاستجابة. اخفضها، فينخفض كلاهما. هذا هو إعداد الاستعادة الوحيد الذي يمكنك تغييره دون إعادة إنشاء الفهرس. لذلك اضبطه باستخدام مجموعة ثابتة من الاستعلامات التي تعرف إجاباتها الصحيحة مسبقاً، وتوقف عندما لا تعود الاستعادة إلى التحسن.
هناك حالة شائعة يجب الانتباه إليها. يتفاعل ef_search بصورة سيئة مع عبارة WHERE انتقائية، لأن الفهرس يعيد عدداً ثابتاً من المرشحين، ثم يُطبَّق عامل التصفية بعد ذلك. قد يؤدي عامل تصفية يستبعد معظم الصفوف إلى حصولك على أقل من LIMIT نتيجة، رغم وجود صفوف مطابقة في الجدول. يعالج pgvector 0.8 ذلك باستخدام عمليات المسح التكرارية:
SET hnsw.iterative_scan = relaxed_order;يُعاد عندئذٍ مسح الفهرس بحثاً عن مزيد من المرشحين حتى يتحقق الحد، وبحد أقصى hnsw.max_scan_tuples، وقيمته الافتراضية 20000. يحافظ strict_order على الترتيب الدقيق حسب المسافة، لكنه يستهلك موارد أكبر. هذه هي الميزة غير المتوفرة في حزمة Ubuntu 0.6.0. وظهور نتائج ناقصة عند استخدام عامل تصفية هو الطريقة التي تكتشف بها غيابها.
لماذا يجب أن يتسع الفهرس في RAM
بحث HNSW هو اجتياز لرسم بياني. تقرأ كل قفزة عقدة مخزنة في موضع لا يرتبط بالعقدة السابقة، لذلك يكون نمط الوصول قريباً من الوصول العشوائي، ولا تفيد القراءة المسبقة. ما دام الرسم البياني موجوداً في RAM، تكون كل قفزة مرجعاً إلى الذاكرة. وعندما لا يعود موجوداً فيها، قد تتحول القفزة إلى قراءة من القرص، ويصبح البحث الذي يلامس بضع مئات من العقد بحاجة إلى بضع مئات من عمليات القراءة.
توضح وثائق Qdrant ذلك بعبارة محددة: "إذا خزّنت نصف عدد المتجهات في RAM، فسيتضاعف زمن استجابة البحث تقريباً." خطط بناءً على هذه العبارة.
عندما لا يتسع الفهرس فعلياً، فكل خيار ينطوي على مفاضلة يجب أن تختارها عن قصد.
- استخدم memory-map للمتجهات كي يخزّن نظام التشغيل الصفحات كثيرة الاستخدام مؤقتاً ويترك الصفحات قليلة الاستخدام على القرص. يتطلب ذلك استخدام مساحة تخزين NVMe (non-volatile memory express) سريعة حتى يكون الأداء مقبولاً.
- نفّذ quantisation، بحيث تخزّن كل بُعد في بايت واحد بدلاً من أربعة. يؤدي ذلك إلى تقليل حجم المتجهات إلى الربع، مع تكلفة صغيرة وقابلة للقياس في recall.
- حوّل النوع إلى
halfvecفي pgvector، ما يقلل الحجم إلى النصف مع خسارة في recall أقل من quantisation باستخدام بايت واحد. - استخدم نموذجاً أصغر لإنشاء embeddings. هذا هو الإصلاح الأقل تكلفة، لكنه الخيار الذي يتجاهله الناس لأنه يتطلب إنشاء embeddings للمجموعة النصية من جديد.
أنماط الفشل والنصوص التي ستظهر لك
could not open extension control file. حزمة pgvector الخاصة بإصدار Postgres الرئيسي المشغّل غير مثبتة. اطبع الإصدار باستخدام sudo -u postgres psql -tAc 'SHOW server_version' وثبّت الحزمة المطابقة postgresql-NN-pgvector.
ERROR: expected 768 dimensions, not 1536. نوع العمود والنموذج غير متوافقين. لقد غيّرت نماذج التضمين ولم تُعد إنشاء التضمينات. لا يوجد إصلاح جزئي هنا، لأن المتجهات الناتجة من نموذجين مختلفين غير قابلة للمقارنة إطلاقاً، ولذلك يجب إعادة توليد كل صف.
يستغرق الاستعلام وقتاً طويلاً ويعرض EXPLAIN فحصاً تسلسلياً. فئة مشغّل الفهرس ومشغّل الاستعلام غير متطابقين. لا يخدم vector_cosine_ops إلا <=>. شغّل EXPLAIN ANALYZE على الاستعلام وابحث عن Index Scan using ... on chunks. إذا ظهر Seq Scan on chunks بدلاً منه، فأعد بناء الفهرس باستخدام فئة المشغّل المطابقة للمشغّل الذي تستخدمه فعلياً في الاستعلام.
عدد الصفوف أقل من LIMIT، ويوجد شرط WHERE. هذا هو فخ التصفية المذكور أعلاه. ارفع قيمة hnsw.ef_search، أو انتقل إلى pgvector 0.8 واضبط hnsw.iterative_scan.
ينتهي بناء الفهرس باختفاء العملية من دون ظهور خطأ في psql. يؤدي ضبط maintenance_work_mem على معظم موارد الجهاز، مع حاجة shared_buffers وتطبيقك أيضاً إلى الذاكرة، إلى تشغيل قاتل نفاد الذاكرة في النواة. يعرض sudo dmesg -T | grep -i 'killed process' السطر الذي يذكر postgres. خفّض الإعداد، أو ابنِ الفهرس على خطة أكبر ثم استعد التفريغ.
اختيار الحل
إذا كنت تشغّل Postgres بالفعل ولديك أقل من بضعة ملايين من المتجهات، فاستخدم pgvector. يوجد الفهرس بجانب البيانات، ويكون التصفية عبارة عن عبارة WHERE، وتشملها نسخك الاحتياطية الحالية تلقائياً. إذا كان حجم الفهرس كبيراً بما يكفي لتحتاج إلى حد مستقل للذاكرة، أو كنت تحتاج إلى تصفية مكثفة للحمولة، فشغّل Qdrant بجانبه وتقبّل تشغيل خدمة ثانية.
عندما يكون عدد المتجهات أقل تقريباً من مئة ألف، قِس البحث بالمسح الشامل قبل تثبيت أي شيء. يوفّر البحث الشامل استرجاعاً كاملاً ولا يتطلب خطوة إنشاء فهرس، ولذلك ليس حلاً وسطاً عند هذا الحجم. بل هو الحل الصحيح، واللجوء إلى فهرس تقريبي بدلاً منه يعني تحمّل الضبط والضغط على RAM مقابل أجزاء من الألف من الثانية لم تكن تنفقها أصلاً.
FAQ
هل أحتاج إلى قاعدة بيانات متخصصة للمتجهات، أم يكفي Postgres؟
إذا كانت بياناتك موجودة في Postgres، فإن pgvector يكفي لفترة أطول بكثير مما توحي به معظم المقارنات. يخزّن المتجهات في عمود عادي، لذلك يكون البحث المصفّى عبارة عن عبارة WHERE، كما تغطي النسخة الاحتياطية الحالية الفهرس. انتقل إلى مخزن متخصص مثل Qdrant عندما يحتاج حمل المتجهات إلى حد مستقل للذاكرة، أو عندما تحتاج إلى تصفية الحمولة وتقليل الدقة اللذين لا يوفرهما pgvector.
كم عدد المتجهات التي يمكن لخادم VPS واحد استضافتها؟
احسب ذلك بدلاً من التخمين باستخدام number_of_vectors * dimensions * 4 bytes * 1.5. يشغل مليون متجه ذي 768 بُعداً نحو 4.3 GiB، لذلك تستوعبها خطة بسعة 8 GB مع ترك مساحة لـPostgres. ويشغل مليون متجه ذي 3072 بُعداً نحو 17 GiB، ويتطلب خطة أكبر بكثير. العامل الأكثر تأثيراً في هذا العدد هو بُعد نموذج التضمين لديك، لذلك اختر النموذج مع مراعاة تكلفة الذاكرة.
لماذا يكون البحث في المتجهات بطيئاً عندما يكون الفهرس على الجهاز نفسه؟
لا تكون الشبكة هي المشكلة عندما يعمل كل شيء على جهاز واحد، لذا افحص العاملين المؤثرين. أولاً، قِس زمن استدعاء التضمين وحده، لأن إنشاء متجه الاستعلام على CPU يستغرق غالباً وقتاً أطول بكثير من البحث نفسه. ثانياً، تحقق من وجود الفهرس في RAM. ينتقل بحث HNSW عشوائياً عبر رسم بياني، لذلك عندما يتجاوز الرسم البياني سعة القرص تصبح كل قفزة قراءة من القرص، وتوضح إرشادات Qdrant أن خفض عدد المتجهات المحتفظ بها في RAM إلى النصف يضاعف زمن استجابة البحث تقريباً.
هل ينبغي أن أنشئ فهرس HNSW أصلاً؟
ليس قبل الوصول إلى نحو مئة ألف متجه. يقرأ الفحص الشامل n * d * 4 بايت لكل استعلام، أي 307 MB عند وجود 100,000 متجه ذي 768 بُعداً، ويمكن لوحدة CPU حديثة بثّ هذه البيانات خلال عشرات المللي ثانية مع استرجاع كامل ومن دون خطوة إنشاء فهرس. قِس زمن الفحص على أجهزتك أولاً. أنشئ الفهرس عندما يصبح زمن الفحص المقاس بطيئاً فعلاً، لا لمجرد أن مقالاً عن نتائج الاختبارات أوصى بذلك.
ما التكلفة الفعلية لرفع m؟
تتمثل التكلفة في زمن الإنشاء والإدراج، وهي أكبر بكثير من تكلفة الذاكرة. عند 768 بُعداً، تؤدي زيادة القيمة من m = 16 الافتراضية إلى m = 64 إلى إضافة 512 بايت من روابط الرسم البياني لكل متجه، مقابل 3072 بايت لبيانات المتجه، لذلك ترتفع الذاكرة الإجمالية بنحو 12 بالمئة. لكن كل عملية إدراج يجب أن تعثر على عدد من الجيران يعادل أربعة أضعاف العدد السابق وتربطهم. اضبط ef_search أولاً، إذ يمكن تغييره دون تكلفة ولا يحتاج إلى إعادة إنشاء الفهرس.