1 сентября я вёл вебинар о том, как научить агента читать документацию к внутреннему фреймворку. Демо было про код: нишевый Java-фреймворк, библиотека без исходников, AGENTS.md рядом с проектом.

А первый вопрос из зала был не про код. Системный аналитик собирает систему, которая отвечает по корпусу приказов ИБ, ГОСТов и спецификаций. Ему нужен ответ со ссылкой на пункт. Структуру документа трогать нельзя: документ юридически значим.

В чате возразили. К концу эфира развилась дискуссия.

В коде у агента есть арбитр - это компилятор. Выдумал метод — сборка красная. Ловится не всё: на том же вебинаре у участника сборка прошла, а экран упал в рантайме. Но арбитр есть.

У нормативки арбитра нет. Пересказ несуществующего пункта выглядит так же, как правильный ответ. Как построить себе такого арбитра и поиск под него?


Чем нормативка отличается от документации к коду

Требование

Почему

Что это значит для поиска

Оригинал без изменений

юридическая значимость

агенту идёт исходный текст, не выжимка

Реквизиты: документ, редакция, статья, часть, пункт

ссылка ведёт в точку

реквизиты лежат у каждого фрагмента; модель их не восстанавливает

Редакции

акт меняется, а в весах модели и в старом индексе — прежняя редакция

дата редакции живёт в самом узле

Морфология русского

«оператора персональных данных» и «оператор» — разные строки

поиск понимает словоформы

Таблицы и приложения

меры часто оформлены матрицей «мера × уровень»

таблицу нельзя резать как текст

Перекрёстные ссылки

«в соответствии с частью 3 статьи 19…»

ответ требует двух документов сразу

Отказ

ответа в корпусе может не быть

«не нашёл» — тоже правильный ответ

Закрытый контур и аудит

корпус и вопросы — сами по себе данные ИБ

поиск локальный, запросы журналируются

Затраты

индексация и ответ стоят токенов

подготовка один раз, ответ дёшево

Пример про редакцию. Государственные информационные системы много лет защищали по приказу ФСТЭК № 17. С 1 марта 2026 года его заменил приказ ФСТЭК России от 11.04.2025 № 117, Минюст зарегистрировал его 16.06.2025 под № 82619 — официальная публикация. Отменил он пять позиций: приказы № 17, № 27, № 106, № 61 и пункт 1 изменений из приказа № 159. Аттестаты, выданные до 1 марта 2026 года, «считаются действительными» — так в пункте 3. С 1 сентября 2026 года Требования действуют уже в редакции приказа № 137 от 08.05.2026. Цепочка редакций не кончается. За ней и должен следить индекс.

Теперь о пересказах. По обзорам ходят оговорки: аттестат действует «до истечения срока» и «при неизменности конфигурации». В тексте приказа их нет, они из вторичных источников. Настоящее ограничение лежит в другом документе: информационное сообщение ФСТЭК от 12.03.2026 № 240/22/1492 требует дополнительных аттестационных испытаний после модернизации системы.

Модель, обученная на текстах до 2025 года, ответит «по приказу № 17». Фактически правильно, но документ устарел.

Про это и будет статья.


Три варианта решения из эфира

Мой. Статичная документация — это файлы. Агент ищет grep’ом и читает сам. MCP для статичного корпуса — лишняя шестерёнка, ещё один вызов в цепочке. Слабой модели корпус стоит заранее разложить в индекс под её окно.

Участник в чате. grep — прошлый век. Нужен локальный поисковый индекс, как поиск в Windows, и отдать его агенту через MCP. Про RAG у него было уточнение: первые этапы конвейера возвращают документы, пересказывает только последний, генеративный. Выкинь его — оригиналы на месте.

Аналитик из зала. RAG тяжёлый: база, векторное хранилище, подготовка данных. Главное — при подготовке модель может переиначить пункт, и оригинал потеряется. Он сравнил это с неопределённым поведением в C++: под капотом что-то оптимизировали, а что —неизвестно. Ему интересен иерархический индекс: навигация по дереву со ссылками на пункты. Затраты: индексация — проход по всему корпусу.

