Гібридний RAG для юридичних текстів: чанкінг, BM25, BGE та переранжування
Юридичний підхід RAG до Конституції Індії: розділення на батьківські та дочірні частини, пошук за допомогою BM25 та BGE, об’єднання рангів, переранжування за допомогою крос-енкодера та розширення запиту, протестовано на тестових даних.
Джерела: Beyond Basic RAG: Building a Production-Style Legal Research Assistant (Part-I)
Юридичний текст є складним випробуванням для будь-якої системи пошуку інформації. Візьмемо, наприклад, Конституцію Індії: вона містить сотні статей та пунктів, а також додатки, поправки, таблиці та мережу взаємних посилань. Уривок, який відповідає на запитання, може складатися з одного речення, проте його правильне розуміння може залежати від сусідньої статті, відповідного винятку чи іншої норми, на яку він посилається. Тому корисна система має знаходити баланс між точністю та контекстом, знаходячи саме той уривок, не відокремлюючи його від тексту, який надає йому значення.
Багато початкових рішень для генерації з підсиленням за допомогою пошуку (RAG) слідують простому алгоритму: витягувати текст з PDF, розділяти його на фрагменти фіксованого розміру, вбудовувати їх та повертати найбільш відповідні результати. Це гарна вихідна точка, але у продакшн-системах зазвичай потрібно більше. Вони мають зберігати структуру документа, підтримувати як точний, так і семантичний пошук, переорієнтувати рейтинг кандидатів, відновлювати навколишній контекст, відстежувати кожен результат до його джерела та перевіряти, чи справді були знайдені необхідні докази.
У цьому описі розглядається дослідницький проєкт, який відтворює такий робочий процес у стилі виробництва у формі, яку можна перевіряти етап за етапом, використовуючи Конституцію Індії як перший корпус даних, оскільки її структура виявляє багато слабкостей звичайного векторного пошуку. Це не є готовим продуктом. Це спроба дослідити, як зазвичай використовувані компоненти пайплайну RAG поєднуються між собою, де вони зламуються та як оцінка може вплинути на наступне рішення щодо проектування.
1. Прийом даних: від юридичного PDF до структурованого контенту
Перш ніж можна буде розглядати процеси чанкінгу, використання ембеддингів чи пошуку інформації, вихідний PDF має перетворитися на контент, який програмне забезпечення може надійно обробляти. PDF фіксує зовнішній вигляд документа, а не обов’язково спосіб, яким програма має його читати. Заголовки, речення, примітки, списки, таблиці та межі сторінок можуть здаватися очевидними для людини, проте під час базової екстракції вони перетворюються на розривчастий текст. Якщо втратити цю структуру на етапі обробки, на наступних етапах утворюватимуться чанки з неправильним порядком читання, відокремленими заголовками чи пошкодженими таблицями.
У проекті використовувався MinerU для перетворення Конституції у формат Markdown, зберігаючи при цьому важливу інформацію про форматування. Його обрали тому, що, за словами автора, він більш надійно обробляв таблиці та списки порівняно з іншими методами екстракції. Метою було не просто зібрати кожне слово, а й зберегти взаємозв’язки між ними. Екстрагований контент зберігав:
- як вкладаються заголовки,
- де починається та закінчується кожен абзац та речення,
- списки з пунктами та номеровані списки,
- таблиці,
- яка сторінка є джерелом кожного елемента, та
- примітки разом із структурою навколо них.
Markdown підходить для цієї ролі як проміжний формат. Його можна читати під час ручної перевірки, і він містить структурні позначки, які може обробити Python. Заголовок на кшталт ## Основні права може згодом допомогти у створенні батьківських блоків, тоді як списки та таблиці можна розпізнати та обробити як окремі блоки.
Екстракція виконувалась у Google Colab, а не на локальному комп’ютері. OCR та аналіз форматування можуть бути ресурсоємними, особливо для довгого документа, наповненого таблицями та складним форматуванням, і ноутбук із обмеженими характеристиками апаратного забезпечення мав труднощі з виконанням цих завдань. 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 ранжує записи за кількістю слів, які запит має спільних з кожною дочірньою гілкою. Під час створення індексу шляхи до розділів та текст дочірніх елементів перетворюються на маленькі літери та розбиваються на токени; індекс фіксує, які дочірні елементи містять кожен термін, з якою частотою він зустрічається та наскільки є рідкісним у всьому корпусі. Важливими є три фактори: частота зустрічання терміну запиту в дочірньому елементі, ступінь рідкісності цього терміна в корпусі та довжина дочірнього елемента у порівнянні з середнім значенням. Рідкісні терміни мають більше значення, ніж поширені слова, що робить BM25 особливо ефективним для обробки номерів статей та конкретних юридичних виразів.
Наприклад, пошук за Article 23 compulsory service сильно фаворизує дочірні елементи, які містять 23, compulsory та service. 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: надає більше балів, коли перша релевантна підчастина знаходиться ближче до верху.
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 Hierarchical Navigable Small World:
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 отримає занадто мало уваги, і переранжування об’єднаних кандидатів не гарантує, що обидві сторони залишаться у списку.
Натомість багатоетапне запитання можна розділити на спрямовані підзапитання:
- Які заходи та юрисдикція передбачені статтею 32?
- Які повноваження та юрисдикція передбачені статтею 226?
- У чому конституційні відмінності між статтями 32 та 226?
Кожен підзапит проходить усю процедуру пошуку (BM25 та щільний пошук, потім об’єднання рейтингів, потім переранжування за допомогою крос-енкодера, потім розширення батьківських елементів), а отримані батьківські елементи об’єднуються та усувають дублікати, щоб створити об’єднані докази. Це запобігає ситуації, коли одна частина складного запиту використовує ресурси, призначені для іншої, та полегшує перевірку результатів пошуку, оскільки кожна гілка відповідає конкретній потребі.
Отримана стратегія маршрутизації
Оцінка показала, що жодна окрема трансформація запиту не підходить для кожного запитання. Прямі запитання вже містять сильні сигнали та повинні залишатися такими, як є. Концептуальні запитання виграють від розширення словникового запасу, а багатоетапні запитання потребують розділення, щоб кожна вимога мала свою окрему гілку:
- Прямі фактологічні чи специфічні запитання до статей: пошук з використанням оригінального запиту.
Цей механізм маршрутизації уникає зайвої зміни простих запитів, водночас надаючи складним запитам необхідну структуру. Початкова оцінка в Kaggle реалізована у файлі code/hybrid_eval.ipynb, а подальші експерименти з розширенням та розділенням запитів — у файлі code/hybridrag.ipynb.
Уроки з проекту
Основний урок полягає у тому, що кращі рішення типу RAG рідко залежать від однієї ідеальної моделі. Якість отримання даних залежить від послідовних рішень щодо екстракції, часткового розбиття інформації, створення ембеддингів, поєднання кандидатських варіантів, переурочення пріоритетів та відновлення контексту. Процес оцінювання змусив змінити кілька підходів. У щільних тестах замість Nomic використовується BGE, при цьому зберігаються семантичні межі; експерименти на Kaggle показали, що концептуальні запитання краще обробляються за допомогою термінології, близької до оригіналу; а проблеми, пов’язані з кількома етапами обробки, вказують на необхідність використання спеціалізованих підзапитів та окремих каналів отримання даних.
Природними наступними кроками є обгортання цього конвеєра у додаток-чатбот із структурованим бекендом та фронтендом для постановки запитань та перевірки джерел, потім пакування його за допомогою Docker та розгортання на хмарній платформі, де завантаження моделей, використання пам’яті, затримки, постійні індекси, журналування, масштабування та витрати стають проблемами. Система все ще розвивається, і деякі проблеми залишаються нерозв’язаними, що і є метою цього завдання: забезпечити можливість перевірки кожного етапу, знайти місця, де втрачаються дані, та дозволити оцінкам спрямовувати подальші вдосконалення.