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

۱. چرا سند را به یک بردار خلاصه نکنیم؟

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

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

Qdrant در گزارش ۱۳ اکتبر ۲۰۲۶ دربارهٔ مدل‌های Perplexity، کاربرد نمایش چندبرداری و امتیازدهی دیرهنگام را در کنار فشرده‌سازی و بازامتیازدهی توضیح می‌دهد. این گزارشِ سازنده، زمینهٔ روز این درس است؛ شاهد مستقل کیفیت محصول یا نتیجهٔ آزمایش ما محسوب نمی‌شود. اینجا نه مدل آن گزارش را دریافت کرده‌ایم و نه فشرده‌سازی و شاخص آن را بازسازی می‌کنیم.

در درس شباهت کسینوسی سند، یک بردار با یک بردار مقایسه می‌شد. این بار هر ردیف، یک جزء جداست و سند می‌تواند چند ردیف داشته باشد. بردارهای این تمرین بی‌بُعد هستند؛ به آن‌ها کلمهٔ فارسی یا مفهوم مهندسی نسبت نمی‌دهیم. معنای زبانی، تنها با ساختن چند عدد و مرتب کردن امتیازها ایجاد نمی‌شود.

۲. محاسبهٔ دستی؛ بیشینه برای هر ردیف پرس‌وجو

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

دو ردیف پرس‌وجو را [1,0] و [0,1] می‌گذاریم. سند الف همین دو ردیف، سند ب ردیف‌های [1,0] و [-1,0] و سند ج فقط ردیف [0.6,0.8] را دارد. طول هر بردار یک است؛ بنابراین ضرب داخلی در این مثال برابر شباهت کسینوسی خواهد بود. برنامه بردارها را خودکار نرمال نمی‌کند.

برای سند ج، ماتریس شباهت دو در یک است: ردیف نخست ۰٫۶ و ردیف دوم ۰٫۸. بیشینهٔ هر ردیف همان عدد آن ردیف و جمع آن‌ها ۱٫۴ است. سند الف برای هر جزء یک تطبیق یک دارد و امتیاز دو می‌گیرد. در سند ب، سهم جزء نخست یک و سهم جزء دوم صفر است؛ امتیاز یک می‌شود. پس ترتیب همین دادهٔ ساختگی، الف، ج و ب است.

در ضرب ماتریسی NumPy، عبارت Q @ D.T ردیف‌های پرس‌وجو را مقابل ستون‌های سند معتبر می‌گذارد. بیشینه با axis=1 ستون‌های هر ردیف را می‌پیماید؛ سپس جمع، روی سهم اجزای پرس‌وجو انجام می‌شود. بیشینهٔ کل ماتریس فقط یک جفت را نگه می‌دارد و برای سند ج، به‌اشتباه پاسخ ۰٫۸ به جای ۱٫۴ می‌دهد.

۳. قرارداد ورودی و دام پرکنندهٔ صفر

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

تابع اصلی فقط ndarray دقیق، دوبعدی و float64 می‌پذیرد؛ تعداد ویژگی‌ها باید مثبت و در دو طرف برابر باشد. همهٔ مختصات، حتی ردیف‌های پرکننده، باید متناهی و داخل بازهٔ منفی یک تا یک باشند. طول ردیف‌های فعال با تلورانس مطلق یک در ده به توان دوازدهِ منفی و بدون تلورانس نسبی، نزدیک یک کنترل می‌شود. ورودی خراب تعمیر یا بی‌صدا به نوع دیگری تبدیل نمی‌شود.

ماسک سند، آرایهٔ بولی یک‌بعدی با یک مقدار برای هر ردیف سند است. پیش از ساخت ماتریس، ردیف‌های نامعتبر حذف می‌شوند. پرس‌وجوی خالی و سندِ خالی یا سراسر ماسک‌شده خطا هستند؛ ما برای بیشینهٔ مجموعهٔ خالی، صفر قراردادی نمی‌سازیم. ماسک بر پایهٔ مقدار بردار حدس زده نمی‌شود و مسئولیت اعلام ردیف معتبر، با فراخواننده است.

