المستوى: متوسط. الدليل ده يفترض إنك تعرف أساسيات SQL وعندك جدول PostgreSQL شغّال (الإصدار 13 أو أحدث).
هتخرج من المقال ده وعندك بحث عربي كامل جوّه PostgreSQL يرجّع النتيجة في أقل من 10 مللي ثانية على ملايين الصفوف، من غير ما تركّب Elasticsearch ولا تدفع سيرفر زيادة.
بحث نصي عربي كامل في PostgreSQL بدون Elasticsearch
المشكلة باختصار
أغلب المطورين بيعملوا البحث بـ ILIKE '%كلمة%'. الطريقة دي بتفشل على مقياس حقيقي. مع كل استعلام، PostgreSQL بيقرأ كل صف في الجدول (Sequential Scan). على جدول بـ 2 مليون منتج، ده معناه مئات المللي ثانية لكل بحث، وبيزيد كل ما البيانات تكبر. وكمان ILIKE مبيرتّبش النتائج بالصلة، فالنتيجة الأهم ممكن تطلع في الآخر.
الفكرة أولًا: فهرس آخر الكتاب
تخيّل كتاب 900 صفحة وعايز كل صفحة فيها كلمة "التصميم". قدامك طريقتين. الأولى: تقلّب الكتاب صفحة صفحة من الأول للآخر. دي بطيئة، وهي بالظبط اللي بيعملها ILIKE. الطريقة التانية: تفتح فهرس آخر الكتاب، تلاقي كلمة "التصميم" وجنبها أرقام الصفحات على طول.
ده بالظبط اسمه الفهرس المعكوس (Inverted Index)، وهو أساس أي محرك بحث. بدل ما يدوّر في كل النص، بيبني قايمة مقلوبة: كل كلمة وجنبها الصفوف اللي فيها.
علميًا: PostgreSQL بيحوّل النص لنوع بيانات اسمه tsvector. الـ tsvector بيقطّع الجملة لكلمات (tokens)، بيشيل الكلمات الشائعة (زي "في" و"من")، وبيرجّع الكلمة لأصلها (تجذير). بعد كده فهرس GIN بيربط كل كلمة بالصفوف اللي فيها، فالبحث بيبقى قفزة مباشرة مش مسح كامل.
الخطوات: من عمود نصي لبحث مفهرس
الافتراض إن عندك جدول منتجات بالشكل ده: عمود title وعمود body فيهم نص عربي.
1) ضيف عمود tsvector محسوب تلقائيًا
بدل ما تحدّث الفهرس بإيدك، خلّي PostgreSQL يحسبه لوحده في عمود مخزّن:
ALTER TABLE products
ADD COLUMN search_vec tsvector
GENERATED ALWAYS AS (
to_tsvector('arabic', coalesce(title,'') || ' ' || coalesce(body,''))
) STORED;العمود ده بيتحدّث لوحده مع أي INSERT أو UPDATE، فمفيش داعي لـ trigger يدوي. لو PostgreSQL عندك أقدم من 12، استخدم trigger بدل الـ generated column.
2) اعمل فهرس GIN
CREATE INDEX products_search_idx
ON products USING gin(search_vec);GIN هو الفهرس الموصى به رسميًا للبحث النصي الكامل في PostgreSQL. من غيره، الاستعلام هيفضل يمسح الجدول كله حتى لو العمود موجود.
3) اكتب الاستعلام والترتيب
SELECT id, title,
ts_rank(search_vec, q) AS rank
FROM products, websearch_to_tsquery('arabic', 'مطاعم بحرية رخيصة') AS q
WHERE search_vec @@ q
ORDER BY rank DESC
LIMIT 20;الدالة websearch_to_tsquery بتفهم كتابة المستخدم العادية زي ما بيكتب في جوجل، فمش محتاج تنضّف مدخلاته. والعلامة @@ معناها "هل المستند مطابق للاستعلام". وts_rank بيدّي درجة صلة لكل صف علشان الأهم يطلع الأول.
الأرقام: قبل وبعد
على جدول تجريبي بـ 2 مليون صف (أرقام تقديرية من EXPLAIN ANALYZE):
- بدون فهرس (Seq Scan): حوالي 850 مللي ثانية لكل بحث.
- بفهرس GIN (Bitmap Index Scan): حوالي 9 مللي ثانية.
ده تحسّن حوالي 90 ضعف على نفس البيانات ونفس السيرفر. السيناريو الواقعي: متجر إلكتروني بـ 40 ألف زيارة/يوم، البحث كان بياخد ثانية وقت الذروة وبيضغط على قاعدة البيانات؛ بعد الفهرس بقى شبه فوري والحِمل نزل بشكل واضح.
الـ trade-offs اللي لازم تعرفها
بتكسب سرعة، بتخسر مساحة وكتابة. فهرس GIN بياخد مساحة إضافية (ممكن توصل 30 إلى 50% من حجم العمود النصي)، وبيخلّي الـ INSERT وUPDATE أبطأ شوية لأنه بيحدّث الفهرس مع كل تعديل. الافتراض هنا إن القراءة عندك أكتر من الكتابة بكتير، وده صحيح في أغلب تطبيقات البحث.
نقطة مهمة في العربي: إعداد 'arabic' بيعمل تجذير وبيشيل كلمات الوقف، لكنه مبيوحّدش كل صور الهمزة والتشكيل. لو محتاج "إسلام" و"اسلام" يتطابقوا، طبّع النص قبل الفهرسة: وحّد الألف وشيل التشكيل. الـ trade-off هنا إنك بتكسب استدعاء أعلى (recall) مقابل خطوة معالجة زيادة.
متى لا تستخدم هذه الطريقة
لو محتاج بحث ضبابي على أخطاء إملائية أو جزء من كلمة (زي "مطع" توصل لـ "مطعم")، الـ Full Text Search مش الأداة الصح لأنها بتطابق كلمات كاملة مجذّرة؛ استخدم امتداد pg_trgm. ولو عندك مليارات المستندات وبحث موزّع وترتيب معقّد جدًا، ساعتها Elasticsearch بيدفع تكلفته. لكن تحت مليون لـ عشرة ملايين صف، بحث PostgreSQL كفاية وأبسط في التشغيل.
التحقق من أنه يعمل
شغّل الاستعلام جوّه EXPLAIN ANALYZE وبُص على نوع المسح:
EXPLAIN ANALYZE
SELECT id FROM products
WHERE search_vec @@ websearch_to_tsquery('arabic', 'مطعم بحري');لو شفت Bitmap Index Scan on products_search_idx يبقى الفهرس شغّال. لو شفت Seq Scan، غالبًا الإعداد اللغوي في الاستعلام مختلف عن اللي في العمود المحسوب — لازم الاتنين يكونوا بنفس الإعداد 'arabic'، وإلا PostgreSQL مش هيستخدم الفهرس.
الخطوة التالية
افتح أكبر جدول عندك فيه نص عربي، وضيف عليه عمود search_vec المحسوب وفهرس GIN بس. بعدين شغّل بحثك القديم داخل EXPLAIN ANALYZE قبل وبعد، وقارن الزمن. لو نزل من مئات المللي ثانية لأرقام أحادية، الفهرس شغّال صح.
المصادر
- PostgreSQL Documentation — Chapter 12: Full Text Search: https://www.postgresql.org/docs/current/textsearch.html
- PostgreSQL Documentation — 12.9 GIN and GiST Index Types: https://www.postgresql.org/docs/current/textsearch-indexes.html
- PostgreSQL Documentation — 12.3 Controlling Text Search (ts_rank و websearch_to_tsquery): https://www.postgresql.org/docs/current/textsearch-controls.html
- PostgreSQL Documentation — Generated Columns: https://www.postgresql.org/docs/current/ddl-generated-columns.html
- Snowball Arabic Stemmer (المدمج في PostgreSQL 13+): https://snowballstem.org/algorithms/arabic/stemmer.html
- PostgreSQL Documentation — pg_trgm (للبحث الضبابي وجزء الكلمة): https://www.postgresql.org/docs/current/pgtrgm.html