Englishworlber.sa
علم المملكة العربية السعوديةشركة سعودية · الرياض

Security

pgvector وأمان مستوى الصف: بحث RAG يحترم الصلاحيات في PostgreSQL

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

٤ نوفمبر ٢٠٢٦ · 9 دقيقة قراءة

pgvector and row-level security: permission-aware RAG search in PostgreSQL

تبدأ أغلب مساعدات RAG الداخلية بالطريقة نفسها: تُحمَّل مكتبة السياسات وأرشيف العقود وتذاكر الدعم لعدة سنوات في جدول فيه عمود من نوع vector، ويُبنى عليه فهرس HNSW، وتُربط به واجهة محادثة. العرض التجريبي ينجح. ثم يسأل أحد موظفي الموارد البشرية سؤالًا، فيقتبس المساعد في إجابته مراجعة رواتب ما كان ينبغي أن يطّلع عليها أصلًا.

الحل يبدو بسيطًا: لا تسترجع إلا المقاطع التي يحق للمستخدم قراءتها. والمكان الطبيعي لهذه القاعدة في PostgreSQL هو أمان مستوى الصف (Row-Level Security، أو RLS)، لأنها تُطبَّق حينها على كل مسار استعلام، لا على المسار الذي تذكّره المطوّر فقط. لكن حين تجمع RLS مع فهرس متجهات تقريبي، تسوء النتائج بصمت، وقد تصل إلى صفر صفوف. فيما يلي السبب، وإعدادات pgvector التي تعالجه، وتفاصيل RLS التي تحدد هل تُطبَّق السياسة أصلًا، ولماذا تستحق المتجهات (embeddings) حماية الوثائق نفسها. وفي هذه النقطة الأخيرة تحديدًا يفيد CYBERTEC PostgreSQL Enterprise Edition ‏(PGEE).

بنية الجدول

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

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE doc_chunk (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
document_id bigint NOT NULL,
tenant_id int NOT NULL,
allowed_groups int[] NOT NULL,
body text NOT NULL,
embedding vector(1024) NOT NULL
);

CREATE INDEX doc_chunk_embedding_hnsw
ON doc_chunk USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON doc_chunk (tenant_id);
CREATE INDEX ON doc_chunk USING gin (allowed_groups);

ALTER TABLE doc_chunk ENABLE ROW LEVEL SECURITY;
ALTER TABLE doc_chunk FORCE ROW LEVEL SECURITY;

CREATE POLICY chunk_read ON doc_chunk
FOR SELECT TO rag_app
USING (
tenant_id = current_setting('app.tenant_id')::int
AND allowed_groups && string_to_array(current_setting('app.group_ids'), ',')::int[]
);

استدعاء current_setting('app.tenant_id') دون وسيط missing_ok يرفع خطأً إذا لم يُضبط المتغير، وهذا مقصود: الطلب الذي ينسى تحديد هوية المستخدم يفشل بوضوح، بدل أن يعمل بمرشّح فارغ.

المشكلة الأولى: الفهرس يعيد المرشّحين، والسياسة تحذفهم

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

وسياسة RLS، من وجهة نظر المنفّذ، مجرد شرط إضافي. يضيف PostgreSQL تعبير السياسة إلى الاستعلام، وبحسب التوثيق يطبّق شروط السياسات عمومًا قبل شروط المستخدم في الاستعلام، حتى لا تطّلع دوال غير موثوقة على صفوف محمية، والاستثناء الوحيد هو الدوال الموسومة leakproof. فحين يختار المخطِّط فهرس HNSW لاستعلامنا، يُخرج الفهرس حتى 40 مقطعًا هي الأقرب على مستوى الجدول كله، ثم تستبعد السياسة كل مقطع يتبع جهة أخرى أو مجموعات أخرى.

خذ نموذج صلاحيات واقعيًا. موظف في إدارة واحدة لدى جهة واحدة قد يحق له قراءة 1% من المحتوى أو أقل. اطلب LIMIT 10، وكثيرًا ما يعود صفّان أو لا شيء. لا يظهر أي خطأ. يحصل التطبيق على سياق فارغ أو ضعيف، فإما أن يقول النموذج إنه لم يجد شيئًا، وإما أن يجيب من الفتات الذي وصله.

يظهر هذا في خطة التنفيذ. شغّل استعلام الاسترجاع داخل EXPLAIN (ANALYZE, BUFFERS) بدور التطبيق ومع ضبط متغيرات الجلسة، وابحث عن: مسح على الفهرس doc_chunk_embedding_hnsw مع Order By على المسافة، وشروط السياسة تحت Filter، ورقم كبير في Rows Removed by Filter، وعدد صفوف فعلي أقل من LIMIT. وإذا اختبرت بحساب superuser، أو بمالك الجدول دون FORCE، فلن ترى المشكلة أصلًا، لأن RLS لا تُطبَّق.

