Зачем это вообще нужно

В любой компании старше пары лет знания размазаны по вики, папкам с регламентами, почтовым перепискам, 1С и Битрикс24. Новичок ищет нужный документ полчаса или идёт спрашивать коллегу, и коллега отвлекается.

Очевидный ответ — «давайте сделаем ChatGPT по нашим документам». Но почти всегда тут же звучит требование: данные не должны уходить в чужое облако. Значит, модель, поиск и хранилище должны жить на своём железе.

В статье описываю стек, который собирал на практике для таких задач: RAG поверх XWiki, файлов и почты, доступ к 1С и Битрикс24 через инструменты агента, локальная модель на vLLM. Без маркетинга: что из чего состоит, как связано и где ломается.

Архитектура целиком

Слева документы: они заранее проходят конвейер загрузки и ложатся в индекс. Справа 1С и Битрикс24: агент запрашивает их через MCP в момент вопроса. Все обращения к моделям идут через один шлюз, облачная модель подключается только если это допускает политика компании.

Загрузка данных: самая долгая часть

Качество ответов гораздо сильнее определяется тем, как данные попали в индекс, а не выбором модели. Источники три, и у каждого свои проблемы.

XWiki

Первая версия забирала данные напрямую: SSH на сервер, выгрузка из MySQL, чтение вложений с диска. Работает, но хрупко: любое обновление XWiki может поменять схему БД, а права доступа вики при таком чтении игнорируются.

Правильный путь — REST API XWiki: список пространств и страниц, содержимое страницы, список и скачивание вложений. Инкрементальное обновление строится по дате изменения страницы: храним её в MongoDB и переобрабатываем только изменившееся.

Файлы и вложения

doc, docx, pdf, сканы — всё приводим к тексту с сохранением структуры (заголовки, таблицы). Для сканов и PDF-картинок использую PaddleOCR. Старый .doc лучше сразу конвертировать в docx через LibreOffice в headless-режиме, чем искать парсер под него.

Почта

Самый коварный источник. В цепочках каждое ответное письмо цитирует предыдущие, и в индексе один и тот же текст оказывается по десятку раз. Поиск возвращает пять копий одного фрагмента и вытесняет полезное. Решение: собирать письма в треды по заголовкам In-Reply-To и References и вырезать цитирование до чанкинга.

На большом архиве полная обработка почты и вложений у меня занимала до двух недель. Поэтому новую версию индекса строю в отдельных коллекциях MongoDB и Qdrant, а старая продолжает отвечать пользователям. Переключение — одной переменной окружения.

Аннотации вопросами

Приём, который заметно улучшил поиск: для каждого документа локальная LLM генерирует список вопросов, на которые он отвечает, и краткое описание. Индексируются и чанки текста, и эти вопросы. Пользователь спрашивает своими словами, а его запрос семантически ближе к вопросу, чем к канцелярскому тексту регламента. Аннотации пересчитываются автоматически при изменении документа.

Ещё одна деталь: в метаданных чанка храните дату документа. Иначе на вопрос про текущий порядок модель с равной уверенностью процитирует регламент пятилетней давности.

1С и Битрикс24: не индексировать, а спрашивать

Учётные данные в векторную базу класть не нужно. Остатки, сделки и задачи меняются каждый час, а семантический поиск по таблице с суммами даёт мусор. Вместо этого агент получает инструменты и сам делает запрос в момент вопроса.

Как достать данные

  • 1С — стандартный интерфейс OData. Базу публикуют на веб-сервере, нужные справочники и регистры включают в состав интерфейса, дальше обычные HTTP-запросы с $filter и $format=json. Для сложной логики удобнее HTTP-сервис в конфигурации, который отдаёт уже собранный ответ.

  • Битрикс24 — REST API через входящий вебхук с нужными правами: crm.deal.list, crm.company.list, tasks.task.list и так далее. Для тиражируемого решения правильнее локальное приложение с OAuth.

