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

۱. واحد واژگانی و فهرست جایگزین را تعریف کنیم

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

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

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

جدول، دیکشنریِ ازقبل‌ساخته‌شده است؛ اگر در ساخت دیکشنری یک کلید را دوبار بنویسیم، مقدار قبلی پیش از فراخوانی از بین رفته است. این کد آن تاریخچه را بازیابی نمی‌کند.

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

۲. جهت قاعده و بسط فقط یک گام را حفظ کنیم

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

برای هر واژهٔ اصلی، گروهی شامل خودش و جایگزین‌های مستقیمش ساخته می‌شود. تکرارها با حفظ اولین ترتیب کنار می‌روند. برای نمونه، فهرست «سنسور، حسگر، سنسور» در قاعدهٔ حسگر، گروه نهایی «حسگر، سنسور» می‌دهد. افزودن دوبارهٔ خودِ واژه، گروه تازه یا امتیاز بیشتر ایجاد نمی‌کند.

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

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

۳. AND میان گروه‌ها و OR داخل هر گروه بماند

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

گروه اول «حسگر یا سنسور» و گروه دوم «دما یا temperature» است. پذیرش سند، داشتن دست‌کم یک عضو از هر دو گروه را می‌خواهد. در سند D01، حسگر و دما آمده‌اند؛ در D02، سنسور و temperature. هر دو پذیرفته می‌شوند. ترتیب نمایش شناسه‌ها، ترتیب رشته‌ای است و رتبه‌بندی ارتباط یا امتیازدهی نیست.

D03 فقط حسگر دارد و گروه دوم را برآورده نمی‌کند. D04 فقط temperature دارد و گروه اول را ندارد. D05 شامل sensor و دماست، ولی sensor در بسطِ یک‌گامِ حسگر نیست؛ پس گروه اولش نادرست می‌ماند. D06 با موتور و جریان، هیچ‌یک از دو گروه را برآورده نمی‌کند. ردِ هر سند، دو مقدار درست یا نادرستِ گروه‌ها را جدا ثبت می‌کند.

اگر چهار واژهٔ بسط‌یافته را در یک فهرست OR بریزیم، D01 تا D05 پذیرفته می‌شوند. سه سند اضافی نسبت به قراردادِ دوگروهی وارد شده‌اند؛ این تغییر، فقط به دست‌کاری منطق رخ داده است. اینجا برچسب ارتباطِ واقعی یا دادهٔ ارزیابی نداریم؛ از این مقایسهٔ دستی، افزایش دقت یا بازیابیِ موتور جست‌وجو نتیجه نمی‌گیریم.

۴. کد کامل را با مرجعِ انتخاب جایگزین مقایسه کنیم

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

فایل synonym_query_groups.py با Python 3.14.5 و کتابخانهٔ استاندارد اجرا شده است. مسیر اصلی برای هر سند، OR هر گروه و سپس AND گروه‌ها را حساب می‌کند. مرجع مستقل، همهٔ انتخاب‌های یک جایگزین از هر شرط را می‌سازد و می‌سنجد آیا دست‌کم یک ترکیب کامل در سند حضور دارد؛ گروه‌های کامپایل‌شدهٔ مسیر اصلی را مصرف نمی‌کند.

"""Bounded authored one-hop term alternatives, AND groups / OR within groups.

Not a tokenizer, linguistic synonym resource, Lucene graph analyzer or ranking.
"""
from itertools import permutations, product
import json
import platform


def valid_term(term):
    return type(term) is str and 1 <= len(term) <= 24 and not any(char.isspace() for char in term)


def compile_groups(query, rules):
    if type(rules) is not dict or len(rules) > 16:
        raise ValueError('rules')
    for source, alternatives in rules.items():
        if not valid_term(source):
            raise ValueError('term')
        if type(alternatives) is not list or len(alternatives) > 3:
            raise ValueError('rule_alternatives')
        if any(not valid_term(value) for value in alternatives):
            raise ValueError('term')
    if type(query) is not list or len(query) > 4:
        raise ValueError('query')
    if any(not valid_term(value) for value in query):
        raise ValueError('term')
    groups, expanded_members = [], 0
    for original in dict.fromkeys(query):
        group = tuple(dict.fromkeys([original] + rules.get(original, [])))
        expanded_members += len(group)
        if expanded_members > 8:
            raise ValueError('expansion_budget')
        groups.append(group)
    return tuple(groups)


