balkhaev.com Блог · AI-агенты и автоматизации

Корпоративный AI-агентчасть 5 из 9

Знания компании: поиск по документам

Индексируем вики компании, пишем MCP-сервер полнотекстового поиска и учим агента искать по документам так, как ищет человек.

обновлено 8 мин чтенияagentssearchragmcp

Корпоративный AI-агентчасть 5 из 9
  1. 01Концепция: агент, который знает компанию
  2. 02Первый разговор с моделью
  3. 03Агентный цикл и инструменты
  4. 04MCP: подключаем внутренние системы
  5. 05Знания компании: поиск по документам
  6. 06Права доступа: SSO, OAuth и агент от имени сотрудника
  7. 07Контекст и деньги: кэширование, сжатие истории и лимиты
  8. 08Интерфейс: веб-чат и бот в Slack
  9. 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 и агента, который ходит в системы от имени конкретного сотрудника и видит только то, что видит он.