پرس‌وجوی تک‌ردیفی [1,0] و سند معتبر [-1,0] امتیاز منفی یک دارند. اگر ردیف صفرِ پرکننده را بی‌ماسک وارد بیشینه کنیم، صفر جای منفی یک را می‌گیرد. کد، این مسیر خامِ غلط را عمداً جدا محاسبه می‌کند تا خطا دیده شود؛ تابع معتبر ردیف صفر فعال را نمی‌پذیرد. بریدن امتیاز منفی نیز بخشی از قرارداد نیست.

این ماسک ذخیره‌سازی سند با توکن‌های ویژهٔ [MASK] در رمزگذار پرس‌وجوی مقالهٔ اصلی تفاوت دارد؛ آن توکن‌ها پس از رمزگذاری، بازنمایی آموخته‌شده دارند. مثال ما افزونهٔ پرس‌وجو یا ماسک پرس‌وجو ندارد. حذف همهٔ بردارهایی که نامشان «ماسک» است، بازسازی درست آن معماری نیست.

۴. کد کامل و بررسی مستقل هجده نتیجه

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

فایل token_maxsim.py را با دستور python token_maxsim.py اجرا کنید. این نسخه با پایتون ۳٫۱۴٫۵ و NumPy نسخهٔ ۲٫۴٫۶ اجرا شده است. مسیر اصلی ضرب ماتریسی دارد؛ مرجع مستقل، ضرب‌ها را با حلقهٔ اسکالر و math.fsum جمع می‌کند. مرجع فقط برای همین داده‌های دست‌سازِ از قبل معتبر است و اعتبارسنج عمومیِ جداگانه محسوب نمی‌شود.

"""Original tiny MaxSim geometry fixture; no tokenizer, model, index or retrieval data."""
import json
import math
import platform
import numpy as np


def validated(query, document, document_mask=None):
    for name, matrix in (('query', query), ('document', document)):
        if type(matrix) is not np.ndarray or matrix.ndim != 2:
            raise ValueError(name + ' must be an exact two-dimensional ndarray')
        if matrix.dtype != np.dtype('float64') or matrix.shape[1] == 0:
            raise ValueError(name + ' needs float64 rows with positive dimension')
        if not np.isfinite(matrix).all() or np.any(np.abs(matrix) > 1):
            raise ValueError(name + ' coordinates must be finite and in [-1, 1]')
    if query.shape[0] == 0 or query.shape[1] != document.shape[1]:
        raise ValueError('A nonempty query and equal feature dimensions are required')
    if document_mask is None:
        mask = np.ones(document.shape[0], dtype=bool)
    else:
        if (type(document_mask) is not np.ndarray or document_mask.dtype != np.dtype('bool')
                or document_mask.shape != (document.shape[0],)):
            raise ValueError('Document mask must be an exact one-dimensional bool ndarray')
        mask = document_mask
    if not mask.any():
        raise ValueError('At least one valid document row is required')
    valid_document = document[mask]
    for matrix in (query, valid_document):
        if not np.allclose(np.linalg.norm(matrix, axis=1), 1.0, rtol=0, atol=1e-12):
            raise ValueError('Every active row must already have unit length')
    return query, valid_document


def maxsim(query, document, document_mask=None):
    q, d = validated(query, document, document_mask)
    similarities = q @ d.T  # rows=query tokens, columns=valid document tokens
    per_query_max = np.max(similarities, axis=1)
    return {'matrix': similarities, 'per_query_max': per_query_max,
            'score': float(np.sum(per_query_max))}


def loop_reference(query, document):
    # Independent scalar loop on the already validated, authored fixture only.
    return math.fsum(max(math.fsum(float(a) * float(b) for a, b in zip(qrow, drow))
                         for drow in document) for qrow in query)


def near(actual, expected):
    np.testing.assert_allclose(actual, expected, rtol=0, atol=1e-12,
                               equal_nan=False, strict=True)
    return True


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