Что понятно:

  • Генеративный последний шаг придётся отключать или закрывать проверкой цитат.

  • MCP на качество поиска не влияет: это транспорт.

Остальное открыто. Дешевле ли сохранить оригинал, чем восстанавливать его из пересказа - замерим. А насколько замороченным должен быть сам поиск, если читать всё равно будет модель, — тут мы с участником из чата не сошлись.


Что уже есть готового

При написании статьи я поискал готовые решения.

Правовые базы с ИИ. У КонсультантПлюс есть ИИ-помощник, а с июля 2026 года — экспериментальная «Проверка ссылок». Она смотрит, действуют ли акты, на которые сослалась нейросеть. Сверяет ли она цитату с текстом пункта, из описания непонятно. У Гаранта — ассистент ИСКРА и API Гарант Коннект, на базе Гаранта работает Нейроюрист от Яндекса. Кодекс запустил КодексНейро: ответы по ГОСТам и другой НТД со ссылками на пункты.

Сильная сторона у всех — редакции. Их там ведут десятилетиями, и повторять эту работу в своём контуре незачем: актуальный текст проще забирать через API. Нейроюрист ещё и принимает свои документы, а в бизнес-тарифах у него заявлена «работа в контуре компании». Но ответ везде пишет модель. Это вариант D ниже, а аналитику нужен оригинал.

Lexis+ AI и Westlaw AI тоже построены на RAG с генерацией и продавались как «hallucination-free». В 2024 году Стэнфорд их проверил: галлюцинации нашлись в 17–33% ответов.

MCP-серверы по праву РФ. garant-mcp отдаёт дословный текст статьи и редакцию на дату, но ходит в Гарант через сессию браузера с вашей подпиской. russian-law-mcp держит федеральные законы в локальной SQLite с полнотекстовым поиском. Оба под Apache 2.0.

Поиск, выдачу оригинала и проверку цитат придётся собрать в своём контуре. Вопрос в том, как обойтись минимумом деталей.


Пять подходов

A. Файлы и grep

Корпус лежит в репозитории в markdown, и агент ищет ripgrep’ом и читает найденное. Подготовка: сохранить нумерацию пунктов при конвертации. Агент получает оригинал, без промежуточных шагов.

Упираемся в морфологию. Строка «оператора персональных данных» не содержит строку «оператор персональных данных». Часть этого лечится стеммингом прямо в регулярке:

rg -i 'оператор\w*\s+персональн\w*' corpus/

Шаблон найдёт «ОПЕРАТОРА персональных» и «операторам персональных», а «персональных данных оператором» пропустит: порядок слов другой. Синоним и перефраз не найдёт вовсе. Сильная модель догадается резать окончания и перебирать варианты, слабая не догадается.

Ответ зависит от движка. Я сделал прогон в трёх локалях. Нужны две фичи: привести кириллицу к нижнему регистру и считать её за \w. У ripgrep и у ugrep 7.8.4 есть обе, в любой локали. GNU grep умеет обе только в UTF-8-локали. grep -P не считает кириллицу за \w, пока не поставишь (*UCP). Две версии одного ugrep разошлись между: 3.11.2 из Debian 12 не приводит к нижнему регистру.

А обратный порядок слов не нашёлся нигде, что неприятно. Ни на одном движке, ни в одной локали.

grep как точка отсчёта годится. Подготовка ничего не стоит, чтение стоит токенов —найденные файлы агент читает целиком.

B. Полнотекстовый индекс с морфологией

Каждый пункт — строка в индексе с реквизитами. Поиск ранжирует пункты по совпадению нормализованных слов. Индекс доступен агенту через CLI или тонкий MCP-сервер, в ответе приходят оригинальные пункты.

В PostgreSQL есть встроенная конфигурация russian со стеммером Snowball:

