به محتوای اصلی

جست‌وجوی فارسی در دیتابیس؛ حل مشکل ی، ک و نیم‌فاصله

«کتابها» رکورد «کتاب‌ها» را پیدا نمی‌کند، چون دو رشته‌ی متفاوت‌اند. چهار جایی که جست‌وجوی فارسی می‌شکند، راه‌حل درست، و چرا توصیه‌ی رایج داده را خراب می‌کند.

عاطفه امیری۸ دقیقه مطالعه
در این مطلب

کاربر در جعبه‌ی جست‌وجوی اپ شما می‌نویسد «کتابها» و هیچ نتیجه‌ای نمی‌گیرد، در حالی که رکوردی با عنوان «کتاب‌ها» همان‌جا در دیتابیس نشسته است. این باگ نیست؛ دو رشته‌ی متفاوت‌اند که فقط روی صفحه یکی به نظر می‌رسند. جست‌وجوی فارسی چهار جای مشخص می‌شکند — ی و ک عربی، نیم‌فاصله و کاراکترهای نامرئی، اِعراب، و ارقام فارسی — و راه‌حلش یک تابع نرمال‌سازی است که باید هم روی داده و هم روی عبارت جست‌وجو، دقیقاً یکسان اجرا شود. این مقاله می‌گوید هر چهار مورد از کجا می‌آیند، چرا توصیه‌ی رایج در نتایج فارسی امروز غلط است، و پیاده‌سازی درستش چه شکلی دارد.

چرا جست‌وجوی فارسی نتیجه نمی‌دهد؟

مشکل از دیتابیس نیست، از خط فارسی است: یک کلمه بیش از یک شکل بایتی دارد و کاربرها همه‌ی شکل‌ها را تولید می‌کنند. چهار خانواده‌ی اصلی:

۱. ی و ک عربی. کیبورد عربی، کیبورد قدیمی ویندوز و متنی که از ورد یا از یک سایت دیگر کپی شده، «ي» و «ك» عربی می‌دهند نه «ی» و «ک» فارسی. برای دیتابیس این‌ها کاراکترهای کاملاً متفاوتی‌اند: كتاب هیچ‌وقت با کتاب برابر نمی‌شود. همین یک مورد، بیشترین سهم را در «چرا پیدا نمی‌کند» دارد.

۲. نیم‌فاصله و کاراکترهای نامرئی. «می‌خواهم» و «میخواهم» برای چشم تقریباً یکی‌اند ولی اولی یک کاراکتر اضافه دارد: نیم‌فاصله (ZWNJ). کنارش کاراکترهای نامرئی دیگری هم در متن‌های کپی‌شده پیدا می‌شوند — نشانگرهای جهت متن (LRM و RLM) و کشیده (ـ) — که هیچ‌کدام دیده نمی‌شوند و همه‌شان تطابق را خراب می‌کنند.

۳. اِعراب. «کِتاب» و «کتاب». تقریباً هیچ‌کس اعراب تایپ نمی‌کند، ولی هرکس که بکند انتظار دارد نتیجه‌ی بدون اعراب را هم ببیند. همین برای «ة» و «ۀ» و همزه‌ی روی الف («مأمور» در برابر «مامور») هم صادق است.

۴. ارقام فارسی و عربی. «۱۲۳» و «123» و «١٢٣» سه رشته‌ی متفاوت‌اند. برای جست‌وجوی کد سفارش یا شماره‌ی مدل، این یعنی نتیجه‌ی خالی.

هیچ‌کدام از این‌ها روی صفحه دیده نمی‌شوند. برای همین است که این باگ معمولاً دیر پیدا می‌شود و وقتی پیدا شد، اول به دیتابیس یا فریم‌ورک شک می‌کنند: داده درست است، کوئری درست است، و نتیجه خالی است.

راه‌حلی که نتایج فارسی می‌گویند، و چرا امروز غلط است

اگر همین حالا این مسئله را جست‌وجو کنید، چیزی که پیدا می‌کنید مطالبی است از ۱۳۸۸ تا ۱۳۹۵ درباره‌ی SQL Server و SQLite، و توصیه‌ی مشترکشان یکی از این دو است: یا موقع ذخیره، کاراکترهای فارسی را به عربی (یا برعکس) تبدیل کنید، یا در هر کوئری یک زنجیره‌ی REPLACE بنویسید.

هر دو مشکل دارند و اولی جدی‌تر است.

