فهرست مقاله
در جستوجوی یک پایگاه دانش فارسی، دیده نشدن یک سند همیشه از ضعف مدل زبانی نیست. گاهی عنوان سند با «ک» فارسی ذخیره شده، اما پرسوجو «ك» عربی دارد؛ گاهی هم فاصلههای اضافه مانع مقایسهٔ دقیق میشوند. این آموزش یک آزمایش کوچک و تألیفی میسازد تا چنین تصمیمی را پیش از اتصال به موتور جستوجو بررسی کنیم. هدف، انتخاب یک قرارداد روشن برای کلید بازیابی است؛ متن اصلی باید برای نمایش و بازبینی محفوظ بماند.
۱. متن اصلی و کلید بازیابی دو نقش متفاوت دارند

چهار سند ساختگی با شناسههای ثابت میسازیم: «کنترل موتور»، «کاهش مصرف»، «میرود» و «میرود». در مثال، دو مورد آخر عمداً دو رشتهٔ مجزا هستند. تابع جستوجو فقط برابری کلید کامل را بررسی میکند؛ نه تحلیل معنا دارد، نه ریشهیابی و نه رتبهبندی. این محدودیت باعث میشود اثر تغییر حروف و فاصله را بدون عوامل پنهان ببینیم. معنای زبانی همهٔ این رشتهها نیز از نتیجهٔ آزمایش قابل استنتاج نیست.
برای هر سند، شناسه و متن ورودی را نگه میداریم و کلید پردازششده را جدا میسازیم. ذخیره کردن فقط کلید، امکان نمایش املای اولیه یا بررسی دلیل تطبیق را از بین میبرد. برای یک سامانهٔ سازمانی میتوان نسخهٔ سیاست ساخت کلید را نیز کنار نمایه ثبت کرد؛ این پیشنهاد طراحی ماست. وقتی سیاست عوض شود، بازسازی کلیدها از متن اصلی ممکن است، اما بازیابی متن حذفشده از یک کلید همسانشده الزاماً ممکن نیست.
همسانسازی باید در زمان نمایهسازی و زمان پرسوجو قرارداد یکسان داشته باشد. اجرای آن تنها روی ورودی کاربر، سندی را که با شکل دیگری نمایه شده پیدا نمیکند. همچنین نتیجهٔ برابر شدن دو کلید به معنی مجاز بودن ادغام دو سند نیست. شناسههای مستقل باید باقی بمانند تا لایهٔ بعدی بتواند دسترسی، زمینه و ارتباط نتیجه با پرسش را جداگانه بررسی کند؛ این برنامه آن لایهها را پیادهسازی نمیکند.
۲. NFC با سیاست نگاشت حروف فارسی یکی نیست

ضمیمهٔ یونیکد دربارهٔ نرمالسازی میان همارزی کانونی و همارزی سازگاری فرق میگذارد و استفادهٔ بیبررسی از صورتهای سازگاری را توصیه نمیکند. در قرارداد این درس، ابتدا NFC اجرا میشود، سپس فقط دو حرف عربی کاف و یاء به شکل فارسی انتخابشده نگاشت میشوند و فاصلههای سفید پیاپیِ میانی به یک فاصله تبدیل و فاصلههای ابتدا و انتها حذف میشوند. این نگاشت فارسی، تصمیم صریح برنامهٔ ماست؛ اجرای NFC بهتنهایی «ك» را «ک» نمیکند.
نیمفاصله، اعراب، نوع رقم و عرض سازگاری حروف را در این سیاست حفظ میکنیم. بنابراین «میرود» با «میرود» یک کلید ندارد و «۱۲» نیز با «12» برابر نمیشود. این انتخاب برای نشان دادن کنترل تمایزهاست، نه ادعای اینکه تمام کاربردهای جستوجوی فارسی باید همین رفتار را داشته باشند. اگر دامنهٔ داده نیاز دیگری دارد، باید ابتدا نمونهٔ مثبت و منفیِ همان تغییر تعریف و اثر برخوردها بررسی شود.
بهعنوان نمونهٔ مستقل، حرف لاتین تمامعرض با همتای معمولیاش در سیاست حاضر جدا میماند، اما NFKC میتواند آن تمایز را حذف کند. همین تفاوت نشان میدهد چرا نام مبهم «پاکسازی متن» قرارداد کافی نیست. این کد کاراکترهای خطرناک را فیلتر نمیکند، HTML را امن نمیسازد و برای یکسانسازی نام کاربری یا احراز هویت طراحی نشده است. کلید جستوجو را نباید جایگزین کنترل امنیتی یا مرز دسترسی اسناد کرد.
۳. کد کامل و شانزده بررسی برای یک نمایهٔ کوچک

