Надеюсь, все знают что такое RAG :) Для тех, кто не знает: это такая система, которая позволяет искать информацию и отвечать на вопросы по внутренней документации.

Архитектура RAG может быть как очень простой, так и весьма замысловатой. В самом простом виде она состоит из следующих компонентов:

  • Векторное хранилище — хранит документы в виде чанков - небольших фрагментов текста.

  • Ретривер — механизм поиска. Получает на вход искомую строку и ищет в векторном хранилище похожие на нее чанки (по косинусному сходству).

  • LLM — большая языковая модель, которая на основе найденных чанков формирует окончательный ответ.

Более сложные решения могут включать в себя реранкер, гибридный поиск и другие хитрые плюшки (на Хабре есть много статей с подробным описанием RAG'а).

Но, даже самая навороченная архитектура не справится с некоторыми вопросами. Рассмотрим такой пример:

Какая была прибыль в компании Магнит за 2020 год.

Тут ничего сложного. С таким вопросом справится даже RAG на одном семантическом поиске. Но если его “немного” усложнить:

Найди в годовых отчетах компании Магнит за последние 3 года упоминания ключевых рисков. Выдели, как менялась формулировка этих рисков от года к году.

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

  1. Определить, какой сейчас год (допустим 2025).

  2. Затем найти в БД информацию за каждый год:

    1. Какие были ключевые риски в годовом отч��те компании Магнит в 2022 году?

    2. Какие были ключевые риски в годовом отчете компании Магнит в 2023 году?

    3. Какие были ключевые риски в годовом отчете компании Магнит в 2024 году?

  3. Проанализировать полученные ответы и выдать финальный ответ.

Обычный RAG ни на что подобное не способен. Для такой задачи нужен агентный RAG.

Чем же они отличается? Обычный RAG, хотя и может иметь некоторые ответвления, но это всегда прямолинейный последовательный конвейер: ретривер → реранкер → LLM.

В агентном раге нет никакого жестко заданного пайплайна. Есть агент (на базе LLM) и есть набор инструментов (один из которых — ретривер), к которым он может обращаться. И агент сам решает когда и какой инструмент вызвать для выполнения задачи. Он может вызвать один инструмент, а может все (причем в любой последовательности), а может вызвать один и тот же инструмент множество раз, если предыдущие результаты ему не понравились. В процессе работы над запросом агент накапливает историю всех вызовов. И в конце концов LLM выдает финальный ответ.

А сейчас попробуем реализовать игрушечный пример агентного RAG’а, который сможет ответить на такой вопрос:

Найди в годовых отчетах компании Магнит за последние 5 лет упоминания ключевых рисков. Выдели, как менялась формулировка этих рисков от года к году.

Легенда:

Реализация

Шаг 1. Векторное хранилище

Векторное хранилище необходимо чтобы хранить чанки и вектора на их основе. По этим векторам мы будем сопоставлять запрос пользователя и чанк. И тем самым находить и возвращать наиболее релевантные чанки.

В качестве векторного хранилища будем использовать хорошо зарекомендовавшую себя БД Qdrant:

1.1. Скачиваем docker-образ кудранта:

docker pull qdrant/qdrant

1.2. Запускаем контейнер:

docker run -p 6333:6333 -p 6334:6334 \
    -v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
    qdrant/qdrant

После этого web-интерфейс Qdrant будет доступен по адресу:

localhost:6333/dashboard

1.3. Создаем коллекцию, в которой будут храниться чанки документов:

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams

# Создаем подключение
client = QdrantClient(url='http://localhost:6333') 

# Создаем коллекцию
client.create_collection(
    collection_name = 'rag_agent',
    vectors_config = VectorParams(size=1536, distance=Distance.DOT),
)

Шаг 2. Загрузка документов

Для экспериментов я накачал с сайта ИНИОН РАН кучу разных отчетов за разные года и от разны�� компаний (и положил их все в одной папке).

Если взглянуть на них, то можно обнаружить что они имеют довольно сложную структуру: много колонок, много графиков, много таблиц и т.д. Я перепробовал пару десятков PDF-парсеров. В принципе с задачей справились три: Marker, olmOCR, Docling. По внешнему виду мне больше всего понравился Marker - его и будем использовать

Для красивого оформления Marker вставляет много служебных символов (тире). Другие кандидаты — olmOCR, Docling — делают это проще. Например, с помощью HTML-тэгов. Поэтому с т.з. RAG может лучше подойдут другие два кандидата — надо тестировать.

2.1. Т.к. отчеты довольно длинные, а Marker работает очень небыстро, то мы предварительно распарсим все PDF файлы и сохраним их текстовое содержимое в TXT-файлах.

import os
import glob
from marker.converters.pdf import PdfConverter
from marker.models import create_model_dict
from marker.output import text_from_rendered

# Формируем PDF-парсер
converter = PdfConverter(artifact_dict=create_model_dict())

# Формируем список всех PDF файлов и путей до них
files = glob.glob(os.path.join('/docs', '*.pdf'))

# Проходимся по каждому файлу
for f in files:
    full_file_name = f.split('/')[-1] # вытаскиваем название файла из пути
    file_name = full_file_name.split('.')[0] # вытаскиваем название без расширения

    # Парсим PDF файл
    rendered = converter(f)
    text, _, _ = text_from_rendered(rendered)

    # Сохраняем файл
    with open(f'/docs/{file_name}.txt', 'w', encoding='utf-8') as new_file:
        new_file.write(text)

Здесь мы:

  • Создаем PDF-парсер.

  • Формируем список всех PDF файлов в указанной папке:

    • Вытаскиваем текст из PDF файла с помощью Marker’а.

    • Сохраняем текст в TXT-файле с тем же названием.

2.2. Теперь нам нужно перевести эти отчеты в вектор и загрузить в кудрант. Для получения эмбедингов из чанков мы будем использовать один из топовых (согласно MTEB) энкодеров для русского языка — FRIDA.

Сначала скачайте его:

git lfs clone https://huggingface.co/ai-forever/FRIDA

2.3. Для загрузки документов в коллекцию выполните такой код:

import os
import glob
import uuid
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct
from sentence_transformers import SentenceTransformer
from langchain_text_splitters import RecursiveCharacterTextSplitter

# Создаем подключение к Qdrant
q_client = QdrantClient(url='http://localhost:6333') 

# Подгружаем эмбедер
emb_model = SentenceTransformer('/models/FRIDA')

# Создаем сплиттер
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size = 1500,
    chunk_overlap = 500,
    separators = ['\n\n', '\n', ' ', ''])