العلاج: المسح التكراري للفهرس

أضاف الإصدار 0.8.0 من pgvector ما يسمى المسح التكراري (iterative index scans). عندما تترك التصفية عددًا قليلًا جدًا من الصفوف، يعود المسح إلى الفهرس ليجلب مرشّحين إضافيين حتى تكتمل النتائج أو يبلغ الحد hnsw.max_scan_tuples (‏20,000 افتراضيًا). وله وضعان: strict_order يحافظ على الترتيب الدقيق حسب المسافة، وrelaxed_order يسمح بخروج النتائج بترتيب غير دقيق قليلًا مقابل استرجاع أفضل، ثم تعيد ترتيبها عبر CTE من نوع MATERIALIZED.

تصبح معاملة الاسترجاع هكذا:

BEGIN;
SELECT set_config('app.tenant_id', '42', true),
set_config('app.group_ids', '7,19,233', true);

SET LOCAL hnsw.iterative_scan = relaxed_order;
SET LOCAL hnsw.ef_search = 100;
SET LOCAL hnsw.max_scan_tuples = 20000;

WITH hits AS MATERIALIZED (
SELECT id, document_id, body,
embedding <=> $1 AS distance
FROM doc_chunk
ORDER BY distance
LIMIT 10
)
SELECT * FROM hits ORDER BY distance + 0;
COMMIT;

إضافة + 0 في ORDER BY الخارجي مطلوبة على PostgreSQL 17 وما بعده بحسب توثيق pgvector. والوسيط الثالث في set_config قيمته true، أي أن الهوية تبقى لهذه المعاملة فقط. وهذا مهم مع PgBouncer في وضع transaction pooling، حيث قد تعود المعاملة التالية على اتصال الخادم نفسه لمستخدم آخر. لو استخدمت SET على مستوى الجلسة لتسربت هوية مستخدم إلى استعلام مستخدم آخر.

وللمسح التكراري سقف. فالحد max_scan_tuples يقيّد العمل في كل استعلام، والتوثيق يوضح أنه حد تقريبي ولا يشمل المسح الأول. فإذا كان المستخدم يرى جزءًا ضئيلًا من المحتوى، فقد لا تكفي حتى 20,000 صف لجمع ما يلزم من نتائج. رفع الحد يعني زمن استجابة أطول مقابل استرجاع أفضل، وهناك أيضًا hnsw.scan_mem_multiplier للحالات التي لا يعود فيها رفع max_scan_tuples بفائدة. لا نذكر هنا أرقامًا للاسترجاع أو زمن الاستجابة، لأنها تعتمد على بياناتك وأبعاد المتجهات وتوزيع الصلاحيات لديك. قِسها بنفسك.

حين تكون الشريحة المرئية صغيرة، لا تبحث في الرسم البياني المشترك

إذا كانت الصلاحيات تقسّم المحتوى إلى شرائح صغيرة كثيرة، فالأفضل أن تبحث من البداية في بنية أصغر. وإرشادات pgvector تشير إلى ثلاثة خيارات:

  • بحث دقيق على مجموعة مُصفّاة مسبقًا. مع فهرس B-tree على tenant_id (أو فهرس متعدد الأعمدة) يستطيع المخطِّط جلب الصفوف المرئية أولًا ثم حساب المسافات بدقة. الاسترجاع هنا كامل، والسرعة جيدة حين تكون الشريحة صغيرة. ويذكر توثيق pgvector أن الفهارس الدقيقة تعمل جيدًا مع الشروط التي تطابق نسبة صغيرة من الصفوف. تحقق من الخطة الفعلية عبر EXPLAIN، فالقرار للمخطِّط.

  • فهارس HNSW جزئية حين تكون قيم التصفية قليلة، مثل فهرس مستقل لوحدة أعمال كبيرة وحساسة.

  • تقسيم الجدول حسب الجهة (list partitioning). في قسم تعدد الجهات (multitenancy) يحذّر توثيق pgvector من أن الفهرس التقريبي المشترك يجعل متجهات جهة ما تؤثر في جودة الاسترجاع وسرعته لدى جهات أخرى، ويوصي بالتقسيم أو بجداول منفصلة. كل قسم يحصل على رسم HNSW خاص به، وتقليم الأقسام (partition pruning) على tenant_id يُبقي البحث داخله.

CREATE TABLE doc_chunk (
tenant_id int NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
allowed_groups int[] NOT NULL,
body text NOT NULL,
embedding vector(1024) NOT NULL,
PRIMARY KEY (tenant_id, id)
) PARTITION BY LIST (tenant_id);

