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

۱. مسئلهٔ مهندسی و قرارداد فهرست‌های ورودی

شناسهٔ مشترک سند در دو فهرست بازیابی
تصویر مفهومی: شناسهٔ مشترک سند در دو فهرست بازیابی؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

فرض کنید کاربر دربارهٔ فرمان دادن به بوبین کنتاکتور جست‌وجو می‌کند. پنج شناسهٔ آموزشی relay، coil، contactor، sensor و drive را برای سندهای فرضی در نظر می‌گیریم. عنوان سند و محتوای فنی در این مثال ساخته نشده، چون هدف سنجیدن فهم متن نیست. فهرست اول به ترتیب relay، coil، contactor و sensor است؛ فهرست دوم contactor، relay، drive و coil. نام‌های lexical و semantic فقط نقش فرضی این دو مسیر را مشخص می‌کنند، نه خروجی واقعی BM25 یا یک مدل embedding.

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

این مرحله با سنجش کیفیت ارتباط فرق دارد. برای تعریف برچسب ارتباط و سنجیدن نتیجهٔ نهایی، آموزش ارزیابی رتبه‌بندی جست‌وجو در پایتون را کنار این مثال بخوانید. دانستن اینکه تابع ادغام درست حساب می‌کند، به معنی دانستن اینکه سند برتر پاسخ درستی دارد نیست.

۲. فرمول RRF و تفاوت سه پارامتر

سهم رتبه در ترکیب فهرست‌های جست‌وجو
تصویر مفهومی: سهم رتبه در ترکیب فهرست‌های جست‌وجو؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

در مقالهٔ اصلی RRF در SIGIR 2009، امتیاز سند از جمع معکوس رتبه با یک ثابت به دست می‌آید. برای هر فهرستی که سند در آن حضور دارد، سهمی برابر 1 / (constant + rank) اضافه می‌کنیم. رتبه از یک شروع می‌شود. در قرارداد فهرست‌های محدود ما، نبود سند در یک مسیر سهم صفر دارد؛ سندی که هیچ مسیر نیاورده، از ادغام ظاهر نمی‌شود.

ثابت ۶۰ را برای نمونه انتخاب کرده‌ایم؛ این انتخاب، تضمین بهترین نتیجه برای دادهٔ فارسی یا مدارهای الکتریکی نیست. با بزرگ‌تر شدن ثابت، تفاوت سهم رتبه‌های نزدیک کم‌تر می‌شود. رتبهٔ اول یک فهرست در مثال، سهم ۱/۶۱ دارد. سند relay که در رتبه‌های یک و دو است، مجموع ۱/۶۱ و ۱/۶۲ می‌گیرد. عدد به دست آمده بی‌واحد است و احتمال صحت سند یا درصد اطمینان پاسخ نیست.

راهنمای Elasticsearch ثابت رتبه، پنجرهٔ نامزدها و تعداد خروجی را جدا می‌کند. در کد ما constant شدت کاهش سهم را تعیین می‌کند؛ window تعداد اعضای ابتدای هر فهرست ورودی را محدود می‌سازد؛ برش نتیجه پس از ادغام، تعداد نمایش را تعیین می‌کند. این تابع آموزشی، تمام قواعد صفحه‌بندی Elasticsearch را شبیه‌سازی نمی‌کند. برابر گرفتن این سه عدد می‌تواند منبع خطای طراحی باشد.

۳. پیاده‌سازی کامل و آزمون‌های مستقل

آزمون پیاده‌سازی ادغام رتبه‌ها در پایتون
تصویر مفهومی: آزمون پیاده‌سازی ادغام رتبه‌ها در پایتون؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

فایل زیر را با نام rrf_search.py ذخیره و با python rrf_search.py اجرا کنید. وابستگی خارجی ندارد و در پایتون ۳٫۱۴٫۵ اجرا شده است. برای جمع سهم‌ها از Fraction استفاده کرده‌ایم تا تساوی دقیق کسری به خطای گرد کردن وابسته نشود. این انتخاب برای شفافیت آزمون است؛ دربارهٔ سرعت آن نسبت به موتورهای جست‌وجو ادعایی نداریم. نمایش اعشاری خروجی فقط برای خواندن راحت‌تر است.