CREATE TABLE clause (
    ref      text PRIMARY KEY,   -- '152-ФЗ/ст.3/п.2'
    doc      text NOT NULL,
    revision date NOT NULL,      -- дата редакции
    body     text NOT NULL,      -- оригинальный текст пункта
    tsv      tsvector GENERATED ALWAYS AS (to_tsvector('russian', body)) STORED
);
CREATE INDEX clause_tsv ON clause USING gin (tsv);

SELECT ref, revision, body
FROM clause, plainto_tsquery('russian', 'обязанности оператора персональных данных') q
WHERE tsv @@ q
ORDER BY ts_rank(tsv, q) DESC
LIMIT 5;

Я прогнал на PostgreSQL 17.11, и всё отработало. «Оператор», «оператора» и «операторам» дают одну лексему 'оператор'.

Встроенного русского стеммера в SQLite FTS5 нет: unicode61 не стеммит, porter работает только на английском. Snowball с русским стеммером подключается внешним расширением: fts5-snowball или sqlite3-unicodesn для FTS3/4. Их я не проверял. А это сборка из исходников и ещё одна зависимость в контуре. Из коробки есть токенизатор trigram — поиск по подстроке:

CREATE VIRTUAL TABLE clause USING fts5(body, ref UNINDEXED, tokenize='trigram');
SELECT ref FROM clause WHERE clause MATCH 'оператор персональн';

Я проверял его на живой базе. Запрос «оператор» находит и «Оператор», и «оператора». Запрос «оператора» не находит «оператор». Триграммы — подстроки, а не морфология: в запрос кладут основу слова. Ошибки вы при этом не увидите: запрос короче трёх символов возвращает ноль строк с кодом возврата 0.

Если нужен отдельный сервис — есть Manticore со стеммингом и лемматизацией для русского и OpenSearch с анализатором russian. У Manticore лемматизатор ставится отдельным языковым пакетом, из коробки идёт стемминг. Стемминг я и проверял. Лемматизатор и OpenSearch — нет.

Тут буксуем на вопросах своими словами. Человек спрашивает, можно ли передавать персональные данные за рубеж. В законе это «трансграничная передача». Запрос вернул 0 строк. А запрос «трансграничная передача персональных данных» нужный пункт нашёл.

Общие слова как бы есть: и в запросе, и в пункте лежат лексемы 'персональн' и 'дан'. Не хватает остальных. «Передавать» даёт 'передава', «передача» даёт 'передач' — Snowball не сводит глагол и отглагольное существительное к одной основе. Слова 'рубеж' в пункте нет, там 'трансграничн'. А plainto_tsquery соединяет термы через AND, и одного непопавшего терма хватает, чтобы строка не нашлась.

А так, индексация дешёвая, и моделей не требует.

C. Векторный поиск без генерации

Пункты превращаются в эмбеддинги, запрос ищется по смысловой близости. Агенту идут оригинальные пункты с реквизитами. Это укороченная RAG из чата.

Главное решение — как резать текст. Чанкинг «по N токенов» режет пункт пополам и склеивает хвост одного пункта с началом следующего. Для нормативки чанк — это пункт или статья, а границы берутся из нумерации.

На перефраз этот способ работает: «за рубеж» ↔ «трансграничная передача». А с точностью похуже. Термины, номера и коды мер находятся неточно. Матрица «мера × уровень защищённости» при нарезке теряет смысл вовсе.

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

D. Классический RAG с генерацией

Тот же поиск, что в B или C, плюс последний шаг: найденные фрагменты вместе с вопросом уходят модели, и она пишет ответ своими словами. Для справки это удобно, а для нормативки опасно. Пересказ может поменять модальность — «должен» становится «рекомендуется». Теряет условие «если…». Теряет пункт из перечня. Даже с инструкцией «цитируй дословно» у генерации остаётся соблазн подчистить цитату.

В методике ниже D — контрольный вариант. Внедрять его я не советую, он нужен, чтобы измерить, сколько теряет генерация.

E. Иерархический индекс