def retrieve(query, rules, documents):
    groups = compile_groups(query, rules)
    if type(documents) is not dict or len(documents) > 64:
        raise ValueError('documents')
    for identifier, tokens in documents.items():
        if not valid_term(identifier):
            raise ValueError('document_id')
        if type(tokens) is not list or len(tokens) > 16:
            raise ValueError('document_tokens')
        if any(not valid_term(value) for value in tokens):
            raise ValueError('term')
    hits, trace = [], []
    for identifier in sorted(documents):
        present = set(documents[identifier])
        group_hits = [any(term in present for term in group) for group in groups]
        accepted = bool(groups) and all(group_hits)
        if accepted:
            hits.append(identifier)
        trace.append({'document_id': identifier, 'group_hits': group_hits, 'accepted': accepted})
    return {'groups': [list(group) for group in groups], 'matched_ids': hits, 'trace': trace}


def direct_assignment_oracle(query, directed_edges, present):
    """Independent DNF: enumerate one direct alternative assignment per clause."""
    alternatives = []
    for term in query:
        alternatives.append([term] + [target for source, target in directed_edges if source == term])
    if not alternatives:
        return False
    return any(set(assignment) <= set(present) for assignment in product(*alternatives))


def rejects(call, message):
    try:
        call()
    except ValueError as error:
        return str(error) == message
    return False


def run_example():
    query = ['حسگر', 'دما']
    rules = {'حسگر': ['سنسور', 'حسگر', 'سنسور'], 'دما': ['temperature'],
             'سنسور': ['sensor'], 'sensor': ['حسگر']}
    documents = {'D01': ['حسگر', 'دما'], 'D02': ['سنسور', 'temperature'],
                 'D03': ['حسگر'], 'D04': ['temperature'], 'D05': ['sensor', 'دما'],
                 'D06': ['موتور', 'جریان']}
    original = json.dumps([query, rules, documents], ensure_ascii=False, sort_keys=True)
    result = retrieve(query, rules, documents)
    flat_terms = {term for group in result['groups'] for term in group}
    flat_or_ids = sorted(identifier for identifier, terms in documents.items() if set(terms) & flat_terms)
    exact_budget = {'A': ['a1', 'a2', 'a3'], 'B': ['b1', 'b2', 'b3']}
    overlapping = retrieve(['A', 'B'], {'A': ['X'], 'B': ['X']}, {'D': ['X']})
    queries = list(permutations('ABC', 2))
    possible_edges = [(a, b) for a in 'ABC' for b in 'ABC' if a != b]
    cases, oracle_ok = 0, True
    for included in product((False, True), repeat=6):
        edges = [edge for edge, keep in zip(possible_edges, included) if keep]
        fixture_rules = {term: [target for source, target in edges if source == term] for term in 'ABC'}
        for bits in product((False, True), repeat=3):
            tokens = [term for term, keep in zip('ABC', bits) if keep]
            for pair in queries:
                actual = bool(retrieve(list(pair), fixture_rules, {'D': tokens})['matched_ids'])
                oracle_ok &= actual == direct_assignment_oracle(list(pair), edges, tokens)
                cases += 1
    invalid_terms = [True, 1, 1.0, None, '', 'two words', 'x' * 25]
    exposed = retrieve(query, rules, documents); exposed['groups'][0].append('fake'); exposed['matched_ids'].clear()
    checks = {
        'hand_groups_preserve_original_and_deduplicate': result['groups'] == [['حسگر', 'سنسور'], ['دما', 'temperature']],
        'hand_two_documents_match_both_groups': result['matched_ids'] == ['D01', 'D02'],
        'and_between_groups_rejects_partial_documents': result['trace'][2]['group_hits'] == [True, False] and result['trace'][3]['group_hits'] == [False, True] and not result['trace'][2]['accepted'] and not result['trace'][3]['accepted'],
        'one_hop_does_not_follow_sensor_chain': 'sensor' not in result['groups'][0] and not result['trace'][4]['accepted'],
        'directed_rule_does_not_create_reverse_rule': compile_groups(['temperature'], rules) == (('temperature',),),
        'directed_cycle_does_not_trigger_recursive_expansion': compile_groups(['حسگر'], rules) == (('حسگر', 'سنسور'),) and compile_groups(['sensor'], rules) == (('sensor', 'حسگر'),),
        'flattened_or_has_three_extra_fixture_matches': flat_or_ids == ['D01', 'D02', 'D03', 'D04', 'D05'] and sorted(set(flat_or_ids) - set(result['matched_ids'])) == ['D03', 'D04', 'D05'],
        'duplicate_query_term_does_not_duplicate_clause': compile_groups(['حسگر', 'حسگر', 'دما'], rules) == compile_groups(query, rules),
        'unknown_term_is_retained_without_guessing': retrieve(['لیزر'], rules, documents)['groups'] == [['لیزر']] and retrieve(['لیزر'], rules, documents)['matched_ids'] == [],
        'empty_query_is_explicitly_match_none': retrieve([], rules, documents)['matched_ids'] == [] and retrieve([], rules, documents)['groups'] == [],
        'unused_invalid_rule_still_rejects': rejects(lambda: compile_groups(['A'], {'unused': ['two words']}), 'term'),
        'term_type_length_and_whitespace_contract': all(rejects(lambda term=term: compile_groups([term], {}), 'term') for term in invalid_terms),
        'query_rule_and_document_shapes_are_bounded': rejects(lambda: compile_groups(('A',), {}), 'query') and rejects(lambda: compile_groups(['A'] * 5, {}), 'query') and rejects(lambda: compile_groups(['A'], []), 'rules') and rejects(lambda: compile_groups(['A'], {str(i): [] for i in range(17)}), 'rules') and rejects(lambda: compile_groups(['A'], {'A': ['B'] * 4}), 'rule_alternatives') and rejects(lambda: retrieve(['A'], {}, {'D': 'A'}), 'document_tokens') and rejects(lambda: retrieve(['A'], {}, {True: []}), 'document_id') and rejects(lambda: retrieve(['A'], {}, {str(i): [] for i in range(65)}), 'documents') and rejects(lambda: retrieve(['A'], {}, {'D': ['A'] * 17}), 'document_tokens'),
        'expansion_budget_includes_eight_rejects_nine': sum(map(len, compile_groups(['A', 'B'], exact_budget))) == 8 and rejects(lambda: compile_groups(['A', 'B', 'C'], exact_budget), 'expansion_budget'),
        'one_token_can_satisfy_two_overlapping_groups': overlapping['matched_ids'] == ['D'] and overlapping['trace'][0]['group_hits'] == [True, True],
        'exact_matching_does_not_normalize_case_or_zwnj': retrieve(['sensor'], {}, {'D': ['Sensor']})['matched_ids'] == [] and retrieve(['حسگر'], {}, {'D': ['حس‌گر']})['matched_ids'] == [],
        'input_order_and_new_outputs_are_independent': json.dumps([query, rules, documents], ensure_ascii=False, sort_keys=True) == original and retrieve(query, rules, dict(reversed(list(documents.items())))) == result,
        'independent_alternative_assignment_oracle': cases == 3072 and bool(oracle_ok),
    }
    assert len(checks) == 18 and all(type(value) is bool and value for value in checks.values()), checks
    return {'python': platform.python_version(), 'dependencies': 'Python standard library only',
            'scope': 'Authored atomic one-hop query alternatives, not linguistic equivalence proof or Elasticsearch/Lucene analyzer/ranking',
            'results': {'query': query, 'authored_rules': rules, 'documents': documents,
                        'evaluation': result, 'incorrect_flat_or_fixture_ids': flat_or_ids,
                        'oracle_cases_inside_one_group': cases,
                        'limits': {'max_query_entries': 4, 'max_rule_keys': 16, 'max_raw_alternatives_per_rule': 3,
                                   'max_expanded_group_members': 8, 'max_documents': 64, 'max_tokens_per_document': 16,
                                   'atomic_term': 'exact str1..24codepoints, no whitespace; no normalization'}}, 'checks': checks}


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