# Формируем список всех TXT файлов и путей до них
files = glob.glob(os.path.join('/docs', '*.txt'))

# Проходимся по каждому файлу
for f in files:
    print(f)
    file_name = f.split('/')[-1] # вытаскиваем название файла из пути

    # Подгружаем файл
    text = open(f, 'r').read()
    # Разбиваем текст на чанки
    chunks = text_splitter.split_text(text)    

    # Проходимся по каждому чанку
    for chunk_text in chunks:
        # Добавляем к чанку название файла
        chunk_text = f'Файл: {file_name}\n{chunk_text}'

        # Формируем структуру для Qdrant
        point = PointStruct(
            id = str(uuid.uuid4()),
            vector = emb_model.encode(chunk_text, prompt_name='search_document'),
            payload = {'file': file_name, 'chunk': chunk_text})

        # Отправляем в Qdrant
        _ = qclient.upsert(collection_name = 'rag_agent', points = [point], wait = True)

Тут мы:

  • Создаем:

    • Подключение к Qdrant.

    • С помощью SentenceTransformer загружаем эмбедер.

    • Сплитер.

  • Вытаскиваем все TXT файлы из папки и проходимся по каждому из них:

    • Считываем текст из файла.

    • Разбиваем текст на чанки (по 1500 на чанк с нахлестом в 500).

    • Проходимся по всем чанкам:

      • Формируем объект, который содержит:

        • Уникальный идентификатор

        • Вектор — эмбединг который выдала нам FRIDA на основе текста чанка.

        • Название файла

        • Текст чанка.

      • Отправляем чанк в кудрант.