Дерево: документ → раздел → статья → пункт. У каждого внутреннего узла короткое резюме. Модель спускается от корня и на каждом шаге выбирает ветку, как человек листает оглавление.

Идея не новая: в 2023 году её описали в статье про MemWalker. Там модель по дороге копит найденное в рабочей памяти и отвечает, когда собранного хватает.

PageIndex. Авторы называют его «vectorless, reasoning-based RAG»: без векторной базы и без чанкинга. Дерево извлекается из разметки документа без LLM, модель индексации только резюмирует. Поиск — «agentively search that tree with LLM reasoning».

По их данным — 98,7% точности на FinanceBench, а подать те же PDF целиком на вход модели стоит в 2,1 раза дороже на 52 страницах и в 16,6 раза на 420.

Заявлены протоколы: OpenAI-совместимый API, Anthropic и OpenRouter.

OpenKB. Надстройка над PageIndex. CLI «компилирует» документы в вики: резюме, страницы понятий и сущностей, перекрёстные ссылки, линт базы. Длинные PDF разбирает через PageIndex, документы принимает через markitdown, к моделям ходит через LiteLLM. Лицензия Apache 2.0.

Вики OpenKB — накопленный пересказ. Как навигация она полезна: страница понятия «уровень защищённости» ведёт сразу в три документа. Как источник цитаты не годится. Цитата идёт из листа-оригинала. Это и есть ответ аналитику из зала.

Слабые места:

  • Плохое резюме уводит модель в чужую ветку, и она уверенно отвечает не оттуда.

  • Перекрёстные ссылки прыгают между ветками, дерево их не видит, нужны дополнительные рёбра.

Ещё одно слабое место — редакции: дерево держит только текущую. SAT-Graph RAG решает это графом: норма в нём живёт отдельно от своих версий, у каждой версии есть даты, а изменяющий акт — отдельный узел. Поэтому можно достать текст на нужную дату и восстановить, чем и когда пункт поменяли. Пример — Конституция Бразилии со всеми поправками. Статья описывает архитектуру, готового кода я не нашёл; пересказ на русском есть на Хабре. Для in-house из неё хватит малого: хранить у листа даты действия и ссылку на изменяющий акт.

Расходы: на индексацию. О ней ниже. На вопрос уходит несколько коротких вызовов.

А что с Context7?

MCP-сервер Context7 решает похожую задачу для публичных библиотек: resolve-library-id и query-docs возвращают агенту актуальную документацию. Это облачный сервис с публичным каталогом, бэкенд закрыт. Для внутреннего корпуса в закрытом контуре он не годится.

Сводка

Жирным — то, до чего руки дошли прогнать. А остальное — оценка по устройству подходов.

A. grep

B. индекс с морфологией

C. векторы без генерации

D. RAG с генерацией

E. дерево

Агент получает

оригинал

оригинал

оригинал

пересказ

оригинал (+ резюме как карта)

Словоформы

частично, регулярками

да

да

да

по формулировке резюме

Перефраз

нет

нет

да

да

по формулировке резюме

Таблицы

как текст

как текст

плохо при нарезке

плохо при нарезке

узел = таблица

Перекрёстные ссылки

агент ходит сам

агент ходит сам

нет

нет

нужны доп. рёбра

Подготовка

конвертация

конвертация + индекс

+ эмбеддинги

+ эмбеддинги

+ резюме узлов

Закрытый контур

да

да

нужна локальная модель эмбеддингов

+ модель генерации

проверять режим инструментов


“Компилятор” для цитат

Везде, кроме D, агент получает оригинал, но ответ всё равно пишет модель. Она может сослаться не на тот пункт или подправить цитату. Обе ошибки можно ловить проверкой: цитата должна найтись именно в том пункте, на который ссылается ответ.

import re
import unicodedata