هجده گروه اجرایی، پاسخ دستی، جهت، چرخه، تکرار، پرسش تهی، ورودی نامعتبر و سقف‌ها را بررسی می‌کنند. همهٔ شصت‌وچهار گراف قاعدهٔ جهت‌دار میان سه واژه، هشت زیرمجموعهٔ واژه‌های سند و شش پرسشِ دوواژه‌ای مقایسه می‌شوند: ۳۰۷۲ حالت در یک گروه مرجع. این حالت‌ها یا اجرای مجدد، تعداد گروه‌ها را افزایش نمی‌دهند.

راهنمای SynonymGraphFilter در Lucene برای جایگزین‌های چندواژه‌ای از گراف توکن و ملاحظات موقعیت صحبت می‌کند. برنامهٔ ما موقعیت، چندواژهٔ جایگزین، تحلیلگر Lucene یا Elasticsearch ندارد. جست‌وجوی عبارت با نمایهٔ مکانی نیز مسئلهٔ متفاوتی است؛ حضور اعضای گروه، پشت‌سرهم‌بودن عبارت را ثابت نمی‌کند.

۵. سقف بسط و چهار پرسش پاسخ‌دار را مشخص کنیم

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

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

یک محدودیتِ دیگر را عمداً آزموده‌ایم: اگر دو گروهِ A و B هر دو X را جایگزین مجاز بدانند، سند تک‌واژه‌ایِ X می‌تواند هر دو شرط را برآورده کند. تعداد وقوع مستقل یا دو مفهوم مستقل مطالبه نشده است. این رفتار یادآوری می‌کند که درست‌بودن کد، مناسب‌بودن خودِ قواعد واژگانی را تضمین نمی‌کند.

