Локальный AI-агент, который читает технические паспорта плавильных печей и нормативную базу, сопоставляет характеристики оборудования с порогами закона и обосновывает класс опасности ОПО — со ссылкой на статью, таблицу и страницу источника.
100% on-premise · LangGraph ReAct · Qwen3-8B (llama.cpp) · Qdrant hybrid RRF · FastAPI SSE · React + TypeScript · Docker Compose
Чтобы зарегистрировать опасный производственный объект, специалисту нужно свести два разных мира документов: технический паспорт конкретной печи (ёмкость ванны, вид расплава, температура) и нормативную базу — 116-ФЗ с приложениями и Постановление Правительства РФ №1363 с типовыми наименованиями ОПО. Работа механическая, но цена ошибки — неверный класс опасности в госреестре.
Сложность в источниках:
- Паспорта существуют только как сканы — перекошенные страницы, колонтитулы, таблицы характеристик.
- Закон — иерархия статей и пунктов, где ответ часто лежит в приложении, а не в теле статьи.
- Обычный векторный поиск путает
ОПП-1иОПП-1,2-07и с равной уверенностью цитирует оглавление.
Отдельное требование заказчика: контур закрытый. Ни модель, ни эмбеддинги, ни векторная база не могут покидать сервер предприятия.
Агент решает задачу не одним поиском, а планом: сначала достаёт факты об оборудовании, затем ищет норму, затем сопоставляет. Четыре инструмента — четыре разных источника истины.
| Инструмент | Что делает |
|---|---|
industrial_machines_search |
Характеристики, комплектность, конструкция, регламент обслуживания четырёх печей ОПП. Модель печи из запроса превращается в жёсткий фильтр по метаданным. |
law_search |
Классификация ОПО и классы опасности I–IV, обязанности эксплуатирующей организации, декларирование, лицензирование, страхование, производственный контроль. |
naming_search |
Подбор типового наименования (именного кода) по ПП РФ №1363: раздел промышленности, опасные процессы, границы объекта, правила идентификации. |
get_document |
Аварийный выход из RAG: если после двух переформулировок фрагмент не нашёлся, агент забирает документ целиком и читает его сам. |
Поверх инструментов:
- Обоснование, а не ответ. Итог оформляется по фиксированному шаблону: признак ОПО, класс опасности и сравнение «параметр оборудования против порога из закона».
- Проверка фактов после ответа. Второй проход LLM сверяет каждое число и класс из ответа с текстом, реально вернувшимся из поиска.
- Видимый ход рассуждений. Reasoning-поток модели, вызовы инструментов и их результаты стримятся в интерфейс отдельными каналами — пользователь видит, откуда взялся вывод.
- Ссылка на источник обязательна. Каждое утверждение сопровождается статьёй, приложением, таблицей или страницей паспорта.
Всё, что нужно для ответа, живёт внутри одной сети Docker: две инференс-ноды llama.cpp (генерация и эмбеддинги), векторная база, файловый сервис документов, агент и фронтенд за nginx. Единственный порт наружу — 80.
flowchart LR
U["Инженер<br/>браузер"] --> NG["nginx :80<br/>SSE без буферизации"]
NG --> FE["React SPA<br/>Vite build"]
NG -->|"POST /agent/chat"| AG["FastAPI + LangGraph<br/>ReAct-агент :8001"]
AG -->|"чат, reasoning,<br/>fact-check"| LC["llama-chat :8080<br/>Qwen3-8B Q4_K_M"]
AG -->|"dense-вектор запроса"| LE["llama-embed :8081<br/>BGE-M3 Q4_K_M"]
AG -->|"hybrid query<br/>dense + sparse"| QD[("Qdrant :6333<br/>documents / law / naming")]
AG -->|"полный текст .md"| DS["docs-api :8000"]
GPU-слои llama.cpp пробрасываются override-файлом, поэтому одна и та же конфигурация поднимается на CUDA, Apple Metal и чистом CPU.
Агент работает в цикле ReAct: думает, вызывает инструмент, смотрит результат, при необходимости переформулирует запрос. Всё это время фронтенд получает события по SSE — ожидание не выглядит как зависший спиннер.
sequenceDiagram
autonumber
participant U as Инженер
participant A as Агент · LangGraph
participant L as Qwen3-8B
participant Q as Qdrant + BGE-M3
U->>A: "Какой класс опасности у печи ОПП-3,0?"
A->>L: планирование, reasoning-бюджет 2048 токенов
L-->>A: вызвать industrial_machines_search
A->>Q: hybrid-поиск + фильтр device_model = ОПП-3,0
Q-->>A: фрагменты паспорта, section_path, page_num
A-->>U: SSE tool-call / tool-result
L-->>A: вызвать law_search
A->>Q: "класс опасности объекта с расплавами металлов"
Q-->>A: норма 116-ФЗ со ссылкой на статью
L-->>A: стрим ответа по шаблону
A-->>U: SSE thinking / tokens (потоки разделены)
A->>L: проверка фактов: ответ против контекста поиска
L-->>A: список подтверждённых утверждений
A-->>U: SSE verification
Всё это — один HTTP-запрос с одним SSE-потоком. Верификация запускается только если инструменты действительно что-то вернули: проверять нечего, если модель не опиралась на документы.
Исходники — PDF-сканы паспортов и текст нормативных актов. Перед распознаванием страницы выпрямляются: угол наклона определяется преобразованием Хафа по длинным горизонтальным линиям, затем страница поворачивается и обрезается от колонтитулов. Кривой скан — главный источник мусорных чанков.
flowchart TB
subgraph P1["1 · Подготовка"]
A1["PDF-скан паспорта"] --> A2["PyMuPDF → растр страниц"]
A2 --> A3["OpenCV: детекция наклона<br/>по Хафу, deskew, crop"]
A3 --> A4["Docling: layout + таблицы<br/>→ структурный Markdown"]
B1["116-ФЗ, ПП РФ №1363"] --> A4
end
subgraph P2["2 · Чанкинг по структуре"]
A4 --> C1{"тип документа"}
C1 -->|закон| C2["разбор по статьям,<br/>главам, приложениям,<br/>затем по пунктам"]
C1 -->|паспорт| C3["разбор по заголовкам<br/>разделов, крупные —<br/>по абзацам"]
C1 -->|ПП 1363| C4["строка таблицы = чанк:<br/>наименование, процессы,<br/>границы, правила"]
end
subgraph P3["3 · Обогащение и загрузка"]
C2 --> D1["метаданные: device_model,<br/>section_path, page_num,<br/>chunk_type, parent_content"]
C3 --> D1
C4 --> D1
D1 --> D2["dense: BGE-M3"]
D1 --> D3["sparse: термовые веса"]
D2 --> D4[("Qdrant: именованные<br/>векторы в одной точке")]
D3 --> D4
end
Чанкинг не универсальный, а по типу документа: у закона единица смысла — пункт статьи, у паспорта — раздел, у ПП №1363 — строка таблицы наименований. Одна общая стратегия резала бы нормы посередине.
Плотные векторы отлично ловят перефразировку («ёмкость ванны» ↔ «объём расплава»), но одинаково
близко располагают ОПП-1 и ОПП-1,2-07. Разреженные — наоборот, точны в кодах и цифрах, но
беспомощны к синонимам. В домене, где модель печи и пороговое значение решают всё, нужны обе ветки.
flowchart LR
Q["Запрос:<br/>«ёмкость ванны ОПП-3,0»"] --> RX["regex: марка печи<br/>из текста запроса"]
RX --> F["Filter: must device_model,<br/>must_not оглавление<br/>и служебные чанки"]
Q --> DV["BGE-M3 → dense"]
Q --> SV["sparse-эмбеддер → sparse"]
DV --> P1["Prefetch dense<br/>limit 2k"]
SV --> P2["Prefetch sparse<br/>limit 2k"]
F -.->|"применяется к обеим ветвям"| P1
F -.-> P2
P1 --> RRF{{"Reciprocal Rank Fusion"}}
P2 --> RRF
RRF --> TOP["top-10 фрагментов<br/>+ section_path + page_num"]
Ранги двух ветвей сливаются по RRF на стороне Qdrant — одним запросом, без второго сетевого хопа и без ручной нормализации несопоставимых скоров.
Модель печи как жёсткий фильтр, а не как подсказка.
Ранняя версия отвечала характеристиками ОПП-1,2-07 на вопрос про ОПП-1: строки паспортов
семантически почти идентичны. Решение — вытаскивать модель регулярным выражением из запроса
и превращать в must-условие Qdrant, а оглавления и служебные чанки таблиц отсекать через must_not.
→ Смешение моделей в выдаче устранено; оглавление больше не попадает в контекст.
Разделение reasoning-потока и ответа на лету.
Qwen3 отдаёт размышления в тегах <think>, и при стриминге тег приходит разорванным между чанками.
Написан инкрементальный фильтр с буфером: он придерживает хвост длиной в тег, поэтому рассуждения
и финальный текст уходят в разные SSE-события и никогда не перемешиваются.
→ Пользователь видит ход мысли отдельным блоком, в историю диалога он не попадает.
Проверка фактов вторым проходом. После ответа собирается контекст из всех результатов инструментов, и отдельный вызов LLM с ролью независимого проверщика ищет в ответе числа, классы опасности и пороги, проверяя наличие каждого в контексте. Вызов обёрнут в таймаут: сбой проверки не должен ронять ответ. → Итог помечается «✓ Все утверждения подтверждены» либо «⚠ Найдены расхождения».
Одна конфигурация под три вида железа.
Установщик определяет окружение сам: NVIDIA — CUDA-образы llama.cpp с GPU-override, macOS с
llama.cpp — нативные серверы с Metal и проксированием через host.docker.internal, иначе — CPU.
База знаний приезжает заказчику снапшотами Qdrant и восстанавливается тем же скриптом.
→ Развёртывание на закрытом контуре — один ./install.sh.
Промпт как регламент, а не как пожелание. Системный промпт описывает два рабочих сценария по шагам, задаёт шаблон ответа и явно запрещает обобщения: цитировать только полученное из инструментов, до трёх переформулировок запроса перед признанием «данных нет», всегда указывать источник. Описания инструментов включают примеры формулировок и стратегию отступления. → Модель на 8B параметров держит дисциплину вывода без дообучения.
Инженер: Печь ОПП-3,0 — это опасный производственный объект? Если да, какой класс опасности?
Вызовы агента:
→ industrial_machines_search — «ёмкость ванны, вид расплава ОПП-3,0»
→ law_search — «класс опасности объекта с расплавами металлов»
Ответ по шаблону:
- Оборудование: Печь плавильная ОПП-3,0
- Является ОПО: да
- Почему ОПО: получение и использование расплавов чёрных металлов — признак из 116-ФЗ
- Класс опасности: по порогу массы расплава из приложения к закону
- Обоснование: ёмкость ванны из паспорта сопоставлена с пороговым значением нормы, обе цифры со ссылками
✓ Все утверждения подтверждены — ёмкость ванны и порог найдены в контексте
Инженер: Под каким типовым наименованием регистрировать участок с печью ОПП-1?
→ industrial_machines_search — назначение и вид процесса
→ naming_search — «плавка чёрных металлов в печи» (описание процесса, не марка печи)
Агент возвращает типовое наименование из ПП РФ №1363 с указанием раздела классификации, перечня опасных процессов и границ объекта. Если кандидатов несколько — объясняет, почему выбран именно этот, по основной функции оборудования.
Инженер: Какая периодичность капитального ремонта и габаритные размеры у ОПП-1,2-07?
Агент отвечает данными из соответствующих разделов паспорта с номерами страниц. Если фрагмент
не находится после двух переформулировок — забирает документ целиком через get_document
и отвечает по полному тексту.
| Компонент | Роль | Почему так |
|---|---|---|
| LangGraph | ReAct-агент, цикл «мысль → инструмент → наблюдение» | Нужны были повторные попытки поиска и потоковые события по каждому шагу, а не одиночный retrieve-then-read |
| Qwen3-8B Q4_K_M | Генерация, планирование, fact-check | Управляемый reasoning-бюджет, хороший русский и tool-calling при ~5 ГБ квантованных весов |
| llama.cpp server | Инференс LLM и эмбеддингов | OpenAI-совместимый API локально; один образ покрывает CUDA, Metal и CPU |
| BGE-M3 | Плотные эмбеддинги | Сильная многоязычная модель, устойчива к русской юридической лексике |
| Qdrant | Векторное хранилище, гибридный поиск | Именованные dense/sparse-векторы, серверный RRF, фильтры по метаданным, снапшоты для поставки |
| Docling | PDF → структурный Markdown | Восстанавливает разметку и таблицы; работает офлайн на локальных артефактах моделей |
| OpenCV · PyMuPDF | Deskew, обрезка, растеризация | Кривой скан ломает распознавание таблиц раньше, чем до них дойдёт OCR |
| FastAPI · SSE | Стриминговый API агента | Асинхронность под долгие вызовы инструментов; SSE проще WebSocket для однонаправленного потока |
| React 18 · TypeScript · Vite | Интерфейс чата | Отдельные блоки под reasoning, шаги инструментов и результат верификации |
| Docker Compose · nginx | Оркестрация и точка входа | Отключённая буферизация прокси — обязательное условие живого SSE |
.
├── agent/ # FastAPI + LangGraph: граф, инструменты, SSE-сервер, память
│ ├── graph.py # ReAct-агент над локальной LLM
│ ├── tools.py # 4 инструмента, гибридный поиск в Qdrant
│ ├── server.py # SSE-стрим, think-фильтр, проверка фактов
│ └── config.py # системный промпт-регламент
├── frontend/ # React + TypeScript + Vite
├── ingestion/ # пайплайн подготовки данных (запуск на хосте)
├── models/ # GGUF-веса (qwen3-8b, bge-m3)
├── qdrant-snapshots/ # база знаний для восстановления
├── files/ # исходные и конвертированные документы
├── docs_server.py # файловый API полных текстов
├── docker-compose.yml # 7 сервисов
├── docker-compose.gpu.yml # override проброса NVIDIA GPU
├── nginx.conf # точка входа, SSE без буферизации
└── install.sh # авто-детект железа, загрузка моделей, восстановление базы
| Компонент | Минимум | Рекомендуется |
|---|---|---|
| CPU | 8 ядер | 8+ ядер |
| RAM | 16 GB | 32 GB |
| GPU | NVIDIA 8 GB VRAM | NVIDIA 12+ GB VRAM |
| Диск | 20 GB свободно | 50 GB SSD |
| ОС | Ubuntu 22.04 / 24.04 | Ubuntu 24.04 |
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp dockercurl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt update && sudo apt install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart dockerПроверка что GPU виден:
nvidia-smichmod +x install.sh
./install.shСкрипт автоматически:
- определит режим работы (CUDA / Metal / CPU);
- скачает GGUF-веса моделей;
- запустит сервисы и дождётся готовности Qdrant;
- восстановит базу знаний из снапшотов;
- поднимет агент, фронтенд и nginx после загрузки модели в память.
http://localhost
docker compose up -d # запустить
docker compose down # остановить
docker compose restart agent # перезапустить один сервис
docker compose ps # статус
docker compose logs -f agent # логи сервисаСервис не запускается
docker compose logs <имя_сервиса>GPU не определяется
nvidia-smi
docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smiБаза знаний пустая
curl http://localhost:6333/collectionsЕсли коллекции отсутствуют — повторить восстановление снапшотов:
for snapshot in qdrant-snapshots/*.snapshot; do
filename=$(basename "$snapshot")
collection=$(echo "$filename" | sed 's/-[0-9].*//')
curl -X POST \
"http://localhost:6333/collections/${collection}/snapshots/upload?priority=snapshot" \
-H "Content-Type: multipart/form-data" \
-F "snapshot=@${snapshot}"
doneМодель отвечает медленно — проверить, что она работает на GPU, а не CPU:
docker compose logs llama-chat | grep "ggml_cuda"Если строки с ggml_cuda есть — GPU используется.
| Сервис | Порт | Описание |
|---|---|---|
| nginx | 80 | Точка входа, веб-интерфейс |
| llama-chat | 8080 | LLM (qwen3-8b) |
| llama-embed | 8081 | Эмбеддинги (bge-m3) |
| agent | 8001 | FastAPI агент |
| qdrant | 6333 | Векторная база данных |
| docs-api | 8000 | API документов |