"""Authored RRF fusion of synthetic rankings, not a search-engine benchmark."""
from fractions import Fraction
import json
import platform

LEXICAL = ["relay", "coil", "contactor", "sensor"]
SEMANTIC = ["contactor", "relay", "drive", "coil"]


def fuse(rankings, constant=60, window=4):
    if type(constant) is not int or constant < 1:
        raise ValueError("constant must be a positive integer")
    if type(window) is not int or window < 1:
        raise ValueError("window must be a positive integer")
    if len(rankings) < 2:
        raise ValueError("Provide at least two ranked lists")
    scores, parts = {}, {}
    for source_index, ranking in enumerate(rankings):
        if any(not isinstance(doc, str) or not doc for doc in ranking):
            raise ValueError("Document IDs must be nonempty strings")
        if len(ranking) != len(set(ranking)):
            raise ValueError("Duplicate document in one ranked list")
        for rank, doc in enumerate(ranking[:window], start=1):
            contribution = Fraction(1, constant + rank)
            scores[doc] = scores.get(doc, Fraction(0)) + contribution
            parts.setdefault(doc, []).append({"source": source_index, "rank": rank,
                                              "contribution": str(contribution)})
    order = sorted(scores, key=lambda doc: (-scores[doc], doc))
    return [{"id": doc, "score": float(scores[doc]),
             "exact_score": str(scores[doc]), "contributions": parts[doc]} for doc in order]


def rejects(call):
    try:
        call()
    except ValueError:
        return True
    return False


def run_example():
    inputs = [LEXICAL.copy(), SEMANTIC.copy()]
    baseline = fuse(inputs)
    shallow = fuse(inputs, window=2)
    scores = {x["id"]: Fraction(x["exact_score"]) for x in baseline}
    swapped = fuse(list(reversed(inputs)))
    # Source indexes in explanations change on swapping; compare IDs and scores.
    projection = lambda result: [(x["id"], x["exact_score"]) for x in result]
    ties = fuse([["a", "b"], ["b", "a"]])
    checks = {
        "rank_starts_at_one": scores["relay"] == Fraction(1, 61) + Fraction(1, 62),
        "union_once_per_document": len(baseline) == 5 and {x["id"] for x in baseline} == set(LEXICAL + SEMANTIC),
        "missing_from_one_list_contributes_zero": scores["sensor"] == Fraction(1, 64),
        "expected_fixture_order": [x["id"] for x in baseline] == ["relay", "contactor", "coil", "drive", "sensor"],
        "list_order_preserves_scores_and_order": projection(baseline) == projection(swapped),
        "ties_use_document_id": [x["id"] for x in ties] == ["a", "b"] and ties[0]["exact_score"] == ties[1]["exact_score"],
        "window_limits_candidate_union": {x["id"] for x in shallow} == {"relay", "coil", "contactor"},
        "output_slice_is_not_candidate_window": len(baseline[:2]) == 2 and len(shallow) == 3,
        "unretrieved_document_not_invented": "overload" not in scores,
        "constant_changes_scores": fuse(inputs, constant=1)[0]["exact_score"] != baseline[0]["exact_score"],
        "one_empty_list_is_allowed": len(fuse([LEXICAL, []])) == 4,
        "all_empty_lists_return_empty": fuse([[], []]) == [],
        "duplicate_id_rejected": rejects(lambda: fuse([["relay", "relay"], []])),
        "invalid_constant_rejected": all(rejects(lambda v=v: fuse(inputs, constant=v)) for v in (0, -1, True, 1.5)),
        "invalid_window_rejected": rejects(lambda: fuse(inputs, window=0)),
        "input_lists_unchanged": inputs == [LEXICAL, SEMANTIC],
    }
    assert all(checks.values()), checks
    return {"python": platform.python_version(), "synthetic_rankings_only": True,
            "constant": 60, "window_per_list": 4, "input_rankings": inputs,
            "fused": baseline, "window_two_order": [x["id"] for x in shallow],
            "checks": checks}


if __name__ == "__main__":
    print(json.dumps(run_example(), indent=2))

