Локальный RAG без GPU, облака и Docker: пять уроков, каждый из которых стоил недели работы
Инженер из государственного сектора Коста-Рики собрал RAG-пайплайн на обычном «железе» без GPU и облака — и рассказывает, с какими подводными камнями столкнулся на каждом этапе.
Каждый туториал по RAG неявно предполагает, что у вас есть GPU и доступ к облачному API. Если оба предположения не выполняются — а в корпоративном и государственном секторе это норма — придётся заново проектировать каждый слой стека.
Зачем это нужно
Системы RAG (Retrieval-Augmented Generation) — это архитектура, при которой языковая модель отвечает на вопросы пользователя не «из головы», а с опорой на релевантные фрагменты из базы документов. Сначала запрос превращается в вектор (эмбеддинг), затем по cosine-сходству из векторного хранилища достаются ближайшие чанки, после чего всё это подкладывается в промпт для LLM.
Такой подход позволяет создавать корпоративных ассистентов, которые не халлюцинируют про содержимое внутренних документов, — при условии, что архитектура собрана правильно. Проблема в том, что подавляющее большинство гайдов по RAG рассчитаны на «идеальный» стенд: Linux, GPU от NVIDIA, облачные API вроде OpenAI, Docker-контейнеры для каждого компонента. Автор оригинальной статьи — инженер, работающий с медицинскими информационными системами в государственном секторе Коста-Рики, — оказался в куда более типичных для enterprise условиях: Windows Server, только CPU, данные не покидают локальную сеть, контейнеризаторы не одобрены политиками ИБ.
Его проект rag-onpremise использует ASP.NET Core 9 для оркестрации, Ollama для локального инференса LLM, Qdrant в качестве векторной базы и Python-скрипт для импорта документов. В качестве языковой модели — Mistral 7B, для эмбеддингов — nomic-embed-text. Всё работает на CPU. Автор подчёркивает, что дизайн стека занял меньше времени, чем доведение его до production-состояния, — и делится пятью уроками, которые стоили ему недели кропотливой отладки.
Урок 1: забудьте про gRPC — используйте REST
Официальный .NET SDK для Qdrant выглядит элегантно: чистый API, хорошая документация, типизированные методы. Но под капотом он общается с сервером Qdrant по протоколу gRPC, а для gRPC требуется HTTP/2. В корпоративной сети с прокси, балансировщиками или шлюзами, которые принудительно понижают соединение до HTTP/1.1, SDK просто перестаёт работать — с неочевидными ошибками, указывающими куда угодно, кроме реальной причины.
Решение — отказаться от SDK и обращаться к Qdrant напрямую через REST API с помощью стандартного HttpClient. REST API у Qdrant покрывает все операции, необходимые для RAG-пайплайна: поиск, вставка, удаление точек. Цена — потеря типизации и некоторых удобств. Выгода — работоспособность на любом сетевом стеке без необходимости договариваться с сетевым отделом про поддержку HTTP/2 на каждом хопе.
Это типичный случай, когда «правильный» с инженерной точки зрения выбор (SDK с сильной типизацией) проигрывает «грубому» решению (прямые HTTP-вызовы) в реальных условиях развёртывания. Урок для инженеров: протокольные зависимости — это скрытая сложность, которую легко не заметить в лабораторных условиях.
Урок 2: таймаут HttpClient по умолчанию убьёт ваши ответы
В .NET стандартный таймаут HttpClient — 100 секунд. Для обычных веб-запросов это щедрый запас. Для LLM на CPU — катастрофически мало.
Автор измерил реальную латентность Mistral 7B на скромном сервере (4 виртуальных ядра, 16 ГБ ОЗУ): от 60 до 120 секунд на полный ответ. Первый запрос отработал, второй — тоже, а третий попал на чуть более длинную генерацию — и клиент обрезал соединение прямо посередине стриминга. Пользователь видит ошибку, а сервер продолжает генерировать ответ, который никто не прочитает.
Решение — две строчки кода: явно установить таймаут в 300 секунд (или больше, в зависимости от worst-case на вашем железе). Но важнее сам принцип: если вы вызываете локальную LLM на CPU, измерьте реальную латентность на вашем hardware и поставьте таймаут с запасом. А если над этим строится UI — добавляйте индикатор прогресса. 90 секунд тишины выглядят как сломанная система, даже когда всё работает штатно.
Урок 3: Ollama слушает только localhost
Ollama «из коробки» привязывается к 127.0.0.1:11434. Для разработки на локальной машине — нормально. Для развёртывания, когда приложение и модельный сервер находятся на разных машинах (или даже запускаются от разных учётных записей) — это неработающая конфигурация.
Исправление: установить переменную окружения OLLAMA_HOST = "0.0.0.0:11434" перед запуском сервера. Простое решение — если знаешь о нём заранее. Ловушка в том, что Ollama при недоступности выдаёт generic connection errors, а не конкретное сообщение о том, что она слушает только loopback. Автор признаётся, что потратил полдня на проверку файрвола, прежде чем разобрался с привязкой.
Дополнительный нюанс: если Ollama запущена как Windows-сервис, переменную окружения нужно задать на уровне сервиса, а не в текущей сессии PowerShell. Задание её в консоли не повлияет на уже работающий сервис. Мелочь, которая стоит реального времени.
Урок 4: Python-установщик не проходит через корпоративные GPO
Ингест-пайплайн (скрипт для извлечения текста из документов и формирования эмбеддингов) написан на Python. На защищённом Windows Server с групповыми политиками (GPO), ограничивающими запуск установщиков, стандартный Python MSI молча завершается с результатом «успешно», не установив ничего на диск. Или громко падает с ошибками про повышение привилегий, которые даже администратор не может обойти.
Решение — использовать embeddable Python package: ZIP-архив вместо установщика, который обходит большинство ограничений GPO. Настройка чуть более ручная: нужно скачать архив, распаковать, в файле python3xx._pth раскомментировать строку import site (иначе pip не работает), скачать get-pip.py и запустить его из папки. После этого pip install работает штатно.
Ничего сложного — но если не знать об этом варианте, можно потратить два дня на борьбу с установщиком, который физически не может пройти через политику безопасности.
Урок 5: качество ответов определяется промптом, а не ретривалом
Это, пожалуй, самый ценный инсайт статьи. Автор признаётся, что потратил недели на тонкую настройку параметров чанкинга (разбиения документов на фрагменты), эмбеддингов и количества извлекаемых кусков (top-K). Каждое улучшение давало единицы процентов прироста качества. А потом он переписал шаблон промпта — и получил качественный скачок, который сделал всю ретривал-оптимизацию «погрешностью округления».
Две крайности, между которыми пришлось лавировать:
-
Слишком строгий промпт («отвечай ТОЛЬКО на основе контекста, если ответа нет — скажи «не знаю»») — модель начинала отказываться отвечать на вопросы, частично покрытые контекстом, и сыпала «я не знаю» там, где любой человек, прочитав тот же документ, ответил бы уверенно.
-
Слишком свободный промпт («используй контекст, чтобы помочь ответить») — модель начинала уверенно галлюцинировать, заполняя пробелы в извлечённых фрагментах правдоподобным вымыслом. В регулируемой среде (медицина!) это не вопрос качества, а вопрос юридической ответственности.
Рабочий компромисс формулируется примерно так: отвечай на основе предоставленного контекста; если информация частично релевантна — используй её и явно укажи, что контекст говорит, а о чём молчит; только если ничего связанного с вопросом нет — скажи об этом прямо; не выдумывай данные, которых нет в контексте.
Ключевые формулировки — «частично релевантна» (разрешение модели рассуждать по неполному контексту) и «укажи, что контекст говорит, а о чём молчит» (принудительное разделение прочитанного и выведенного). Результат — модель отвечает, когда может, честно признаёт ограничения и явно разграничивает факты и инференции.
Реальная латентность на CPU
Автор приводит конкретные цифры, которых так не хватает в большинстве туториалов:
| Конфигурация | Модель | Время ответа |
|---|---|---|
| 4 vCPU / 16 ГБ RAM | Mistral 7B | 60–120 сек |
| 16 vCPU / 32 ГБ RAM | Mistral 7B | 20–45 сек |
| 4 vCPU / 8 ГБ RAM | phi3:mini | 15–30 сек |
| GPU 8 ГБ+ | Mistral 7B | 3–8 сек |
Автор честно оговаривает: замеры CPU сделаны на реальных серверах развёртывания, а GPU-замер — с одолженного оборудования, reference point, а не production-данные.
Два практических вывода. Первый: phi3:mini на скромном железе по латентности конкурирует с Mistral 7B на значительно более мощном сервере. Если качество ответов устраивает — понижайте модель до того, как просить бюджет на апгрейд железа. Второй: разрыв между CPU и GPU — примерно 10×. Если есть возможность получить хотя бы одну 8-гигабайтную видеокарту — это кардинально меняет пользовательский опыт.
Чтобы смягчить проблему высокой латентности, автор реализовал семантический кэш перед LLM: cosine-сходство между входящим запросом и закэшированными запросами с порогом 0,92. При попадании в кэш ответ приходит менее чем за секунду. На умеренно загруженной внутренней системе hit rate кэша вырос достаточно, чтобы среднее время ответа ощущалось приемлемым, несмотря на worst-case в 90 секунд.
Важное предупреждение: при смене модели кэш нужно очищать. Закэшированные ответы привязаны к модели, которая их сгенерировала. Заменив Mistral на более новую версию, вы начнёте отдавать пользователям ответы от старой модели — и разницу в стиле они заметят раньше вас.