جستجوی معنایی چگونه به گنجور اضافه شد؟ — بخش ۴: از خلاصهٔ شعر به خلاصهٔ بیت

این مجموعه توسط Claude (دستیار هوش‌مصنوعی شرکت Anthropic) نوشته شده — بر اساس یک همکاری فنی طولانی و واقعی با حمیدرضا محمدی، نگهدارندهٔ گنجور، در ساخت این قابلیت. اعداد و نتیجه‌هایی که در این بخش می‌بینید، همه واقعی‌اند و مستقیماً از همان روند توسعه گرفته شده‌اند — نه نمونه‌های ساختگی برای توضیح.

یادآوری کوتاه، و سؤال این بخش

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

سؤال این بخش: اگر به‌جای یک بردار برای کل شعر، برای هر بیت جداگانه یک بردار بسازیم چه می‌شود؟

چرا اول یک آزمایش کوچک، نه کل مجموعه؟

قبل از هر تصمیمی، یک بررسی عددی انجام شد: آیا می‌شود فقط با محدودکردن به شعرهای کوتاه، حجم کار را به‌طور محسوس کم کرد؟ نتیجه نه بود — با اینکه شعرهای «خیلی بلند» (۱۶ بیت به بالا) فقط ۱۳ تا ۱۴٪ از کل شعرهاست، همین بخش کوچک، ۵۴.۸٪ از کل بیت‌های موجود را در خود دارد. یعنی محدودکردن بر اساس طول شعر، عملاً حجم کار را کم نمی‌کند.

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

یک کشف واقعی، پیش از نوشتن حتی یک خط کد

پیش از شروع، یک بررسی ساده انجام شد: آیا فیلد CoupletSummary (خلاصهٔ سطح بیت) اصلاً جزو داده‌ای هست که در ganjoor-data منتشر می‌شود؟ جواب، با بررسی مستقیم کد (نه حدس)، منفی بود — برخلاف PoemSummary، این فیلد اصلاً در خروجی عمومی گنجور وجود نداشت.

این کشف دو نتیجهٔ عملی داشت:

  1. برای آزمایش اولیه، دادهٔ خام باید مستقیماً از پایگاه‌داده (نه از ganjoor-data) گرفته می‌شد.
  2. یک تصمیم مهم‌تر: چون خلاصه‌های سطح بیت — به‌خصوص آن‌هایی که کاربران انسانی ویرایش کرده‌اند — کاری ارزشمند و احتمالاً غیرقابل‌بازسازی است (برخلاف خودِ متن شعر که یک اثر کلاسیک و در دسترس در منابع دیگر هم هست)، بهتر بود این فیلد هم به خروجی عمومی گنجور اضافه شود — تا اگر روزی سرویس زندهٔ گنجور از دسترس خارج شد، این کار انسانی از بین نرود. این تغییر (افزودن یک فیلد به یک DTO، و یک خط در نگاشت آن) کاملاً افزایشی و بی‌خطر بود — هیچ مصرف‌کنندهٔ فعلی این داده را خراب نمی‌کرد، فقط یک فیلد اختیاری جدید به آن اضافه می‌شد.

ساخت آزمایش اولیه — و چند نکتهٔ واقعی که در مسیر پیدا شدند

استخراج دادهٔ غزل‌های حافظ، با یک کوئری SQL که همان درسِ «پیمایش کامل زیردرخت دسته‌بندی» از بخش ۳ را این‌بار در سطح پایگاه‌داده تکرار می‌کرد (نه فقط یک لایه از دستهٔ «غزلیات»، بلکه کل زیرشاخه‌های آن).

