تخطّى إلى المحتوى الرئيسي
البحث الهجين في Postgres باستخدام pgvector: ملاحظاتنا الميدانية حول HNSW وtsvector ودمج الترتيب التبادلي

البحث الهجين في Postgres باستخدام pgvector: ملاحظاتنا الميدانية حول HNSW وtsvector ودمج الترتيب التبادلي

August 25, 2026
هندسة الذكاء الاصطناعي
10 دقيقة قراءة

الخلاصة: pgvector هو امتداد لـ Postgres يضيف أنواع أعمدة المتجهات وفهارس الجيران الأقرب التقريبية، وهو بمفرده محرك بحث متواضع. كل خلل في الاسترجاع واجهه فريقنا داخل ميزة RAG تم إصلاحه بدمج تشابه pgvector مع البحث النصي الكامل في Postgres عبر دمج الترتيب التبادلي، لا بشراء قاعدة بيانات متجهات ولا بتبديل نموذج التضمين.

كان خط الاسترجاع الأول لدينا عبارة عن جملة واحدة ORDER BY embedding <=> $1 LIMIT 5 مع موجّه نصي. بدا ممتازاً في العرض التقديمي، ثم فشل مع الاستعلامات التي يكتبها المستخدمون فعلياً: أكواد الأخطاء الدقيقة، وأسماء الدوال، وأرقام الفواتير، وأكواد المنتجات. تشابه جيب التمام سعيد تماماً بإعادة خمس فقرات تتحدث عن الفوترة بينما كتب المستخدم رقم فاتورة حرفياً يظهر في صف واحد فقط.

هذه ملاحظاتنا من نقل ذلك الخط إلى البحث الهجين على Postgres 17 مع pgvector 0.8، مع إبقاء المتجهات في نفس قاعدة بيانات التطبيق بدل خدمة منفصلة.

أهم النقاط

- يضيف pgvector 0.8.0 أربعة أنواع للمتجهات (vector وhalfvec وbit وsparsevec) ونوعين من فهارس الجيران الأقرب التقريبية (HNSW وIVFFlat) إلى Postgres. لكنه لا يضيف المطابقة بالكلمات المفتاحية ولا إعادة الترتيب ولا التقطيع.

- البحث بالمتجهات وحده يفشل مع المعرّفات الحرفية. التضمين يشفّر المعنى، لذا فإن رمزاً حرفياً مثل ERR_MODULE_NOT_FOUND لا يملك موقعاً مميزاً في فضاء المتجهات، بينما يطابقه البحث النصي الكامل في Postgres بدقة.

- دمج الترتيب التبادلي يمنح كل صف درجة تساوي مجموع 1 / (k + الترتيب) عبر جميع أذرع الاسترجاع، وقيمة k = 60 هي الافتراض الشائع، وبذلك لا نحتاج أبداً إلى معايرة مسافة جيب التمام مقابل ts_rank_cd.

- HNSW هو الفهرس الافتراضي وIVFFlat هو الاستثناء. يُبنى HNSW على جدول فارغ ويمنح استرجاعاً أفضل مقابل زمن الاستجابة، بينما يُبنى IVFFlat أسرع ويستهلك ذاكرة أقل لكنه يتطلب وجود صفوف تمثيلية في الجدول أولاً.

- جملة WHERE في استعلام HNSW قد تُعيد صفوفاً أقل مما يطلبه LIMIT. الإعداد hnsw.iterative_scan الذي أضافه pgvector 0.8.0 هو الحل.

ماذا يضيف pgvector فعلياً إلى Postgres؟

pgvector امتداد لـ Postgres يضيف أنواع أعمدة المتجهات ومعاملات المسافة وفهارس الجيران الأقرب التقريبية إلى قاعدة بيانات Postgres عادية. تفعّله بأمر CREATE EXTENSION vector; فتحصل على أربعة أنواع تخزين: vector بأعداد عشرية من أربعة بايتات، وhalfvec بأعداد من بايتين وأُضيف في pgvector 0.7.0، وbit للتكميم الثنائي، وsparsevec.