{
  "python": "3.14.5",
  "dependencies": "Python standard library only",
  "scope": "Authored atomic one-hop query alternatives, not linguistic equivalence proof or Elasticsearch/Lucene analyzer/ranking",
  "results": {
    "query": [
      "حسگر",
      "دما"
    ],
    "authored_rules": {
      "حسگر": [
        "سنسور",
        "حسگر",
        "سنسور"
      ],
      "دما": [
        "temperature"
      ],
      "سنسور": [
        "sensor"
      ],
      "sensor": [
        "حسگر"
      ]
    },
    "documents": {
      "D01": [
        "حسگر",
        "دما"
      ],
      "D02": [
        "سنسور",
        "temperature"
      ],
      "D03": [
        "حسگر"
      ],
      "D04": [
        "temperature"
      ],
      "D05": [
        "sensor",
        "دما"
      ],
      "D06": [
        "موتور",
        "جریان"
      ]
    },
    "evaluation": {
      "groups": [
        [
          "حسگر",
          "سنسور"
        ],
        [
          "دما",
          "temperature"
        ]
      ],
      "matched_ids": [
        "D01",
        "D02"
      ],
      "trace": [
        {
          "document_id": "D01",
          "group_hits": [
            true,
            true
          ],
          "accepted": true
        },
        {
          "document_id": "D02",
          "group_hits": [
            true,
            true
          ],
          "accepted": true
        },
        {
          "document_id": "D03",
          "group_hits": [
            true,
            false
          ],
          "accepted": false
        },
        {
          "document_id": "D04",
          "group_hits": [
            false,
            true
          ],
          "accepted": false
        },
        {
          "document_id": "D05",
          "group_hits": [
            false,
            true
          ],
          "accepted": false
        },
        {
          "document_id": "D06",
          "group_hits": [
            false,
            false
          ],
          "accepted": false
        }
      ]
    },
    "incorrect_flat_or_fixture_ids": [
      "D01",
      "D02",
      "D03",
      "D04",
      "D05"
    ],
    "oracle_cases_inside_one_group": 3072,
    "limits": {
      "max_query_entries": 4,
      "max_rule_keys": 16,
      "max_raw_alternatives_per_rule": 3,
      "max_expanded_group_members": 8,
      "max_documents": 64,
      "max_tokens_per_document": 16,
      "atomic_term": "exact str1..24codepoints, no whitespace; no normalization"
    }
  },
  "checks": {
    "hand_groups_preserve_original_and_deduplicate": true,
    "hand_two_documents_match_both_groups": true,
    "and_between_groups_rejects_partial_documents": true,
    "one_hop_does_not_follow_sensor_chain": true,
    "directed_rule_does_not_create_reverse_rule": true,
    "directed_cycle_does_not_trigger_recursive_expansion": true,
    "flattened_or_has_three_extra_fixture_matches": true,
    "duplicate_query_term_does_not_duplicate_clause": true,
    "unknown_term_is_retained_without_guessing": true,
    "empty_query_is_explicitly_match_none": true,
    "unused_invalid_rule_still_rejects": true,
    "term_type_length_and_whitespace_contract": true,
    "query_rule_and_document_shapes_are_bounded": true,
    "expansion_budget_includes_eight_rejects_nine": true,
    "one_token_can_satisfy_two_overlapping_groups": true,
    "exact_matching_does_not_normalize_case_or_zwnj": true,
    "input_order_and_new_outputs_are_independent": true,
    "independent_alternative_assignment_oracle": true
  }
}

پرسش اول: چرا D02 پذیرفته شد؟ پاسخ: سنسور عضو گروه اول و temperature عضو گروه دوم است؛ هر دو شرط برقرارند، حتی اگر دو واژهٔ اصلی عیناً در سند نباشند.

پرسش دوم: چرا D05 رد شد؟ پاسخ: فقط گروه دما را دارد. رسیدن از حسگر به sensor نیازمند دو قاعدهٔ پیاپی است و بسطِ انتخاب‌شده فقط یک گام دارد.

پرسش سوم: چرا OR تخت، همان پرسش قبلی نیست؟ پاسخ: داشتن تنها یک واژه از همهٔ گزینه‌ها کافی می‌شود؛ در نتیجه D03، D04 و D05 برخلاف قراردادِ دوگروهی اضافه می‌شوند.

پرسش چهارم: با نه عضوِ بسط‌یافته چه می‌شود؟ پاسخ: کل کامپایل با خطا متوقف می‌شود؛ کم‌کردن گزینه‌ها برای گرفتن نتیجهٔ ظاهراً معتبر انجام نمی‌شود. منابع در ۲۴ مهر ۱۴۰۵ خوانده شدند؛ نسخهٔ API مرجع Lucene 10.3.1 جدا ثبت شده است. هیچ موتور یا مدل جست‌وجویی اجرا نشده و این آموزش خبر تازه نیست.

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

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