Корпоративный AI-агентчасть 5 из 9
- 01Концепция: агент, который знает компанию
- 02Первый разговор с моделью
- 03Агентный цикл и инструменты
- 04MCP: подключаем внутренние системы
- 05Знания компании: поиск по документам
- 06Права доступа: SSO, OAuth и агент от имени сотрудника
- 07Контекст и деньги: кэширование, сжатие истории и лимиты
- 08Интерфейс: веб-чат и бот в Slack
- 09В прод: периметр, аудит и проверки
В прошлой части агент научился отвечать по таблицам. Но в таблицах лежат цифры, а знания компании в основном живут в тексте: регламенты, договоры, разборы инцидентов, протоколы встреч. На вопрос «какие условия оплаты в договоре с клиентом» SQL не ответит.
Что такое RAG и почему не обязательно векторы
RAG (retrieval-augmented generation) означает только одно: перед ответом найти нужные документы и дать их модели. Как именно искать, в определении не сказано. Но за последние годы RAG почти стал синонимом одного рецепта: документы режут на куски, каждый кусок превращают в эмбеддинг, при вопросе находят несколько ближайших векторов и вставляют их в промпт.
У этого рецепта две слабости.
Одна попытка. Если поиск с первого раза принёс не те куски, модель ответит по ним или скажет, что не знает. Переспросить она не может.
Векторы плохо ищут точные вещи. Эмбеддинг передаёт общий смысл фрагмента, а в рабочих вопросах часто важна точная строка: номер договора 2026-041, название клиента, код ошибки, фамилия. Полнотекстовый поиск находит такое сразу, а векторный может поставить выше фрагмент «про что-то похожее». На корпоративных документах, где много имён и терминов, BM25 обычно не хуже эмбеддингов, а лучше всего они работают вместе.
У нас уже есть агентный цикл, поэтому поиск становится просто ещё одним инструментом. Модель сама формулирует запрос, смотрит на результат, ищет другими словами, открывает документ целиком и сверяет с базой данных. Так ищет человек. Агенты для кода устроены так же: Claude Code ищет по проекту через grep и чтение файлов, без векторного индекса. Решает то, что модель может искать много раз и проверять найденное.
Поэтому начнём с полнотекстового поиска с ранжированием BM25. Он дешёвый, его легко отлаживать (видно, почему документ нашёлся), ему не нужно пересчитывать эмбеддинги при каждой правке документа, и он точно находит имена и номера. Векторы добавим, когда увидим конкретные промахи, о них в конце статьи.
Шаг 1. Документы
Для примера возьмём папку wiki/ с markdown-файлами: отпуска, командировки, договор с клиентом, разбор инцидента, политика оплаты труда, памятка новичку. В настоящей компании это выгрузка из Confluence, Notion или Google Drive, но формат удобно приводить к тому же виду: заголовок, текст, метаданные.
---
title: "Договор: Северсталь-Сервис"
access: [sales, management]
---
## Условия оплаты
Оплата поэтапно: 30% аванс после подписания, 50% после запуска на первом заводе,
20% после приёмки на всех трёх. Счета оплачиваются в течение 30 календарных дней.Обратите внимание на поле access: кто может видеть документ. В этой части мы его только сохраняем, а проверять будем в следующей, когда агент узнает, кто его спрашивает. Сохранять права вместе с индексом нужно с самого начала: добавить их потом в работающий индекс сложнее, чем кажется.
Шаг 2. Индексация
index.ts проходит по папке, разбирает frontmatter, режет каждый документ на фрагменты и пишет их в две таблицы: docs с документами целиком (для чтения) и полнотекстовую chunks (для поиска):
CREATE TABLE docs (path TEXT PRIMARY KEY, title TEXT, access TEXT, body TEXT);
CREATE VIRTUAL TABLE chunks USING fts5(
path UNINDEXED, title, heading, body,
tokenize = 'unicode61 remove_diacritics 2'
);Режет он так: сначала по заголовкам ##, а раздел длиннее 1500 символов ещё и по абзацам.
bun index.ts wikiФрагменты по заголовкам. Режем по ##, потому что раздел документа обычно и есть законченная мысль. Длинные разделы дополнительно делим по абзацам, чтобы фрагмент был примерно полстраницы. Слишком маленькие фрагменты теряют контекст, слишком большие размывают ранжирование и съедают контекст модели.
Заголовок документа и раздела хранятся отдельно. Фрагмент «Счета оплачиваются в течение 30 дней» сам по себе не говорит, о каком клиенте речь. Название документа «Договор: Северсталь-Сервис» рядом с ним решает эту проблему, и в поиске мы дадим заголовкам больший вес.
Полная пересборка. Для сотни документов это доли секунды. Когда документов станут тысячи, переходите на инкрементальное обновление: храните хеш содержимого и переиндексируйте только изменившиеся файлы, а изменения узнавайте из вебхуков Confluence или Notion.
Шаг 3. MCP-сервер поиска
wiki-server.ts устроен как сервер базы из прошлой части, только инструментов два: search и read. read достаёт документ из docs по пути. Вся работа search это один запрос:
SELECT path, title, heading, snippet(chunks, 3, '«', '»', '…', 32) AS snippet
FROM chunks WHERE chunks MATCH ?
ORDER BY bm25(chunks, 0, 5, 3, 1)
LIMIT 8Перед запросом слова из вопроса приводятся к виду "оплат"* OR "северстал"*: у длинных слов отрезаются последние две буквы, и поиск идёт по префиксу. Описание инструмента подсказывает модели, как искать: «Ищи короткими запросами из ключевых слов, переформулируй, если ничего не нашлось. Чтобы прочитать документ целиком, вызови read».
search и read вместе. Поиск возвращает короткие фрагменты, чтобы модель могла быстро просмотреть десяток вариантов и не потратить на это весь контекст. Если фрагмент выглядит нужным, модель открывает документ целиком через read и читает разделы вокруг. Человек делает то же самое: сначала смотрит выдачу, потом открывает страницу.
BM25 с весами. bm25(chunks, 0, 5, 3, 1) задаёт вес каждой колонке: совпадение в названии документа в пять раз важнее совпадения в тексте, в заголовке раздела в три. Это простое правило заметно улучшает выдачу.
Морфология. Встроенный токенизатор SQLite не знает русских окончаний: «оплата» и «оплаты» для него разные слова. Поэтому мы обрезаем окончания длинных слов и ищем по префиксу. Это грубо, но для начала работает. В проде используйте Postgres с конфигурацией russian для полнотекстового поиска или OpenSearch с морфологическим анализатором.
OR вместо AND. Запрос «условия оплаты Северсталь» находит фрагменты, где есть хотя бы одно слово, а BM25 поднимает наверх те, где совпало больше. Строгое AND чаще возвращает пустоту, а пустая выдача хуже длинной.
Описание инструмента учит модель искать. Фраза «ищи короткими запросами из ключевых слов, переформулируй, если ничего не нашлось» реально меняет поведение: без неё модель склонна отправлять в поиск весь вопрос пользователя целиком.
Шаг 4. Подключаем к агенту
Агент из прошлой части не меняется, достаточно добавить сервер в mcp.json:
{
"db": { "command": "bun", "args": ["db-server.ts"], "env": { "DB_PATH": "company.db" } },
"wiki": { "command": "bun", "args": ["wiki-server.ts"], "env": { "WIKI_DB": "wiki.db" } }
}И одну строку в системный промпт: «Ссылайся на документы вики по их пути, например travel.md». Ответ со ссылкой можно проверить, а без ссылки ему не верят.
Пробуем
Задайте вопрос, для которого нужны оба источника: «какие условия оплаты у Северсталь-Сервис и сколько мы с ними заработали в этом году?». Агент найдёт договор в вики, прочитает раздел про оплату, затем пойдёт в базу за суммой выигранных сделок и соберёт ответ с двумя источниками.
Другие вопросы для проверки: «сколько суточных положено за границей?», «почему упал платёжный сервис и было ли такое раньше?». На втором агент найдёт в разборе инцидента упоминание похожей проблемы в марте и честно скажет, что подробностей о мартовском случае в вики нет.
И снова контрольный вопрос: «какой средний рост окладов заложен на 2026 год?». Агент ответит из документа с политикой оплаты труда, хотя по полю access его могут видеть только HR и руководство. Это мы исправим в следующей части.
Когда добавлять векторы и что попробовать до них
Полнотекстовый поиск промахивается, когда пользователь и документ говорят о том же разными словами: «отгул» и «день без сохранения зарплаты», «уволиться» и «расторжение трудового договора», вопрос на английском и документ на русском. Агент частично компенсирует это, переформулируя запрос, но не всегда догадывается.
До векторного индекса стоит попробовать дешёвые шаги:
- Синонимы в инструменте поиска. Небольшой словарь корпоративных терминов («отгул» → «отпуск без сохранения») расширяет запрос до FTS. Это пара десятков строк, и поведение полностью предсказуемо.
- Подсказка в описании инструмента. Попросите модель искать двумя-тремя формулировками, включая официальные термины. Модель и так переформулирует, но явная инструкция заметно повышает долю удачных поисков.
- Контекст у фрагментов. Фрагмент «оплата в течение 30 дней» без названия договора бесполезен. При индексации к каждому фрагменту добавляют строку о том, откуда он: документ, раздел, клиент. В Anthropic этот приём описывали под названием Contextual Retrieval, он улучшает и полнотекстовый, и векторный поиск. Наш индекс уже кладёт рядом с текстом название документа и заголовок раздела, это простейший вариант того же приёма.
Если промахи остаются, векторный поиск добавляют рядом с полнотекстовым, а не вместо него. Каждый фрагмент при индексации превращают в эмбеддинг (через API, например Voyage, или локальной моделью вроде multilingual-e5), хранят в pgvector или в отдельной векторной базе, а при вопросе делают оба поиска. Списки объединяют методом Reciprocal Rank Fusion. Ему не нужно сравнивать несравнимые оценки BM25 и косинусной близости, достаточно позиций в списках:
// Каждый список отсортирован по убыванию релевантности, элементы это id фрагментов
function rrf(lists: string[][], k = 60) {
const scores = new Map<string, number>();
for (const list of lists) {
list.forEach((id, rank) => {
scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank + 1));
});
}
return [...scores].sort((a, b) => b[1] - a[1]).map(([id]) => id);
}Фрагмент, который оказался высоко в обоих списках, поднимается наверх, а найденный только одним способом всё равно попадает в выдачу. Если и этого мало, следующий шаг это реранкер: модель, которая получает вопрос и 30–50 кандидатов из гибридного поиска и пересортировывает их по смыслу. Он медленнее поиска, поэтому работает только с короткой выдачей.
Где векторы действительно выигрывают:
- вопросы своими словами к документам, написанным канцеляритом;
- вопрос на одном языке, документы на другом;
- «найди похожее»: похожие инциденты, договоры, обращения клиентов;
- очень большие архивы, где агент не может позволить себе десяток поисков на один вопрос.
Где они не нужны: точные номера, имена и коды, а также небольшая вики, где агенту хватает пары запросов.
Решайте по измерениям, а не по моде. Записывайте вопросы, на которые агент не нашёл ответ, хотя документ был, и добавляйте их в набор проверочных вопросов из части 9. Тогда видно, что дал каждый шаг: синонимы, контекст фрагментов, векторы, реранкер.
Что дальше
Агент знает базу и вики, но отвечает всем одинаково и показывает всё. В следующей части добавим главное для корпоративного агента: вход через корпоративный SSO, OAuth и агента, который ходит в системы от имени конкретного сотрудника и видит только то, что видит он.