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

Задача звучит тривиально: сделал чат, загрузил документы, спросил — получил ответ. На практике всё намного интереснее: надо правильно собрать архитектуру, не смешать лишние данные, поднять локальные модели, победить странности парсинга документов и многое другое.

Сначала надо зафиксировать архитектуру

Первое решение было не техническим, а дисциплинарным: зафиксировать MVP и больше ничего в него не добавлять.

Очень хотелось сразу сделать свою веб-морду, граф знаний, автоматическую синхронизацию папок, красивый переключатель моделей, отдельные пайплайны и всё остальное, что обычно превращает рабочий прототип в стройку века.

Я решил внедрять стандартную схему:

Пользователь
↓
RAGFlow Web UI
↓
отдельная база знаний по одному станку
↓
hybrid retrieval
↓
Qwen3-Embedding-8B
↓
Qwen3-Reranker-4B
↓
локальная LLM или внешняя исследовательская модель по API

Важный момент: для первого варианта я сделал два chat-профиля на одной и той же базе знаний. У них одинаковые документы, но разные LLM API: один локальный (который развернут на NVIDIA Jetson), второй внешний исследовательский по API Openrouter (это дает доступ к сильным моделям типо GPT-6).

Это простое решение, но оно сильно снижает риск галлюцинаций. База знаний одна, retrieval один, меняется только генератор ответа.

Почему RAG, а не просто дообучить модель?

В заводской документации нельзя полагаться на память модели. Тут важна точность. RAG дает эту точность, т.к. мы всегда знаем первоисточник. А вот ессли дообученная модель уверенно придумает назначение сигнала, канал модуля или связь с электрической схемой - то мы это даже понять не сможем сразу.

Поэтому главный принцип я зафиксировал так:

LLM не решает, искать ей документы или нет.
Retrieval выполняется обязательно.
Reranking выполняется обязательно.
И только потом LLM получает найденный контекст + мой промт.

То есть модель аккуратный переводчик между человеком и уже найденными фрагментами документации.

Для поиска выбрал hybrid retrieval. Семантический поиск нужен для запросов вроде: найти описание узла по смыслу. А текстовый поиск нужен для заводской реальности:

X161
D5708
W1482
5A25
CH187

Если искать только по смыслу, такие штуки легко потерять. А если искать только по точному совпадению, система плохо понимает человеческие вопросы.

Локальный embedding: без него базы знаний вообще нет

Первой обязательной моделью был embedding. Без него документы не превращаются в векторное пространство, а значит нормального semantic retrieval нет.

Я поднял локальный сервис на Qwen3-Embedding-8B в BF16. Работает через vLLM в Docker и отдаёт OpenAI-compatible /v1/embeddings API.

Критерии были практические:

русский
английский
китайский
техническая терминология
длинные описания
желательно OpenAI-compatible API

Reranker: то, на чем я всегда настаиваю

Reranker я по умолчанию считаю желательным. Без embedding, например, базы нет вообще. Без reranker поиск хуже, но уже можно тестировать.

В итоге поднял Qwen3-Reranker-4B в BF16. Он тоже работает локально, в общей Docker-сети с RAGFlow. Отдельно настроил persistent model cache, чтобы веса модели не скачивались и не пересобирались заново при каждом пересоздании контейнера.

Почему 4B, а не 8B? По бенчмаркам разница для reranking небольшая, а местами 4B даже выглядит лучше. Зато 8B полезнее отдать embedding-модели, потому что именно она строит первичное пространство поиска.

Проверял не на абстрактных фразах, а на промышленном multilingual-примере: русский запрос, русский релевантный фрагмент, китайское описание и нерелевантные документы. Релевантные куски оказались сверху, нерелевантные ушли вниз.

Локальная LLM через OpenAI-compatible API

С генератором ответа я специально сделал слой совместимости. RAGFlow не должен знать, что внутри конкретно Qwen, Ollama или что-то ещё.

Для него это просто OpenAI-compatible endpoint:

/v1/models
/v1/chat/completions
model = qwen3:32b
Bearer auth

Локальная модель Qwen3:32B была подключена через собственный Flask-прокси к Ollama. Это оказалось правильным архитектурным решением: если завтра модель начнёт вести себя плохо, можно заменить backend, но сохранить тот же контракт API, что позволяет быстро менять модель, не меняя архитектуру всего проекта.

Был и интересный момент: после запуска embedding и reranker старый memory guard на NVIDIA Jetson начал блокировать загрузку сразу трех моделей (включая Qwen3:32B). Пришлось разбиратся в причинах и поднимать лимит RAM guard до 85%, отдельно изменить guard по GPU-памяти и оставить reserve под модель. Оказывается, даже мощностей NVIDIA Jetson не всегда хватает под локальные задачи.