def norm(s: str) -> str:
    s = unicodedata.normalize("NFKC", s)   # неразрывный пробел → обычный и т. п.
    s = s.replace("\u00ad", "")            # мягкий перенос
    s = s.replace("ё", "е").replace("Ё", "Е")
    s = re.sub(r"[«»„“”\"]", '"', s)       # все кавычки → одна
    s = re.sub(r"[‐‑‒–—−]", "-", s)        # все тире и дефисы → один
    return re.sub(r"\s+", " ", s).strip()

MIN_WORDS = 6   # «в соответствии с» совпадёт где угодно

def check_quote(quote: str, ref: str, corpus: dict[str, str]) -> str:
    source = corpus.get(ref)
    if source is None:
        return "ссылки нет в корпусе"
    if len(norm(quote).split()) < MIN_WORDS:
        return "цитата слишком короткая"
    if norm(quote) not in norm(source):
        return "в указанном пункте такого текста нет"
    return "ok"

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

Чего проверка не делает: она не знает, тот ли это пункт по смыслу. Она гарантирует, что цитата честная и ссылка настоящая. Смысл оценивает эксперт. Зато проверка отсекает самый вредный класс ошибок — правдоподобный текст, которого в документе нет. Компилятор в программировании тоже не гарантирует, что программа делает нужное. Он гарантирует, что вызванное существует.

И так же, как компилятор, её стоит встроить в цикл работы агента. Когда вайбкодим, мы пишем: «после генерации выполни ./gradlew compileJava». Для нормативки аналогично: «после ответа вызови функцию проверку цитат; если хоть одна не прошла — ищи заново или отвечай „не нашёл“».

Писать проверку с нуля не обязательно. В библиотеке instructor есть пример как раз про это: модель возвращает ответ вместе с дословными цитатами, валидатор ищет их в контексте и выбрасывает ненайденные. Если вместо этого бросать ошибку, instructor с max_retries переспросит модель — это и есть «ищи заново». С локальными моделями библиотека тоже работает.

Citations API у Anthropic решает задачу на уровне платформы. Текст цитаты вырезает из документа сама система, модель его не генерирует. Если подать каждый пункт отдельным блоком (custom content), ссылка вернётся с номером блока, то есть пункта. Но это облако, в закрытый контур оно не встанет. Приём переносится и в контур: модель называет номер пункта, а текст цитаты подставляет код.


B-дерево для маленькой модели

На вебинаре я сравнил иерархический индекс с B-деревом на диске. Там размер узла подбирают под блок диска, а ветвление — под то, сколько указателей влезает в блок. Цель — меньше перемещений головки. Здесь блок — эффективное окно маленькой модели, а перемещение головки — вызов модели.

  • Лист — пункт или статья, от сотен до полутора тысяч токенов. Его читает модель ответа, не навигатор.

  • Внутренний узел — список дочерних узлов с заголовками и резюме, по s ≈ 50–100 токенов на узел.

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

  • Ветвление f ≈ W_eff / s; глубина d = ⌈log_f L⌉, где L — число листьев. Вызовов на вопрос — примерно d плюс возвраты.

Прикидка на пальцах. Корпус из шести актов на ~200 страниц — это порядка 200 тыс. токенов и ~300 листьев-пунктов. При W_eff ≈ 4 тыс. токенов и s ≈ 70 ветвление f ≈ 50, глубина 2. Корпус на 2 млн токенов даёт глубину 3. Навигатор читает 2–3 узла по ~4 тыс. токенов — 8–12 тыс. токенов вместо всего корпуса.

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

Цена индексации. Дерево строится регулярками по нумерации, модель для этого не нужна: так же PageIndex извлекает структуру из разметки без LLM. Модель пишет только резюме внутренних узлов, одним проходом по тексту разделов. Листья не резюмируются, у них есть заголовок и первая фраза. Вот и ответ про цену: платим один раз и только за резюме.

Обновление. Поправка в пункт меняет лист и резюме на пути к корню — это d узлов. Новая редакция документа — перестройка его ветки. Как вставка в B-дерево.

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

Путь вопроса «какие меры идентификации и аутентификации нужны для 3-го уровня защищённости»: через два документа, в таблицу.
Путь вопроса «какие меры идентификации и аутентификации нужны для 3-го уровня защищённости»: через два документа, в таблицу.

