جستجوی معنایی چگونه به گنجور اضافه شد؟ — بخش ۲: معماری سمت سرور و درس‌های یک حادثهٔ واقعی در Production

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

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

در بخش ۱، برای هر شعر یک بردار عددی ساختیم و در دو فایل ذخیره کردیم: embeddings.f32 (خودِ بردارها) و embeddings-index.json (اینکه هر بردار مال کدام شعر است). این کار یک‌بار، با پایتون، برای همهٔ شعرهای موجود انجام شد.

اما جستجوی زنده کاربر باید داخل سرویس اصلی گنجور اتفاق بیفتد — سرویسی که با ‎C#‎/‎.NET‎ نوشته شده، نه پایتون. سؤال این بخش این است: وقتی کاربری چیزی تایپ می‌کند، آن سرویس چطور آن جمله را به بردار تبدیل می‌کند، آن را با ۱۲۹ هزار بردار ذخیره‌شده مقایسه می‌کند، و نتیجه را برمی‌گرداند — و مهم‌تر، این کار چطور بدون به‌خطرانداختن بقیهٔ سایت انجام شد؟ چون همان‌طور که خواهیم دید، بار اول اصلاً این‌طور نبود.

چرا نمی‌شود همان کد پایتون را مستقیم اجرا کرد؟

پایتون و ‎C#‎ دو دنیای کاملاً جدا از هم‌اند؛ کد یکی مستقیماً داخل دیگری اجرا نمی‌شود. آنچه واقعاً مشترک است، فقط فایل مدل ONNX است — همان چیزی که در بخش ۱ توضیح دادیم (تشبیه آهنگ و MP3). یعنی باید در ‎C#‎ هم یک نسخهٔ کامل از «خواندن جمله → تبدیل به بردار» بازسازی شود — با ONNX Runtime نسخهٔ ‎.NET‎، نه نسخهٔ پایتون، اما روی همان فایل مدل.

دو تکهٔ اصلی این بازسازی:

  • EmbeddingIndex — بردارهای از قبل ساخته‌شدهٔ همهٔ شعرها (همان دو فایل بخش ۱) را یک‌بار، هنگام بالا آمدن سرویس، در حافظه بارگذاری می‌کند و آماده نگه می‌دارد.
  • QueryEmbedder — دقیقاً همان کاری که اسکریپت پایتون برای هزاران شعر انجام می‌داد، اما اینجا فقط برای یک جمله (همان چیزی که کاربر تایپ کرده) و در لحظه انجام می‌دهد.

باگ واقعی که دقیقاً همان هشدار بخش ۱ بود

در پایان بخش ۱ گفتیم: «اگر دو طرف حتی کمی متفاوت عمل کنند… نتیجه‌ها بی‌معنا می‌شوند — بدون اینکه هیچ خطایی هم دیده شود.» این دقیقاً همان اتفاقی بود که افتاد.

در ‎.NET‎، ساختن توکنایزر (چیزی که جمله را به قطعات کوچک‌تر برای مدل می‌شکند) با یک خط سادهٔ مثل BpeTokenizer.Create(vocabStream, mergesStream) نوشته شد. این خط بدون هیچ خطایی اجرا می‌شد — اما برای متن فارسی (یا هر متن غیر-لاتین)، بی‌صدا صفر توکن تولید می‌کرد. هیچ Exception‌ای پرتاب نمی‌شد؛ فقط نتیجهٔ جستجو کاملاً بی‌ربط بود، بدون هیچ نشانه‌ای که بگوید مشکل از کجاست.

راه‌حل، بعد از کلی بررسی، ساختن توکنایزر با گزینه‌های دقیق‌تر بود:

new BpeOptions(vocabPath, mergesPath) { ByteLevel = true }

و یک نکتهٔ ظریف دیگر: در پایتون، فایل tokenizer.json خودش به‌طور خودکار یک توکن ویژه به نام EOS (شناسهٔ عددی 151643) را به انتهای هر جمله اضافه می‌کند. اما وقتی توکنایزر مستقیماً از فایل‌های vocab.json و merges.txt ساخته می‌شود (که در ‎.NET‎ این‌طور بود)، این افزودن خودکار اتفاق نمی‌افتد — باید دستی اضافه شود. اگر این را فراموش کنید، دوباره همان الگو تکرار می‌شود: هیچ خطایی نیست، فقط بردار نهایی کمی متفاوت از نسخهٔ پایتون است، و نتیجه‌های جستجو ظریف و غیرقابل‌ردیابی بد می‌شوند.

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

حادثهٔ اول: کل سایت پایین رفت

اولین نسخهٔ این قابلیت، بارگذاری EmbeddingIndex و QueryEmbedder را داخل سازندهٔ (constructor) کنترلر اصلی گنجور (GanjoorController) انجام می‌داد — همان کنترلری که تقریباً تمام درخواست‌های سایت از آن عبور می‌کنند.