کد زیر با پایتون ۳٫۱۴٫۵ اجرا شده و فقط کتابخانهٔ استاندارد میخواهد. آن را در فایل persian_search_keys.py ذخیره و با دستور python persian_search_keys.py اجرا کنید. مرجع unicodedata تابع normalize و متغیر نسخهٔ پایگاه داده را شرح میدهد. گزارش اجرای ما نسخهٔ پایگاه دادهٔ ۱۶٫۰٫۰ را ثبت میکند؛ مطالعهٔ استاندارد جدیدتر به معنی اجرای دادههای همان نسخه در محیط محلی نیست.
"""Authored small exact-key retrieval experiment, not a production search engine."""
import json
import platform
import unicodedata as ud
POLICY = 'fa-key-v1-nfc-kaf-yeh-whitespace-preserve-zwnj'
MAP = str.maketrans({'\u0643': '\u06a9', '\u064a': '\u06cc'})
def search_key(text):
if not isinstance(text, str):
raise TypeError('A Unicode string is required')
return ' '.join(ud.normalize('NFC', text).translate(MAP).split())
def index_documents(documents, transform):
result = {}
for identifier, original in documents:
result.setdefault(transform(original), []).append(identifier)
return result
def rejects(value):
try:
search_key(value)
except TypeError:
return True
return False
def run_example():
documents = [('d1', 'کنترل موتور'), ('d2', 'کاهش مصرف'),
('d3', 'می\u200cرود'), ('d4', 'میرود')]
original_copy = list(documents)
queries = [('كنترل موتور', 'd1'), ('کاهش مصرف', 'd2'),
('می\u200cرود', 'd3'), ('میرود', 'd4')]
raw = index_documents(documents, lambda x: x)
normalized = index_documents(documents, search_key)
raw_hits = sum(expected in raw.get(q, []) for q, expected in queries)
normalized_hits = sum(expected in normalized.get(search_key(q), [])
for q, expected in queries)
collision_index = index_documents([('a', 'كنترل'), ('b', 'کنترل')], search_key)
samples = ['', ' كنترل\tموتور ', 'يادگيري', 'می\u200cرود', 'میرود',
'e\u0301', 'A', '۱۲', '12', 'عَلَم', 'علم']
aggressive = {search_key(x).replace('\u200c', '') for x in ['می\u200cرود', 'میرود']}
checks = {
'arabic_kaf_and_yeh_mapped': search_key('يادگيري كنترل') == 'یادگیری کنترل',
'nfc_alone_does_not_map_kaf': ud.normalize('NFC', 'ك') != 'ک',
'canonical_sequence_composed': search_key('e\u0301') == '\u00e9',
'whitespace_collapsed': search_key(' \tکنترل\n موتور ') == 'کنترل موتور',
'zwnj_preserved': search_key('می\u200cرود') != search_key('میرود'),
'diacritics_preserved': search_key('عَلَم') != search_key('علم'),
'digit_scripts_preserved': search_key('۱۲') != search_key('12'),
'compatibility_width_preserved': search_key('A') != search_key('A'),
'nfkc_width_difference_demonstrated': ud.normalize('NFKC', 'A') == 'A',
'policy_idempotent_on_fixture': all(search_key(search_key(x)) == search_key(x) for x in samples),
'four_expected_ids_found': normalized_hits == 4 and raw_hits == 2,
'collision_keeps_all_ids': collision_index['کنترل'] == ['a', 'b'],
'original_strings_unchanged': documents == original_copy,
'empty_input_remains_empty': search_key(' \t') == '',
'bytes_rejected': rejects(b'abc'),
'aggressive_zwnj_removal_merges_fixture': len(aggressive) == 1,
}
assert all(checks.values()), checks
return {'python': platform.python_version(), 'unicode_database': ud.unidata_version,
'policy': POLICY, 'synthetic_documents': 4, 'labeled_queries': 4,
'raw_expected_id_hits': raw_hits, 'normalized_expected_id_hits': normalized_hits,
'collision_ids': collision_index['کنترل'], 'checks': checks}
if __name__ == '__main__':
print(json.dumps(run_example(), ensure_ascii=False, indent=2))
نمایه از کلید به فهرست شناسهها میرسد. اگر دو سند پس از نگاشت یک کلید پیدا کنند، append هر دو شناسه را حفظ میکند. استفاده از انتساب سادهٔ یک مقدار میتوانست سند قبلی را بیصدا کنار بگذارد. در یک پروژهٔ بزرگ، شمارش برخوردها و نگهداری نمونهٔ متنهای اولیه راهی برای تشخیص تغییر رفتار است. آزمایش کوچک ما هزینهٔ حافظه، زمان پاسخ یا سیاست حذف سند را اندازه نگرفته است.
شانزده بررسی، نگاشت دو حرف، ترکیب کانونی، فاصله، حفظ تمایزهای انتخابشده، تکرارپذیری سیاست روی نمونهها، عدم تغییر متن ورودی و نگهداری همهٔ شناسههای برخوردکرده را پوشش میدهند. دادهٔ بایتی رد میشود، زیرا قرارداد تابع رشتهٔ یونیکد است. رشتهٔ خالی همچنان خالی میماند؛ کاربرد عملی باید جدا تصمیم بگیرد پرسوجوی خالی را رد کند یا چگونه به کاربر پاسخ دهد. موفقیت این بررسیها، اثبات درستی هر سیاست دلخواه روی همهٔ رشتهها نیست.
۴. خواندن خروجی بدون تعمیم نتیجه به موتور جستوجو