تبدیل داده‌ی ذخیره‌شده، تخریب داده است. اگر «ی» را در متن اصلی به «ي» تبدیل کنید، دیگر متن کاربر را ندارید — نسخه‌ی تغییریافته‌اش را دارید. اولین جایی که این خودش را نشان می‌دهد خروجی گرفتن، صورت‌حساب و چاپ است. نرمال‌سازی باید برای مقایسه انجام شود، نه برای ذخیره.

زنجیره‌ی REPLACE داخل کوئری، ایندکس را از کار می‌اندازد. لحظه‌ای که ستون را داخل تابع بپیچید، پلنر دیگر از ایندکس معمولی استفاده نمی‌کند و هر جست‌وجو تبدیل به اسکن کامل جدول می‌شود. روی هزار رکورد کسی نمی‌فهمد؛ روی دویست هزار رکورد، همان صفحه‌ای که کاربر بیشترین استفاده را از آن می‌کند کندترین صفحه‌ی محصول می‌شود.

و مهم‌تر از هر دو: این راه‌حل‌ها ناقص‌اند. وقتی این مسئله را روی PostgreSQL اندازه‌گیری کردیم — هزار و دویست رکورد فارسی نمونه، مقایسه‌ی ILIKE خام با مسیر نرمال‌شده — نتیجه این بود:

کاربر این را می‌نویسدبا LIKE خامبا نرمال‌سازی + tri-gram
میخواهم (بدون نیم‌فاصله)۰ نتیجههمان رکوردها
كتاب (با کاف عربی)۰ نتیجه۱۰ رکورد
محصو (نیم‌کلمه)۰ نتیجه۲۰ رکورد
پوشش کلی نسبت به مسیر درستحدود ۵۰٪مبنا

یعنی نصف نتایجی که کاربر حق داشت ببیند، بی‌سروصدا حذف می‌شدند — بدون خطا، بدون لاگ، فقط یک فهرست کوتاه‌تر که هیچ‌کس نمی‌داند کوتاه است.

نرمال‌سازی درست دقیقاً چه چیزهایی را یکی می‌کند؟

فهرست کامل چیزی که یک تابع نرمال‌سازی فارسی باید بپوشاند، همان چیزی است که در فیکارو پیاده شده:

دستهچه چیزی به چه چیزینمونه
حروف عربیي و ى ← ی ، ك ← کاحراز هويت = احراز هویت
ه و ت گردة و ۀ ← همرتبة = مرتبه
همزه روی الفأ و إ و آ ← امأمور = مامور
نامرئی‌هانیم‌فاصله، ZWJ، LRM، RLM و کشیده حذفمی‌خواهم = میخواهم
اِعرابفتحه، کسره، ضمه، تشدید، سکون حذفکِتاب = کتاب
ارقامارقام فارسی و عربی ← ارقام لاتین۱۲۳ = 123
حالت حروفlowercase و trim Order = order

دو نکته که معمولاً جا می‌افتند. «آ» را هم باید به «ا» تبدیل کرد — کسی که دنبال «اب» می‌گردد انتظار دارد «آب» را ببیند، و این تصمیمِ عمدی است نه سهو. و کشیده (ـ) یک کاراکتر واقعی است، نه صرفاً کشش بصری: «کــتاب» با «کتاب» برابر نیست مگر حذفش کنید.

قانون طلایی: نرمال‌سازی باید متقارن باشد

این مهم‌ترین جمله‌ی کل مقاله است: همان تابع باید هم موقع ایندکس‌کردن سطرها و هم موقع پردازش عبارت کاربر اجرا شود. اگر یک طرف نرمال شود و طرف دیگر نه، دو طرف هیچ‌وقت به هم نمی‌رسند و نتیجه دقیقاً همان صفحه‌ی خالی اول مقاله است.

مشکل عملی اینجاست که معمولاً دو پیاده‌سازی لازم می‌شود: یکی به زبان بک‌اند برای عبارت کاربر، و یکی داخل SQL برای ایندکس. و هر جا دو پیاده‌سازی از یک قاعده وجود داشته باشد، دیر یا زود از هم دور می‌شوند.

در فیکارو دو چیز جلوی این را می‌گیرد و هر دو ارزش کپی کردن دارند:

  • تابع SQL نسخه‌دار است، نه قابل جایگزینی. اسمش _apicute_fa_norm_v1 است و هر تغییری در قواعد یعنی ساختن v2 و بازسازی ایندکس‌ها — نه ویرایش همان تابع. دلیلش این است که ایندکسِ ساخته‌شده با قواعد قدیمی، رشته‌هایی را نگه می‌دارد که سمت کوئری دیگر تولیدشان نمی‌کند، و این خرابی هیچ صدایی ندارد.
  • یک تست، برابری دو پیاده‌سازی را روی یک پیکره‌ی متن می‌سنجد. تنها چیزی که بین این دو تابع و خرابی خاموش ایندکس ایستاده، همین تست است.