Инструменты через MCP

Инструменты оформляю как MCP-сервер на FastMCP. Плюс в том, что один и тот же сервер подключается к своему агенту, к Open WebUI и к любому MCP-клиенту без переделок. Упрощённый пример:

import os
import httpx
from fastmcp import FastMCP

mcp = FastMCP("corp-tools")
B24 = os.environ["B24_WEBHOOK"]   # https://<портал>/rest/<id>/<токен>/
ODATA = os.environ["ODATA_URL"]   # https://<сервер>/<база>/odata/standard.odata/
AUTH = (os.environ["ODATA_USER"], os.environ["ODATA_PASS"])

@mcp.tool
def deals_by_company(company_id: int, limit: int = 20) -> list[dict]:
    """Открытые сделки компании в Битрикс24."""
    r = httpx.post(B24 + "crm.deal.list.json", json={
        "filter": {"COMPANY_ID": company_id, "CLOSED": "N"},
        "select": ["ID", "TITLE", "STAGE_ID", "OPPORTUNITY"],
    }, timeout=30)
    return r.json()["result"][:limit]

@mcp.tool
def find_counterparty(name: str) -> list[dict]:
    """Поиск контрагента в 1С по части наименования."""
    q = f"substringof('{name}', Description)"
    r = httpx.get(ODATA + "Catalog_Контрагенты", auth=AUTH, params={
        "$filter": q, "$top": 10,
        "$select": "Ref_Key,Description,ИНН", "$format": "json",
    }, timeout=30)
    return r.json()["value"]

if __name__ == "__main__":
    mcp.run(transport="http", host="0.0.0.0", port=8100)

Имена объектов и реквизитов зависят от конфигурации, проверяйте их в метаданных ($metadata). В боевом коде строку фильтра нужно экранировать: аргументы инструментов составляет модель, а не доверенный код.

Права доступа

Инструменты только на чтение, технический пользователь с минимальными правами в 1С и вебхук с ограниченным набором разрешений. Если разные сотрудники должны видеть разное, фильтруйте на стороне инструмента по пользователю, а не просите модель «не показывать» лишнее. Запись в учётные системы через агента — только с подтверждением человека.

Модель и инференс

Итоговый выбор: vLLM и MoE-модель семейства Qwen с небольшим числом активных параметров (Qwen3.6-35B-A3B). Для нескольких одновременных пользователей скорость ответа оказалась важнее последних процентов качества.

Почему MoE, а не плотная модель

Я сравнивал три варианта: большую MoE на ~120B, плотную на ~27B и MoE на 35B с ~3B активных. Большая едва помещается в одну карту и оставляет мало места под KV-кэш. Плотная на 27B даёт хорошее качество, но заметно медленнее для пользователя. MoE на 35B с 3B активных отвечает быстро, нормально работает с инструментами и оставляет память под параллельные запросы.

Тестировал на A100 80 ГБ, целевое железо — одна карта класса RTX PRO с 96 ГБ. Ваши цифры скорости будут зависеть от квантизации, длины контекста и числа пользователей, так что замерьте на своём сценарии.

Настройки, которые важны

  • Длина контекста. Я держу 32K. Больше для RAG обычно не нужно, а каждый лишний токен съедает память под параллельные сессии.

  • Вызов инструментов. В vLLM нужно явно включить автовыбор инструментов и указать парсер под семейство модели, иначе вызовы приходят текстом.

  • Ключ API. Даже во внутренней сети — vLLM даёт OpenAI-совместимый API, и без ключа его может дёрнуть кто угодно.

Эмбеддинги и поиск

Эмбеддинг-модель поднимается отдельным контейнером, вектора хранятся в Qdrant, исходные документы и аннотации — в MongoDB. Для русскоязычных документов берите мультиязычную модель и проверяйте её на своих же вопросах: публичные бенчмарки тут плохо предсказывают результат.