CREATE TABLE doc_chunk_t42 PARTITION OF doc_chunk FOR VALUES IN (42);
CREATE INDEX ON doc_chunk_t42 USING hnsw (embedding vector_cosine_ops);

أبقِ سياسة RLS حتى مع التقسيم: التقسيم للاسترجاع والسرعة، والسياسة هي ضابط الوصول. واكتب شرط الجهة في نص الاستعلام نفسه، لا في السياسة فقط، فالقيمة الصريحة أو المعامل في WHERE هو ما يجعل التقليم موثوقًا.

المشكلة الثانية: السياسة التي تظنها مطبّقة ليست كذلك

ثلاثة تفاصيل في RLS تحدد هل يحمي هذا كله شيئًا:

  1. حسابات superuser والأدوار التي تحمل BYPASSRLS تتجاوز أمان مستوى الصف دائمًا. إذا كانت خدمة RAG تتصل بحساب postgres، فالسياسة مجرد زينة.

  2. مالك الجدول يتجاوز RLS ما لم يكن الجدول مضبوطًا على FORCE ROW LEVEL SECURITY. أدوات الترحيل تنشئ الجداول غالبًا بالدور نفسه الذي يستخدمه التطبيق لاحقًا. افصل دور المالك عن rag_app، أو افرض RLS، والأفضل أن تفعل الأمرين.

  3. سحب الصلاحية يجب أن يصل إلى المقاطع. بما أن قائمة الصلاحيات منسوخة إلى المقاطع، فإن إزالة مجموعة من وثيقة يجب أن تحدّث allowed_groups في كل مقاطعها ضمن المعاملة نفسها، عبر trigger أو في مسار الكود نفسه الذي يغيّر صلاحيات الوثيقة. واختبر ذلك: اسحب الصلاحية، ثم استعلم فورًا بحساب المستخدم الذي سُحبت منه.

ومتغيرات الجلسة نفسها حدّ ثقة. فسياسة RLS هنا تفرض الهوية التي يضبطها التطبيق، وهي تحميك من شرط WHERE منسي، أو نقطة API جديدة، أو أداة تحليل تتصل بدور rag_app. لكنها لا تحميك من خادم تطبيق مخترق يضبط معرّفات مجموعات مستخدم آخر. إذا كان هذا التهديد واردًا عندك، فاجعل دالة SECURITY DEFINER تتحقق من رمز موقّع ثم تضبط المتغيرات، واسحب من التطبيق القدرة على ضبطها مباشرة.

المشكلة الثالثة: المتجهات ليست بيانات مجهولة

تتعامل فرق كثيرة مع عمود المتجهات على أنه بيانات مشتقة لا ضرر منها، والأبحاث تقول غير ذلك. فقد أظهرت ورقة Morris وزملائه ("Text Embeddings Reveal (Almost) As Much As Text"، مؤتمر EMNLP 2023) أن طريقتهم Vec2Text استعادت 92% من النصوص المكوّنة من 32 رمزًا (token) حرفيًا من متجهاتها، واستعادت 89% من الأسماء الكاملة في ملاحظات سريرية بعد تحويلها إلى متجهات. وخلاصة الباحثين أن المتجهات يجب أن تُعامل بالاحتياطات نفسها التي يُعامل بها النص الأصلي.

ولهذا في منظومة RAG مبنية على PostgreSQL نتائج عملية:

  • النص موجود على القرص أكثر من مرة: في عمود body، وفي عمود embedding، وفي صفحات فهرس HNSW، وفي WAL، وعلى كل نسخة متماثلة (replica)، وفي كل نسخة احتياطية.

  • النسخة الاختبارية المُقنّعة ليست مُقنّعة إذا أبقيت المتجهات الأصلية. فإذا أُخفي محتوى body بينما حُسب embedding من النص الحقيقي، تبقى البيانات الشخصية قابلة للاستعادة من حيث المبدأ. أعد حساب المتجهات من النص المُقنّع، أو احذف العمود من النسخ غير الإنتاجية.

  • إذا كانت الوثائق تحتوي بيانات شخصية، فالمتجهات تستحق المعاملة نفسها في سجل البيانات الذي تعدّه لنظام حماية البيانات الشخصية وفي قواعد الاحتفاظ. ويشمل ذلك الحذف: حذف وثيقة يعني حذف مقاطعها ومتجهاتها، وانتهاء صلاحية النسخ الاحتياطية القديمة وفق الجدول الزمني نفسه.

  • مكان حساب المتجهات لا يقل أهمية عن مكان تخزينها. واجهة embedding مستضافة خارج المملكة تستقبل نص المقطع كاملًا، أما تشغيل نموذج الـ embedding على بنية تحتية داخل المملكة فيُبقي النص والمتجهات محليين.