def run_example():
    query = np.array([[1.0, 0.0], [0.0, 1.0]])
    documents = {
        'doc-a': np.array([[1.0, 0.0], [0.0, 1.0]]),
        'doc-b': np.array([[1.0, 0.0], [-1.0, 0.0]]),
        'doc-c': np.array([[0.6, 0.8]]),
    }
    original = query.copy(), {key: value.copy() for key, value in documents.items()}
    results = {key: maxsim(query, value) for key, value in documents.items()}
    ranking = sorted(results, key=lambda key: (-results[key]['score'], key))
    reverse_score = maxsim(documents['doc-c'], query)['score']
    padded_document = np.array([[-1.0, 0.0], [0.0, 0.0]])
    one_query = query[:1]
    masked = maxsim(one_query, padded_document, np.array([True, False]))['score']
    raw_padding_score = float(np.max(one_query @ padded_document.T, axis=1).sum())
    bad_nan = query.copy()
    bad_nan[0, 0] = np.nan
    bad_inf = query.copy()
    bad_inf[0, 0] = np.inf
    checks = {
        'hand_matrix_for_single_document_token': near(results['doc-c']['matrix'], np.array([[0.6], [0.8]])),
        'hand_maxima_and_sum_are_0_6_0_8_and_1_4': near(results['doc-c']['per_query_max'], np.array([0.6, 0.8])) and near(results['doc-c']['score'], 1.4),
        'scalar_loop_matches_matrix_path_for_each_authored_document': all(near(results[key]['score'], loop_reference(query, doc)) for key, doc in documents.items()),
        'authored_ranking_is_a_c_b': ranking == ['doc-a', 'doc-c', 'doc-b'] and near(np.array([results[key]['score'] for key in ranking]), np.array([2.0, 1.4, 1.0])),
        'score_above_one_is_not_a_probability': near(results['doc-a']['score'], 2.0),
        'query_document_reversal_is_asymmetric': near(reverse_score, 0.8) and not math.isclose(reverse_score, results['doc-c']['score']),
        'duplicating_a_document_row_does_not_change_maximum': near(maxsim(query, np.vstack((documents['doc-c'], documents['doc-c'])))['score'], 1.4),
        'a_new_matching_document_row_can_increase_score': near(maxsim(query, np.vstack((documents['doc-b'], query[1:])))['score'], 2.0),
        'duplicating_query_row_adds_its_contribution': near(maxsim(np.vstack((query, query[:1])), documents['doc-c'])['score'], 2.0),
        'row_permutations_preserve_sum': near(maxsim(query[::-1], documents['doc-a'][::-1])['score'], 2.0),
        'valid_negative_match_is_preserved_by_document_mask': near(masked, -1.0),
        'unmasked_zero_padding_would_spuriously_raise_negative_maximum': near(raw_padding_score, 0.0) and raw_padding_score > masked,
        'empty_or_all_masked_document_rejected': rejects(lambda: maxsim(query, np.empty((0, 2)))) and rejects(lambda: maxsim(query, documents['doc-a'], np.array([False, False]))),
        'empty_query_rejected': rejects(lambda: maxsim(np.empty((0, 2)), documents['doc-a'])),
        'wrong_rank_dimension_and_mask_shape_rejected': rejects(lambda: maxsim(query.ravel(), documents['doc-a'])) and rejects(lambda: maxsim(query, np.array([[1.0, 0.0, 0.0]]))) and rejects(lambda: maxsim(query, documents['doc-a'], np.array([[True, True]]))),
        'nonfinite_or_wrong_dtype_inputs_rejected': rejects(lambda: maxsim(bad_nan, documents['doc-a'])) and rejects(lambda: maxsim(bad_inf, documents['doc-a'])) and rejects(lambda: maxsim(query.astype(np.float32), documents['doc-a'])),
        'zero_or_nonunit_active_rows_rejected': rejects(lambda: maxsim(query, np.array([[0.0, 0.0]]))) and rejects(lambda: maxsim(query, np.array([[0.5, 0.0]]))),
        'inputs_are_not_modified': np.array_equal(query, original[0]) and all(np.array_equal(documents[key], original[1][key]) for key in documents),
    }
    assert len(checks) == 18 and all(type(value) is bool for value in checks.values())
    assert all(checks.values()), checks
    return {
        'python': platform.python_version(), 'numpy': np.__version__,
        'scope': 'Authored unit-vector geometry only; no tokenization, learned model, Qdrant, real documents or quality benchmark',
        'query_vectors': query.tolist(),
        'document_vectors': {key: value.tolist() for key, value in documents.items()},
        'doc_c_similarity_matrix': results['doc-c']['matrix'].tolist(),
        'doc_c_per_query_max': results['doc-c']['per_query_max'].tolist(),
        'scores': {key: value['score'] for key, value in results.items()},
        'ranking': ranking, 'reversed_doc_c_query_score': reverse_score,
        'negative_match_with_padding_excluded': masked,
        'incorrect_raw_zero_padding_score': raw_padding_score,
        'unit_length_absolute_tolerance': 1e-12,
        'comparison_absolute_tolerance': 1e-12,
        'checks': checks,
    }


