لو متجرك العربي فيه 38 ألف منتج وبتستخدم WHERE name LIKE '%موبايل%'، انت بتدفع ثانيتين على كل استعلام، وعمرك ما هترجع نتيجة لو المستخدم كتب "موبيل" بدون ياء. PostgreSQL عنده full-text search مدمج بيحل المشكلة دي في 12ms بدون ما تنزّل Elasticsearch ولا تدفع $50 شهرياً.
بحث عربي ذكي في PostgreSQL: من LIKE البطيء لـ tsvector السريع
المشكلة باختصار
الـ LIKE في SQL بيعمل sequential scan على كل صف. على جدول فيه 38K منتج، ده معناه قراءة كل سطر من القرص ومقارنته. الأسوأ: LIKE '%كلمة%' ميقدرش يستخدم أي index، لأن الـ B-tree بيرتب من البداية مش من النص الجوّاني.
زي القصة دي: متجر إلكتروني مصري عنده 38K منتج. كان الاستعلام بياخد 1,840ms في المتوسط، و54% بس من البحثات بترجّع نتيجة صحيحة (لأن المستخدمين بيكتبوا "موبيل" أو "مُوبايل" أو "تليفون"). بعد التطبيق: 12ms، و recall 91%.
المفهوم الأساسي: tsvector زي فهرس الكتاب
تخيل إنك في مكتبة فيها 10,000 كتاب وحد سألك "فين كتب التاريخ؟". مش هتفتح كل كتاب وتقرا. هتروح للفهرس في آخر كل كتاب، تشوف الصفحات اللي فيها كلمة "تاريخ"، وتجيب الكتاب على طول. tsvector بيعمل نفس الفكرة بالظبط: بياخد النص ويحوّله لقائمة من lexemes (جذور الكلمات بدون حركات وبدون أحرف مكررة)، ويخزنهم في GIN index اللي مصمم خصيصاً لقوائم الـ tokens.
علمياً: PostgreSQL بيمر النص على text search configuration. الـ configuration دي بتقرر 3 حاجات: إيه الـ stopwords (كلمات تتجاهل زي "في" و "من")، إيه الـ stemming algorithm (يرجع "كتاب", "كتب", "مكتبة" لجذر واحد)، وإيه الـ normalization (شيل الحركات والتشكيل).
الحل: 4 خطوات
1) إنشاء configuration عربي
PostgreSQL مفيهوش Arabic configuration افتراضي، لكن فيه Snowball stemmer عربي مدمج من نسخة 13+. هنبنيه:
CREATE TEXT SEARCH CONFIGURATION arabic (COPY = simple);
CREATE TEXT SEARCH DICTIONARY arabic_stem (
TEMPLATE = snowball,
Language = arabic
);
ALTER TEXT SEARCH CONFIGURATION arabic
ALTER MAPPING FOR asciiword, word, numword, asciihword, hword
WITH arabic_stem;2) دالة تطبيع النص
مستخدم بيكتب "مُوبايل" بضمة، الثاني "موبيل" بدون ياء، الثالث "إلكترونيات" بهمزة. لازم نوحّدهم قبل ما نخزن.
CREATE OR REPLACE FUNCTION normalize_arabic(input TEXT)
RETURNS TEXT AS $$
BEGIN
RETURN regexp_replace(
regexp_replace(
regexp_replace(input, '[ًٌٍَُِّْ]', '', 'g'),
'[إأآا]', 'ا', 'g'
),
'[ىي]', 'ي', 'g'
);
END;
$$ LANGUAGE plpgsql IMMUTABLE;الخط الأول بيشيل الحركات (فتحة, ضمة, كسرة, شدّة, سكون). الثاني بيوحّد كل أشكال الألف. الثالث بيوحّد الياء والألف المقصورة. الدالة IMMUTABLE علشان نقدر نبنيها داخل GENERATED column.
3) إضافة عمود tsvector محسوب تلقائياً
ALTER TABLE products
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector(
'arabic',
normalize_arabic(
coalesce(name, '') || ' ' || coalesce(description, '')
)
)
) STORED;
CREATE INDEX products_search_idx
ON products USING GIN(search_vector);الـ STORED هنا مهم: العمود بيتحسب وقت الـ INSERT/UPDATE ويخزن، فالقراءة سريعة. لو استخدمت VIRTUAL هيتحسب وقت الاستعلام، وده هيلغي فايدة الـ index.
4) قاموس المرادفات
المستخدم المصري بيكتب "موبايل"، السعودي بيكتب "جوال"، التونسي بيكتب "تليفون". كلهم نفس المنتج. هنخلي PostgreSQL يفهم ده:
# /etc/postgresql/16/main/tsearch_data/arabic_syn.syn
موبايل تليفون جوال هاتف
سيارة عربية موتر
حاسوب كمبيوتر لاب لابتوبCREATE TEXT SEARCH DICTIONARY arabic_synonyms (
TEMPLATE = synonym,
SYNONYMS = arabic_syn
);
ALTER TEXT SEARCH CONFIGURATION arabic
ALTER MAPPING FOR word
WITH arabic_synonyms, arabic_stem;الاستعلام النهائي
SELECT
id,
name,
ts_rank(search_vector, query) AS rank
FROM products,
to_tsquery('arabic', normalize_arabic('موبايل سامسونج')) query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;على 38K منتج: 12ms متوسط، p95 = 28ms. نفس البحث بـ LIKE: 1,840ms و recall 54%.
Trade-offs لازم تعرفها
- حجم الـ index: GIN بياخد حوالي 35-45% من حجم الجدول. عندك 8GB من البيانات؟ توقع 3.2GB إضافية على القرص. هتكسب سرعة، هتدفع disk.
- زمن INSERT بيزيد: من 0.4ms لـ 2.1ms في المتوسط. لو write-heavy (>5K INSERT/ثانية)، الـ GIN index هيبقى bottleneck. الحل: استخدم
fastupdateأو materialized view. - مفيش fuzzy match: لو المستخدم كتب "مبايل" بدلاً من "موبايل" (typo)، مش هتلاقي. لازم تضيف
pg_trgmextension للـ trigram matching جنب tsvector. - اللهجات بتختلف: "كمبيوتر" في الخليج = لاب توب. في مصر = ديسك توب. قاموس المرادفات لازم يتبني حسب الجمهور المستهدف، مش one-size-fits-all.
متى متستخدمش الطريقة دي
- عندك أكتر من 10M document وبتعمل aggregations معقدة (faceted search, bucketing) → Elasticsearch أنسب.
- محتاج بحث semantic (يفهم إن "هاتف بكاميرا كويسة" تشبه "موبايل تصويره حلو") → استخدم embeddings + vector search، مش tsvector.
- الـ write load عندك > 5K INSERT/ثانية مستمر → الـ GIN trigger هياكل CPU، فكر في pipeline منفصل.
- محتاج autocomplete سريع تحت 5ms أثناء الكتابة →
pg_trgmمع GiST index أسرع من tsvector في الحالة دي.
الخطوة التالية
افتح schema جدول الـ products بتاعك دلوقتي وقيس قبل/بعد:
-- قبل
EXPLAIN ANALYZE
SELECT * FROM products WHERE name LIKE '%موبايل%';
-- بعد
EXPLAIN ANALYZE
SELECT * FROM products
WHERE search_vector @@ to_tsquery('arabic', 'موبايل');لو الفرق في الزمن أكبر من 50×، انت على الطريق الصحيح. لو لأ، الـ GIN index غالباً مش اتعمل صح — شغّل REINDEX INDEX products_search_idx; وقيس تاني. لو لسه بطيء، تأكد إن generated column فعلاً STORED مش VIRTUAL.
المصادر
- PostgreSQL Official Docs — Full Text Search: postgresql.org/docs/current/textsearch.html
- PostgreSQL Docs — GIN Indexes Internals: postgresql.org/docs/current/gin.html
- Snowball Arabic Stemmer Algorithm: snowballstem.org/algorithms/arabic/stemmer.html
- PostgreSQL Wiki — Synonym Dictionaries: wiki.postgresql.org/wiki/Full_Text_Search
- pg_trgm Extension Reference: postgresql.org/docs/current/pgtrgm.html