معاملات المسافة هي الجزء الجدير بالحفظ، لأن اختيار المعامل الخاطئ يضعف الترتيب بصمت بدل أن يرمي خطأ. المعامل <=> هو مسافة جيب التمام، و<-> هي المسافة الإقليدية L2، و<#> هو الجداء الداخلي السالب، و<+> هي مسافة L1. نموذج text-embedding-3-small من OpenAI ومعظم نماذج التضمين المستضافة تُعيد متجهات مُطبّعة، لذا نستخدم <=> مع فهرس vector_cosine_ops ولا نعود للتفكير في الأمر.

يجب أن يطابق صنف معامل الفهرس معامل الاستعلام. فهرس HNSW مبني بـ vector_l2_ops لن يُستخدم في استعلام يرتّب باستخدام <=>، وسيعود Postgres إلى المسح التتابعي دون أي تحذير. أول تحقيق أجريناه في بطء pgvector كان بالضبط هذا التعارض، وظهر في أمر EXPLAIN ANALYZE واحد.

المخطط الذي استقررنا عليه يحتفظ بجدول واحد للمقاطع فيه عمود تضمين واحد وعمود tsvector مُولَّد، حتى لا ينحرف البحث بالكلمات المفتاحية عن نص المحتوى: CREATE TABLE chunks (id bigserial PRIMARY KEY, document_id bigint NOT NULL, body text NOT NULL, embedding vector(1536) NOT NULL, tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED);

يدعم هذا الجدول فهرسان: CREATE INDEX chunks_embedding_hnsw ON chunks USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64); وCREATE INDEX chunks_tsv_gin ON chunks USING gin (tsv);

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

لماذا فشل البحث بالمتجهات وحده في المطابقات الحرفية؟

يفشل البحث بالمتجهات وحده في المطابقات الحرفية لأن التضمين يشفّر المعنى لا الرموز الحرفية. حين يبحث موظف الدعم عن ERR_MODULE_NOT_FOUND، يقع تضمين هذه السلسلة في الجوار العام لكلمات مثل وحدة واستيراد وخطأ، وهو قريب بما يكفي لترتيب صفحة استكشاف أخطاء عامة فوق الفقرة الوحيدة التي تحتوي الكود حرفياً.

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

حجم المقطع يزيد هذا التخفيف سوءاً. مقطع بطول 1500 رمز يُنتج متجهاً واحداً يمثّل متوسط كل ما فيه، فلا تحرّك الجملة الحاسمة الواحدة ذلك المتجه إلا قليلاً. تقليص المقاطع إلى نحو 200 إلى 400 رمز مع تداخل بسيط حسّن استرجاعنا أكثر من أي تغيير في نموذج التضمين جرّبناه.

البحث النصي الكامل في Postgres يعاني من الفشل المعاكس تماماً. التعبير websearch_to_tsquery('english', 'how do I stop being billed') لن يطابق مستنداً عنوانه Subscription termination، لأن لا جذور مشتركة بينهما. البحث المعجمي يتقن المعرّفات ويفشل في إعادة الصياغة، والبحث بالمتجهات يتقن إعادة الصياغة ويفشل في المعرّفات. هذا التكامل في الفشل هو الحجة الكاملة للبحث الهجين.

كيف ندمج البحث النصي الكامل والبحث بالمتجهات في استعلام SQL واحد؟

ننفّذ البحثين كتعبيرين CTE منفصلين، ونرتّب كلاً منهما على حدة، ثم ندمج الترتيبين بدمج الترتيب التبادلي داخل استعلام Postgres واحد. RRF يتجاهل الدرجات الخام تماماً ويستخدم الموضع فقط، ولهذا لا يحتاج معايرة: مسافة جيب التمام تقع تقريباً بين 0 و2، بينما ts_rank_cd رقم أهمية غير محدود، وأي محاولة لموازنتهما مباشرة تتحول إلى مطاردة ثوابت سحرية.