تابع، شناسهٔ خالی، تکرار درون فهرست، ثابت نامعتبر و پنجرهٔ صفر را رد می‌کند. دو فهرست خالی خروجی خالی دارند؛ خالی بودن یکی از مسیرها در قرارداد ما قابل پذیرش است. از نظر عملیاتی باید جداگانه بفهمیم آن مسیر واقعاً نتیجه نداشته یا به علت خطا پاسخی نداده است؛ تابع عددی این تفاوت را نمی‌داند. شناسهٔ سند معیار شکستن تساوی است و نباید این سیاست آموزشی را رفتار تضمین‌شدهٔ همهٔ موتورهای جست‌وجو معرفی کرد.

شانزده آزمون اجرا می‌شود: شروع رتبه از یک، اجتماع یکتای شناسه‌ها، سهم صفر مسیر غایب، ترتیب مورد انتظار، ثبات با جابه‌جایی فهرست‌ها، تساوی قطعی، محدودیت پنجره، تفاوت پنجره با برش خروجی، نبود سند بازیابی‌نشده، اثر ثابت، ورودی‌های خالی، رد تکرار، پارامترهای نامعتبر و حفظ ورودی. تغییر شمارهٔ مسیرها در توضیح سهم‌ها طبیعی است؛ هنگام جابه‌جایی، شناسه و امتیاز نهایی مقایسه می‌شوند.

۴. تحلیل خروجی و خطای پنجرهٔ کوچک

پنجرهٔ محدود نامزدها پیش از ادغام نتایج
تصویر مفهومی: پنجرهٔ محدود نامزدها پیش از ادغام نتایج؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

ترتیب ثبت‌شده relay، contactor، coil، drive و sensor است. سه سند نخست از هر دو مسیر سهم دارند. امتیاز relay حدود ۰٫۰۳۲۵۲۲ و contactor حدود ۰٫۰۳۲۲۶۶ است؛ فاصلهٔ کوچک این اعداد را نباید تفاوت معنادار کیفیت تعبیر کرد. sensor تنها در رتبهٔ چهار فهرست اول حضور دارد و امتیازش ۱/۶۴ است. این حساب‌ها از ورودی ساختگی خودمان به دست آمده‌اند، نه از آزمایش روی کاربران.

{
  "python": "3.14.5",
  "synthetic_rankings_only": true,
  "constant": 60,
  "window_per_list": 4,
  "input_rankings": [
    [
      "relay",
      "coil",
      "contactor",
      "sensor"
    ],
    [
      "contactor",
      "relay",
      "drive",
      "coil"
    ]
  ],
  "fused": [
    {
      "id": "relay",
      "score": 0.03252247488101533,
      "exact_score": "123/3782",
      "contributions": [
        {
          "source": 0,
          "rank": 1,
          "contribution": "1/61"
        },
        {
          "source": 1,
          "rank": 2,
          "contribution": "1/62"
        }
      ]
    },
    {
      "id": "contactor",
      "score": 0.032266458495966696,
      "exact_score": "124/3843",
      "contributions": [
        {
          "source": 0,
          "rank": 3,
          "contribution": "1/63"
        },
        {
          "source": 1,
          "rank": 1,
          "contribution": "1/61"
        }
      ]
    },
    {
      "id": "coil",
      "score": 0.031754032258064516,
      "exact_score": "63/1984",
      "contributions": [
        {
          "source": 0,
          "rank": 2,
          "contribution": "1/62"
        },
        {
          "source": 1,
          "rank": 4,
          "contribution": "1/64"
        }
      ]
    },
    {
      "id": "drive",
      "score": 0.015873015873015872,
      "exact_score": "1/63",
      "contributions": [
        {
          "source": 1,
          "rank": 3,
          "contribution": "1/63"
        }
      ]
    },
    {
      "id": "sensor",
      "score": 0.015625,
      "exact_score": "1/64",
      "contributions": [
        {
          "source": 0,
          "rank": 4,
          "contribution": "1/64"
        }
      ]
    }
  ],
  "window_two_order": [
    "relay",
    "contactor",
    "coil"
  ],
  "checks": {
    "rank_starts_at_one": true,
    "union_once_per_document": true,
    "missing_from_one_list_contributes_zero": true,
    "expected_fixture_order": true,
    "list_order_preserves_scores_and_order": true,
    "ties_use_document_id": true,
    "window_limits_candidate_union": true,
    "output_slice_is_not_candidate_window": true,
    "unretrieved_document_not_invented": true,
    "constant_changes_scores": true,
    "one_empty_list_is_allowed": true,
    "all_empty_lists_return_empty": true,
    "duplicate_id_rejected": true,
    "invalid_constant_rejected": true,
    "invalid_window_rejected": true,
    "input_lists_unchanged": true
  }
}

