Надеюсь, все знают что такое 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 с нуля | Алгоритмы Машинного обучения с нуля

Комментарии (1)


  1. northrop
    16.11.2025 23:03

    Интересно, что нужно по железу для запуска такого набора сервисов.