MCP — это просто транспорт

В споре «grep против MCP» смешались два вопроса: как искать и как доставить результат агенту. MCP отвечает на второй. За MCP-сервером может стоять grep, полнотекстовый индекс, векторная база или дерево. А агенту всё равно.

MCP оправдан, когда данные меняются; когда один корпус обслуживает много потребителей и копию у каждого держать нельзя; когда клиентские машины слабые, а поиск тяжёлый; когда нужен аудит — кто и что спрашивал у корпуса. Для ИБ последнее само по себе требование.

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

На вебинаре я назвал MCP лишней шестерёнкой, и для статичной документации к коду я так и думаю. Для нормативки в ИБ-контуре аргумент аудита может перевесить.


Что выбрать?..

Гипотеза по устройству подходов.

Ситуация

Начать с

Добавить, если…

Корпус до 1–2 тыс. страниц, меняется редко, сильная модель

A — файлы и grep

B, если много промахов по словоформам

То же, но слабая локальная модель

B — индекс с морфологией

E, если много обзорных вопросов («все меры группы…»)

Большой корпус, обзорные и перекрёстные вопросы

E — дерево, с B как запасным путём

C — для вопросов своими словами

Пользователи спрашивают своими словами

B + C

Нужен ответ связным текстом

D только поверх проверки цитат


Как проверить: методика и прогноз

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

Корпус: 152-ФЗ, ПП № 1119, приказ ФСТЭК № 21, 187-ФЗ, приказ ФСТЭК № 239. Плюс пара «приказ № 17 → приказ № 117» как кейс про редакцию.

Для чистоты эксперимента вопросы должен писать специалист по ИБ. Штук 20-30, реквизиты плюс дословный фрагмент.

Категория

Пример

Прямой факт

«Что закон называет трансграничной передачей персональных данных?»

Перефраз

«Можно ли передавать персональные данные за рубеж?»

Таблица

«Какие меры группы ИАФ обязательны для 3-го уровня защищённости?»

Два документа

как определить уровень защищённости и какие меры ему соответствуют

Агрегация

«Перечислите все группы мер из приказа № 21»

Редакция

«По какому приказу защищать новую ГИС?»

Негативный

вопрос о документе, которого в корпусе нет; правильный ответ — «в корпусе нет»

Протокол. Во всех вариантах одна модель ответа и одна инструкция: «цитируй дословно с реквизитами; если в корпусе нет — так и скажи». В варианте E добавляется маленькая модель навигации. Версии всех моделей фиксируются. Меряю точность реквизитов, дословность цитат, правильность ответа по оценке эксперта, отказ на негативных вопросах и цену — токены, время, число вызовов. Не меньше трёх прогонов на вариант: главный урок того же вебинара в том, что один прогон агента не доказывает ничего.

Прогноз:

#

Ожидаю

Что опровергнет

H1

B точнее на вопросах с терминами и номерами мер, C лучше на вопросах своими словами

C не хуже B на терминах или B не хуже C на перефразах

H2

у D худшая дословность цитат даже с инструкцией

дословность D на уровне A–C

H3

E лучше всех на агрегации и вопросах к двум документам, хуже — там, где резюме узла вводит в заблуждение

E не выигрывает у B на агрегации

H4

на корпусе в ~200 страниц A или B с сильной моделью не хуже E

E заметно лучше уже на малом корпусе

H5

без проверки цитат все варианты иногда «отвечают» на негативные вопросы; с проверкой ложных ответов меньше

проверка не снижает число ложных ответов

Работаете с нормативкой — пришлите три вопроса с эталоном: документ, редакция, пункт, дословный фрагмент.


Со времени того эфира правильный ответ мне пока не стал очевиден. Я по-прежнему ставлю на grep как на базу, но морфологию учитывать всё равно придётся.

А как устроена работа LLM с документацией у вас? И где оно вас подводило — вот это интереснее всего.

Комментарии (0)