if __name__ == '__main__':
    print(json.dumps(run_example(), ensure_ascii=False, indent=2))

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

{
  "python": "3.14.5",
  "numpy": "2.4.6",
  "scope": "Authored unit-vector geometry only; no tokenization, learned model, Qdrant, real documents or quality benchmark",
  "query_vectors": [
    [
      1.0,
      0.0
    ],
    [
      0.0,
      1.0
    ]
  ],
  "document_vectors": {
    "doc-a": [
      [
        1.0,
        0.0
      ],
      [
        0.0,
        1.0
      ]
    ],
    "doc-b": [
      [
        1.0,
        0.0
      ],
      [
        -1.0,
        0.0
      ]
    ],
    "doc-c": [
      [
        0.6,
        0.8
      ]
    ]
  },
  "doc_c_similarity_matrix": [
    [
      0.6
    ],
    [
      0.8
    ]
  ],
  "doc_c_per_query_max": [
    0.6,
    0.8
  ],
  "scores": {
    "doc-a": 2.0,
    "doc-b": 1.0,
    "doc-c": 1.4
  },
  "ranking": [
    "doc-a",
    "doc-c",
    "doc-b"
  ],
  "reversed_doc_c_query_score": 0.8,
  "negative_match_with_padding_excluded": -1.0,
  "incorrect_raw_zero_padding_score": 0.0,
  "unit_length_absolute_tolerance": 1e-12,
  "comparison_absolute_tolerance": 1e-12,
  "checks": {
    "hand_matrix_for_single_document_token": true,
    "hand_maxima_and_sum_are_0_6_0_8_and_1_4": true,
    "scalar_loop_matches_matrix_path_for_each_authored_document": true,
    "authored_ranking_is_a_c_b": true,
    "score_above_one_is_not_a_probability": true,
    "query_document_reversal_is_asymmetric": true,
    "duplicating_a_document_row_does_not_change_maximum": true,
    "a_new_matching_document_row_can_increase_score": true,
    "duplicating_query_row_adds_its_contribution": true,
    "row_permutations_preserve_sum": true,
    "valid_negative_match_is_preserved_by_document_mask": true,
    "unmasked_zero_padding_would_spuriously_raise_negative_maximum": true,
    "empty_or_all_masked_document_rejected": true,
    "empty_query_rejected": true,
    "wrong_rank_dimension_and_mask_shape_rejected": true,
    "nonfinite_or_wrong_dtype_inputs_rejected": true,
    "zero_or_nonunit_active_rows_rejected": true,
    "inputs_are_not_modified": true
  }
}

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

۵. طول سند، نقش پرس‌وجو و چهار تمرین پاسخ‌دار

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

تعریف رسمی مقایسه‌گر چندبرداری Qdrant نیز جمعِ بهترین تطبیق هر جزء را مشخص می‌کند. نقش دو طرف یکسان نیست: در دادهٔ ما، اگر سند ج را پرس‌وجو و پرس‌وجوی قبلی را سند بگیریم، فقط یک بیشینهٔ ۰٫۸ داریم. امتیاز همچنین احتمال نیست؛ پاسخ دو برای سند الف کاملاً با تعریف جمع سازگار است.

تمرین اول: تکرار ردیف سند ج چه می‌کند؟ پاسخ: بیشینه‌ها همان ۰٫۶ و ۰٫۸ می‌مانند و امتیاز ۱٫۴ است. تمرین دوم: افزودن ردیف [0,1] به سند ب چه می‌کند؟ پاسخ: سهم دوم از صفر به یک می‌رسد و جمع دو می‌شود؛ افزایش عدد، اثبات بهتر شدن ارتباط معنایی نیست.

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

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

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