اینجا یک قانون کلی و مهم در برنامه‌نویسی سرور نقض شد: اگر بارگذاری یک وابستگی داخل سازنده شکست بخورد، تمام endpointهایی که از آن کنترلر استفاده می‌کنند از کار می‌افتند — نه فقط آن قابلیت خاص. و دقیقاً همین اتفاق افتاد: یک شکست در بارگذاری مدل یا بردارها، کل سایت گنجور را با خودش به پایین کشید (خطای ۵۰۳ روی همهٔ صفحات، نه فقط جستجوی معنایی).

راه‌حل دو بخش داشت:

  1. جستجوی معنایی به یک کنترلر کاملاً جداگانه (SemanticSearchController) منتقل شد — دیگر هیچ ربطی به کنترلر اصلی سایت نداشت.
  2. یک الگوی جدید ساخته شد به نام LazySemanticSearchResources — که هرگز در زمان بالا آمدن سرویس چیزی بارگذاری نمی‌کند و هرگز Exception پرتاب نمی‌کند. به‌جای آن، بارگذاری واقعی مدل و بردارها را تا اولین درخواست واقعی به تعویق می‌اندازد، و اگر آن بارگذاری شکست بخورد، فقط همان یک درخواست خطا می‌گیرد — بقیهٔ سایت، از جمله خودِ همین قابلیت برای درخواست‌های بعدی (اگر مشکل موقتی بوده)، دست‌نخورده می‌ماند.

نکتهٔ کلی‌تر برای هر پروژهٔ مشابه: یک قابلیت جدید و آزمایشی، به‌خصوص اگر شامل بارگذاری چیزی سنگین (مثل یک مدل هوش مصنوعی چند صد مگابایتی) باشد، هرگز نباید بتواند چیزی را که از قبل کار می‌کرده خراب کند. جداسازی کامل (isolation) نه یک ظرافت اضافه، بلکه یک پیش‌نیاز است.

حادثهٔ دوم: کرش کامل پردازش سرور

بعد از رفع مشکل اول، یک بار دیگر کل فرایند سرور (w3wp.exe، فرایندی که IIS برای اجرای سایت استفاده می‌کند) به‌طور کامل کرش کرد — نه فقط یک خطای ۵۰۳ در سطح برنامه، بلکه مرگ کامل خودِ فرایند، با کد خطای سیستمی c0000005 (نقض دسترسی به حافظه، Access Violation) که به فایلی به نام MSVCP140.dll مربوط می‌شد.

روش عیب‌یابی — نکته‌ای که ارزش یادگیری دارد

به‌جای حدس‌زدن یا تغییردادن کد به امید حل مشکل، اول باید مطمئن می‌شدیم دقیقاً چه چیزی این کرش را ایجاد می‌کند. برای این کار، یک برنامهٔ کنسول کوچک و جدا (نه خودِ سایت زنده) روی همان سرور ساخته و اجرا شد که فقط همان بخش مشکوک از کد (بارگذاری مدل ONNX) را اجرا می‌کرد — در محیطی امن، جدا از کاربران واقعی. همین آزمایش کوچک نشان داد مشکل واقعاً یک DllNotFoundException است، نه یک باگ در کد خودمان.

علت واقعی: نسخهٔ Visual C++ Redistributable نصب‌شده روی سرور قدیمی بود — کتابخانه‌ای که ONNX Runtime نسخهٔ ‎.NET‎ در پشت صحنه به آن نیاز دارد. راه‌حل، نصب آخرین نسخه از aka.ms/vs/17/release/vc_redist.x64.exe بود — نه هیچ تغییری در کد خودِ پروژه.

یک عارضهٔ جانبی: IIS خودش را خاموش کرد

بعد از این کرش‌ها، یک مکانیزم امنیتی در IIS به نام Rapid-Fail Protection فعال شد — که وقتی یک Application Pool در بازهٔ زمانی کوتاهی چندبار کرش کند، خودش را به‌طور خودکار متوقف می‌کند (برای جلوگیری از کرش‌های پی‌درپی و بی‌پایان). راه‌حل ساده بود اما اگر ندانید کجا را نگاه کنید گیج‌کننده است: در IIS Manager، زیر Application Pools، باید دستی دوباره Start می‌شد.

معماری جداسازی زیرساخت

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

  • روی دامنهٔ اصلی API گنجور (api.ganjoor.net)، جستجوی معنایی کاملاً غیرفعال نگه داشته شد — یعنی مدل یا بردارها اصلاً آنجا بارگذاری نمی‌شوند.
  • یک دامنهٔ کاملاً جداگانه (ganjgah.ir) با Application Pool مستقل خودش برای این قابلیت اختصاص یافت.
  • تعداد Worker Processهای IIS از ۸ به ۱ کاهش یافت. چرا این عدد مهم است؟ چون هر Worker Process، اگر این قابلیت را بارگذاری کند، خودش به‌تنهایی حدود ۹ گیگابایت حافظه مصرف می‌کند (مدل
  • بردارهای همهٔ شعرها). اگر ۸ Worker Process همزمان همین را بارگذاری می‌کردند، مصرف حافظه به رقمی غیرمنطقی می‌رسید.

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


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

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

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

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