З.Ы.1. Обратите внимание, что мы в каждый чанк добавляем название файла. Если название файла будет содержательным, то это добавит общий контекст происходящего к каждому чанку. Эта техника называется Contextual Retrieval.

З.Ы.2. При работе с FRIDA для качественного перевода текста в вектора нужно использовать правильные префиксы: search_query, search_document, paraphrase, categorize, categorize_sentiment, categorize_topic, categorize_entailment. Почитайте в документации как это нужно делать: HF, Хабр.

Шаг 3. MCP-сервер

Проанализировав запрос, мы поняли что для его выполнения нам нужны две функции:

  • Одна возвращает текущий год.

  • Вторая выполняет поиск по чанкам.

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

Реализовывать мы их будем посредством MCP сервера. MCP сервер дает LLM информацию, какие инструменты ей доступны, а также выступает как прокси для вызова этих самых инструментов. Чуть более подробно (и с примером) про MCP-сервера можете почитать в моей статье: Разработка MCP-сервера на примере CRUD операций.

Создайте файл python3 mcp_server.py:

from datetime import date
from fastmcp import FastMCP
from qdrant_client import QdrantClient
from sentence_transformers import SentenceTransformer

# Инициализация MCP сервера
mcp = FastMCP('Employee Management System')

# Создаем подключение
q_client = QdrantClient(url='http://localhost:6333')

# Подгружаем эмбедер
emb_model = SentenceTransformer('/models/FRIDA')

# Получить текущую дату
@mcp.tool()
def get_current_date():
    '''Возвращает текущую дату'''
    return str(date.today())

# Поиск по чанкам
@mcp.tool()
def chunks_search(query):
    '''
    Ищет релевантные фрагменты текста в векторной базе данных.
    Возвращает список наиболее релевантных чанков объединенных в один текст.
    '''

    search_result = q_client.query_points(
        collection_name = 'rag_agent',
        query = emb_model.encode(query, prompt_name='search_query'),
        with_payload = True,
        limit = 10
    ).points

    chunks = [s.payload['chunk'] for s in search_result]
    chunks = '\n\n'.join(chunks)  

    return chunks

if __name__ == '__main__':
    # Запуск сервера
    mcp.run(transport='http', host='192.168.0.108', port=9000)

Здесь мы:

  • Инициируем сервер.

  • Создаем подключение к кудрант.

  • Подгружаем фриду — она нам понадобится для получения эмбединов из искомого запроса.

  • Объявляем две функции:

    • get_current_date — возвращает текущую дату.

    • chunks_search — выполняет поиск чанков на основе входящей строки. Найденные чанки объединяются в одну длинную строку.

  • Запускаем сервер на 9000 порту.

Запускаем сервер в терминале:

python3 mcp_server.py

Теперь сервис доступен по адресу:

http://192.168.0.108:9000/mcp

Шаг 4. LLM

LLM это мозг нашего агента. Она обрабатывает информацию и определяет какие инструменты вызвать. Но чтобы использовать LLM в качестве агента она должна обладать одной важной функцией — Function Calling (или Tool Calling). Не многие локальные LLM да еще и небольшого размера могут похвастаться таким функционалом. Одна из них — Qwen3 14B — ее и будем использовать.

4.1. Скачаем LLM:

git lfs clone https://huggingface.co/Qwen/Qwen3-14B

4.2. Далее скачиваем докер-образ vLLM:

docker pull vllm/vllm-openai:v0.10.1.1

4.3. Для запуска модели выполните в терминале примерно такую команду:

docker run \
    --gpus all \
    -v /models/qwen/Qwen3-14B/:/Qwen3-14B/ \
    -p 8000:8000 \
    --env "TRANSFORMERS_OFFLINE=1" \
    --env "HF_DATASET_OFFLINE=1" \
    --ipc=host \
    --name vllm \
    vllm/vllm-openai:v0.10.1.1 \
    --model="/Qwen3-14B" \
    --tensor-parallel-size 2 \
    --max-model-len 40960 \
    --enable-auto-tool-choice \
    --tool-call-parser hermes \
    --reasoning-parser deepseek_r1

Теперь наша модель доступна как сервис по адресу: http://localhost:8000

Более подробно, как в Qwen можно вызывать инструменты можно почитать в официальной документации.

Шаг 5. Агент

Ну вот мы и добрались до агента :) Запилить его можно и на чистом питоне, но это довольно громоздкая махина, а изобретать велосипед не хочется. Поэтому мы воспользуемся готовым фреймворком — Agno. Это относительно новая библиотека. Она неплохо себя показала в работе, еще не успела обрасти ненужным функционалом как некоторые ее коллеги и обладает приличной документацией (что примечательно со своим AI-ассистентом для ответов на вопросы по этой самой документации).

from agno.agent import Agent
from agno.models.vllm import VLLM
from agno.db.sqlite import SqliteDb
from agno.tools.mcp import MCPTools
from agno.utils.pprint import pprint_run_response

mcp_tools = MCPTools(transport='streamable-http', url='http://192.168.0.108:9000/mcp')
await mcp_tools.connect()

instruction = '''Ты - интеллектуальный ассистент. Твоя задача - отвечать на вопросы пользователей на основе предоставленных документов.

Для обработки запроса тебе доступны два инструмента:
getcurrent_date - возвращает текущую дату.
chunkssearch - выполняет поиск по корпоративной документации.

В корпоративной базе данных хранятся различные отчеты.
Например: "Газпром, Годовой отчет, 2021", "ЛУКОЙЛ, Финансовый отчет по РСБУ, 2020", "X5 Group, Отчет устойчивого развития, 2018".
Информация в корпоративной базе данных разбита на чанки. Каждый чанк содержит название отчета и кусок текста.
Для семантического поиска по чанкам используется инструмент chunkssearch.

Инструкция:
1. Сначала проанализируй запрос и определяйте необходимые подзадачи.
2. Используйте поиск для нахождения релевантной информации.
3. Если необходимо найти информацию из разных периодов, то ищи их с помощью самостоятельных подзапросов.
4. Всегда используй функцию getcurrent_date, если необходимо определить текущую дату.
5. Отвечай на основе найденной информации, не придумывай факты. Если информации недостаточно, укажи это в ответе.
'''

agent = Agent(
    model = VLLM(id='/Qwen3-14B', base_url='http://192.168.0.108:8000/v1'),
    db = SqliteDb(db_file='/rag_agent/agno.db'),
    tools = [mcp_tools], #[current_date,chunks_search]
    instructions = instruction
)

query = '''Найди в годовых отчетах компании Магнит за последние 5 лет упоминания ключевых рисков.
Выдели, как менялась формулировка этих рисков от года к году.'''

result = await agent.arun(query, debug_mode=True)

pprint_run_response(result, markdown=True)

Здесь мы:

  • Подключаемся к MCP серверу и запрашиваем доступные инструменты.

  • Описываем инструкцию для агента.

  • Объявляем агента, которому передаем:

    • Способ подключения к LLM.

    • Способ хранения истории запросов.

    • Все доступные инструменты.

    • Инструкцию.

  • Выполняем запрос через агента и распечатываем результат.

Если посмотреть на лог вызовов, то можно увидеть, что сначала LLM запросила текущую дату.

А затем выполнила 5 вызовов к ретриверу, в которых извлекла информацию по годовым отчетам за каждый из 5 лет.

Обработала всю полученную информацию и выдала вот такой финальный ответ:

Анализ ключевых рисков в годовых отчетах «Магнита» за 2020–2024 гг.
───────────────────────────────────────────────────────

2020 год:

Основные риски:

- Риск ликвидности — контроль за резервами, банковскими операциями и денежными потоками.
- Пандемия COVID-19 — изменение потребительского поведения, нормативного регулирования и прерывание цепочек поставок.
- Экономическая нестабильность — влияние санкций и геополитических факторов (упомянуты в контексте прогнозов).

Формулировка: Акцент на операционную устойчивость, особенно в условиях пандемии. Риски представлены как внешние (пандемия, санкции) и внутренние (ликвидность).

───────────────────────────────────────────────────────

2021 год:

Основные риски:

- Инфляция и рост цен на сырье — давление на потребителей и затраты.
- Геополитическая напряженность — рост волатильности на финансовых рынках и девальвация рубля (с февраля 2022 г.).
- Цифровая трансформация — необходимость адаптации к изменяющимся технологическим требованиям.
- Управление оборотным капиталом — оптимизация запасов и ликвидности.

Формулировка: Расширение списка рисков до макроэкономических и технологических. Появление ESG-аспектов (устойчивое развитие, экологические и социальные обязательства).

───────────────────────────────────────────────────────

2022 год:

Основные риски:

- Санкционное давление — ограничения на международные операции и доступ к финансовым ресурсам.
- Импортозамещение — необходимость перестройки поставок и локализации производства.
- Долговая нагрузка — контроль за уровнем задолженности и дивидендными выплатами.

Формулировка: Акцент на геополитические и финансовые риски, связанные с санкциями. Упоминается стратегическое реорганизование (например, приобретение «Дикси»).

───────────────────────────────────────────────────────

2023 год:

Основные риски:

- Количественная оценка рисков — внедрение систематического подхода к анализу всех бизнес-процессов.
- Экологические и социальные риски (ESG): сокращение выбросов, расхода воды, пищевых отходов.
- Развитие ответственных источников продукции — сотрудничество с местными поставщиками.
- Цифровизация — переход на ERP-системы и электронную торговлю.

Формулировка: Переход от реактивного управления к проактивному. Риски структурированы по категориям (экологические, операционные, стратегические), с акцентом на устойчивое развитие.

───────────────────────────────────────────────────────

2024 год:

Основные риски:

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

Формулировка: Упор на стратегическую подготовленность и инновации. Риски рассматриваются как возможность для развития (например, цифровизация и ESG).

───────────────────────────────────────────────────────

Итог: эволюция формулировок

- 2020–2021: Акцент на внешние шоки (пандемия, санкции) и операционную устойчивость.
- 2022–2023: Расширение до ESG-рисков и цифровизации, акцент на долгосрочные стратегии.
- 2024: Формулировки становятся более систематизированными, риски рассматриваются как инструменты для роста, а не только угрозы.

Вместо вывода

Мы рассмотрели относительно простую реализацию. Но агенты несколько сложнее: у них есть сессии, есть состояния, есть хранилища и много чего другого. Еще можно реализовать кучу агентов, каждый из которых выполняет свою задачу и они взаимодействуют между собой. Но это уже на самостоятельное изучение :)

Из улучшений, которые так и напрашиваются после реализации этого игрушечного примера:

  • Гибридный поиск (BM25 + семантика).

  • Метаданные и фильтры для чанков.

  • Подобрать гиперпараметры для вызова LLM.

Что касается инструментов. В данном пример у нас их всего два инструмента. Но даже сейчас этого кажется уже недостаточно. Возьмем такой пример:

Сравни доходы трех самых крупных компаний за прошлый год.

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

И нигде нет конечного списка инструментов, которые вам могут понадобится. Нужно самим мониторить запросы пользователей и смотреть что им нужно.
З.Ы. Современные агентные библиотеки уже включают в себя готовые инструменты для многих популярных сервисов.

Из недостатков агентного рага:

  • Мониторинг и анализ работоспособности требует больше усилий, чем обычный RAG, поскольку генерируется куда больше информации.

  • Тратится гораздо больше токенов (и не всегда с пользой). Если у вас LLM платная, то это может стать проблемой.

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


Мои курсы: Разработка LLM с нуля | Алгоритмы Машинного обучения с нуля