اگر خودتان پیاده‌اش می‌کنید: تابع SQL را فقط از lower و btrim و translate بسازید. هر سه واقعاً IMMUTABLE هستند، پس می‌توانید تابع را IMMUTABLE علامت بزنید و روی خروجی‌اش ایندکس بسازید — چیزی که با یک تابع PL/pgSQL معمولی ممکن نیست.

چرا LIKE کافی نیست و tri-gram چه فرقی دارد

نرمال‌سازی مسئله‌ی «شکل‌های مختلف یک کلمه» را حل می‌کند، ولی مسئله‌ی «کاربر نصف کلمه را تایپ کرده» را نه. کسی که «محصو» می‌نویسد هنوز در حال تایپ است و انتظار دارد همان لحظه چیزی ببیند.

اینجاست که pg_trgm وارد می‌شود: متن را به قطعه‌های سه‌حرفی می‌شکند و شباهت را می‌سنجد، به‌جای اینکه دنبال تطابق دقیق بگردد. سه چیز درباره‌اش بدانید:

  • آستانه‌ی شباهت را خودتان تعیین کنید، نه سرور. مقدار ۰.۳ (پیش‌فرض خود pg_trgm) در آزمایش ما نیم‌کلمه‌ها را می‌گرفت بدون اینکه کلمات بی‌ربط را بیاورد. مهم‌تر: این مقدار باید در سطح تراکنش ست شود، نه سشن — با connection pooling، یک SET سشنی به کوئری بعدی که همان اتصال را قرض می‌گیرد نشت می‌کند.
  • عملگر مهم است. برای اینکه ایندکس GIN واقعاً استفاده شود باید از عملگر %> استفاده کنید؛ نوشتن word_similarity(...) به‌عنوان شرط، ایندکس را نمی‌گیرد و همان اسکن کامل را می‌دهد. ترتیب هم مهم است: column %> query.
  • ایندکس را با CONCURRENTLY بسازید. ساخت GIN روی جدولی که در حال سرویس‌دهی است می‌تواند طول بکشد؛ فرم معمولی جدول را قفل می‌کند.

در فیکارو: یک تیک، و ?search=

اگر نمی‌خواهید هیچ‌کدام از این‌ها را خودتان بنویسید، در فیکارو این لایه از قبل ساخته شده. روی هر فیلد متنی در مدل داده، «قابل جست‌وجو» را تیک می‌زنید و همان لحظه پارامتر search روی اندپوینت فهرست کار می‌کند:

curl -G "https://api.fikaro.ir/my-shop/v1/product" \
  --data-urlencode "search=کتابهای اشپزی" \
  --data-urlencode "limit=20" \
  -H "Authorization: Bearer apck_..."

عبارت فارسی را حتماً URL-encode بفرستید — چه با --data-urlencode در curl، چه با encodeURIComponent در جاوااسکریپت. فاصله و کاراکترهای غیرلاتین اگر خام داخل query string بروند، بسته به کلاینت یا بریده می‌شوند یا خراب.

سه رفتاری که ارزش دانستن دارند:

۱. عبارت پیش از مقایسه از همان جدول نرمال‌سازی بالا رد می‌شود، پس «كتابها» و «کتاب‌ها» و «کتابها» یک نتیجه می‌دهند. چند فیلد قابل جست‌وجو با هم OR می‌شوند.

۲. ایندکس در پس‌زمینه ساخته می‌شود، نه در مسیر درخواست. تا وقتی آماده نشده، همان نتایج برمی‌گردند — فقط کندتر — و پاسخ هدر X-Search-Degraded: true دارد. یعنی هیچ پنجره‌ای وجود ندارد که در آن جست‌وجو جواب اشتباه بدهد.

۳. اگر روی آن موجودیت هیچ فیلد قابل جست‌وجویی که شما اجازه‌ی خواندنش را داشته باشید نباشد، پاسخ 400 با کد no_searchable_fields است — نه یک فهرست خالی که به نظر بیاید داده‌ای وجود ندارد. شرح کامل پارامترها در کار با API آمده است.

چهار چیزی که موقع پیاده‌سازی باید بدانید