يمنح دمج الترتيب التبادلي كل صف درجة قدرها 1 / (k + الترتيب) في كل ذراع يظهر فيها، ثم يجمع هذه الدرجات. الثابت k يخفّف تأثير المراتب العليا، وقيمة k = 60 هي القيمة الواردة في الورقة الأصلية ولم نحتج يوماً إلى تغييرها.

شكل الاستعلام هو تعبير CTE دلالي يرتّب على embedding <=> $1::vector بحد LIMIT 50، وتعبير CTE للكلمات المفتاحية يرتّب على ts_rank_cd(tsv, websearch_to_tsquery('english', $2)) تنازلياً بحد LIMIT 50، ثم جملة SELECT نهائية تربط التعبيرين بـ LEFT JOIN وترتّب على COALESCE(1.0 / (60 + s.rank), 0.0) + COALESCE(1.0 / (60 + k.rank), 0.0) تنازلياً.

تفصيلان مهمان هنا. نمط LEFT JOIN مع COALESCE هو ما يسمح لصف بالفوز اعتماداً على ذراع واحدة فقط، وهكذا ينجو تطابق كود المنتج الحرفي رغم أن تضمينه لا يحتل أي مرتبة. كما أن websearch_to_tsquery هو المحلل الصحيح لمدخلات المستخدم، لأنه يقبل العبارات بين علامتي اقتباس والاستثناءات المسبوقة بإشارة ناقص، ولا يرمي خطأ صياغة أمام مدخلات عدائية كما يفعل to_tsquery.

نسترجع 50 نتيجة لكل ذراع ونعيد 10. استرجاع عدد أكبر لكل ذراع لا يكلّف شيئاً يُذكر في Postgres ويمنح RRF تداخلاً كافياً ليكون ذا معنى، بينما استرجاع 5 لكل ذراع يُنتج قائمتين منفصلتين فلا يجد الدمج ما يدمجه.

HNSW أم IVFFlat: أي فهرس pgvector نبني؟

ابنِ HNSW ما لم يكن زمن بناء الفهرس أو الذاكرة هو القيد الحاكم. HNSW فهرس رسومي يدعم الإدراج التدريجي ولا يشترط امتلاء الجدول مسبقاً، بينما يقسّم IVFFlat المتجهات الموجودة إلى قوائم ويجب بناؤه بعد توفر بيانات تمثيلية وإلا انهار معدل الاسترجاع.

- البناء على جدول فارغ: HNSW نعم، وIVFFlat لا لأنه يحتاج صفوفاً تمثيلية قبل إنشاء الفهرس.

- معاملات البناء: HNSW يستخدم m وef_construction، وIVFFlat يستخدم lists.

- مفتاح التحكم وقت الاستعلام: HNSW يستخدم hnsw.ef_search بقيمة افتراضية 40، وIVFFlat يستخدم ivfflat.probes بقيمة افتراضية 1.

- ملف التكلفة: HNSW أبطأ في البناء وأكثر استهلاكاً للذاكرة، وIVFFlat أسرع وأخف.

- الاسترجاع مقابل زمن الاستجابة: HNSW أفضل، وIVFFlat أدنى عند زمن استجابة مكافئ.

- أحمال الإدراج الكثيفة: HNSW يعالجها تدريجياً، وIVFFlat يتدهور ويحتاج إعادة بناء دورية.

المفتاح الذي يغيّر النتائج فعلياً في الإنتاج هو hnsw.ef_search، وهو يتحكم في عدد المرشحين الذين يحتفظ بهم اجتياز الرسم البياني. قيمته الافتراضية 40، ورفعه يبادل زمن الاستجابة بالاسترجاع. نضبطه لكل معاملة عبر SET LOCAL hnsw.ef_search = 100 بدل ضبطه عالمياً، حتى تستخدم مهمة إعادة الفهرسة الخلفية قيمة مختلفة عن استعلام المستخدم.

لماذا يُعيد مرشّح WHERE صفوفاً أقل من LIMIT؟