Самая важная часть - база знаний

Потом началась менее романтичная, но самая полезная часть: собрать корпус документов.

По одному станку документы лежали в разных источниках: официальные PDF, электрические схемы, выгрузки PLC/HMI/Motion, таблицы регистров, комментарии, alarm maps, сервисные материалы, исследования, ML-артефакты.

Первичная инвентаризация дала:

617 файлов всего
386 RAG-friendly
231 бинарный, графический или проектный файл

Потом прошёлся по дублям:

82 точных дубля по SHA256
43 файла уже были в RAGFlow
261 новый уникальный RAG-friendly файл

В работу ушло около 280 документов, а после всех ремонтов и дополнений итоговая база получилась такой:

320 документов полностью обработаны
4 дали zero chunks (их пришлось "копать" руками)
40 049 chunks
примерно 19 млн tokens

Где всё ломалось

CSV оказались не UTF-8

Часть PLC и HMI exports падала при chunking из-за кодировки. Некоторые файлы были UTF-16, другие cp1251 или cp1250.

Решение простое: автоматически перекодировать в UTF-8-SIG и повторно отправить на индексацию. После этого проблемные CSV нормально попали в repair_ok.

Большие документы превышали контекст embedding-модели

Некоторые Markdown/PDF chunks превышали лимит embedding-контекста. Большие Markdown-файлы пришлось физически разрезать на небольшие самостоятельные документы, зато потом retrieval работает предсказуемо.

JSON не всегда RAG-friendly

Несколько structured JSON-файлов давали zero chunks. Я перевёл их в Markdown, сохранив структуру полей в виде:

object[index].field: value

После этого документы нормально проиндексировались.

UI-режим оказался важнее, чем казалось

Самый неприятный баг был не в моделях, а в режиме работы RAGFlow.

В режимах Low / Medium / High / Ultra система переходила в agentic workflow и пыталась заставить LLM самостоятельно вызвать инструмент rag (так она настроена из коробки). А локальный Qwen в этой конфигурации такой workflow корректно не поддерживал и давал ожидаемый отказ.

В логах было видно, что фактические chunks до модели не доходят. Модель то отвечала странно, то начинала фантазировать из своих весов.

Помог режим Native. После переключения логика стала такой, как и нужно:

retrieval обязателен
↓
reranking обязателен
↓
контекст передаётся LLM
↓
ответ строится по найденным источникам

Для диагностики я добавил audit trail в LLM API: сохраняется исходный запрос, payload в backend, streaming chunks, итоговый текст, usage, длительность, статус и request ID. Это быстро показало, что прокси не искажает запросы, а неправильный prompt приходит выше по цепочке.

Контрольный тест

Финально я проверял систему на точном промышленном вопросе по PLC-сигналу. Нужно было найти конкретный идентификатор, шкаф, модуль/канал, назначение сигнала и соседние сигналы.

Система прошла полный путь:

вопрос
↓
embedding
↓
hybrid retrieval
↓
reranking
↓
контекст
↓
локальная LLM
↓
ответ со ссылкой на источник

Правильная таблица с нужным диапазоном сигналов стабильно находилась среди первых результатов и передавалась генератору.

Это был важный момент: не просто чат ответил красиво, а именно retrieval нашёл нужный технический фрагмент.

Что я бы вынес как практические правила

Если собирать похожую систему для документации, я бы держался таких правил:

  1. Не смешивать разную документацию в одну базу знаний. Например, одинаковые PLC-адреса на разных машинах быстро превратят поиск в кашу.
  2. Делать hybrid retrieval, потому что техническая документация живёт одновременно в смыслах и в точных идентификаторах.
  3. Не давать LLM право решать, нужен ли поиск. В промышленной документации retrieval должен быть обязательным.
  4. Reranker полезен, его можно считать вторым слоем качества.
  5. Проверять не демо-вопросами, а реальными PLC/HMI/электрическими идентификаторами.
  6. Делать audit trail сразу. Когда пайплайн длинный, без логов быстро становится ничего непонятно.

Вывод

В итоге получился рабочий локальный MVP для анализа заводской документации: отдельная база знаний по одному станку, локальные embedding и reranker, локальная LLM через совместимый API, возможность подключать внешнюю исследовательскую модель и нормальный end-to-end путь от вопроса до ответа по источникам.

Самое ценное здесь не в том, что рядом с документацией появился чат. Ценность в том, что появилась инженерно контролируемая прослойка между живым вопросом специалиста и большим техническим архивом: с изоляцией данных, понятным retrieval, логами, repair-процессом документов и возможностью менять модели без перестройки всей системы.