جست‌وجو یک کانال خواندن است، پس باید مثل خواندن کنترل دسترسی شود. این را معمولاً هیچ‌کس نمی‌گوید: اگر روی فیلدی جست‌وجو بدهید که کاربر اجازه‌ی دیدنش را ندارد — قیمت خرید، هش رمز، یادداشت داخلی — با چند درخواست پشت‌سرهم می‌شود مقدارش را حرف‌به‌حرف حدس زد. وجود یا نبودِ نتیجه، خودش جواب است. در فیکارو فهرست فیلدهای قابل جست‌وجو از روی چیزی ساخته می‌شود که همان صداکننده حق خواندنش را دارد، و فیلد مخفی اصلاً اجازه‌ی قابل‌جست‌وجو شدن ندارد.

داده‌ی ذخیره‌شده را دست نزنید. نرمال‌سازی روی مسیر مقایسه انجام می‌شود؛ متن اصلی کاربر باید دست‌نخورده بماند.

ایندکس‌تان را روی دقیقاً همان عبارتی بسازید که در کوئری می‌نویسید. ایندکس روی عبارت نرمال‌شده‌ی یک فیلد ساخته می‌شود؛ اگر کوئری همان عبارت را کلمه‌به‌کلمه تکرار نکند — حتی یک تفاوت کوچک در نحوه‌ی نوشتنش — پلنر بی‌صدا ایندکس را کنار می‌گذارد. خطایی نمی‌گیرید؛ فقط محصولی دارید که هرچه موفق‌تر می‌شود کندتر می‌شود.

دو حالت «خالی» را از هم جدا کنید. «چیزی پیدا نشد» و «این موجودیت اصلاً قابل جست‌وجو نیست» برای کاربر یک صفحه‌ی خالی‌اند و دو معنی کاملاً متفاوت دارند.

اگر مدل داده‌ای که این فیلدها رویش می‌نشینند را هنوز نساخته‌اید، ساخت API بدون کدنویسی از صفر تا اولین درخواست جلو می‌رود؛ و اگر دنبال جای درست نگه‌داشتن خود داده‌اید، دیتابیس آنلاین برای اپلیکیشن گزینه‌ها را از هم جدا می‌کند. متن فارسی تنها جایی نیست که داده‌ی ایرانی دردسر می‌سازد — همین شکل مسئله برای تاریخ هم تکرار می‌شود، و تاریخ شمسی در دیتابیس و API می‌گوید چرا ذخیره‌ی شمسی اشتباه است و راه درستش چیست.

سوالات متداول

چرا جستجوی فارسی در دیتابیس نتیجه نمی‌دهد؟
چون یک کلمه‌ی فارسی بیش از یک شکل بایتی دارد: ی و ک عربی در برابر فارسی، نیم‌فاصله و کاراکترهای نامرئی، اِعراب، و ارقام فارسی. دو رشته‌ای که روی صفحه یکی به نظر می‌رسند برای دیتابیس متفاوت‌اند. راه‌حل، نرمال‌سازی متن پیش از مقایسه است — با همان تابع، هم روی داده‌ی ایندکس‌شده و هم روی عبارت کاربر.
مشکل «ی» و «ک» عربی را با تبدیل داده‌ها حل کنم؟
نه. تبدیل کاراکترهای متن ذخیره‌شده یعنی دیگر متن اصلی کاربر را ندارید، و اولین جایی که ضربه می‌زند خروجی و چاپ و صورت‌حساب است. نرمال‌سازی باید فقط روی مسیر مقایسه انجام شود: ستون به‌صورت نرمال‌شده ایندکس می‌شود و عبارت جست‌وجو هم با همان قاعده نرمال می‌شود، ولی مقدار ذخیره‌شده دست‌نخورده می‌ماند.
نیم‌فاصله چطور روی جست‌وجو اثر می‌گذارد؟
نیم‌فاصله (ZWNJ) یک کاراکتر واقعی است که دیده نمی‌شود، پس «می‌خواهم» و «میخواهم» دو رشته‌ی متفاوت‌اند و تطابق دقیق بینشان شکست می‌خورد. راه درست حذف آن در مرحله‌ی نرمال‌سازی است، همراه با ZWJ و نشانگرهای جهت متن و کشیده که همگی نامرئی‌اند.
برای جستجوی فارسی از pg_trgm استفاده کنم یا tsvector؟
به نیازتان بستگی دارد: tsvector برای تطابق کلمه‌ی کامل و رتبه‌بندی سریع‌تر است، و pg_trgm تطابق نیم‌کلمه و شباهت می‌دهد — یعنی «محصو» هم «محصول» را پیدا می‌کند، که برای جعبه‌ی جست‌وجوی زنده همان چیزی است که کاربر انتظار دارد. هرکدام را انتخاب کنید، نرمال‌سازی فارسی جدا از این تصمیم است و همچنان لازم است.

مطالب مرتبط