Корпоративный чат-бот по внутренним документам: как мы строим RAG для клиентов на 50 РМ
Каждый второй наш клиент на 50 рабочих местах держит регламенты, инструкции и прайсы в разрозненных папках на файловом шаринге или в 1С-Документообороте. Найти нужный пункт там быстрее через звонок коллеге, чем через поиск — это честная правда. Мы в ITfresh закрываем эту проблему корпоративным чат-ботом на базе RAG (Retrieval-Augmented Generation). Сотрудник задаёт вопрос обычными словами — получает точный ответ со ссылкой на конкретный документ и конкретный пункт, а не пачку файлов, которые ещё надо открыть и прочитать. Дальше — наша методология: как мы собираем такого бота от исходного корпуса документов до продакшена, с реальными компонентами, параметрами и порогами, которые применяем на проектах.
Почему поиск по шарингам не работает и в чём именно измеряется провал
Прежде чем предлагать RAG-бота, мы проводим короткий аудит. Просим 5–7 сотрудников из разных отделов найти ответ на конкретный вопрос по действующему регламенту — например, «сколько дней отпуска можно взять авансом» или «какая скидка для дилера 2-го уровня в майском прайсе». На юрлицо до 50 РМ поиск в среднем занимает от 4 до 12 минут. И это ещё не всё: часть сотрудников стабильно находит устаревшую версию документа. На шаринге одновременно лежат «Прайс_2025_final.xlsx» и «Прайс_2025_final_v2_испр.xlsx», а актуальный третий файл — в почте у бухгалтера. Классика.
Дело не в «плохом поиске Windows» — проблема системная. Обычный полнотекстовый поиск, будь то grep по файлам, встроенный поиск SharePoint или Яндекс.Диска, ищет по совпадению слов, а не по смыслу вопроса. Сотрудник спрашивает «можно ли уйти на удалёнку по семейным обстоятельствам», а в регламенте написано «дистанционная работа предоставляется по заявлению сотрудника при наличии уважительных причин» — буквально ни одного совпадающего слова, хотя ответ прямой. Именно эту задачу решает RAG: вопрос и документ сопоставляются не по строкам, а по векторам в пространстве смыслов — embeddings. Релевантный фрагмент передаётся большой языковой модели, которая формулирует ответ на естественном языке и указывает источник.
На аудите мы отдельно фиксируем объём и тип корпуса. Сколько документов — обычно от 80 до 400 файлов на компанию до 50 РМ. В каких форматах — DOCX, PDF, XLSX-прайсы, иногда сканы с печатью. Как часто меняются — регламенты раз в квартал, прайсы раз в неделю или чаще. Эти цифры напрямую определяют выбор компонентов: от векторной БД до частоты переиндексации. Без этих данных разговор про архитектуру вести бессмысленно.
Архитектура контура: из каких слоёв собран наш RAG
Контур мы собираем из пяти слоёв, и каждый — заменяемый модуль, не монолит. Это принципиально важно. Если через год клиент решит перейти с Qdrant на pgvector или сменить модель эмбеддингов — переписывается один слой, а не вся система с нуля.
- Приём и нормализация документов — конвертация DOCX/PDF/XLSX в чистый текст с сохранением структуры (заголовки, таблицы, нумерация пунктов), извлечение метаданных: название документа, раздел компании, дата последнего изменения, гриф доступа.
- Чанкинг и обогащение контекстом — разбиение текста на смысловые фрагменты с добавлением ситуативного контекста к каждому фрагменту (подробно в следующем разделе).
- Индексация — построение векторных представлений (embeddings) через Voyage AI и одновременно лексического индекса (BM25 или полнотекстовый индекс Postgres) для гибридного поиска.
- Retrieval и reranking — по запросу пользователя система достаёт top-N кандидатов из векторного и лексического индекса, объединяет и переранжирует их моделью-реранкером, оставляя top-K самых релевантных фрагментов.
- Генерация ответа — отобранные фрагменты подставляются в системный промпт Claude вместе с вопросом пользователя, модель формулирует ответ строго на основе переданного контекста и указывает источник.
Оркестрацию слоёв 2-4 мы делаем на LangChain (модули langchain-text-splitters и langchain-postgres/langchain-qdrant) или на LlamaIndex — выбор зависит от того, что удобнее интегрировать с уже используемым у клиента стеком; концептуально пайплайн идентичен. Слой 5 всегда — прямые вызовы Claude API (Anthropic Messages API), без прослойки агентских фреймворков: это упрощает диагностику при разборе неверных ответов.
Послойная архитектура нужна ещё по одной причине: у каждого клиента свой «источник правды» для документов. У одних — папка на Яндекс.Диске, у других — 1С-Документооборот с версионированием, у третьих регламенты живут прямо в Confluence или Битрикс24. Слой приёма и нормализации мы пишем под конкретный источник отдельным коннектором — обычно 150–300 строк кода на Python поверх готовых библиотек для парсинга DOCX и PDF. Всё, что ниже по пайплайну, остаётся без изменений. На практике это сильно ускоряет повторные внедрения: второй и третий проект на похожем стеке занимают у нас заметно меньше времени, чем первый.
Чанкинг: почему мы режем документ на фрагменты по 800 токенов и добавляем к каждому «паспорт контекста»
Самая частая ошибка в самодельных RAG-решениях — резать документ по фиксированному числу символов без учёта структуры. Мы используем рекурсивный сплиттер (в LangChain — RecursiveCharacterTextSplitter), который сначала пытается резать по границам абзацев (\n\n), затем по строкам (\n), затем по словам, и только в крайнем случае — посимвольно. Это сохраняет пункт регламента или строку прайса целиком, а не обрывает её на середине фразы.
Целевой размер фрагмента — около 800 токенов с перекрытием (chunk overlap) около 100 токенов между соседними чанками. Это нужно, чтобы фраза на границе не потерялась ни в одном из фрагментов. Для плотных таблиц — прайс-листов, тарифных сеток — мы уменьшаем чанк до границ одной логической строки. Иначе смысл строки «Услуга / Тариф Стандарт / Тариф Премиум» размывается при разрезании между колонками, и бот выдаёт чушь.
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=100,
separators=["\n\n", "\n", ". ", " ", ""],
length_function=count_tokens, # считаем токены, не символы
)
chunks = splitter.split_text(document_text)Ключевой приём, который мы переняли из инженерной практики Anthropic по контекстному поиску (contextual retrieval) — перед эмбеддингом каждого фрагмента мы добавляем к нему короткую «справку», объясняющую, откуда этот кусок и о чём документ в целом: например, «Фрагмент из Положения об оплате труда, раздел 4 «Компенсации», редакция от 12.05.2026» перед самим текстом пункта. Эту справку генерирует дешёвая модель (у нас — Claude Haiku 4.5, id claude-haiku-4-5), которой на вход подаётся весь документ целиком и конкретный фрагмент, а на выходе — 1-2 предложения контекста. Затем контекст приклеивается к фрагменту и уже эта склейка идёт в эмбеддинг и в лексический индекс. Такой подход снимает главную болезнь наивного чанкинга — «семантическую изоляцию» фрагмента, когда кусок текста без контекста означает не то же самое, что в составе документа. По собственным замерам на пилотных проектах добавление контекста к фрагментам заметно снижает долю ситуаций, когда система не находит нужный пункт среди верхних кандидатов — это согласуется с методикой, которую публично описывал Anthropic применительно к контекстным эмбеддингам и контекстному BM25.
Эмбеддинги: почему Voyage AI, а не «универсальная модель из коробки»
У самого Anthropic нет собственного API эмбеддингов — компания прямо рекомендует для этой задачи модели Voyage AI, с которой у Anthropic партнёрство. Для корпоративных RAG на русском корпусе документов мы используем voyage-3.5 как модель по умолчанию (хорошее соотношение качества и цены на многоязычных текстах) и voyage-3-large, когда клиент готов заплатить за более высокое качество на сложных смешанных корпусах (регламенты, ГОСТы, технические инструкции с терминологией). Если в базе много IT-регламентов или инструкций с фрагментами кода и конфигураций, берём доменную модель voyage-code-3 — она обучена именно на технических текстах и командах.
Важный практический параметр — output_dimension: модели семейства voyage-3.5 и voyage-3-large поддерживают усечённую размерность вектора (256 / 512 / 1024 / 2048), при этом 1024 — значение по умолчанию. Для клиента до 50 РМ с корпусом в несколько сотен документов мы почти всегда берём 1024: это компромисс между точностью поиска и размером индекса — увеличение размерности до 2048 даёт прирост точности в единицы процентов, но вдвое увеличивает объём векторного индекса и расход памяти на HNSW-графе.
| Модель Voyage | Когда используем | Размерность (output_dimension) |
|---|---|---|
| voyage-3.5 | Базовый выбор: регламенты, инструкции, HR-документы | 1024 (по умолчанию) |
| voyage-3-large | Сложный смешанный корпус, высокие требования к точности | 1024, реже 2048 |
| voyage-3.5-lite | Большой корпус (тысячи документов), приоритет скорости и цены | 512 |
| voyage-code-3 | IT-регламенты, SOP с командами и конфигами | 1024 |
Все запросы к Voyage идут пакетно — batch, а не по одному фрагменту. Это дешевле и заметно сокращает время полной переиндексации базы после массового обновления регламентов. Казалось бы, мелочь — но на практике разница ощутимая.
Векторное хранилище: как выбираем между pgvector, Qdrant и Chroma
Для клиента до 50 РМ выбор векторной БД почти никогда не про «какая быстрее в бенчмарках». Счёт идёт на сотни тысяч, максимум единицы миллионов векторов — с этим справится любое из трёх распространённых решений. Реальный вопрос другой: что уже есть в инфраструктуре клиента и кто будет это администрировать после сдачи проекта.
| Хранилище | Когда берём | Ключевые параметры HNSW |
|---|---|---|
| pgvector (расширение PostgreSQL) | У клиента уже есть PostgreSQL (например, под 1С-совместимый бэкенд или CRM) — не плодим лишний сервис | m=16 (по умолчанию), ef_construction 128-200 для прод-индекса, ef_search настраивается на лету под сессию запроса |
| Qdrant | Корпус растёт быстро, нужна отдельная фильтрация по метаданным (отдел/гриф доступа) на уровне БД, скалярная квантизация для экономии RAM | m и ef_construct в конфиге коллекции, quantization_config со scalar int8, обновление параметров на лету без пересоздания коллекции (начиная с версии 1.4) |
| Chroma | Быстрый пилот/PoC, локальный PersistentClient без отдельного сервера | configuration с hnsw.space=cosine, ef_construction, max_neighbors — задаются один раз при создании коллекции и не меняются потом |
Для типового клиента с уже развёрнутым PostgreSQL мы ставим pgvector прямо в существующий инстанс — отдельная схема rag, отдельная роль с правами только на неё:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE rag.chunks (
id bigserial PRIMARY KEY,
doc_id text NOT NULL,
department text NOT NULL,
access_level int NOT NULL DEFAULT 1,
content text NOT NULL,
embedding vector(1024)
);
CREATE INDEX chunks_hnsw_idx ON rag.chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 160);
SET hnsw.ef_search = 80; -- на время сессии, компромисс recall/latencyЕсли клиент ожидает быстрый рост корпуса — например, планирует загрузить архив договоров за 5 лет — или требует изоляцию по отделам на уровне БД, мы ставим отдельный контейнер Qdrant со скалярной квантизацией int8. Это сокращает объём, который держится в RAM, примерно в 4 раза. Потеря точности есть, небольшая — компенсируем её на этапе reranking.
Retrieval pipeline: гибридный поиск и reranking, а не «просто похожие вектора»
Чисто векторный поиск по косинусному расстоянию регулярно промахивается на точных идентификаторах — номерах приказов, артикулах из прайса, аббревиатурах («ДС» — дополнительное соглашение, а не что-то ещё). Поэтому мы всегда строим гибридный поиск: параллельно с векторным top-N идёт лексический поиск (BM25 или полнотекстовый индекс tsvector в самом PostgreSQL) по тем же чанкам, а результаты объединяются.
Дальше — обязательный этап reranking. Мы берём с векторного и лексического поиска суммарно около 100-150 кандидатов-чанков и прогоняем их через модель-реранкер (Cohere rerank-v3.5, контекстное окно 4096 токенов, поддержка более 100 языков включая русский), которая уже честно «читает» пару вопрос-фрагмент целиком, а не сравнивает векторы, и переупорядочивает кандидатов по реальной релевантности. Из переранжированного списка в промпт модели уходит top-10-20 фрагментов — этого достаточно даже для составных вопросов вроде «какая скидка для нового дилера и какие документы нужны для перехода на следующий уровень».
Публичные замеры связки «контекстные эмбеддинги + контекстный BM25 + reranking», которые Anthropic описывал по своей методике контекстного поиска, показывают снижение доли промахов в top-20 выдачи в разы — по сравнению с наивным векторным поиском без контекста и без реранкера. Наш опыт на пилотах у клиентов эту картину подтверждает. Основная масса случаев «бот не нашёл ответ, хотя он есть в базе» уходит именно после добавления reranking — не после смены модели эмбеддингов, как многие ожидают.
Мы отдельно считаем latency-бюджет. Гибридный retrieval укладывается в 150–300 мс, reranking 100 кандидатов — ещё 200–400 мс. Итоговая задержка до передачи контекста в Claude — около полусекунды. На фоне времени генерации самого ответа пользователь эту паузу просто не замечает.
Отдельно проговариваем с клиентом ограничение самого reranker: контекстное окно у rerank-v3.5 — 4096 токенов на пару запрос-документ, поэтому передавать в реранкер целые документы вместо чанков нельзя технически, и это ещё один аргумент в пользу дисциплинированного чанкинга на этапе индексации, а не «на глазок». Мы также логируем сырые оценки релевантности (score) от реранкера по каждому запросу — если для нового типа вопросов средний score у top-1 кандидата систематически низкий, это сигнал, что в базе просто нет документа с ответом, и вопрос нужно эскалировать не на более дорогую модель, а на владельца регламента, чтобы он написал недостающий раздел.
Генерация ответа: маршрутизация между моделями Claude и prompt caching для системного промпта
Гнать каждый вопрос через самую дорогую модель — и дорого, и бессмысленно. Для типового запроса «какой номер телефона службы поддержки указан в регламенте» мощная модель — откровенный перебор. Поэтому мы выстраиваем маршрутизацию по сложности запроса: простые вопросы идут по дешёвому маршруту, сложные — туда, где это оправдано.
| Модель Claude | Задача в контуре | Типовой сценарий |
|---|---|---|
Claude Haiku 4.5 (claude-haiku-4-5) | Классификация запроса, переформулировка вопроса, генерация контекстной справки к чанкам при индексации | «Уточни, к какому отделу относится вопрос» |
Claude Sonnet 4.6 (claude-sonnet-4-6) | Основная генерация ответов по найденным фрагментам | 90%+ вопросов сотрудников — прямые факты из одного-двух документов |
Claude Opus 4.6 (claude-opus-4-6) | Эскалация на сложные, составные или потенциально противоречивые вопросы | «В регламенте А написано одно, а в приказе Б — другое, что применимо к моему случаю» |
Решение об эскалации с Sonnet на Opus принимает сам Sonnet: если после первой попытки ответа модель не может уверенно сослаться на единственный непротиворечивый фрагмент (это видно по структурированному полю confidence в ответе, которое мы требуем в системном промпте), запрос повторяется с Opus и расширенным набором top-K фрагментов.
Системный промпт — это не только инструкция «отвечай только на основе переданных фрагментов, не выдумывай», но и достаточно объёмный блок с общими правилами компании (структура отделов, принятые сокращения, формат ссылок на источник). Этот блок одинаков для всех запросов в рамках сессии, поэтому мы кэшируем его через cache_control с типом ephemeral: при 5-минутном окне кэша повторное чтение блока стоит 10% от цены обычного входного токена, а первая запись в кэш — 1,25x от обычной цены. Для чат-бота с постоянным потоком вопросов в рабочие часы это ощутимо снижает счёт по API, потому что неизменная часть промпта (правила компании, инструкции по цитированию) может занимать больше токенов, чем сам вопрос и найденные фрагменты.
{
"role": "system",
"content": [
{
"type": "text",
"text": "Ты — ассистент компании ... Отвечай только на основе фрагментов ниже.",
"cache_control": {"type": "ephemeral"}
}
]
}Каждый ответ бота обязан заканчиваться явной ссылкой на источник: название документа, раздел, дата редакции — те метаданные, которые мы прикрепили к чанку ещё на этапе индексации. Это не косметика. Юрист или бухгалтер клиента должен иметь возможность быстро перепроверить ответ по первоисточнику, а не слепо доверять генерации. Без этого любой RAG-бот — просто красивая игрушка.
Права доступа и контур данных: кто и что может спросить у бота
В компании до 50 РМ почти всегда есть документы с разным уровнем доступа. Общий регламент отпусков видят все, а положение об оплате труда руководителей — только бухгалтерия и топ-менеджмент. Наивная реализация RAG, где все документы лежат в одном общем индексе без разграничения, — прямой путь к утечке. Бот с одинаковой готовностью процитирует зарплату директора рядовому менеджеру, если формально нашёл релевантный фрагмент. Мы с таким сценарием сталкивались на аудитах у новых клиентов — и это всегда неприятный разговор.
Поэтому каждый чанк при индексации получает метаданные department и access_level, а сам retrieval выполняется с обязательным фильтром по этим полям на уровне запроса к БД — не постфактум фильтрацией уже полученных результатов на стороне приложения, а именно в самом SQL/API-запросе к векторному хранилищу. В pgvector это условие WHERE access_level <= :user_level в одном запросе вместе с оператором ANN-поиска; в Qdrant — payload-фильтр, который движок применяет до построения списка кандидатов ANN, а не после.
Уровень доступа пользователя бот берёт не с его слов — self-report в чате доверять нельзя. Источник — корпоративная директория: Active Directory или 1С, через которые у клиента уже работает аутентификация во всех остальных сервисах. Сессия чат-бота привязывается к учётной записи через SSO. Никаких анонимных токенов.
Отдельный вопрос — где физически находится контур. Если регламенты клиента содержат персональные данные или коммерческую тайну, векторную БД и приложение-оркестратор мы разворачиваем либо на собственной инфраструктуре клиента, либо в изолированном контуре у нас. Наружу — к Anthropic и Voyage AI — уходят только текстовые фрагменты для эмбеддинга и генерации. Сами документы периметр не покидают. Во внешние API идут фрагменты, и мы не храним их дольше времени обработки одного запроса.
Как мы внедряем: этапы, сроки и метрики, по которым сдаём проект
Внедрение мы всегда разбиваем на пилот и продакшен. Почему? Потому что качество ответов зависит прежде всего от качества исходного корпуса документов — архитектура здесь далеко не единственный фактор. На нашей практике это нагляднее всего видно именно на живых примерах, а не на слайдах презентации. Клиент должен сам это увидеть — до того, как мы идём дальше.
- Неделя 1 — аудит и сбор корпуса. Собираем актуальные версии документов, снимаем дубли и устаревшие копии, размечаем гриф доступа и принадлежность к отделу вместе с ответственным у клиента (обычно HR или руководитель офиса).
- Неделя 2 — индексация и настройка pipeline. Чанкинг с контекстным обогащением, эмбеддинги Voyage, построение HNSW-индекса, настройка гибридного retrieval и reranking с параметрами по умолчанию (top-150 → rerank → top-15).
- Неделя 3 — пилот на 10-15 сотрудниках. Собираем набор из 40-60 реальных вопросов от разных отделов, прогоняем через бота, вручную оцениваем каждый ответ по шкале «точный / частично точный / неверный / не нашёл» и считаем retrieval failure rate — долю вопросов, где нужный фрагмент вообще не попал в top-K после reranking.
- Неделя 4 — донастройка и запуск. По результатам пилота чаще всего донастраиваем не модель, а корпус: находим документы без чёткой структуры заголовков, вручную размечаем таблицы, которые плохо распознались при конвертации PDF. Затем — запуск на всю компанию через привычный канал (Telegram-бот, виджет в интранете или в существующем корпоративном мессенджере).
После запуска ведём два эксплуатационных процесса. Первый — переиндексация: при изменении регламента ответственный сотрудник кладёт новую версию в отслеживаемую папку, вебхук или cron-задача (в нашей практике — раз в ночь плюс ручной триггер «переиндексировать сейчас» для срочных правок вроде обновления прайса) пересчитывает эмбеддинги только для изменившихся документов, а не всей базы — это сокращает время обновления с часов до минут. Второй — журнал вопросов без уверенного ответа: если модель вернула низкий confidence, вопрос и её попытка ответа падают в отдельную очередь на еженедельный разбор, что одновременно служит и метрикой качества бота, и списком пробелов в самих регламентах компании — нередко бот вскрывает вопросы, на которые в документах компании попросту нет чёткого ответа.
Частые вопросы
- Нужно ли переносить все документы компании в векторную базу сразу?
- Нет, и мы этого не советуем. Начинаем с 2-3 категорий с самой высокой частотой вопросов — обычно это HR-регламенты (отпуска, командировки, компенсации) и актуальный прайс-лист. Остальные категории добавляем после пилота, когда видно, какие вопросы реально задают сотрудники, а какие документы никто никогда не открывает и индексировать их бессмысленно.
- Что если бот не найдёт ответ или найдёт неточный фрагмент?
- Системный промпт прямо запрещает модели отвечать, если переданные фрагменты не содержат уверенного ответа на вопрос — в этом случае бот сообщает, что не нашёл информацию, и предлагает переформулировать вопрос или обратиться к конкретному человеку/отделу. Такие случаи попадают в отдельный журнал для еженедельного разбора, а не остаются незамеченными.
- Может ли бот случайно показать сотруднику документ с закрытым доступом?
- Фильтр по department и access_level применяется на уровне самого запроса к векторной базе, до этапа генерации ответа — модель Claude физически не получает на вход фрагменты, к которым у пользователя нет доступа, поэтому процитировать их она не может даже теоретически.
- Сколько стоит содержать такого бота в месяц для компании на 50 РМ?
- Основные статьи расходов — вызовы Claude API за генерацию ответов (маршрутизация большей части вопросов на более дешёвую модель Sonnet вместо Opus и prompt caching системного промпта снижают эту статью в разы), эмбеддинги Voyage AI (разовая индексация плюс небольшие довычисления при обновлении документов) и хостинг векторной БД, который при выборе pgvector в уже существующем PostgreSQL клиента часто оказывается нулевым — сервис просто становится ещё одной схемой в имеющейся базе.
- Чем это отличается от обычного поиска по ключевым словам в 1С или на шаринге?
- Ключевое отличие — поиск по смыслу вопроса, а не по совпадению слов: сотрудник получает готовый сформулированный ответ со ссылкой на источник, а не список файлов, которые нужно открыть и прочитать самостоятельно. Плюс гибридный retrieval и reranking страхуют от того, что чисто смысловой поиск иногда промахивается на точных идентификаторах и номерах документов.