اگر پنجرهٔ هر مسیر را دو کنیم، drive و sensor پیش از ادغام حذف می‌شوند و تنها سه شناسه باقی می‌مانند. در مقابل، گرفتن دو عضو اول خروجی کامل، فقط نمایش را به دو نتیجه محدود می‌کند. بنابراین افزایش تعداد نتایج در رابط کاربر نمی‌تواند سندی را که از پنجرهٔ ورودی بیرون مانده برگرداند. برای بررسی این مسئله باید فهرست نامزدهای هر مسیر و پارامترهای همان اجرا را ثبت کرد، نه فقط پاسخ نهایی را.

این مثال نشان نمی‌دهد پنجرهٔ بزرگ‌تر همیشه بهتر است؛ ممکن است هزینهٔ بیشتری تحمیل کند یا نامزدهای نامرتبط اضافه کند. برای انتخاب، دادهٔ ارزیابی جدا، زمان پاسخ و کیفیت لازم است. همچنین کپی کردن یک مسیر به عنوان مسیر دوم شواهد تازه تولید نمی‌کند؛ سهم تکراری می‌تواند همان ترجیح را تقویت کند. تنوع واقعی مسیرها و استقلال ارزیابی باید از شکل ظاهری دو فهرست جدا سنجیده شود.

۵. پاسخ تمرین‌ها و اتصال به پایگاه دانش

بازبینی ارتباط اسناد پس از ادغام جست‌وجو
تصویر مفهومی: بازبینی ارتباط اسناد پس از ادغام جست‌وجو؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

تمرین اول: سهم contactor چگونه محاسبه می‌شود؟ پاسخ: رتبهٔ سوم مسیر اول و رتبهٔ اول مسیر دوم، یعنی ۱/۶۳ به‌علاوهٔ ۱/۶۱؛ حدود ۰٫۰۳۲۲۶۶. تمرین دوم: سند overload که در هیچ فهرستی نیست کجای خروجی است؟ پاسخ: حضور ندارد؛ RRF جایگزین بازیابی نامزدها نیست. تمرین سوم: با پنجرهٔ دو چند نتیجه داریم؟ پاسخ: سه شناسه، زیرا relay مشترک است؛ تعداد خروجی لزوماً دو برابر اندازهٔ پنجره نیست.

تمرین چهارم: اگر دو سند امتیاز برابر بگیرند چه می‌شود؟ پاسخ: در کد ما ترتیب واژگانی شناسه‌ها تصمیم می‌گیرد؛ آزمون a و b همین قرارداد را بررسی می‌کند. تمرین پنجم: آیا تغییر constant همان تغییر تعداد همسایه‌های برداری است؟ پاسخ: خیر. راهنمای Azure AI Search نیز ثابت RRF را از شمار همسایه‌ها جدا می‌کند؛ امتیاز بازرتبه‌بندی معنایی هم مرحله و خروجی جداگانه‌ای دارد.

در پایگاه دانش سازمانی و RAG، پس از ادغام هنوز کنترل دسترسی، تازگی نسخهٔ سند و استناد پاسخ لازم است. حذف سند ممنوع در انتهای نمایش، جای طراحی درست مجوزها در مسیر بازیابی را نمی‌گیرد. این مثال دسترسی، شبکه، embedding یا تولید پاسخ را پیاده نکرده است. منابع در ۲۳ مهر ۱۴۰۵ خوانده شده‌اند؛ مطلب حاضر آموزش پایه است و نتایج مقالهٔ سال ۲۰۰۹ را به عملکرد محصولات شرکت نسبت نمی‌دهد.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۳ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.