Главная / Статьи

Гибридный RAG для юридических текстов: разбиение на фрагменты, BM25, BGE и переранжирование

Правовой пайплайн RAG для Конституции Индии: разбиение на родительские и дочерние фрагменты, поиск с использованием BM25 и BGE, объединение рангов, переранжирование с помощью кросс-кодера и расширение запроса, протестировано на тестовых данных.

Издатель: REACTAPP.TOP20 мин чтения4590 слов

Источники: Beyond Basic RAG: Building a Production-Style Legal Research Assistant (Part-I)

Юридический текст представляет собой серьезный вызов для любой системы поиска информации. Возьмем, к примеру, Конституцию Индии: она содержит сотни статей и пунктов, а также дополнения, приложения, поправки, таблицы и сложную сеть внутренних ссылок. Ответ на вопрос может состоять из одного предложения, но его правильное понимание может зависеть от соседней статьи, связанного исключения или другого положения, на которое оно ссылается. Поэтому эффективная система должна находить баланс между точностью и контекстом, находя именно тот фрагмент текста, не отрывая его от общего контекста, который придает ему смысл.

Многие варианты реализации технологии генерации с усилением на основе поиска (RAG) следуют простой схеме: извлекают текст из PDF, делят его на фрагменты фиксированного размера, встраивают их и возвращают наиболее близкие результаты. Это неплохая отправная точка, но производственные системы обычно требуют большего. Им необходимо сохранять структуру документа, поддерживать как точный, так и семантический поиск, переориентировать рейтинг кандидатов, восстанавливать окружающий контекст, отслеживать каждый результат до его источника и определять, действительно ли были получены необходимые доказательства.

В данном тексте описывается исследовательский проект, целью которого является воссоздание подобного рабочего процесса в промышленных условиях в форме, позволяющей анализировать его на каждом этапе. В качестве первого корпуса данных используется Конституция Индии, поскольку её структура выявляет множество слабостей обычных векторных поисковых систем. Это не готовый продукт — это попытка изучить, как взаимодействуют стандартные компоненты пайплайна RAG в промышленных условиях, где возникают сбои, и как оценка может повлиять на последующие решения по проектированию.

1. Прием данных: от юридических PDF к структурированному контенту

Прежде чем можно будет рассматривать разбиение на фрагменты, использование эмбеддингов или механизмов поиска, исходный PDF должен преобразоваться в формат, который программное обеспечение сможет надежно обрабатывать. PDF сохраняет внешний вид документа, но не обязательно способ его чтения программой. Заголовки, пункты, сноски, списки, таблицы и границы страниц могут казаться очевидными для человека, но при базовой обработке остаются в виде разрозненного текста. Потеря такой структуры на этапе ввода данных приводит к тому, что на последующих этапах формируются фрагменты с неправильным порядком чтения, оторванными заголовками или поврежденными таблицами.

В проекте для преобразования Конституции в формат Markdown с сохранением важной информации о макете использовался инструмент MinerU. Он был выбран потому, что, по мнению автора, он более надежно обрабатывал таблицы и списки по сравнению с другими методами извлечения данных. Целью было не просто собрать каждое слово, но и сохранить связи между ними. Извлеченный контент сохранял:

  • как вложены заголовки,
  • где начинается и заканчивается каждый абзац и предложение,
  • списки с шрифтовыми маркерами и номерациями,
  • таблицы,
  • какая страница является источником каждого элемента, и
  • сноски вместе с структурой, их окружающей.

Markdown подходит в качестве промежуточного формата для этой цели. Его можно читать во время ручной проверки, и он содержит структурные маркеры, которые может обработать Python. Заголовок вроде ## Основные права позже может служить ориентиром при создании родительских блоков, в то время как списки и таблицы могут быть распознаны и рассмотрены как отдельные блоки.

Экстракция выполнялась в Google Colab, а не на локальном компьютере. OCR и анализ макета могут потребовать значительных ресурсов, особенно для длинного документа, содержащего множество таблиц и сложного форматирования; ноутбук с ограниченными характеристиками 하드웨어 испытывал трудности с такой нагрузкой. Google Colab предоставляет более подходящую изолированную среду с более мощными вычислительными мощностями, поэтому её стоит рассмотреть на этом этапе, если у вас ограниченные локальные характеристики 하드웨어. Команда, использованная в ноутбуке, была следующей:

!mineru -p c.pdf -o output --dump-content-list -b pipeline -l en

2. Разбиение на части

Поскольку конституция представлена в структурированном формате Markdown, следующим вопросом было то, как её разделить для поиска. Обычным решением является разбиение на фиксированного размера части: разрезание после определённого количества символов или токенов, часто с некоторым перекрытием. Этот метод прост и приемлемо работает для однородного прозаического текста, но юридические документы организованы по смыслу, а не по длине. Фиксированные границы могут:

  • отделять заголовок от текста под ним,
  • разрезать пункт предложения от условия, которое его ограничивает,
  • разделить список пополам между его элементами,
  • разбить таблицу на неполные фрагменты и
  • соединять не связанные между собой элементы лишь потому, что они помещаются в одно окно токенов.

Большие части сохраняют больше контекста, но затрудняют поиск. Меньшие части лучше соответствуют запросу, однако важные окружающие условия могут исчезнуть. Поэтому в проекте была использована двухуровневая стратегия:

Structured document
        ↓
Heading-aware parent chunks
        ↓
Semantic child chunks

Почему два уровня помогают

Поиск осуществляется среди мелких деталей, поскольку их ограниченный объем предоставляет алгоритму BM25 более чистые сигналы для точного поиска. После выбора детали её parent_id указывает на более крупный родительский раздел:

Query
  ↓
Retrieve focused child
  ↓
Read parent_id
  ↓
Return complete parent context

Это делит обязанности на две. Дочерние чанки обеспечивают точность извлечения информации, а родительские чанки — полноту контекста.

Родительские чанки с учетом заголовков

Алгоритм формирования родительских чанков опирается на два элемента, создаваемых во время обработки данных:

data/processed/cr.md
data/extracted/auto/blockpage.json

Файл Markdown содержит очищенный текст и его видимую структуру. Файл blockpage.json хранит упорядоченные блоки макета, сгенерированные во время извлечения, включая информацию о уровнях заголовков. Markdown остается основным источником текста, а JSON служит упорядоченным структурным справочником для соотнесения заголовков Markdown с извлеченным документом. Реализация находится в одном скрипте:

code/chunking_parent.py

Алгоритм работает в фиксированном порядке.

Шаг 1: загрузка обоих файлов. Скрипт считывает отформатированный Markdown, а также JSON-файл с блоками макета:

md_text = MARKDOWN_PATH.read_text(encoding="utf-8")
with BLOCKPAGE_PATH.open("r", encoding="utf-8") as file:
    blockpage = json.load(file)

Использование обоих элементов предотвращает преобразователь в блоки от обработки Markdown как обычного, неструктурированного текста. Markdown предоставляет содержимое, а JSON помогает сохранять порядок заголовков, найденных в процессе извлечения.

Шаг 2: разделить Markdown на блоки. Разделителем служат пустые строки:

for raw_block in md_text.split("\n\n"):

Каждый блок затем классифицируется как один из нескольких типов:

heading
paragraph
list
table
superscript
blank

Текст, начинающийся с #, ## или другого уровня заголовка, например, считается заголовком:

HEADING_RE = re.compile(r"^(#{1,6})\s+")

Списки, таблицы и блоки с верхним индексом имеют свои собственные правила обнаружения, что позволяет сохранять их связь с разделом, к которому они относятся.

Шаг 3: отслеживание активного заголовка. При появлении заголовка его текст добавляется в текущий heading_path. Скрипт также находит соответствующий извлеченный заголовок в blockpage.json, просматривая JSON последовательно и не пересканируя его заново каждый раз:

def find_heading(heading_text: str):
    nonlocal search_pos
    for index in range(search_pos, len(blockpage)):
        block = blockpage[index]        if (
            block.get("type") == "text"
            and block.get("text") == heading_text
            and "text_level" in block
        ):
            search_pos = index + 1
            return block

Режим сканирования только вперед сохраняет заголовки в их первоначальном порядке и предотвращает совпадение с более ранним идентичным заголовком.

Шаг 4: сбор связанного контента. Параграфы, списки, таблицы и подобные блоки добавляются в текущую родительскую стек-структуру. Когда появляется новый заголовок после того, как текст тела уже был собран, текущая родительская структура закрывается, и начинается новая. Концептуально это выглядит так:

