Корпоративный чат-бот по внутренним документам: как мы строим 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"}
}
]
}Каждый ответ бота обязан заканчиваться явной ссылкой на источник: название документа, раздел, дата редакции — те самые метаданные, которые мы прикрепили к чанку ещё на этапе индексации. Это не косметика: юрист или бухгалтер клиента должен иметь возможность быстро перепроверить ответ по первоисточнику, а не слепо доверять генерации.
Права доступа и контур данных: кто и что может спросить у бота
В компании до 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 страхуют от того, что чисто смысловой поиск иногда промахивается на точных идентификаторах и номерах документов.