Пайплайн переносится на русский примерно наполовину. Архитектура — постраничный ранкинг, гибридный поиск, реранкер, структурированный вывод — переезжает без единой правки. Ломается всё, что касается языка: токенизация номеров законов, BM25-слой, обязательность OCR и границы чанков. Ниже — что именно, с цифрами со стенда.
Откуда данные
Мы участвовали в соревновании по RAG на корпусе права Дубайского международного финансового центра (DIFC) на платформе agentic-challenge.ai. Корпус английский, около 590 страниц.
Результат — 24 место из 355 команд, финальный скор 0.578. Не победа, но верхние 7% — и, что важнее для этой статьи, вся наша специализация до этого была русскоязычной. Именно поэтому перенос обратно на российское право оказался таким показательным: мы точно знали, как пайплайн ведёт себя на английском, и могли отделить языковые проблемы от архитектурных.
Задача: на входе вопрос по корпусу, на выходе JSON с ответом, флагом «можно ли ответить» и списком страниц-источников. Два класса вопросов — deterministic (boolean, число, имя, дата; сверяются exact match) и free_text (что решил суд, в чём суть нормы).
Метрика составная, и её устройство определило стратегию:
Total = S × G × T × F
S = точность ответа (детерминированные + свободный текст)
G = F-beta, β = 2.5, по страницам (grounding)
T = наличие телеметрии
F = коэффициент за скорость первого токена
β = 2.5 означает, что recall по страницам весит сильно больше precision. Добавить лишнюю страницу почти бесплатно, потерять правильную — дорого. Дальше мы это использовали везде.
Стек на соревновании: text-embedding-3-large (1024d), rank_bm25, FAISS IndexFlatIP, реранкер BAAI/bge-reranker-v2-m3, Claude Sonnet 4 на deterministic и Gemini Flash / GPT-4.1 на free_text.
После соревнования мы перенесли этот пайплайн на российское право — уже как продукт.
Что перенеслось без единой правки
Это скучная, но важная часть: архитектурные решения оказались языконезависимыми.
- Постраничный ранкинг. Одна страница PDF — один чанк. Основа точного цитирования: модель ссылается на страницу, которую можно открыть и проверить.
- Гибридный поиск dense + BM25 со слиянием через RRF.
- Реранкер поверх объединённой выдачи, а не поверх каждой ветки отдельно.
- Структурированный вывод через function calling вместо парсинга JSON из текста.
- Детерминированный постпроцессинг вместо надежды на промпт.
- Мета-индекс для прямого lookup по номеру статьи или дела.
- Пиннинг конкретного провайдера модели после успешного прогона.
Что сломалось на русском
Токенизация — главная неочевидная проблема
Стандартный токенайзер режет «44-ФЗ» на «44» и «фз». После этого BM25 плывёт: запрос «что в 44-ФЗ про закупки» матчит любой документ, где встречается число 44.
В английском аналога нет — «Section 44» так не разваливается, потому что номер и слово изначально разделены пробелом и не образуют единого токена-идентификатора.
Решение: детектим в запросе номер закона регуляркой и ставим точный keyword-фильтр по полю law_number, а не надеемся на BM25. Две оговорки, без которых оно ломается:
- фильтр ставится только при однозначности. «Сравни 44-ФЗ и 223-ФЗ» — не ставится;
- если фильтр дал ноль результатов — повтор без него. Иначе recall проседает на опечатках и редких номерах.
BM25-слой переписан целиком
rank_bm25 на английском → Elasticsearch с собственным русским анализатором. Что в нём:
- char-фильтр ё → е. В законах вперемешку «зачётный» и «зачетный»; без нормализации это разные токены, и половина запросов по «рассрочкам» не находит «рассрочек».
- Snowball russian stemmer.
- Кастомный стоп-лист = стандартный русский минус
не,без,до,после. Это не косметика: удаление этих слов меняет правовой смысл нормы, а стандартный список их выбрасывает. - Синонимы только на search-time:
гк → гражданский кодекс,ооо → общество с ограниченной ответственностью,ст → статья. Не раздувают индекс и правятся без переиндексации.
OCR стал обязательным
Русские сканы — решения Верховного суда — без OCR просто выпадали из индекса. Не «искались хуже», а отсутствовали.
Ввели инвариант: ноль нормативных документов с нулём чанков, и проверяем его автоматическим аудитом корпуса. Включение OCR вернуло 19 решений ВС, которых в индексе не было вообще.
Типология источников
Российская правовая система различает авторитетные источники (НПА) и рекомендательные (практика, доктрина). В DIFC прямого аналога не было.
Из этого выросла квотированная выдача: два независимых поиска с раздельными лимитами. Без квот доктрина вытесняет законы из топа — её тексты многословнее и лексически ближе к формулировке вопроса.
Границы чанков, а не их размер
Ожидаемый вопрос — «какой оптимальный размер чанка для русского». У нас на него нет ответа: мы режем не по размеру, а по странице.
Русская специфика оказалась в границах:
- разрыв страницы в PDF — физический артефакт, он регулярно попадает в середину предложения. Чанк дотягивается в начало следующей страницы до первой настоящей точки, чтобы не эмбеддить обрубок вида
«…при условии, что сми»; - список сокращений, после которых точка не является концом предложения (
ст.,п.,г.,руб.), пришлось собирать под юридический корпус отдельно; - статья кодекса регулярно разорвана между страницами — отсюда постатейная сборка поверх страничного индекса.
Единственный измеренный рычаг, связанный с объёмом: бюджет контекста 10 000 → 14 000 токенов дал +3.4 п.п. попадания нормы в контекст.
Три вещи, которые сработали лучше ожидаемого
Постатейная сборка (small-to-big). Ищем по страницам, а в модель отдаём целую статью, а не обрубок страницы. Целостность статьи выросла с 0.15 до 0.891 на тестовой выборке и с 0.29 до 0.882 на отложенной. Попадание в цель при этом не просело, а слегка выросло: 0.949 → 0.974.
Честный отказ при слабых источниках. Один порог по скору топ-результата, откалиброванный по Юдену. Доля корректных отказов на негативных вопросах выросла с 0.09 до 0.73 на тесте и с 0.17 до 0.67 на отложенной — при этом ни один позитивный вопрос не начал отказываться. Соотношение цены и эффекта лучшее из всего, что мы делали: это изменение одной константы в конфиге.
Нормализация ё → е. Двухстрочный char-фильтр.
Что не сработало, хотя по статьям должно было
Постатейная сборка «в лоб» активно вредила
Три подхода подряд, все на тестовой выборке при базовой линии 0.949:
| Подход | Попадание нормы в контекст | |---|---| | Заменить страницы на статьи глобально | 0.526 | | Только для кодексов, с откатом на страницы | 0.795 | | Топ-1 статья | 0.641 |
Мы честно записали «small-to-big не работает» и пошли дальше.
Оба вывода оказались артефактами наших собственных ошибок.
Первая: мерили не ту метрику. Попадание в контекст слепо к целостности статьи — оно не видит, что статья подана рваной.
Вторая, и она интереснее: разворачивали статьи по списку элементов из парсера, а он смешивает страницы тела статьи со страницами, которые её просто цитируют. У ст. 395 ГК в элементах числились страницы 223, 342, 375, 440 — а заголовок статьи только на 223. То есть прототип раскрывал не те статьи.
Чистый детект по заголовкам «Статья N» в самом тексте плюс новая метрика целостности перевернули вывод на противоположный.
Мораль, ради которой стоило это писать: отрицательный результат в RAG почти всегда надо сначала проверить на дефект метрики, а уже потом ему верить.
Структурные фиксы парсера оказались нейтральны для поиска
Починили извлечение заголовков, вычистили редакционный шум, поправили классификацию. Ожидали роста качества поиска. Получили 0.932 → 0.915 — в пределах шума и скорее вниз.
Причина: цели находятся по телу статьи, а не по заголовку. Ценность фиксов реальная, но в другом месте — корректные данные в интерфейсе и в цитатах плюс фундамент для постатейной сборки.
Хороший антипример рассуждения «улучшили данные — должно улучшиться качество».
Глобальный порог не разделяет коллизии, и это доказуемо
Есть класс негативных вопросов про несуществующие в корпусе законы, номер которых совпадает с существующим. Распределения скоров у них инвертированы: у коллизии 0.927 против медианы 0.926 у настоящих попаданий.
Любой порог, который отсекает коллизию, убивает recall. Это не «надо подобрать порог получше» — это «порогом в принципе не решается». Нужен отдельный entity-gate по номеру и токенам названия.
Сложный промпт ухудшил результат
XML-теги, workflow, self-check: 0.805 у простого промпта против 0.775 у сложного.
ColBERT не дал ничего
На 590 страницах результаты идентичны FAISS + BGE. На малом корпусе любой нормальный ретривер находит нужное.
Провайдерская маршрутизация эмбеддингов ненадёжна
OpenRouter не сортирует эмбеддинги по реальной латентности: sort:latency залипает на одном провайдере, order не соблюдается при включённых fallbacks. Плюс провайдеры деградируют за минуты — у нас Nebius прошёл путь 45 мс → HTTP 403 примерно за 10 минут.
Пришлось самим пинговать кандидатов однотокенным запросом и пинить быстрейшего живого, с перепроверкой по таймеру и мгновенно при отказе. Латентность запроса: 3994 мс → 707 мс.
Почему у нас нет графа
Ожидаемый вопрос, раз речь о праве: где Graph RAG.
Его нет, и это осознанно. Класс задач, где граф выигрывает, узкий и реальный — отсылочные и бланкетные нормы:
Запрос «ответственность поставщика за недопоставку» — поиск находит ст. 511 ГК (восполнение недопоставленного). Но сама статья говорит: покупатель вправе отказаться в порядке ст. 523. Без ст. 523 ответ неполный, а по векторной близости она в топ не попадёт — там другая лексика.
Ключевое: бо́льшую часть этого эффекта берёт одношаговое раскрытие ссылок, а не граф. Цель ссылки хранится в том же формате, что и список элементов чанка, поэтому раскрытие — это скалярный фильтр в векторной базе. Без графовой СУБД, без обхода и без переэмбеддинга: метаданные обновляются на месте. Реранкер потом сам отсеет нерелевантное.
Полноценный граф нужен там, где нужен обратный и многошаговый обход: «какие статьи ссылаются на ст. 15», цепочки ст. 506 → ст. 523 → ст. 450. Сценарии реальные, но редкие.
И то, о чём обычно не думают: граф надо пересобирать при каждом обновлении корпуса, а корпус права меняется постоянно. Это не разовая стройка, а постоянная эксплуатация.
Как это мерилось
Без этого раздела все цифры выше — разговор.
Стенд: корпус 109 документов / 9082 чанка. Эталонный набор 122 вопроса: 105 с ответом и 17 негативных. Разбит на тюнинговую выборку 89 и замороженную отложенную 33. Все решения валидировались на отложенной, а не на той, по которой крутили ручки.
Дневник из 15 записей с точками отката — каждое решение откатывается одной командой. Версионирование окупилось: одну версию мы отреверсили как чистый размен.
Что означают метрики:
- попадание нормы в контекст — дошёл ли дословный текст правильной нормы до модели. Отличается от recall@k: цель может быть найдена поиском, но срезана бюджетом контекста, и recall этого не покажет;
- целостность статьи — доля случаев, когда модель получила статью целиком, а не фрагмент;
- корректные отказы на негативах — доля вопросов без ответа в корпусе, где система честно отказалась вместо правдоподобной выдумки.
Что бы делали заново
Порядок, в котором стоит собирать такую систему. Граф — не первая фаза, а последняя:
- корректная доменная обработка документов и постатейная сборка;
- гибридный поиск с квотами по типу источника плюс реранкинг;
- проверка цитат после генерации;
- стенд с эталонным набором и замороженной отложенной выборкой — до любых оптимизаций. Иначе не отличить улучшение от шума, мы это прошли на своей шкуре;
- одношаговое раскрытие ссылок скалярным фильтром;
- и только если пятое упрётся в потолок — граф. Хранилище наращивать по порядку: рекурсивные запросы в PostgreSQL → NetworkX в памяти → отдельная графовая СУБД только на масштабе от миллиона рёбер.