Logo Craft Homelab Docs Нейросети Хостинг Обучение Контакты
Self-hosted RAG на Ollama и LangGraph: гибридный поиск, агент и грабли локальных LLM Трендовые github проекты в нашем телеграм канале. Подпишись →
29 октября 2026 г.

RAG по 12 000 новостей на своём железе: что сработало и что тормозило

Типовой стек RAG-системы опирается на облачные модели и управляемую векторную базу. Когда внешние провайдеры недоступны или данные нельзя отдавать наружу, всё это приходится собирать локально. Ниже — разбор открытого проекта, который за две недели собрал из RSS-лент больше 12 000 новостей, разметил их локальной моделью и отвечает на аналитические вопросы через агента с инструментами.

Стек целиком:

  • Python 3.12, FastAPI;
  • PostgreSQL + pgvector для векторов и полнотекстового поиска;
  • Celery для фоновой обработки;
  • LangGraph для агента;
  • Ollama с моделями Qwen 2.5 14B (генерация и классификация) и BGE-M3 (эмбеддинги);
  • Streamlit как интерфейс;
  • Langfuse для трассировки;
  • Docker Compose — весь проект поднимается через docker compose up -d, тесты гоняются в GitHub Actions.

Слои и изоляция доменной логики

Код разбит на слои domain, db, scraper, rag, agents, api. Доменная логика не зависит от фреймворков, поэтому заменить модель или выкинуть библиотеку можно без переписывания бизнес-правил. Ниже видно, что это пригодилось на практике: LangChain из RAG-пайплайна в итоге удалили целиком.

Почему одних эмбеддингов не хватило

Первая версия поиска была классической: все статьи прогнаны через BGE-M3, запрос ищется по косинусному расстоянию. На простых запросах это работало. На запросах вроде «ключевая ставка ЦБ» выдача заполнялась нерелевантными текстами, а самым показательным оказался запрос «золото»: поиск уверенно находил новости о фигуристах, взявших золото, вместо цен на металл. Векторное сходство плохо различает омонимы — для модели оба «золота» семантически близки.

Решение — гибридный поиск:

  1. Семантическая часть — те же векторы в pgvector, ловят синонимы и перефразирования.
  2. Лексическая часть — BM25 поверх tsvector в PostgreSQL, ловит точные термины: «золото», «ОПЕК», «ставка».
  3. Слияние — Reciprocal Rank Fusion. Каждый документ получает очки по позиции в обоих списках, итоговая выдача строится по сумме.

RRF удобен тем, что не требует приводить к общей шкале косинусное расстояние и BM25-скор: учитываются только ранги. После перехода на гибридную схему Recall@5 вырос с 0.2 до 0.53. Оба индекса живут в одном PostgreSQL, отдельный поисковый движок не понадобился.

Классификация: как модель прятала ответы в «other»

Каждую статью LLM относила к тематической категории. На первом прогоне 74% статей попали в other. Промпт содержал фразу «если сомневаешься — other», и модель использовала эту лазейку при любой неуверенности.

Промпт переписали:

  • у каждой категории появилось чёткое описание, включая retail, macro, incident;
  • фразу про other убрали.

Доля other упала до 0.03%. Дальше провели ручную проверку: взяли случайную выборку из разных категорий и сверили разметку. Точность получилась 85%. Для локальной 14B-модели без дообучения это рабочий уровень. Сама по себе доля 0.03% ничего не говорит о качестве: модель могла бы раскидывать статьи по категориям наугад, и только выборочная проверка это исключила.

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

Агент на LangGraph: три инструмента вместо одного поиска

Обычный RAG отвечает на вопросы вида «что пишут про нефть?». Для аналитики нужны подсчёты и фильтры, поэтому поверх поиска собран агент — конечный автомат на LangGraph с тремя инструментами:

ИнструментЧто делает
search_articlesгибридный поиск из предыдущего раздела
get_metricsSQL-агрегация, например число статей про энергетику за 3 дня
search_by_entityпоиск по извлечённым сущностям (компании) и тональности — результаты NER и sentiment-анализа

На вопрос «есть ли негативные новости про ЦБ?» агент сам выбирает search_by_entity с фильтром по тональности. Граф проходит этапы Planner → Tools → Finalizer.

Трассировка через Langfuse

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

Грабли, на которых теряется производительность

Ollama выгружает модели при переключении

В пайплайне работают две модели: BGE-M3 для эмбеддингов и Qwen 2.5 14B для генерации. По умолчанию Ollama выгружала одну, чтобы загрузить другую, и каждый запрос, где нужны обе, занимал около 60 секунд. Лечится переменной окружения:

OLLAMA_MAX_LOADED_MODELS=2

Обе модели должны помещаться в память одновременно — это стоит проверить до того, как поднимать лимит.

LangChain добавлял 9-кратное замедление

ChatOllama из LangChain внутри собирает ответ через streaming aggregation, и на этом пайплайн терял время в 9 раз. LangChain убрали из RAG-части и перешли на прямой ollama.AsyncClient. Благодаря разделению на слои замена затронула только модуль rag.

Celery, async SQLAlchemy и «зомби»-соединения

Асинхронный SQLAlchemy привязывает пул соединений к event loop. Когда Celery завершал задачу, соединения из пула оставались висеть привязанными к уже мёртвому циклу. Исправление — явно закрывать движок после каждой задачи:

await engine.dispose()

Это стоит делать в любом проекте, где async-код с SQLAlchemy запускается внутри Celery-воркеров.

Последовательная обработка запросов

По умолчанию Ollama обрабатывала запросы по одному. Классификация одной статьи занимала примерно 10 секунд, на 12 000 статей это больше 33 часов. Увеличение параллелизма с 1 до 2 ускорило обработку минимум в 1.5 раза:

OLLAMA_NUM_PARALLEL=2

Каждый параллельный слот требует дополнительной памяти под контекст, так что значение подбирается под конкретную видеокарту. Здесь ещё есть запас для экспериментов.

Куда проект развивается

Сейчас это публичный MVP. Следующий шаг — переход от общего новостного фона к биржевой аналитике: привязка компаний к тикерам Мосбиржи (SBER, GAZP), извлечение рыночных фактов и дашборды по конкретным акциям. Архитектурное решение ADR-001 заранее рассчитано на это расширение без переписывания ядра.

Короткий чек-лист для своего локального RAG

  • Векторный поиск дополните BM25 и слейте выдачи через RRF: на омонимах и точных терминах чистые эмбеддинги проваливаются.
  • Уберите из промпта классификатора разрешение «сомневаешься — кидай в other» и опишите каждую категорию.
  • Проверяйте разметку руками на случайной выборке.
  • Выставьте OLLAMA_MAX_LOADED_MODELS по числу моделей в пайплайне, если хватает памяти.
  • Подберите OLLAMA_NUM_PARALLEL под объём VRAM.
  • Измерьте накладные расходы обёрток вроде LangChain и при необходимости ходите в Ollama напрямую.
  • В Celery-задачах с async SQLAlchemy вызывайте engine.dispose().