Heading
  ├── Paragraph
  ├── Clause
  ├── List
  └── Table
        ↓
     Parent chunk

Шаг 5: присвоение стабильных метаданных. Каждый готовый родительский элемент сохраняется с последовательным идентификатором, идентификатором документа, путем заголовка и полным текстом:

{
    "parent_id": f"parent_{len(chunks):04d}",
    "doc_id": DOC_ID,
    "heading_path": heading_path.copy(),
    "text": "\n\n".join(stack),
}

Упрощённый родитель выглядит так:

{
  "parent_id": "parent_0164",
  "doc_id": "constitution_of_india",
  "heading_path": ["PART XII"],
  "text": "# PART XII\n\nFINANCE, PROPERTY, CONTRACTS AND SUITS..."
}

Одинокий родитель может применять несколько связанных положений, например:

PART XII — Finance
Article 264 — Interpretation
Article 265 — Taxes not to be imposed without authority of law
Article 266 — Consolidated Funds and public accounts
Article 267 — Contingency Fund

В процессе было сгенерировано 333 родительских блока.

Семантические дочерние блоки

Родительские блоки сохраняют контекст целостным, однако некоторые из них слишком велики для точного совпадения, поэтому более крупные из них делятся на меньшие семантические дочерние блоки. Результаты этих работ хранятся в блокноте:

code/semantic-children.ipynb

Короткие тексты-родители, согласно приблизительному порогу в 400 токенов, сохраняются целиком как один элемент, поскольку разделение уже достаточно компактного текста, способного служить целостной единицей, не приносит никакой пользы. Более длинные тексты обрабатываются с помощью инструмента для разбиения на части, который учитывает структуру и сравнивает смысл.

Шаг 1: разделить текст-родитель на блоки. Основной текст делят на заголовки, абзацы, списки и таблицы. HTML-таблицы распознаются с использованием специального шаблона:

table_pattern = re.compile(
    r"(<table[\s\S]*?</table>)",
    re.IGNORECASE
)

Обычный текст разделяется на отдельные блоки с помощью пробелов и переносов строк, а также классифицируется с помощью заголовков и маркеров списков в Markdown, что позволяет обрабатывать прозу, списки и таблицы по-отдельности, вместо того чтобы предполагать, что одна структура подходит для всего.

Шаг 2: привязать заголовки к их содержимому. Заголовок никогда не должен существовать сам по себе как отдельный элемент. Механизм разбиения временно хранит каждый заголовок и добавляет его в начало следующего блока:

if pending_heading:
    block.text = pending_heading + "\n\n" + block.text
    pending_heading = None

Восстановленный элемент сохраняет заголовок, идентифицирующий его содержимое. Последовательные блоки списка также группируются перед семантическим сравнением, что способствует сохранению логического порядка предложений и перечисленных пунктов.

Шаг 3: сравнение соседних абзацев. Для смежных блоков абзацев ноутбук генерирует вкладки BGE:

embedder = SentenceTransformer(
    "BAAI/bge-base-en-v1.5"
)

Затем блокнот вычисляет коэффициент косинусного сходства между каждым абзацем и предыдущим из них:

similarity = cosine_similarity(
    emb1,
    emb2
)[0][0]

Если два блока достаточно похожи по смыслу, они могут оставаться в одном подблоке, а снижение степени сходства указывает на то, что следующий блок открывает новую тему. Настроенный порог составляет:

Similarity threshold: 0.40

Однако сходство — это лишь один из факторов. Также учитываются тип и размер блока.

Шаг 4: применение правил размера и структуры. По умолчанию для семантического разбивателя используются следующие параметры:

Preferred minimum size: 800 characters
Maximum combined size: 1,600 characters
Tiny-block threshold: 150 characters

Блок можно добавить к текущему дочернему элементу, если количество символов в нём всё ещё ниже желаемого минимума, если соседние абзацы достаточно похожи, если один из блоков слишком мал и не должен стоять отдельно, или если последовательные блоки принадлежат к одному и тому же списку или таблице. Общее количество символов в дочернем элементе обычно должно оставаться в пределах 1 600 символов. Таблицу не будут просто прикреплять к обычному тексту лишь для того, чтобы достичь минимального размера:

if (
    current_type == BlockType.TABLE
    and block.type != BlockType.TABLE
) or (
    block.type == BlockType.TABLE
    and current_type != BlockType.TABLE
):
    is_under_min = False

Слишком больший список можно разделить по маркерам элементов, в то время как отдельный крупный блок, не являющийся списком, сохраняется как единая структурная единица и не разрезается произвольно. Результатом является фрагментация, которая не основана ни исключительно на семантике, ни исключительно на размере. Она сочетает в себе оба этих фактора:

Document structure
        +
Semantic similarity
        +
Minimum and maximum sizes

Шаг 5: сохраняйте родительскую ссылку. Каждый сгенерированный дочерний элемент получает свой собственный ID и сохраняет ID своего родителя:

{
    "parent_id": parent["parent_id"],
    "child_id": f"{parent['parent_id']}_child_{index}",
    "doc_id": parent.get("doc_id", ""),
    "heading_path": parent.get("heading_path", []),
    "text": chunk["text"],
    "type": chunk.get("type", "mixed"),
    "embedding": encode_text(chunk["text"])
}

Использование родительского элемента Finance в качестве примера:

parent_0164
│
├── parent_0164_child_1
│   Article 264 and introductory Finance context
│
├── parent_0164_child_2
│   Articles 265 and 266 concerning taxation and public funds
│
└── parent_0164_child_3
    Article 267 concerning the Contingency Fund

Все трое детей сохраняют:

{
  "parent_id": "parent_0164"
}

Хранящееся дитя выглядит так:

{
  "child_id": "parent_0164_child_2",
  "parent_id": "parent_0164",
  "doc_id": "constitution_of_india",
  "heading_path": ["PART XII"],
  "type": "list",
  "text": "265. Taxes not to be imposed save by authority of law...",
  "embedding": [0.012, -0.034, 0.021]
}

В итоге в корпусе содержится 871 дочернего элемента, связанного с 333 родительскими элементами.

3. Гибридный способ поиска: от запроса к контексту родителя

После того как было определено 871 дочернего элемента, возник вопрос о том, как найти подходящие доказательства для запроса пользователя. Поиск осуществляется на уровне дочерних элементов, а не родительских, поскольку короткие фрагменты легче соответствуют конкретной статье, предложению или идее. Более крупные родительские элементы извлекаются только после того, как определяются наиболее значимые дочерние элементы.

Почему одного метода недостаточно

Юридические вопросы могут поступать в разных формах. Запрос вроде «Что говорится в статье 23?» содержит номер статьи, который также присутствует в исходном тексте, поэтому для его обработки хорошо подходит поиск по ключевым словам. Пользователь может задать тот же вопрос иначе: «Может ли государство потребовать от кого-то выполнения обязательных работ?» В этом случае не упоминается статья 23, поэтому системе необходимо связать фразу «потребовать от кого-то выполнения работ» с понятием обязательных работ в общественных целях. Для этих двух случаев требуются разные способы обработки:

  • буквальное совпадение — для номеров статей, юридических фраз, имён и необычных терминов, и
  • сопоставление на основе смысла — когда формулировка вопроса отклоняется от текста.

Вместо выбора система применяет к одному и тому же дочернему корпусу два разных метода поиска: редкий поиск BM25 и плотный поиск BGE.

Редкостная ветвь: BM25

BM25 ранжирует элементы в зависимости от количества слов, общих у запроса и каждого дочернего элемента. Во время индексации пути заголовков и текст дочерних элементов преобразуются в нижний регистр и токенизируются; индекс фиксирует, какие дочерние элементы содержат каждый термин, с какой частотой он встречается и насколько редок в całом корпусе. Важны три фактора: частота появления термина запроса в дочернем элементе, степень редкости этого термина в корпусе и длина дочернего элемента по сравнению со средним значением. Редкие термины имеют больший вес, чем распространенные слова, что делает BM25 особенно эффективным для обработки номеров статей и специфических юридических выражений.

Например, при поиске Статья 23 обязательные службы преимущество явно отдается дочерним элементам, содержащим 23, обязательные и службы. BM25 не нуждается в понимании более широкого смысла, поскольку прямое наличие этих терминов уже является сильным сигналом. Его слабость заключается в том, что релевантный фрагмент может получить низкий рейтинг, если в запросе и в исходном тексте используется разный словарный запас.

