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

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 с документацией у вас? И где оно вас подводило — вот это интереснее всего.