Трендовые github проекты в нашем телеграм канале. Подпишись → 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, запрос ищется по косинусному расстоянию. На простых запросах это работало. На запросах вроде «ключевая ставка ЦБ» выдача заполнялась нерелевантными текстами, а самым показательным оказался запрос «золото»: поиск уверенно находил новости о фигуристах, взявших золото, вместо цен на металл. Векторное сходство плохо различает омонимы — для модели оба «золота» семантически близки.
Решение — гибридный поиск:
- Семантическая часть — те же векторы в pgvector, ловят синонимы и перефразирования.
- Лексическая часть — BM25 поверх
tsvectorв PostgreSQL, ловит точные термины: «золото», «ОПЕК», «ставка». - Слияние — 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_metrics | SQL-агрегация, например число статей про энергетику за 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().