استعلام HNSW مع جملة WHERE قد يعيد صفوفاً أقل مما يطلبه LIMIT لأن اجتياز الفهرس يجمع مرشحي ef_search أولاً ثم يُطبَّق المرشّح بعد ذلك. إذا طلبت 10 مقاطع تخص مستأجراً واحداً يملك 0.1% من الجدول، فسيُستبعد معظم المرشحين الأربعين الافتراضيين وتعود بصفين أو ثلاثة.

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

- تفعيل المسح التكراري. أضاف pgvector 0.8.0 الإعداد hnsw.iterative_scan الذي يواصل مسح الفهرس حتى ينجو عدد كافٍ من الصفوف من المرشّح. استخدم relaxed_order للإنتاجية أو strict_order حين يجب أن تعود النتائج بترتيب المسافة الدقيق، وحُدّ من العمل بـ hnsw.max_scan_tuples.

- استخدام فهرس جزئي حين يكون المرشّح مجموعة قيم ثابتة صغيرة، مثل WHERE deleted_at IS NULL. فهرس HNSW جزئي على الصفوف الحية يزيل المشكلة بدل تخفيفها.

- رفع ef_search كأداة خشنة حين يكون المرشّح انتقائياً بدرجة معتدلة فقط. يكلّف زمن استجابة ولا يتوسع مع المرشحات شديدة الانتقائية.

ما الذي انكسر في الإنتاج؟

عدم تطابق الأبعاد هو أول خطأ يواجهه أي فريق: ERROR: expected 1536 dimensions, not 768. عمود vector(1536) قيد صارم، لذا فإن تبديل نموذج التضمين ترحيل لا تغيير إعدادات. تضمينات النماذج المختلفة غير قابلة للمقارنة إطلاقاً، ما يعني أن تبديل النموذج يستلزم عموداً جديداً وإعادة تعبئة كاملة وتحويلاً نهائياً، لا جدولاً أُعيد تضمينه جزئياً.

حد الفهرسة عند ألفي بُعد فاجأنا أكثر. يفهرس pgvector أعمدة vector حتى 2000 بُعد، لذا لا يمكن فهرسة نموذج text-embedding-3-large من OpenAI بأبعاده الـ 3072 كعمود vector عادي. أمامنا خياران صادقان: تخزينه كـ halfvec(3072) وفهرسته بـ halfvec_cosine_ops الذي يفهرسه pgvector حتى 4000 بُعد ويخفّض التخزين إلى النصف، أو طلب أبعاد أقل من واجهة البرمجة عبر معامل dimensions، لأن هذه النماذج مدرَّبة بحيث تبقى المتجهات المقتطعة مفيدة.

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

عرض الصف هو المشكلة الصامتة. قيمة vector(1536) تشغل نحو 6 كيلوبايت، وهو ما يتجاوز الحد الذي ينقل عنده Postgres العمود خارج الصف إلى تخزين TOAST، فيصبح كل جلب لصف قراءة إضافية. إبقاء جدول المقاطع نحيفاً، بالمعرّفات والنص والتضمين وعمود tsvector فقط، ثم الربط ببيانات المستند الوصفية، كان أثمن من أي إعادة كتابة للاستعلام.

أخيراً، زمن الاستجابة الذي طاردناه في SQL لم يكن في SQL. تضمين استعلام المستخدم رحلة شبكة إلى مزوّد النموذج في كل عملية بحث، وقد هيمن على المئين الخامس والتسعين لدينا قبل Postgres بكثير. تخزين تضمينات الاستعلامات المتكررة مؤقتاً، وإطلاق نداء التضمين بالتوازي مع بقية عمل الطلب، كان المكسب الحقيقي.

أسئلة شائعة

س: هل نحتاج قاعدة بيانات متجهات مخصصة بدل pgvector؟