Перед поиском локальная модель переписывает запрос с учётом истории диалога. «А для поставщиков?» само по себе ничего не найдёт, а «порядок согласования договора для поставщиков» — найдёт.

Гибрид с облаком — по желанию

Если политика компании допускает облако, полезна схема разделения: предобработка, поиск и работа с инструментами остаются локально, а облачная модель получает только то, что разрешено. Например, обезличенное описание задачи, когда локальный агент застрял. Переключение провайдера делается в шлюзе, сервисы об этом не знают.

Интерфейс и шлюз

Между сервисами и моделями стоит один шлюз с OpenAI-совместимым API, и это решение окупилось больше всего. На нём живут список моделей, проксирование запросов вместе с tools, ключи и статистика по пользователям. Больше ничего: сборка промпта, история и файлы — забота клиентских сервисов.

Что это даёт:

  • замена модели или переезд с Ollama на vLLM затрагивает один конфиг, а не все сервисы;

  • любой новый сервис подключается стандартным OpenAI SDK;

  • видно, кто и сколько нагружает модель.

Исключение — фоновые задачи с большим потоком запросов, например пакетная обработка данных из 1С. Их я пускаю напрямую в vLLM, чтобы не нагружать шлюз.

Open WebUI как фронтенд

Open WebUI закрывает чат, авторизацию, историю и группы пользователей из коробки. RAG-логику я не делаю на его встроенных книгах знаний. Вместо этого свой бэкенд на FastAPI притворяется «моделью» с OpenAI-совместимым API. Open WebUI отправляет ему диалог, а внутри происходит переписывание запроса, поиск, вызов инструментов и ответ. Так логика не зависит от фронтенда, и тот же бэкенд доступен из других систем.

Две детали, которые пользователи ценят больше всего:

  1. Ссылки на источники в каждом ответе: страница вики, файл, письмо. Без них ответу не доверяют, и правильно делают.

  2. Статус поиска в реальном времени: «ищу в почте», «запрашиваю 1С». Агент с инструментами может работать заметно дольше обычного чата, и пустой экран воспринимается как зависание.

Промпты — в одном месте

Все системные промпты и шаблоны лежат в одном prompts.json, а не разбросаны по коду. Когда промптов становится пара десятков, это единственный способ понять, что именно видит модель.

Грабли и выводы

Главный вывод: локальный ассистент делается не моделью, а конвейером данных и аккуратной интеграцией. Модель меняется раз в полгода, а парсеры, аннотации и инструменты остаются.

Что сломалось или обошлось дороже, чем ожидалось:

  • Полная переиндексация — это дни и недели. Инкрементальное обновление по датам изменения нужно закладывать с первого дня, а новую версию индекса строить рядом со старой.

  • Дубли из почтовых цепочек тихо портят выдачу. Заметить это можно, только если смотреть на найденные чанки, а не только на финальный ответ.

  • Извлечение заранее теряет детали. Если LLM заранее вытаскивает «важное» из документа, часть фактов неизбежно теряется. Я перехожу к схеме, где агенты по почте и файлам находят кандидатов по аннотациям, а детали читают из исходника в момент вопроса.

  • Прямой доступ к БД вики быстрее на старте и дороже потом. REST API сразу сэкономил бы время.

  • Смена длины контекста из клиента в Ollama приводила к перезагрузкам модели и тормозам. В vLLM параметры задаются при запуске сервера, и эта проблема уходит.

Что бы я сделал иначе с самого начала:

  1. Собрал бы набор из 50–100 реальных вопросов сотрудников с ожидаемыми источниками и прогонял его после каждого изменения. Без этого любая «оптимизация» — гадание.

  2. Сразу поставил шлюз между сервисами и моделями.

  3. Делал инструменты к 1С и Битрикс24 как MCP-сервер с первой версии, а не вшивал в код агента.

Если делаете похожее или думаете внедрить такое у себя, пишите в комментариях — разберём ваш случай.