در فرآیند توسعهی سیستمهای خودکار پژوهشی یا ایجنتهای کاوشگر متون علمی، طرح فرضیه بسیار سریع انجام میشود، اما اعتبارسنجی پیشینهی آن پرهزینهترین بخش است. اگر بدون بررسی کافی وارد فاز آزمایش یا اجرای کد شویم، منابع محاسباتی زیادی برای اثبات چیزی که پیشتر حل شده به هدر میرود. چالش اصلی این است که موتورهای جستجوی متنی ساده مبتنی بر کلیدواژه، مقالاتی را که از دایرهی لغات متفاوت استفاده کردهاند یا پایههای نظری مشترک دارند نادیده میگیرند.
در این درس یک خط لولهی بازیابی دوگانه طراحی میکنیم. در پاس اول با الگوریتم Okapi BM25 جستجوی واژگانی روی چکیدهها انجام میشود و در پاس دوم، گراف استنادی (Citation Graph) مقالات به روش BFS پیمایش میشود تا مقالات بنیادین و کارهای پیرو مرتبط شکار شوند. تلفیق این دو خروجی و حذف موارد تکراری با شناسهی یکتا، نتایجی جامع و دقیق تولید میکند.
تمام کدهای این سیستم فقط با ساختارهای دادهی کتابخانهی استاندارد پایتون نوشته شدهاند و به بستههای سنگین بیرونی وابسته نیستند؛ بنابراین بدون نیاز به بارگذاری مدلهای حجیم یا استفاده از سرویسهای آنلاین خارجی، کاملاً مستقل و قابل حمل روی سیستم محلی اجرا میشوند.
ساختن فرضیه (Hypothesis) هزینهای ندارد؛ فهمیدن اینکه آیا قبلاً کسی آن را اثبات کرده یا نه، بخش پرهزینهی کار است. لایهی بازیابی (Retrieval Layer) را طوری بسازید که قبل از بالا آمدن ساندباکس (Sandbox) توسط رانر (Runner)، به این سوال پاسخ دهد.
اهداف یادگیری
- مدلسازی رکورد یک مقالهی جمعوجور با فیلدهایی که در ادامهی حلقه خوانده میشوند.
- ساخت یک نمایه (Index) برای BM25 روی چکیدهها، تنها با استفاده از ساختارهای دادهی کتابخانهی استاندارد (stdlib).
- پیمایش گراف استنادی (Citation Graph) برای پیدا کردن مقالاتی که جستجوی واژگانی (Lexical Search) آنها را از قلم انداخته است.
- حذف موارد تکراری بین نتایج پاس واژگانی و پاس گرافی بر اساس شناسه پایدار مقاله.
- کپسولهسازی دو API ساختگی (Mock) در قالب یک کلاینت واحد، تا با اضافه شدن اندپوینتهای واقعی، محل فراخوانی در لایههای بالاتر نیازی به تغییر نداشته باشد.
چرا دو پاس بازیابی؟
جستجوی کلیدواژهای روی چکیدهها، مقالاتی را برمیگرداند که واژگان مشترکی با عبارت جستجو دارند. این کار بخش عمدهی نیاز را پوشش میدهد، اما در دو حالت به بنبست میخورد: اول، زمانی که مقالهی بنیادین از واژگان متفاوتی استفاده میکند؛ مثلاً جستجوی «اتنشن تنک» (Sparse Attention) مقالهای با عنوان «انتخاب بلوک در مسیریابی ترنسفورمر» را پیدا نمیکند. دوم، زمانی که مقالهی مرتبط، یک کار ادامهدار است که به مقالهی مرجع شناختهشدهای ارجاع میدهد؛ در این وضعیت پیدا کردن مقاله مرجع و پیگیری رو به جلوی ارجاعات آن، بسیار کارآمدتر از جستجوی کورکورانه در تمام مجموعهی چکیدههاست.
در این درس هر دو پاس را پیادهسازی میکنیم: الگوریتم BM25 روی چکیدهها موارد تطابق واژگانی را شکار میکند؛ سپس پیمایش گراف استنادی، مجموعهی اولیهی بهدستآمده را با یک یا دو گام لغزش (Hop) به جلو و عقب گسترش میدهد. در نهایت، اجتماع این نتایج بر اساس شناسهی مقاله یکتاسازی شده (Deduplicated) و با امتیازی ترکیبی رتبهبندی میشود.
ساختار دادهی مقاله
Paper
id : str (stable identifier, "p001" for the mock corpus)
title : str
abstract : str
year : int
authors : list[str]
references : list[str] (paper ids this paper cites)
citations : list[str] (paper ids that cite this paper)
source : str (which mock api supplied it, "arxiv" or "s2")
فیلدهای مراجع (References) و استنادات (Citations) گراف استنادی جهتدار را شکل میدهند. از آنجا که دو API ساختگی فیلدهای همپوشان ولی نهچندان یکسانی برمیگردانند، بارگذار پیکره (Corpus Loader) آنها را بر اساس id ادغام میکند.
معماری
کلاینت بازیابی، مدیریت هر دو پاس و مرحلهی merge را بر عهده دارد. فراخواننده عبارت جستجو را به آن تحویل میدهد و لیستی رتبهبندیشده دریافت میکند که هر رکورد آن شامل فیلدهای امتیاز به ازای هر مقاله است (bm25_score، graph_distance، recency_score، final_score) تا دلیل رتبهبندی را روشن کند.
پیادهسازی BM25 از صفر
این پیادهسازی از فرمول استاندارد Okapi BM25 با پارامترهای پیشفرض k1=1.5 و b=0.75 استفاده میکند. نمایه از دو دیکشنری تشکیل شده است: term -> doc_frequency و term -> list of (doc_id, term_count). طول سند برابر با تعداد توکنهای چکیده است و میانگین طول اسناد نیز یک بار در زمان ساخت نمایه محاسبه میشود. امتیازدهی به عبارت جستجو، مجموع idf * tf_norm روی تکتک کلمات کوئری است که در آن tf_norm همان فراوانی وزنی نرمالشده بر اساس طول سند در الگوریتم استاندارد BM25 است.
توکنایزر متن را ابتدا با lower به حروف کوچک تبدیل میکند و سپس آن را بر اساس کاراکترهای غیرالفبایی-عددی برش میزند. در این بخش از ریشهیابی کلمات (Stemming) استفاده نشده است. در یک سیستم محیط عملیاتی (پروداکشن) میتوان ریشهیاب (Stemmer) سبکی را جایگزین کرد، بدون آنکه نیازی به تغییر رابط کاربری باشد.
پیمایش گراف استنادی
گراف یک بار بر اساس پیکرهی داده ساخته میشود. یالهای رو به جلو از یک مقاله به مراجع آن میروند و یالهای رو به عقب از یک مقاله به استنادکنندگان آن متصل میشوند. پیمایش به روش جستجوی اول سطح (BFS) انجام میشود که دانههای اولیهی آن برترین نتایج BM25 هستند و حداکثر تا دو گام لغزش پیش میرود.
سقف دو گام لغزش کاملاً عامدانه تعیین شده است: یک گام لغزش بیش از حد سطحی است، چون ایجنت معمولاً نیازمند والد یا فرزند مستقیم (Immediate) در گراف است؛ از طرفی سه گام لغزش در یک گراف همبند، حجم نتایج را بیش از حد بالا میبرد و معمولاً از موضوع اصلی منحرف میشود. در این درس محدودیت گام لغزش را به شکل پارامتری در تنظیمات قرار دادهایم تا حلقههای بعدی بتوانند آن را محدودتر کنند.
حذف موارد تکراری و رتبهبندی
دو پاس بازیابی، مجموعههایی همپوشان برمیگردانند. عملیات merge بر اساس شناسهی مقاله انجام میشود. امتیاز نهایی هر مقاله از ترکیب وزندار چند فاکتور به دست میآید:
final_score = w_bm25 * bm25_score_norm
+ w_graph * graph_score
+ w_recency * recency_score
در اینجا bm25_score_norm برابر با امتیاز BM25 تقسیم بر حداکثر امتیاز BM25 در مجموعهی ادغامشده است (تا مقدار آن در بازهی بین 0 و 1 قرار گیرد). فاکتور graph_score برای نتایج مستقیم واژگانی برابر با 1، برای یک گام لغزش برابر با 0.6، برای دو گام لغزش برابر با 0.3 و در غیر این صورت 0 است. فاکتور recency_score یک تابع خطی صعودی از 0 در حداقل سال انتشار موجود در پیکره تا 1 در حداکثر سال انتشار است.
وزنهای پیشفرض 0.5، 0.3 و 0.2 هستند. این وزنها در تنظیمات قابل تغییرند؛ مثلاً برای موضوعی قدیمی میتوان وزن تازگی را کاهش داد، در حالی که در حوزههای پرتحول باید آن را بالاتر برد.
پیکرهی دادهی ساختگی
پیکره شامل 100 مقاله است که با تابع build_corpus() تولید میشوند. هر مقاله دارای عنوان و چکیدهی دستنویس در یکی از این پنج موضوع است: اتنشن تنک، افزایش داده با بازیابی، تطبیقدهندههای کمرتبه، تقطیر دانش از مجموعه داده و بسترهای ارزیابی. مراجع و استنادات طوری به هم وصل شدهاند که هر موضوع یک زیرگراف همبند بسازد و چند یال هم میان موضوعات مختلف وجود داشته باشد.
دو کلاینت API ساختگی (ArxivMockClient و SemanticScholarMockClient) دادهها را از همین پیکره میخوانند اما فیلدهای متفاوتی را برمیگردانند: Arxiv عنوان، چکیده، سال انتشار و نویسندگان را میدهد؛ Semantic Scholar مراجع و استنادات را اضافه میکند. کلاینت بازیابی دادهها را بر اساس id اجتماع میگیرد؛ رسیدگی به مغایرت فیلدها بین کلاینتها را به درسهای بعدی موکول میکنیم.
درسهای 52 و 53 چه دادهای میخوانند؟
رانر در درس 52 فیلدهای paper.id، paper.title و سه جملهی اول چکیده را به عنوان کانتکست آزمایش مطالعه میکند. ارزیاب در درس 53 فیلدهای paper.year و paper.references را میخواند تا یک خط مبنا (Baseline) را به مقالهای مشخص نسبت دهد.
کلاینت بازیابی خروجی را در قالب شیء RetrievalResult برمیگرداند که شامل لیست رتبهبندیشده و معیارهای ارزیابی به ازای هر کوئری است: تعداد نتایج، میانگین امتیاز، بالاترین امتیاز و کل زمان اجرای واقعی. رانر این موارد را ثبت میکند تا در مراحل بعدی بتوان کیفیت بازیابی را روی نمودار مشاهدهپذیری پایش کرد.
راهنمای مطالعهی کد
فایل code/main.py کلاسهای Paper، ArxivMockClient، SemanticScholarMockClient، BM25Index، CitationGraph، RetrievalClient و یک دموی قطعی (Deterministic) را تعریف میکند. کلاینتهای ساختگی و پیکرهی داده درون همان فایل قرار گرفتهاند تا این درس کاملاً مستقل و قابل حمل بماند. پیادهسازی BM25 در قالب یک کلاسِ 60 خطی انجام شده و پیمایش گراف نیز یک متد تکمرحلهای است.
فایل code/tests/test_retrieval.py مسیر جستجوی واژگانی، مسیر گرافی، مرحلهی merge، حذف موارد تکراری و همچنین حالت کوئری خالی را پوشش میدهد.
جایگاه این بخش در ساختار کلی
درس 50 یک فرضیه تولید میکند. درس 51 به جستجوی مقالات میپردازد تا مشخص شود آیا تکلیف این فرضیه قبلاً مشخص شده یا نه. اگر اثبات نشده باشد، درس 52 آزمایش را اجرا میکند. در نهایت درس 53 نتایج بازیابی و معیارهای آزمایش را میخواند تا نظر نهایی را صادر کند. کلاینت بازیابی کمهزینهترین بخش در میان این چهار مرحله است و پیش از همه در هماهنگکننده (Orchestrator) به اجرا درمیآید.