ج: ليس لاسترجاع بحجم التطبيقات في نطاق ملايين المقاطع القليلة. إبقاء المتجهات داخل Postgres يعني نسخاً احتياطياً واحداً، ومجمّع اتصالات واحداً، واتساقاً معاملاتياً بين المستندات وتضميناتها، وإمكانية ربط نتائج الاسترجاع بالصلاحيات والبيانات الوصفية في الاستعلام نفسه، وهو ما لا تستطيعه خدمة متجهات منفصلة دون رحلة ثانية.

س: أي معامل مسافة نستخدم مع تضمينات OpenAI؟

ج: استخدم مسافة جيب التمام عبر المعامل <=> مع فهرس HNSW منشأ بـ vector_cosine_ops. يجب أن يطابق صنف معامل الفهرس المعامل المستخدم في جملة ORDER BY، وإلا عاد Postgres بصمت إلى المسح التتابعي.

س: هل يتطلب البحث الهجين تخزين تضمينين لكل مقطع؟

ج: لا. يحتاج البحث الهجين عمود vector واحداً للتشابه الدلالي وعمود tsvector واحداً للمطابقة المعجمية. والأفضل كتابة عمود tsvector كعمود GENERATED ALWAYS AS ... STORED حتى لا ينحرف أبداً عن نص المحتوى.

س: كم نتيجة نسترجع قبل تمريرها إلى النموذج؟

ج: استرجع نحو 50 مرشحاً لكل ذراع، وادمجها بـ RRF، ثم مرّر أفضل 5 إلى 10 مقاطع مدموجة إلى النموذج. تمرير أكثر من ذلك يخفض جودة الإجابة عادةً، لأن السياق غير ذي الصلة ينافس السياق المهم داخل الموجّه.

س: هل دمج الترتيب التبادلي أفضل من الموازنة المرجّحة للدرجات؟

ج: RRF هو الافتراض الأفضل لأنه لا يحتاج معايرة ولا يتأثر باختلاف مقاييس مسافة جيب التمام وts_rank_cd. الموازنة المرجّحة قد تتفوق عليه متى توفرت بيانات أهمية موسومة للمعايرة، وبدونها تصبح المعايرة تخميناً.

س: هل نضيف خطوة إعادة ترتيب فوق البحث الهجين؟

ج: أضف مُعيد ترتيب من نوع cross-encoder فقط بعد ترسيخ البحث الهجين وقدرتك على قياس أن المقطع الصحيح يُسترجع لكن بترتيب منخفض. إعادة الترتيب لا تستعيد مقطعاً لم يُعِده الاسترجاع أصلاً، لذا إصلاح معدل الاسترجاع أولاً هو الخطوة الأعلى أثراً قطعاً.

قراءات إضافية

بناء خادم MCP بعيد في Next.js باستخدام mcp-handler 2: الأدوات وOAuth ومواصفة 2026-07-28 عديمة الحالة
اقرأ المقال

هندسة الذكاء الاصطناعي

بناء خادم MCP بعيد في Next.js باستخدام mcp-handler 2: الأدوات وOAuth ومواصفة 2026-07-28 عديمة الحالة

أصبحنا نبني خوادم MCP البعيدة كـ Route Handler واحد في Next.js باستخدام mcp-handler 2 ومواصفة MCP عديمة الحالة بإصدار 2026-07-28. نشرح هنا كيف نركّبها، وكيف نصمّم أدوات يستدعيها النموذج بشكل صحيح، وكيف نؤمّنها بـ OAuth وبيانات RFC 9728، وكيف نختبرها قبل اتصال أي عميل حقيقي.

لماذا لا يوقف موجّه نظام أفضل حقنَ الأوامر: ملاحظاتنا الميدانية حول الثلاثية القاتلة والأدوات محددة الصلاحيات والمخرجات غير الموثوقة
اقرأ المقال

هندسة الذكاء الاصطناعي

لماذا لا يوقف موجّه نظام أفضل حقنَ الأوامر: ملاحظاتنا الميدانية حول الثلاثية القاتلة والأدوات محددة الصلاحيات والمخرجات غير الموثوقة

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