أين يأتي دور PGEE

يترك PGEE طريقة بحث pgvector كما هي، ويقلّل انكشاف البيانات على القرص الذي وصفناه أعلاه.

  • التشفير الشفاف للبيانات (TDE). تذكر CYBERTEC أن TDE في PGEE يغطي الجداول والفهارس وملفات WAL والملفات المؤقتة والنسخ الاحتياطية. في حمل عمل RAG يعني ذلك أن عمود body وعمود embedding وصفحات فهرس HNSW كلها مشفّرة على القرص مع بقية قاعدة البيانات، دون تعديل في التطبيق.

  • حزم pgvector متوفرة لـ PGEE. يتضمن مستودع حزم PGEE العام لدى CYBERTEC إصدارات من pgvector، ومنها وقت كتابة المقال pgvector 0.8.6 لـ PostgreSQL 18، وهو إصدار يدعم المسح التكراري. ونشرت CYBERTEC أيضًا شرحًا خاصًا بها للبحث الدلالي باستخدام pgvector. تحقق من الحزمة المناسبة لإصدار PGEE لديك أثناء التقييم.

  • إخفاء البيانات (Data masking) للنسخ غير الإنتاجية من النص. وبناءً على ما سبق، خطّط أيضًا لما يحدث للمتجهات في تلك النسخ، فإخفاء عمود النص لا يعيد حساب المتجهات تلقائيًا.

  • التدقيق. تدقيق الكائنات على doc_chunk يسجّل من قرأ أي المقاطع، وهذا ما تحتاجه حين يُعترض على إجابة قدّمها المساعد. راجع مقالنا السابق عن سجلات التدقيق المقاومة للعبث.

سياسة RLS تحدد من يستطيع قراءة الصفوف عبر SQL، وTDE يحدد ما يكشفه قرص أو لقطة (snapshot) أو نسخة احتياطية مسروقة. ونظام RAG في بيئة خاضعة للتنظيم يحتاج الاثنين.

اختبار يمكنك تنفيذه هذا الأسبوع

  1. حمّل شريحة واقعية من المحتوى بتوزيع صلاحيات حقيقي، لا جهة واحدة بصلاحية كاملة.

  2. بدور rag_app، ومع ضبط الهوية عبر set_config(..., true)، نفّذ 50 سؤالًا معتادًا مع LIMIT 10، وسجّل عدد الصفوف العائدة. كرّر مع hnsw.iterative_scan معطّلًا ثم مفعّلًا.

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

  4. اسحب مجموعة من وثيقة، ثم استعلم فورًا بحساب أحد أعضاء تلك المجموعة.

  5. اتصل بحساب مالك الجدول وبدور يحمل BYPASSRLS، وتأكد أن التطبيق لا يستخدم أيًّا منهما.

تواصل مع Worlber

Worlber هي الموزّع المعتمد لـ CYBERTEC في المملكة العربية السعودية. نطبّق PGEE وندعمه من الرياض، بما في ذلك تصميم مخططات pgvector وRAG، وإعداد RLS وTDE، والاختبار الموضح أعلاه.

المصادر

  • توثيق pgvector (التصفية، والمسح التكراري، وتعدد الجهات): https://github.com/pgvector/pgvector

  • توثيق PostgreSQL 18 لسياسات أمان الصفوف: https://www.postgresql.org/docs/18/ddl-rowsecurity.html

  • توثيق PostgreSQL 18 لأمر CREATE POLICY: https://www.postgresql.org/docs/18/sql-createpolicy.html

  • Morris وآخرون، Text Embeddings Reveal (Almost) As Much As Text، ‏EMNLP 2023: https://aclanthology.org/2023.emnlp-main.765.pdf

  • صفحة PGEE لدى CYBERTEC: https://www.cybertec-postgresql.com/en/products/cybertec-postgresql-enterprise-edition/

  • مستودع حزم PGEE لدى CYBERTEC، ‏pgvector: https://deb.cybertec-postgresql.com/public/pool/main/p/pgvector/

  • CYBERTEC، ‏Semantic Search in PostgreSQL: An Overview: https://www.cybertec-postgresql.com/en/semantic-search-in-postgresql-an-overview/

الخطوة التالية

تحدث مع مهندسي Worlber

أرسل لنا بيئتك وهدفك. سنوضح لك ما يصلح، وما لا يصلح، والخطوة الآمنة التالية.

contactus@worlber.com

pgvector وأمان مستوى الصف: بحث RAG يحترم الصلاحيات في PostgreSQL | Worlber Insights