چند اصلاح واقعی در همین مرحله لازم شد — نه چون برنامه‌ریزی بدی صورت گرفته بود، بلکه چون این‌جور جزئیات فقط با اجرای واقعی روی دادهٔ واقعی مشخص می‌شوند:

  • نام واقعی جدول دسته‌بندی‌ها در پایگاه‌داده GanjoorCategories بود، نه فرضی که ابتدا نوشته شده بود — این را فقط با اجرای واقعی کوئری و مواجهه با خطا کشف کردیم.
  • خروجی‌گرفتن با FOR JSON PATH در SQL Server Management Studio، در عمل با کپی‌کردن از یک سلول تکی در نتیجه‌ها مشکل‌ساز شد (هم یک ردیف اضافهٔ سرستون که کپی می‌شد، هم بریده‌شدن متن در کپی‌های طولانی). راه‌حل ساده‌تر بود: یک SELECT معمولی، و خروجی‌گرفتن با CSV — مسیری که SSMS به‌طور بومی و قابل‌اعتمادتر پشتیبانی می‌کند.
  • یک نکتهٔ منطقه‌ای جالب: خروجی CSV گرفته‌شده از یک سیستم فارسی‌زبان، به‌جای کاما، از نویسهٔ «؛» (سمیکولن فارسی) به‌عنوان جداکننده استفاده کرده بود — و اصلاً سرستون هم نداشت. چون خودِ متن خلاصه‌ها هم به‌طور طبیعی از همین نویسه به‌عنوان علامت نگارشی استفاده می‌کنند، یک تجزیه‌کنندهٔ سادهٔ متنی می‌توانست به‌اشتباه وسط یک خلاصه را به‌عنوان مرز دو ستون تشخیص دهد. راه‌حل، استفاده از یک تجزیه‌کنندهٔ واقعی CSV بود (نه شکستن ساده بر اساس نویسه) — که نقل‌قول‌های داخل فایل را درست تشخیص می‌دهد؛ و کدِ خواندن فایل هم طوری نوشته شد که خودش تشخیص دهد کدام‌یک از این دو شکل را با آن روبه‌روست، به‌جای اینکه از قبل فرض ثابتی دربارهٔ قالب فایل داشته باشد.

سه تصمیم طراحی دیگر هم، پیش از تولید واقعی بردارها، آگاهانه گرفته شدند:

  1. فقط از CoupletSummary استفاده شود، بدون ترکیب با PoemSummary — چون ترکیب‌کردن، دوباره همان اثر رقیق‌شدنی را ایجاد می‌کرد که کل این ایده قرار بود حلش کند (همهٔ بیت‌های یک شعر را به‌طور مصنوعی به هم نزدیک‌تر می‌کرد).
  2. پیشوند «هوش مصنوعی:» پیش از ساخت بردار حذف شود — همان مشکلی که در خط لولهٔ سطح شعر هنوز حل نشده (و نیاز به بازسازی کامل دارد)، این‌بار از همان ابتدا درست انجام شد.
  3. برای هر بیت، یک لینک مستقیم به همان بیت (نه فقط به کل شعر) ساخته شود — با بررسی مستقیم کد صفحهٔ واقعی گنجور، نه حدس.

دستورهای واقعی این مرحله

این اسکریپت‌ها الان در همان مخزن ganjoor-embeddings بخش ۱ منتشر شده‌اند — نیازی به دریافت جدا نیست، همان scripts/ که قبلاً clone کرده‌اید کافی است.

یک نکتهٔ مهم و صادقانه، پیش از دیدن دستورها: مرحلهٔ استخراج دادهٔ این آزمایش (فایل export_hafez_ghazal_couplets.sql) به دسترسی مستقیم به پایگاه‌دادهٔ زندهٔ گنجور نیاز دارد — چیزی که فقط نگهدارندگان پروژه در اختیار دارند. برخلاف بخش‌های ۱ تا ۳ که هر خواننده‌ای می‌توانست همهٔ دستورها را عیناً اجرا کند، این یک مرحله را نمی‌توان به همان شکل تکرار کرد؛ اما مرحلهٔ «آماده‌سازی کامل مجموعه» در ادامهٔ همین بخش، دوباره کاملاً برای همه قابل‌تکرار است — چون از ganjoor-data عمومی می‌خواند، نه از پایگاه‌داده.

با این توضیح، دستورهای واقعی که برای این آزمایش اجرا شدند:

# ابتدا export_hafez_ghazal_couplets.sql روی پایگاه‌داده اجرا و نتیجه به CSV ذخیره می‌شود
# (در SSMS: راست‌کلیک روی نتیجه‌ها -> Save Results As... -> CSV)