از چهار پرسوجوی برچسبدار، مقایسهٔ خام دو شناسهٔ مورد انتظار و سیاست جدید هر چهار شناسه را برمیگرداند. این اعداد فقط متعلق به چهار ورودی ساختگیاند. آنها را نرخ بازیابی محصول، دقت جستوجوی واقعی یا نتیجهٔ ارزیابی مستقل معرفی نمیکنیم. برای سنجش ارتباط نتایج در یک مجموعهٔ واقعی، باید برچسب و معیار مناسب داشت؛ مقالهٔ ارزیابی رتبهبندی جستوجو با پایتون مرحلهٔ متفاوتی از این مسیر را توضیح میدهد.
{
"python": "3.14.5",
"unicode_database": "16.0.0",
"policy": "fa-key-v1-nfc-kaf-yeh-whitespace-preserve-zwnj",
"synthetic_documents": 4,
"labeled_queries": 4,
"raw_expected_id_hits": 2,
"normalized_expected_id_hits": 4,
"collision_ids": [
"a",
"b"
],
"checks": {
"arabic_kaf_and_yeh_mapped": true,
"nfc_alone_does_not_map_kaf": true,
"canonical_sequence_composed": true,
"whitespace_collapsed": true,
"zwnj_preserved": true,
"diacritics_preserved": true,
"digit_scripts_preserved": true,
"compatibility_width_preserved": true,
"nfkc_width_difference_demonstrated": true,
"policy_idempotent_on_fixture": true,
"four_expected_ids_found": true,
"collision_keeps_all_ids": true,
"original_strings_unchanged": true,
"empty_input_remains_empty": true,
"bytes_rejected": true,
"aggressive_zwnj_removal_merges_fixture": true
}
}
یک بررسی عمداً سیاست تهاجمی حذف نیمفاصله را روی دو رشته نشان میدهد: با حذف آن، مجموعهٔ دو کلید به یک کلید میرسد. برنامه این ادغام را در سیاست اصلی انجام نمیدهد. نتیجه، هشدار دربارهٔ تغییر تمایزهای قراردادی است و بهتنهایی غلط بودن همهٔ روشهای گسترش پرسوجو را ثابت نمیکند. حتی در صورت اجازهٔ تطبیق آزادتر، بهتر است منشأ تطبیق و شناسهٔ اصلی سند قابل پیگیری باقی بمانند.
۵. پاسخ تمرینها و مسیر اتصال به بازیابی ترکیبی

تمرین اول: آیا NFC برای اختلاف دو نوع کاف کافی است؟ پاسخ: در این مثال خیر؛ بررسی مربوط صریحاً نابرابری را نشان میدهد و نگاشت جدا لازم است. تمرین دوم: اگر دو عنوان یک کلید بسازند، کدام سند حذف میشود؟ پاسخ: در پیادهسازی ما هیچکدام؛ مقدار نمایه فهرست شناسههاست. نمایش و بررسی ارتباط باید با رجوع به همان شناسهها و متن اصلی ادامه پیدا کند.
تمرین سوم: اگر تابع دوباره اجرا شود، چه انتظاری داریم؟ پاسخ: برای نمونههای ثبتشده کلید ثابت میماند و بررسی idempotence همین شرط را کنترل میکند؛ افزودن قواعد جدید نیازمند آزمون دوباره است. تمرین چهارم: آیا یافتن هر چهار شناسه یعنی رتبهبندی درست است؟ پاسخ: خیر؛ آزمایش فقط عضویت شناسهٔ مورد انتظار در پاسخ مقایسهٔ دقیق را بررسی کرده است و امتیاز مرتبط بودن یا ترتیب نتایج ندارد.
در بازیابی ترکیبی میتوان قرارداد کلید را در کنار جستوجوی واژگانی و برداری بررسی کرد، اما نباید از این درس نتیجه گرفت که هر ادغام امتیازی مفید است. مقالهٔ آزمایش RRF و پنجرهٔ نامزدها به مرحلهٔ ادغام رتبهها مربوط است. پیش از تغییر نمایه، نسخهٔ سیاست، نمونههای برخورد و مجموعهٔ پرسشهای ارزیابی را ثبت کنید. منابع این آموزش در ۲۴ مهر ۱۴۰۵ بررسی شدهاند؛ متن، خبر پژوهشی تازه یا گزارش عملکرد محصول نیست.
منابع و مطالعهٔ بیشتر
- Unicode UAX15؛ تفاوت همارزی کانونی و سازگاری، بازنگری۵۸
- Python3.14؛ normalize و نسخهٔ پایگاه دادهٔ یونیکد
بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.


