البحث العربي من المسائل التي تبدو بسيطة إلى أن تنفذها بنفسك. يكتب المستخدم كلمة، وتبحث أنت بـLIKE، وينتهي الأمر. إلى أن يكتب أحدهم «إسلام» وتكون قد خزّنتها في قاعدة البيانات «اسلام»، فلا تعود أي نتيجة.
هذا المقال هو النمط الذي بنينا به البحث في هذا الموقع، إضافة إلى خطأ تشخيصي استغرق ساعتين، وهو الدرس الرئيسي فيه.
المسألة: كلمة واحدة، عدة كتابات#
للعربية عدة خصائص تسبب الإزعاج لقاعدة البيانات.
الهمزة وأشكال الألف#
قد يكتب المستخدم كلمة «إسلام» بثلاث صور:
إسلامبالهمزة تحت الألف — الحرف U+0625اسلامبألف مجردة من الهمزةأسلامبالهمزة فوق الألف
بالنسبة إلى MySQL هذه ثلاث سلاسل مختلفة تماما. والأمر نفسه مع «مسؤول» و«مسئول»، ومع «قرآن» و«قران».
الحروف المتشابهة#
لوحات المفاتيح واللهجات الكتابية المختلفة تنتج حروفا مختلفة:
| الحرف | البديل | الفرق |
|---|---|---|
| ي (U+064A) | ى (U+0649) | الياء والألف المقصورة، وكثيرا ما تُكتب إحداهما مكان الأخرى في آخر الكلمة |
| ة (U+0629) | ه (U+0647) | التاء المربوطة والهاء |
| ك (U+0643) | الكاف الفارسية (U+06A9) | تصل أحيانا من لوحات مفاتيح أو نصوص منسوخة من الفارسية |
كثير من المستخدمين لا يعرفون أصلا أي حرف ترسله لوحة مفاتيحهم.
الأرقام#
123 و123 متطابقان عند الإنسان، لكنهما مختلفان عند قاعدة البيانات. وبالإضافة إلى ذلك توجد صورة ثالثة هي الأرقام الهندية الشرقية الممتدة (U+06F0 وما بعده) التي تأتي أحيانا من نصوص فارسية.
التشكيل والتطويل والمحارف غير المرئية#
الفتحة والكسرة والشدة قد تكون في النص وقد لا تكون. وكذلك التطويل (الكشيدة، U+0640) كما في «العــربية». ومحارف التحكم باتجاه النص تدخل أحيانا عبر النسخ واللصق وهي غير مرئية تماما.
لماذا ليس FULLTEXT هو الحل#
أول ما يخطر في البال هو فهرس النص الكامل في MySQL. وللعربية على الاستضافة المشتركة ثلاث مشكلات جدية معه.
1. الحد الأدنى لطول الكلمة. محرك InnoDB لا يفهرس افتراضيا الكلمات التي تقل عن ثلاثة أحرف:
SHOW VARIABLES LIKE 'innodb_ft_min_token_size';على الاستضافة المشتركة لا يمكنك تغيير هذا المتغير — فتغييره يحتاج إلى صلاحية على مستوى الخادم وإعادة تشغيل الخدمة. أي أن الكلمات العربية القصيرة تسقط من البحث إلى الأبد.
2. لا يوجد مُجذِّع عربي. لا يعرف محرك النص الكامل أن «كتاب» و«كتب» و«الكتب» تنتمي إلى أصل واحد، ولا أن «والكتاب» هي «و + ال + كتاب». وبدون التجذير تضيع الميزة الرئيسية لـFULLTEXT مقارنة بـLIKE.
3. يبقى التطبيع (normalization) لازما. حتى مع FULLTEXT تبقى مشكلة الهمزات والياء والتاء المربوطة والتشكيل على حالها. أي أنك تدفع الثمن ولا تُحل المشكلة الأساسية.
خلاصتنا: كلفته كاملة وفائدته قريبة من الصفر. فاستبعدناه.
الحل: طبقتان للنص#
الفكرة بسيطة. احتفظ بالنص في صورتين:
- عمود العرض، وهو بالضبط ما كتبه المستخدم، مع الهمزات والتشكيل سليمة.
- عمود البحث، وفيه كل شيء موحَّد: التشكيل والتطويل محذوفان، وأشكال الألف موحدة، والياء والتاء المربوطة موحدتان، والأرقام لاتينية.
لماذا عمودان وليس واحدا؟ لأن توحيد الهمزات وحذف التشكيل يغيّر النص الأصلي. إذا طبّعت النص المعروض أيضا فسيرى القارئ «اسلام» بدل «إسلام»، وهذا خطأ إملائي. وإذا لم تطبّع نص البحث فلن تعمل المطابقة. هذان الهدفان لا يجتمعان، فنحتاج عمودين.
المُطبِّع#
صنف صغير بدالتين وهدفين مختلفين:
class ArabicText
{
// للعرض: يوحّد حروف لوحات المفاتيح الأخرى ويزيل المحارف غير المرئية والتطويل، ويُبقي الهمزات والتشكيل
public static function normalize(?string $text): string
{
// الياء والكاف الفارسيتان إلى الياء والكاف العربيتين
$text = strtr((string) $text, ["\u{06CC}" => "\u{064A}", "\u{06A9}" => "\u{0643}"]);
// التطويل ومحارف التحكم باتجاه النص
$text = preg_replace('/[\x{0640}\x{200B}\x{200E}\x{200F}\x{202A}-\x{202E}\x{FEFF}]/u', '', $text);
return trim(preg_replace('/[ \t]+/u', ' ', $text));
}
// للمطابقة: يُحذف التشكيل وتُوحَّد الحروف المتشابهة والأرقام
public static function searchable(?string $text): string
{
$text = self::normalize($text);
$text = preg_replace('/[\x{064B}-\x{065F}\x{0670}]/u', '', $text);
$text = strtr($text, [
'أ' => 'ا', 'إ' => 'ا', 'آ' => 'ا', 'ٱ' => 'ا',
'ى' => 'ي', 'ئ' => 'ي', 'ؤ' => 'و', 'ة' => 'ه',
]);
// الأرقام الهندية الشرقية (U+0660) والممتدة (U+06F0) إلى أرقام لاتينية
$digits = [];
foreach (range(0, 9) as $i) {
$digits[mb_chr(0x0660 + $i)] = (string) $i;
$digits[mb_chr(0x06F0 + $i)] = (string) $i;
}
$text = strtr($text, $digits);
return mb_strtolower($text);
}
}نقطة مهمة: الدالة التي تملأ العمود يجب أن تعالج عبارة بحث المستخدم أيضا. إذا لم تتطابق المعالجتان فلن يُعثر على أي شيء أبدا.
في النموذج (Model)#
public function scopeSearch(Builder $query, ?string $term): Builder
{
$term = ArabicText::searchable($term);
if ($term === '') {
return $query;
}
return $query->where(function (Builder $q) use ($term) {
$q->where('searchable', 'like', "%{$term}%")
->orWhere('search_body', 'like', "%{$term}%");
});
}وتُملأ الأعمدة عند الحفظ لا عند القراءة. يُنفَّذ التوليد مرة واحدة، ولا تقوم الصفحة العامة بأي معالجة.
الخطأ الذي ارتكبناه وهو الدرس الرئيسي#
عندما لم يكن البحث يعمل، كان البلاغ: «البحث لا يعمل مع الهمزة».
قضينا ساعتين في معالجة توحيد الهمزات. كتبنا اختبارات وطبعنا المخرجات، وكان كل شيء صحيحا. «أحمد» كانت تتحول بشكل سليم إلى «احمد».
أما السبب الحقيقي فكان في مكان آخر: عمود البحث كان يُملأ من العنوان والملخص فقط، لا من نص المقال. أي كلمة وردت في النص ولم ترد في العنوان لم تكن موجودة أصلا في أي عمود قابل للبحث. وكان التطبيع سليما منذ اليوم الأول.
القاعدة التي بقيت من هذه الحادثة: عندما لا يتطابق العَرَض مع السبب، افترض أولا أن العَرَض مضلِّل. المستخدم يبلّغ عما رآه، لا عما حدث فعلا. ذهبت ساعتان في فحص شيء كان سليما من البداية.
وكانت طريقة التشخيص الصحيحة بسيطة ويجب أن نبدأ بها: بدل التخمين، انظر إلى العمود نفسه مباشرة:
SELECT id, LEFT(searchable, 120) FROM articles WHERE id = 5;إذا لم يكن نص المقال موجودا هناك فالمشكلة ليست في التطبيع.
عمود النص الكامل وفخ الحذف الناعم#
للبحث في النص أضفنا عمودا مستقلا من النوع mediumText يُملأ من HTML بعد توليده:
public static function searchText(?string $html): string
{
$text = strip_tags((string) $html);
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return ArabicText::searchable($text);
}لماذا من HTML المولَّد وليس من النص الخام؟ لأن strip_tags تزيل الوسم بكل خصائصه، فتُحذف تلقائيا عناوين الصور والروابط — وهي ضوضاء بحثية — بينما يبقى النص داخل كتل الشيفرة، وهو في مدونة تقنية محتوى حقيقي.
الترتيب مهم: أولا strip_tags ثم فك ترميز الكيانات. والعكس يعني أن وسما مكتوبا داخل كتلة شيفرة يتحول أولا إلى وسم فعلي ثم يُحذف.
وهناك فخ وقعنا فيه مرة: عندما تضيف عمودا جديدا وتملؤه للصفوف القديمة، لا تنسَ الصفوف المحذوفة حذفا ناعما (soft delete). سقط مقال محذوف من عملية الملء، وعندما استُعيد عاد بدون نص قابل للبحث — لأن الاستعادة لا تمر عبر مسار الحفظ المعتاد.
Article::withTrashed()->chunkById(100, function ($rows) {
// ...
});اعرف كلفة الأداء#
النمط LIKE '%عبارة%' يُبطل مفعول الفهرس. العلامة البدلية في البداية تعني أن قاعدة البيانات لا تعرف من أي موضع في الفهرس تبدأ، فتقرأ الجدول كله.
شاهد ذلك بـEXPLAIN:
EXPLAIN SELECT * FROM articles WHERE searchable LIKE '%اختبار%';إذا كانت قيمة key تساوي NULL فهذا يعني أنه لم يُستخدم أي فهرس. وهذا مقبول تماما لبضعة آلاف من الصفوف. ومع نمو البيانات يجب أن تنتقل إلى حل آخر.
ملاحظة ذات صلة: إذا كان لديك فهرس على هذا العمود لا يُستخدم أبدا، فإن هذا الفهرس لا يفعل سوى تحديد طول عمودك. شرحنا هنا لماذا يكون الحل الصحيح أحيانا إزالة الفهرس وليس تقصير العمود.
ترتيب بسيط للنتائج بدون محرك بحث#
إذا أردت أن تظهر نتيجة العنوان قبل نتيجة النص فلا حاجة إلى محرك بحث:
$query->orderByRaw(
'CASE WHEN searchable LIKE ? THEN 0 ELSE 1 END',
["%{$term}%"]
);بسيط وكافٍ. يُجرى الترتيب على مجموعة النتائج، لا على الجدول كله.
الفرز الأبجدي العربي مسألة مستقلة#
البحث موضوع، والفرز الأبجدي موضوع آخر. إذا فرزت قائمة أسماء عربية بـORDER BY فقد لا يخرج الترتيب صحيحا — لأن النتيجة تتبع تجميع القاعدة (collation) المستخدم. فمع التجميعات الثنائية يكون الفرز بحسب القيمة العددية للمحارف لا بحسب ترتيب الأبجدية العربية.
نقطتان عمليتان:
1. للحروف المتشابهة قيم عددية مختلفة. فـ«إسلام» و«اسلام» قد تقعان في موضعين مختلفين تماما من القائمة. وبعض تجميعات Unicode تقلل هذا الفرق لكنها لا تغطي كل الحالات، مثل التاء المربوطة والهاء، فلا تعتمد عليها وجرّبها على بياناتك. وإذا وحّدت البيانات بالمُطبِّع أعلاه فستُحل هذه المشكلة تلقائيا.
2. للحصول على ترتيب أبجدي دقيق، نفّذ الفرز في PHP. إذا كانت الإضافة intl متاحة:
$collator = new Collator('ar');
$collator->sort($names);وإذا لم تكن متاحة — وهذا محتمل على الاستضافة المشتركة — فيمكنك إنشاء عمود مساعد تُعاد فيه كتابة الحروف بالترتيب الذي تريده. يستحق ذلك العناء مع القوائم الصغيرة؛ أما القوائم الكبيرة فعليك أن تقدّر كلفته.
ملاحظة مهمة عن طول العمود#
العمود الذي يُملأ بعد التطبيع يأتي من عنوان المقال ونصه، لذلك طوله لا يمكن توقعه. فإذا جعلته varchar(191) — وهو الرقم المألوف الذي يُختار ليتسع في الفهرس — فستحصل على خطأ بمجرد أن يأتي مقال بملخص طويل.
وقعنا في هذا بالضبط، وكانت النتيجة الخطأ 1406 وصفحة 500 في لوحة التحكم. الشرح الكامل والدرس هنا، لكن خلاصته: بما أن الفهرس على هذا العمود لا يُستخدم مع نمط بحثنا أبدا، فقد أزلنا الفهرس وكبّرنا العمود — وليس العكس.
التطبيع مطلوب للرابط المختصر (slug) أيضا لكنه مختلف#
إذا كانت عناوين صفحاتك عربية فستحتاج إلى طبقة ثالثة: الـslug.
قواعده تختلف عن الطبقتين السابقتين:
- تُحذف علامات التشكيل والتطويل ولا تتحول إلى شرطة، فـ
العــربيةتصبحالعربيةلاالعـ-ـربية. - تتحول المسافة إلى شرطة.
- تُحذف المحارف غير المسموح بها في العنوان.
- تُدمج الشرطات المتتالية في شرطة واحدة.
دالة الـslug الافتراضية في لارافيل لا تعطيك رابطا عربيا — فهي تحوّل الحروف العربية إلى حروف لاتينية تقريبية أو تُسقطها، ولن تحصل على الكلمات العربية في العنوان. إن أردت روابط عربية فاكتب دالتك بنفسك:
public static function slug(?string $text, int $limit = 180): string
{
$text = self::normalize($text);
$text = preg_replace('/[\x{064B}-\x{065F}\x{0670}]/u', '', $text);
$text = preg_replace('/[^\p{L}\p{N}]+/u', '-', $text);
$text = trim(preg_replace('/-+/', '-', $text), '-');
return mb_substr(mb_strtolower($text), 0, $limit);
}ملاحظة مهمة: الـslug العربي صالح تماما في العنوان، والمتصفحات ترمّزه. في خريطة الموقع والروابط المعيارية (canonical) اكتب الشكل المرمَّز دائما، لأن أدوات التحقق صارمة مع الشكل الخام.
ولعرض الـslug العربي في الواجهة، تصحح هذه القاعدة في CSS اتجاه النص:
.slug { unicode-bidi: plaintext; }بدونها يظهر الـslug الذي يجمع العربية والإنجليزية مقطعا ومعكوس الترتيب في العرض، بينما تبقى قيمته الفعلية سليمة.
قائمة التحقق#
- احتفظ بطبقتين للنص: معروضة بالهمزات والتشكيل، ومخصصة للبحث بدونهما.
- طبّع عبارة المستخدم بـالدالة نفسها التي بُني بها العمود.
- وحّد أيضا الياء والتاء المربوطة والتشكيل والأرقام، فتوحيد الهمزات وحده لا يكفي.
- املأ الأعمدة المشتقة من مسار واحد فقط، وأجرِ الملء الخلفي على الصفوف المحذوفة حذفا ناعما أيضا.
- قبل تشخيص التطبيع، انظر مرة إلى المحتوى الفعلي للعمود.