Группа с плотным представлением: BGE и FAISS

Группа с плотным представлением осуществляет поиск по смыслу, а не только по совпадению слов. Каждый дочерний элемент представлен путем к его заголовку вместе с его текстом:

embedding_text = heading_path + child_text

Добавление заголовка предоставляет контекст, что помогает связанным фрагментам текста и запросам находиться рядом друг с другом в пространстве эмбеддингов.

Выбор модели эмбеддингов с использованием тестовой базы

В первой версии проекта использовалась модель nomic-ai/nomic-embed-text-v1.5, но автор хотел убедиться, что она наилучшим образом подходит для данного корпуса. В рамках контролируемого теста на глубокий поиск были сравнены четыре варианта: Nomic без префиксов, Nomic с рекомендуемыми префиксами для запросов и документов, BAAI/bge-base-en-v1.5 и intfloat/e5-base-v2.

Каждая модель искала в одном и том же наборе из 871 фиксированного подфрагмента, при этом использовалось 383 вопроса для оценки, автоматически сопоставленные с данными.

  • MRR: начисляет больший балл, когда первый релевантный подфрагмент находится ближе к верху списка.
  • Recall@10 и Recall@20: доля вопросов, у которых есть соответствующий элемент среди первых 10 или 20 результатов.
  • All-recall@10: доля вопросов, у которых есть соответствующий элемент среди всех первых 10 результатов.
  • Mean coverage@10: средний показатель того, какая доля соответствующих элементов попадает в первые 10 результатов по всем вопросам.
  • BGE показал лучшие значения по показателям MRR, Recall@10, Recall@20 и mean coverage@10. Значение MRR выросло с 0,624 у Nomic до 0,652, а Recall@20 — с 0,877 до 0,911. Это имело важное значение, поскольку при последующем слиянии и переранжировании используется ограниченный набор кандидатов, и соответствующий элемент, отсутствующий при первичном поиске, уже невозможно восстановить позже.

    Nomic показал незначительно лучшие результаты по показателю All-recall@10 (0,347 против 0,334), что означает, что он слегка лучше справлялся с поиском всех помеченных деталей при заданиях, включающих несколько деталей. Тем не менее нахождение хотя бы одной значимой детали раньше имело большее значение для восстановления контекста родителя. Задержка обработки запросов была практически одинаковой. Поэтому хранимые эмбеддинги деталей Nomic были заменены на эмбеддинги BGE, при этом существующие семантические границы были сохранены, чтобы изменение эмбеддингов не влияло на ранее принятые решения по разбиению данных.

    Детали представлены в виде обычного текста без каких-либо префиксов с инструкциями. Запросы же получают инструкцию по поиску, рекомендуемую BGE:

    query_text = (
        "Represent this sentence for searching relevant passages: "
        + user_query
    )
    

    В том же модели BGE встроены запросы и дочерние элементы. Векторы нормализуются по стандарту L2, поэтому скалярное произведение ведет себя как косинусная похожесть, причем более высокие значения означают большую степень сходства.

    Индекс FAISS

    Нормализованные дочерние векторы помещаются в иерархический навигируемый индекс малого мира FAISS:

    index = faiss.IndexHNSWFlat(
        embedding_dimension,
        32,
        faiss.METRIC_INNER_PRODUCT,
    )
    
    index.hnsw.efConstruction = 200
    index.hnsw.efSearch = 64
    index.add(embeddings)
    

    HNSW организует эмбеддинги в виде навигируемой графовой структуры, благодаря чему поиск происходит среди перспективных соседних элементов, а не путем сравнения запроса со всеми векторами. Полное сравнение все равно было бы осуществимым для 871 дочернего элемента. Был выбран HNSW для того, чтобы система продолжала функционировать при добавлении в корпус дополнительных законов, правил, решений судов и подобных документов. Дочерние элементы были перэмбеддингованы с помощью code/reembed_children_bge.py, а индекс — пересоздан с помощью code/rebuild_dense_index.py. Материалы по подготовке тестов, определения моделей, запросы и результаты находятся в папке code/dense_benchmark/v2/.

    Слияние двух рейтингов

    BM25 и метод плотного поиска генерируют отдельные списки с рангированием. Документ может занимать высокое место в обоих списках, сильно выделяться только в одном или вообще отсутствовать в одном из них. Чистые оценки нельзя просто сложить, поскольку оценка по BM25 и векторное сходство имеют разные масштабы. Эти два списка с рангированием объединяются с помощью метода Reciprocal Rank Fusion:

    RRF score = Σ 1 / (k + rank + 1)
    

    В этой системе используется значение k = 60. Каждый участник набирает очки в зависимости от своего места в каждом списке, а не от первоначального балла. Участник, занимающий высокие позиции в обоих списках, получает вклады от обоих и обычно поднимается выше, тогда как тот, кто сильен только в одном списке, может оставаться в списке кандидатов. Упрощённый этап слияния:

    sparse_results = bm25_search(query)
    dense_results = dense_search(query)
    
    fused_candidates = reciprocal_rank_fusion(
        sparse_results,
        dense_results,
    )
    

    RRF создает широкий пул кандидатов. Это повышает вероятность сохранения полезных доказательств, но он не сравнивает всё запросы с каждым отрывком подробно. Это задача реранкера.

    Повторная ранжирование с использованием кросс-кодера

    Плотный поиск дешев, поскольку запрос и его составляющие кодируются отдельно, так что эмбеддинги составляющих могут быть вычислены один раз и использованы для каждого запроса. Однако независимая кодировка ограничивает степень, в которой модель может учитывать соотношение слов между собой. Кросс-кодер использует другой подход и принимает одновременно запрос и кандидатские составляющие:

    pairs = [
        (query, child_text),
        ...
    ]
    
    scores = reranker.predict(pairs)
    

    Поскольку запрос и текст поступают как единый ввод, модель может напрямую анализировать взаимосвязь их токенов, что обычно позволяет дать более точную оценку релевантности по сравнению с методами BM25 или плотного поиска.

    candidates = fused_candidates[:30]
    reranked_children = cross_encoder.rank(query, candidates)
    

    Система переранжирования снова оценивает каждый элемент и заново сортирует кандидатов. Ранее определенные позиции BM25, dense и RRF лишь формируют список возможных вариантов, а окончательный порядок определяется оценкой кросс-кодера. Затем система рассматривает первые 20 элементов после переранжирования и сохраняет те, у которых оценка составляет не менее 0,30:

    selected_children = [
        child
        for child in reranked_children[:20]
        if child.score >= 0.30
    ]
    

    Этот порог представляет собой политику фильтрации для конкретной модели и пайплайна. Его не следует рассматривать как универсальную вероятность релевантности, а смена инструментов переранкинга потребует его повторной оценки и калибровки. С таблицами необходимо обращаться особенно осторожно, поскольку некоторые из них превышают лимит в 512 токенов для входных данных инструмента переранкинга. Перед токенизацией рабочий процесс отмечает слишком длинные пары таблиц, чтобы они не вызывали ошибок индексации и не исчезали незаметно.

    Включение дочерних элементов в контекст родителя

    Инструмент переранкинга продолжает обрабатывать дочерние элементы, поскольку они служат единицами для целенаправленного сравнения. Однако возврат только этих дочерних элементов может привести к исключению окружающих предложений, условий, исключений или связанных статей. Поэтому каждый дочерний элемент сохраняет значение parent_id, присвоенное во время разбиения на чанки:

    {
      "child_id": "parent_0082_child_1",
      "parent_id": "parent_0082",
      "text": "23. Prohibition of traffic in human beings and forced labour..."
    }
    

    После завершения фильтрации по пороговому значению система берет parent_id у каждого выбранного дочернего элемента и находит соответствующий родительский элемент в коллекции parent-chunk:

    parent_id = selected_child["parent_id"]
    parent = parent_lookup[parent_id]
    

    Несколько выбранных детей могут иметь одного родителя, и его повторное включение привело бы к дублированию контекста, поэтому дети группируются по parent_id. Каждый уникальный родитель выводится один раз вместе с детьми, приведшими к нему, причем оценка самого лучшего ребенка становится оценкой этого источника-родителя:

    Selected child 1 ──┐
    Selected child 2 ──┼── parent_0082
    Selected child 3 ──┘
    

    Маленький элемент, содержащий соответствующий язык статьи 23, может привлечь parent_0082, чей родительский элемент затем предоставляет более полный раздел «Право на защиту от эксплуатации», включая сопутствующие положения и связанный контент статьи 24. Это сохраняет разделение обязанностей: элементы-дети определяют, какие доказательства являются релевантными, а элементы-родители предоставляют контекст, в котором они присутствуют.

    Возвращаемый родительский источник также сохраняет свой идентификатор документа и путь заголовка, идентификаторы соответствующих детских элементов, их типы блоков и оценки ранжирования, так что возвращаемый источник можно проследить через всю систему, чтобы понять, почему он был выбран. Эксперимент с гибридным поиском находится в файле code/hybridrag.ipynb.

    4. Расширение запроса и многократные вопросы: преодоление пробела в словарном запасе

    Гибридная система хорошо справлялась с задачами, когда формулировки пользователя полностью соответствовали тексту Конституции. Прямой вопрос вроде «Что такое статья 21?» содержит точную ссылку, что даёт алгоритмам BM25 и методам интенсивного поиска сильный сигнал.

    Концептуальные вопросы оказались сложнее. Пользователи часто описывают тот или иной принцип с помощью терминов из судебных решений, учебников или юридических комментариев, а не с использованием собственного языка Конституции. Вопрос о «доктрине основной структуры» может потребовать учёта статей 13 и 368, хотя сам термин «основная структура» в них не встречается. Другими словами, у пользователя правильная юридическая идея, но документы называют её иначе.

    Результаты оценки

    Проблема тестировалась в ноутбуке Kaggle, содержащем прямые вопросы и более сложный набор концептуальных вопросов, соответствующих определенным статьям конституции. Эксперимент был проведен до миграции на BGE. В то время 871 ребенок использовал эмбеддинги из модели nomic-ai/nomic-embed-text-v1.5, а запросы кодировались с помощью той же модели без использования префиксов для поиска. Этот факт важен, поскольку целью эксперимента было выяснение влияния перефразирования запроса, а не сравнение моделей эмбеддингов; поэтому один и тот же гибридный процесс на основе Nomic обрабатывал обе версии каждого концептуального вопроса.

    Вопрос считается пройденным только в том случае, если полученный родительский контекст содержал все связанные с ним целевые статьи, поэтому указанный процент отражает полный охват целевых данных, а не фактическую точность ответа, написанного ИИ. Показательным является сравнение двух концептуальных наборов. Благодаря расширению запроса уровень успешного поиска вырос с 56,0% до 74,5%, что соответствует приросту на 18,5 процентных пунктов. Был восстановлен полный набор целевых данных для ещё 37 вопросов, а количество неудач сократилось с 88 до 51.

    Результаты показали, что многие неудачи были вызваны не отсутствием документов или некорректными границами фрагментов. Данные существовали в корпусе, но первоначальный запрос не предоставлял поисковой системе необходимого словарного запаса для их нахождения.

    Расширение без изменения цели

    Расширенная версия формулирует концептуальный вопрос словами, которые чаще встречаются в исходном тексте. Предположим, вопрос заключается в том, что мешает парламенту изменить сущностную сущность конституции. В расширенной форме могут быть упомянуты доктрина основной структуры — ядро конституции, которое не может быть изменено, законы, противоречащие основным правам, статья 13, а также полномочия по внесению изменений, предусмотренные статьей 368. То, что хочет получить пользователь, не изменилось, но более длинный текст добавляет сигналы для поиска, такие как:

    • общее название доктрины,
    • связанная конституционная терминология,
    • альтернативные формулировки той же идеи,
    • соответствующая институциональная или структурная терминология, и
    • четкие номера статей, когда их можно с уверенностью определить.

    В рамках одного и того же исследования рассматривались такие аспекты, как возможность разделения элементов, суть и существенная часть информации, судебный активизм, а также взаимосвязь основных прав с директивными принципами.

    В экспериментах на Kaggle расширенные вопросы готовились заранее, причем указывались даже вероятные статьи, поэтому их следует рассматривать как эксперимент по контролируемой переформулировке запросов, а не как оценку автоматической модели расширения. Это показывает, насколько может улучшиться процесс поиска, когда запрос содержит язык, соответствующий исходному тексту, однако для реального планировщика запросов потребуется отдельная оценка без доступа к целевым меткам. В более позднем тщательном тестировании Nomic заменили на BGE, поскольку BGE показал лучшие результаты на фиксированном наборе данных, тем не менее результаты Kaggle всё равно повлияли на дизайн: лучшая модель вкладки улучшала семантический поиск, в то время как расширение запросов решало другую проблему — разницу в словарном запасе между пользователем и документом.

    Расширение запросов против подзапросов

    Расширение помогает тогда, когда одна информационная потребность сформулирована незнакомыми или косвенными выражениями, что позволяет создать более полный запрос. Вопросы с несколькими этапами поиска отличаются тем, что в них содержится несколько информационных потребностей, которые могут относиться к разным частям документа. Возьмем, к примеру, запрос на сравнение мер, предусмотренных статьями 32 и 226. Если искать их как один длинный запрос, сигналы будут конкурировать: отличный результат по статье 32 может занять всю список кандидатов, в то время как статья 226 получит слишком мало внимания, и переранжирование объединенных кандидатов не гарантирует, что обе стороны останутся в списке.

    Вместо этого вопрос с несколькими этапами можно разделить на более узкие подзапросы:

    1. Какие меры и полномочия предусмотрены статьей 32?
    2. Какие полномочия и юрисдикция предусмотрены статьей 226?
    3. В чем конституционные различия между статьями 32 и 226?

    Каждый подзапрос проходит через весь процесс поиска (BM25 и плотный поиск, затем слияние рангов, затем переранжирование с использованием кросс-кодера, затем расширение родительских элементов), а полученные родительские элементы объединяются и дедуплицируются в единые доказательства. Это предотвращает использование ресурсов, предназначенных для одной части сложного вопроса, другой частью, а также облегчает анализ результатов поиска, поскольку каждая ветвь соответствует конкретной потребности.

    Результативная стратегия маршрутизации

    Оценка показала, что ни одна форма преобразования запроса не подходит для всех вопросов. Прямые вопросы уже содержат сильные сигналы и должны оставаться в своем первоначальном виде. Концептуальные вопросы выигрывают от расширения словарного запаса, а многократные вопросы требуют разделения, чтобы каждое требование имело собственную ветвь:

    • Прямые фактологические или специфичные для статьи вопросы: выполнять поиск с исходным запросом.
  • Концептуальные вопросы с несоответствием словарного запаса: преобразовать в один запрос, соответствующий исходному тексту.
  • Многократные, сравнительные или многостатьевые вопросы: разделить на специализированные подзапросы и обработать каждый из них отдельно.
  • Такой подход позволяет избегать ненужных изменений простых запросов, одновременно предоставляя сложным запросам необходимую структуру. Исходная оценка в Kaggle реализована в файле code/hybrid_eval.ipynb, а последующие эксперименты по расширению и разветвлению — в файле code/hybridrag.ipynb.

    Уроки, полученные в ходе проекта

    Основной урок заключается в том, что более качественные решения с использованием технологии RAG редко зависят от одной идеальной модели. Качество извлечения информации обусловлено согласованными решениями по экстракции данных, их разбиению на части, использованию эмбеддингов, комбинированию кандидатов, переориентации рейтингов и восстановлению контекста. Процесс оценки привел к изменению нескольких подходов. Вместо теста Nomic был применён тест BGE при сохранении семантических границ; эксперимент на Kaggle показал, что концептуальные вопросы лучше решаются с использованием терминологии, близкой к исходной; а сбои при многократных запросах указали на необходимость использования целенаправленных подзапросов и независимых каналов извлечения информации.

    Естественными следующими шагами являются оформление всей системы в виде приложения-чатбота с структурированным бэкендом и фронтендом для задавания вопросов и анализа источников, затем упаковка этого приложения с помощью Docker и развертывание его на облачной платформе, где возникают вопросы, связанные с загрузкой моделей, использованием памяти, задержками, постоянными индексами, логированием, масштабированием и затратами. Система по-прежнему находится в стадии развития, и некоторые проблемы остаются нерешенными, что и является целью данного эксперимента: обеспечить возможность проверки каждого этапа, выявить места потери данных и использовать результаты оценки для дальнейшего совершенствования.