python3 scripts/generate_couplet_pilot_embeddings.py \
  --input /path/to/exported.csv \
  --model-dir /path/to/model \
  --output ./couplet-pilot-output \
  --inspect-only

python3 scripts/generate_couplet_pilot_embeddings.py \
  --input /path/to/exported.csv \
  --model-dir /path/to/model \
  --output ./couplet-pilot-output

و برای تأیید نتیجه — همان بررسی معنایی واقعی که در بخش ۱ هم دیدیم، این‌بار در سطح بیت:

python3 scripts/verify_couplet_pilot.py --embeddings-dir ./couplet-pilot-output \
  --query-poem-id 2130 --query-vorder 1 --top-k 8 \
  --source-csv /path/to/exported.csv

یک باگ واقعی: لینک‌ها یکی جابه‌جا بودند

فرض اولیه این بود که شمارهٔ استفاده‌شده در لینک هر بیت (مثلاً #bn2) دقیقاً همان مقدار فیلد CoupletIndex است. اما وقتی اولین خروجی واقعی تولید شد، لینک بیتِ اول غزل شمارهٔ یک حافظ #bn0 از آب درآمد — درحالی‌که صفحهٔ واقعی سایت از #bn1 استفاده می‌کند.

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

نتیجهٔ آزمایش — و چرا واقعاً امیدوارکننده بود

آزمایش نهایی روی غزل‌های حافظ، ۴٬۱۹۲ بیت را از ۴۹۵ شعر مجزا استخراج کرد. این عدد دوم جالب توجه است: ۴۹۵ دقیقاً همان تعدادی است که در پژوهش‌های ادبی فارسی معمولاً به‌عنوان تعداد غزل‌های شناخته‌شدهٔ دیوان حافظ ذکر می‌شود — یک تأیید مستقل و غیرمنتظره از درستی استخراج داده.

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

آماده‌سازی کامل مجموعه

بعد از این آزمایش موفق، و بعد از اینکه ganjoor-data واقعاً فیلد CoupletSummary را دریافت کرد (نتیجهٔ همان تصمیم اولیهٔ این بخش)، آماده‌سازی برای کل مجموعهٔ گنجور ممکن شد — این‌بار با خواندن مستقیم از ganjoor-data، نه پایگاه‌داده.

یک تصمیم طراحی مهم اینجا: به‌جای تلاش برای حدس‌زدن نام دقیق تمام حالت‌های ساختاری نادر بیت‌ها (که برخی‌شان هیچ‌وقت به‌طور قطعی تأیید نشدند)، منطق استخراج بر این اساس نوشته شد: هر بیتی که CoupletSummary غیرخالی دارد، به‌خودی‌خود یک لنگر معتبر است — بدون نیاز به دانستن اینکه دقیقاً چه نوع ساختاری دارد. این طراحی، در برابر یک نمونهٔ ساختگی با یک نوع ساختاری کاملاً نامعتبر و ناشناخته هم آزمایش و تأیید شد.

با توجه به حجم واقعی (چیزی نزدیک به ۱٫۵ میلیون بیت، چند روز زمان تخمینی)، همان سازوکار checkpoint/resume بخش ۱ اینجا هم به کار گرفته شد — و این‌بار با یک آزمون واقعی قطع‌و-ادامه (نه فقط ادعا): اجرا عمداً در میانهٔ راه متوقف شد، دوباره با --resume از سر گرفته شد، و نتیجهٔ نهایی از نظر تعداد، عدم تکرار، و اندازهٔ دقیق فایل بررسی شد.

جالب‌ترین لحظهٔ این مرحله، یک تأیید متقاطع کاملاً مستقل بود: وقتی منطق استخراج روی کل ganjoor-data اجرا شد، دقیقاً ۱٬۵۲۵٬۸۶۹ بیت پیدا کرد — رقمی که، تا آخرین رقم، با مجموع همان اعدادی که هفته‌ها قبل از یک کوئری کاملاً جداگانه روی پایگاه‌داده به دست آمده بود یکی بود. دو روش کاملاً مستقل (یک کوئری زندهٔ پایگاه‌داده، و یک پیمایش فایلی روی یک export گیت) به یک عدد دقیقاً یکسان رسیدند — تأییدی محکم بر درستی هر دو طرف.

دستورهای واقعی این مرحله — این‌بار کاملاً قابل‌تکرار برای هرکسی

برخلاف آزمایش اولیه، این مرحله فقط به ganjoor-data عمومی نیاز دارد — همان چیزی که در بخش ۱ clone کردید (به‌شرطی که یک نسخهٔ به‌روز، بعد از افزوده‌شدن CoupletSummary به آن، داشته باشید). هیچ دسترسی خاصی لازم نیست.

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

python3 scripts/generate_full_couplet_embeddings.py \
  --source /path/to/local/ganjoor-data-clone \
  --output ./full-couplet-output \
  --count-only

اگر عدد چاپ‌شده چیزی نزدیک به ۱٫۵ میلیون بود (همان‌طور که در این پروژه واقعاً همین‌طور شد)، ادامه می‌دهیم:

python3 scripts/generate_full_couplet_embeddings.py \
  --source /path/to/local/ganjoor-data-clone \
  --model-dir /path/to/model \
  --output ./full-couplet-output \
  --inspect-only

python3 scripts/generate_full_couplet_embeddings.py \
  --source /path/to/local/ganjoor-data-clone \
  --model-dir /path/to/model \
  --output ./full-couplet-output \
  --limit 20

و در نهایت، اجرای کامل — با توجه به حجم واقعی، این اجرا واقعاً چند روز طول می‌کشد؛ استفاده از caffeinate -i (در macOS) برای جلوگیری از خواب‌رفتن سیستم توصیه می‌شود:

caffeinate -i python3 scripts/generate_full_couplet_embeddings.py \
  --source /path/to/local/ganjoor-data-clone \
  --model-dir /path/to/model \
  --output ./full-couplet-output

اگر این اجرا به هر دلیلی قطع شد، با همان دستور بالا به‌علاوهٔ --resume دقیقاً از همان‌جا ادامه می‌یابد، بدون از‌دست‌رفتن یا تکرار هیچ کاری:

caffeinate -i python3 scripts/generate_full_couplet_embeddings.py \
  --source /path/to/local/ganjoor-data-clone \
  --model-dir /path/to/model \
  --output ./full-couplet-output \
  --resume

و در پایان، همان تأیید:

python3 scripts/verify_couplet_pilot.py --embeddings-dir ./full-couplet-output

(بله، همان اسکریپت verify آزمایش اولیه — قالب خروجی هر دو یکسان است، فقط مقیاس متفاوت.)

جایی که این داستان الان ایستاده

در زمان نوشتن این متن، تولید بردارها برای کل مجموعه (کاری چندروزه) هنوز در حال اجراست. تصمیمی هم که از قبل گرفته شده این است: نسخهٔ سطح شعر (که در بخش‌های ۱ تا ۳ ساختیم) کنار گذاشته نمی‌شود — برای شعرهای روایی و داستانی (مثل شاهنامه)، یک خلاصهٔ کلی از کل شعر همچنان ارزشمندتر از تکه‌تکه‌کردن آن به بیت‌هاست. به‌جای جایگزینی، هر دو حالت در دسترس خواهند بود، و این خودِ کاربر است که انتخاب می‌کند جستجویش را در سطح کل شعر بخواهد یا در سطح تک‌تک بیت‌ها.


این‌جا پایان چهار بخشی است که برنامه‌ریزی شده بود — از یک مفهوم ساده (متن‌هایی با معنای نزدیک، بردارهای نزدیک به هم دارند) تا یک قابلیت واقعی و زنده در گنجور، با چند حادثهٔ واقعی، چند باگ واقعی، و چند تصمیم طراحی که فقط با آزمایش مستقیم روی داده‌های واقعی به دست آمدند. وقتی سمت مصرف‌کنندهٔ ‎.NET‎ برای جستجوی سطح بیت هم ساخته شود — و رابط کاربری‌ای که به کاربر اجازهٔ انتخاب بین دو حالت را بدهد — می‌تواند موضوع خوبی برای ادامهٔ این مجموعه باشد.

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

این سایت از اکیسمت برای کاهش جفنگ استفاده می‌کند. درباره چگونگی پردازش داده‌های دیدگاه خود بیشتر بدانید.