RAGу для программиста

Внедрение AI в программные продукты часто сопровождается необходимостью обогатить контекст LLM специфичными для ваших задач данными. Для обогащения знаний используемой вами модели применяется два подхода:
fine-tuning — дообучение модели на ваших данных. Тут финансовые затраты идут на обучение модели:
ресурсы на fine-tuning в случае on-prem решения,
дополнительные средства за дообучение модели по подписке.
RAG — внешний источник данных, данные из которого передаются в качестве контекста к промпту в LLM. Затраты тут переносятся на размер запроса за счёт увеличения входящего контекста.
Так как я программист, а не Data Scientist, то предпочитаю понятные, переносимые вне зависимости от способа размещения модели, подходы, поэтому мой выбор — RAG. По быстрому запилить свой RAG очень просто. Берём какую-нибудь векторную БД, в моём случае Qdrant, пишем два скрипта — один для создания индекса, второй для сервера запросов:
# Это фрагмент скрипта, полное решение можно найти по ссылке <https://github.com/2union/AutoRAGDemo/blob/main/DirectIndex/index_qdrant.py>
for i in range(0, len(docs), batch_size):
batch = docs[i:i + batch_size]
vectors = embed([d["text"] for d in batch])
points = [
PointStruct(
id=str(uuid.uuid5(uuid.NAMESPACE_URL, doc["id"])),
vector=vector,
payload={**doc["metadata"], "text": doc["text"], "recipe_id": doc["id"]},
)
for doc, vector in zip(batch, vectors)
]
qdrant.upsert(collection_name=COLLECTION, points=points)# Это фрагмент скрипта, полное решение можно найти по ссылке <https://github.com/2union/AutoRAGDemo/blob/main/DirectIndex/rag_server.py>
vector = embed_query(query)
result = qdrant.query_points(
collection_name=COLLECTION,
query=vector,
query_filter=build_filter(req),
limit=req.top_k * 2,
with_payload=True,
)Готово! Можем расходиться?
К сожалению, не всё так просто. Проблема в том, что для получения качественного результата, который не разорит нашу компанию на токенах при формировании запросов к LLM, нужно приготовить более оптимальный RAG. Давайте разберём пример построения такого RAG'а на реальной задаче и поговорим про ограничения подхода «В лоб».
Задача
Возьмём пример из жизни. У нас есть база из 2181 рецепта на русском языке. В каждой записи текст рецепта и метаданные: тип блюда, ингредиенты, аллергены, КБЖУ на 100 г. Над базой работает ИИ-агент шеф-повар на gpt-5-nano. Пользователь говорит: «2000 ккал в день, три приёма пищи, аллергия на глютен», а агент собирает меню только из рецептов базы.
Модель маленькая и дешёвая, поэтому от RAG требуется две вещи:
Найти действительно подходящие рецепты;
Не завалить модель лишним текстом. gpt-5-nano хуже справляется с длинным зашумлённым контекстом, а за каждый лишний токен мы платим.
Подход «в лоб»
Решение из начала статьи находится тут. Оно рабочее: индексирует, ищет, генерирует меню. Разберём, что происходит внутри при запросе POST /menu.
Сначала все пожелания пользователя склеиваются в один текст и превращаются в вектор:
query = f"""
Подбери рецепты для меню.
Ккал в день: {req.kcal_target}
Дней: {req.days}
Приемов пищи в день: {req.meals_per_day}
Аллергии: {", ".join(req.allergies) if req.allergies else "нет"}
...
Идентификаторы рецептов, которые желательно не включать в меню: {", ".join(req.recently_dish_ids) ...}
"""
vector = embed_query(query)Затем из Qdrant берутся top_k * 2 ближайших рецептов, лишние отсеиваются в Python, и до 40 полных карточек уходят в промпт gpt-5-nano.
Выглядит разумно. Но давайте разберём, чего здесь не хватает.
1. Эмбеддинг не понимает чисел и отрицаний
Вектор строки «Ккал в день: 2000, Аллергии: глютен» не означает «рецепты на 2000 ккал без глютена». Embedding-модель кодирует смысл текста, а не логику. Слово «глютен» в запросе скорее приблизит нас к рецептам с глютеном, чем отдалит от них. Та же история с recently_dish_ids: идентификаторы просто попадают в текст запроса, и недавние блюда никак не исключаются.
2. Один запрос на всё меню
Меню состоит из основного блюда, супа, салата, напитка и десерта. А запрос один. В базе 1550 основных блюд из 2181, поэтому в выдаче почти наверняка будут доминировать именно они, а напитков и десертов модели может просто не достаться.
3. Векторизуется полный текст с шагами приготовления
vectors = embed([d["text"] for d in batch])В text лежат и «разогрейте духовку до 175 C», и «выпекайте 25–30 минут». Для выбора блюда в меню это шум: он размывает вектор и раздувает контекст.
4. Только dense-поиск
Векторный поиск хорошо ловит смысл, но может промахнуться на конкретике: «панчетта», «соус Вустершир», «нут». Для таких запросов лексический поиск (BM25) часто точнее, а лучше всего работает их комбинация. Но какая именно комбинация и с какими весами?
5. Фильтр аллергенов сравнивает строки точно
def has_forbidden_allergen(payload: dict, allergies: list[str]) -> bool:
recipe_allergens = [a.lower().strip() for a in payload.get("allergens", [])]
user_allergies = [a.lower().strip() for a in allergies]
return any(a in recipe_allergens for a in user_allergies)В данных молоко встречается как «Молоко», «Молочные продукты», «Молочный белок», «Молочный», «Сливочное масло»… Пользователь с аллергией на «молоко» получит рецепт с «Молочным белком». Это уже не про качество поиска, а про безопасность.
6. И главное: мы не знаем, насколько всё это хорошо
Почему text-embedding-3-small, а не large? Почему top_k = 40, а не 5? Нужен ли реранкер? В решении «в лоб» на эти вопросы нет ответа, потому что нечем измерить качество. Любое изменение — это гадание на кофейной гуще. Поменял модель, посмотрел глазами на пару запросов, вроде стало лучше. А может стало хуже, но мы поймали ошибку выжившего и именно наша пара запросов дала более приемлемый результат.
Пункты 1, 2 и 5 решаются программно: фильтрами Qdrant и отдельными запросами на каждый слот меню. А вот пункты 3, 4 и 6 — это для меня «AI-булшит» (области знаний, которые относятся скорее к DS): подбор модели эмбеддингов, способа поиска, гибридных весов, реранкера, размера контекста и промпта. Решать их надо экспериментами с метриками, и ковыряться во всём этом мне не очень хочется.
Поэтому мой выбор — AutoRAG.
Что такое AutoRAG
AutoRAG — это AutoML для RAG. Вы описываете в YAML, какие варианты хотите сравнить на каждом этапе пайплайна, и даёте два датасета:
corpus — документы, по которым ищем;
qa — вопросы и правильные ответы: какой документ должен найтись и какой ответ сгенерировать.
AutoRAG прогоняет все варианты, считает метрики и для каждого этапа выбирает лучший. Пайплайн описывается иерархически:
node line — группа этапов (поиск, постобработка);
node — этап: semantic_retrieval, hybrid_retrieval, passage_reranker, prompt_maker, generator…
module — конкретная реализация этапа с параметрами;
strategy — по каким метрикам сравнивать модули.
Получается тот же цикл «поменял → измерил», только автоматический и воспроизводимый.
AutoRAG уже упоминался в нескольких статьях на хабре: 1, 2, 3, 4. Сегодня мы потрогаем его более прицельно, для решения нашей задачи.
Подход «Покатай меня, большая черепаха!»
Шаг 1. Готовим данные
Это единственная часть, которую AutoRAG за нас не сделает, и самая важная. Скрипт AutoRag/prepare_data.py готовит оба датасета.
Corpus: карточка вместо полного текста
Рецепт короткий, а агент выбирает рецепты целиком. Поэтому чанкинг не нужен: один рецепт = один документ. Вместо полного текста документом становится компактная карточка, где есть всё для выбора блюда и нет шагов приготовления:
def render_card(doc: dict) -> str:
m = doc["metadata"]
allergens = ", ".join(m.get("allergens") or []) or "нет"
return (
f"ID: {doc['id']}\n"
f"Название: {m.get('title')}\n"
f"Тип блюда: {m.get('dish_type') or 'не указан'}\n"
f"Ингредиенты: {', '.join(m.get('ingredients') or [])}\n"
f"На 100 г: {m.get('kcal_per_100g')} ккал, белки {m.get('protein_per_100g')}, "
f"жиры {m.get('fat_per_100g')}, углеводы {m.get('carbs_per_100g')}\n"
f"Аллергены: {allergens}"
)ID в карточке нужен LLM, чтобы сослаться на рецепт в ответе. Полный текст никуда не пропадает: он ляжет в payload Qdrant для показа пользователю. Нужно проверить, что карточка действительно лучше? Соберите corpus с --variant full и сравните.
QA: синтетические запросы на русском
У AutoRAG есть генератор QA-датасетов, но готовых промптов для русского нет. Свой генератор пишется за полчаса. Для каждого рецепта более сильная модель (gpt-5-mini) придумывает три запроса, на которые этот рецепт — лучший ответ:
QA_PROMPT = """...
1. "ingredients": запрос по 2–4 характерным ингредиентам ("что приготовить из ...").
2. "description": описание желаемого блюда своими словами, БЕЗ названия рецепта.
3. "constraints": тип блюда + ключевой ингредиент + ограничение (аллерген, калорийность).
..."""Каждая запись превращается в строку QA-датасета в формате AutoRAG:
{
"qid": f"{doc['id']}::{kind}",
"query": data[kind].strip(),
"retrieval_gt": [[doc["id"]]], # какой документ должен найтись
"generation_gt": [data["answer"].strip()], # "Подходит рецепт ... (ID ...)"
}Две детали, которые легко упустить:
стратификация по типу блюда. Иначе в тесте будут одни основные блюда, и мы оптимизируем поиск под них;
разбиение dev/test по рецептам, а не по запросам. AutoRAG оптимизирует на qa.parquet, а финальную проверку мы делаем на qa_test.parquet, где рецепты ему не встречались.
ids = sorted({r["retrieval_gt"][0][0] for r in rows})
random.shuffle(ids)
test_ids = set(ids[: int(len(ids) * args.test_share)])Шаг 2. Описываем эксперименты
Делать это будем в config.yaml.
Сначала три модели эмбеддингов. Для каждой AutoRAG сам создаст коллекцию в Qdrant и проиндексирует corpus:
vectordb:
- name: oai_small
db_type: qdrant
collection_name: autorag_oai_small
embedding_model: openai_embed_3_small
...
- name: oai_large
...
embedding_model: openai_embed_3_large
- name: bge_m3
...
embedding_model: huggingface_bge_m3 # локальная мультиязычная модельЗатем этапы поиска: dense, BM25 и их гибриды.
- node_type: semantic_retrieval
strategy:
metrics: [retrieval_recall, retrieval_ndcg, retrieval_mrr]
top_k: 20
modules:
- module_type: vectordb
vectordb: [oai_small, oai_large, bge_m3]
- node_type: lexical_retrieval
...
modules:
- module_type: bm25
# porter_stemmer не работает с кириллицей
bm25_tokenizer: [BAAI/bge-m3, space]
- node_type: hybrid_retrieval
...
modules:
- module_type: hybrid_rrf
weight_range: (4, 80)
- module_type: hybrid_cc
normalize_method: [mm, tmm, z, dbsf]
weight_range: (0.0, 1.0)
test_weight_size: 11Одна строка normalize_method: [mm, tmm, z, dbsf] с test_weight_size: 11 — это 44 варианта гибридного поиска. Руками такой перебор не делает никто.
Дальше реранкер и размер контекста. Именно здесь решается, сколько токенов получит gpt-5-nano:
- node_type: passage_reranker
top_k: [5, 10]
modules:
- module_type: pass_reranker # без реранкера — как контрольная точка
- module_type: flag_embedding_reranker
model_name: BAAI/bge-reranker-v2-m3И, наконец, промпты, которые оцениваются по реальным ответам gpt-5-nano:
- node_type: prompt_maker
strategy:
metrics:
- metric_name: bleu
- metric_name: sem_score
embedding_model: openai_embed_3_small
generator_modules:
- module_type: openai_llm
llm: gpt-5-nano
modules:
- module_type: fstring
prompt: [...]
- module_type: long_context_reorder
prompt: [...]Метрики rouge и meteor я не включал, их токенизаторы рассчитаны на латиницу, в русском тексте они дадут мусор.
Шаг 3. Запуск
Сам запуск описывается парой строк кода:
evaluator = Evaluator(
qa_data_path=str(DATASET_DIR / "qa.parquet"),
corpus_data_path=str(DATASET_DIR / "corpus.parquet"),
project_dir=args.project,
)
evaluator.start_trial(str(resolved), skip_validation=True)
extract_best_config(trial_path=str(trials[-1]), output_path=str(best))В репозитории на github всё упаковано в Docker, а шаги вызываются через make:
cd AutoRag
make build && make qdrant
make prepare SAMPLE=300 # corpus + QA
make optimize # AutoRAG -> work/best_config.yaml
make dashboard # отчёт по каждому этапуНа выходе получаем best_config.yaml: пайплайн, в котором на каждом этапе оставлен победивший модуль. В project/<N>/ лежат summary.csv по каждому узлу с метриками всех вариантов. Их можно показать на ревью, если кто-то спросит «а почему bge-m3?».
Шаг 4. Из эксперимента в прод
AutoRAG нашёл лучшую конфигурацию, но наш сервер работает с Qdrant, а не с AutoRAG. Поэтому нам надо перевести best_config.yaml в простой профиль rag_profile.json:
elif t == "hybrid_retrieval":
profile["mode"] = "hybrid"
# hybrid_cc в Qdrant ближе всего к DBSF (нормализация + взвешенная сумма)
profile["fusion"] = "RRF" if mtype == "hybrid_rrf" else "DBSF"
elif t == "passage_reranker":
profile["reranker"] = None if mtype == "pass_reranker" else mod.get("model_name")
profile["top_k"] = node.get("top_k", profile["top_k"])Затем создаётся продакшн-коллекция: dense-вектор от победившей модели, sparse BM25 с IDF на стороне Qdrant и payload-индексы для фильтров.
qdrant.create_collection(
name,
vectors_config={"dense": models.VectorParams(size=dim, distance=models.Distance.COSINE)},
sparse_vectors_config={"bm25": models.SparseVectorParams(modifier=models.Modifier.IDF)},
)Поиск использует гибридный запрос Qdrant. Фильтры применяются до ранжирования, а не после, как в варианте «в лоб»:
res = qdrant.query_points(
collection_name=collection,
prefetch=[
models.Prefetch(query=dense, using="dense", limit=candidates, filter=query_filter),
models.Prefetch(query=embed_sparse([query], query=True)[0], using="bm25",
limit=candidates, filter=query_filter),
],
query=models.FusionQuery(fusion=models.Fusion[p["fusion"]]),
limit=candidates,
)А «логические» требования, которые эмбеддинг не понимает, теперь выражены фильтрами, а не текстом запроса:
if exclude_allergens:
must_not.append(models.FieldCondition(key="allergens_lc", match=models.MatchAny(any=tags)))
if exclude_ids:
must_not.append(models.FieldCondition(key="recipe_id", match=models.MatchAny(any=exclude_ids)))Агент делает отдельный запрос на каждый слот меню:
hits = search(
"основное блюдо на ужин с курицей",
build_filter(dish_types=["Основное блюдо"], exclude_allergens=req.allergies,
exclude_ids=req.recently_dish_ids),
)
context = format_context(hits) # 5–10 компактных карточек вместо 40 полных рецептовПоследний шаг: make evaluate прогоняет продакшн-поиск по отложенной выборке и считает recall, MRR и nDCG. Это важно, потому что перенос из AutoRAG в Qdrant не идеален: BM25 у них токенизирует по-разному, а hybrid_cc и DBSF — близкие, но не одинаковые алгоритмы. Доверяем метрикам, а не тому, что «должно совпасть».
Важная оговорка: мы можем реализовать наш RAG прямо на базе AutoRAG без переноса на Qdrant. Такое решение я предлагаю просто как пример того, что эксперименты на AutoRAG не привязывают нас к нему.
Что AutoRAG снимает с разработчика
Представим, что тот же RAG мы строим без AutoRAG. Список работ получается такой:
Задача | Своими руками | С AutoRAG |
|---|---|---|
Метрики поиска (recall, nDCG, MRR, precision, F1, MAP) | Реализовать, покрыть тестами, не ошибиться с усреднением при нескольких правильных ответах | metrics: [retrieval_recall, retrieval_ndcg, retrieval_mrr] |
Метрики генерации (BLEU, ROUGE, sem_score, G-Eval) | Подключить библиотеки, привести форматы, написать LLM-судью | одна строка в strategy |
Сравнение моделей эмбеддингов | Для каждой модели создать коллекцию, проиндексировать, следить, чтобы не перепутать коллекции между экспериментами | список в vectordb |
BM25 | Выбрать библиотеку, токенизатор, хранить индекс | module_type: bm25 + список токенизаторов |
Гибридный поиск | Реализовать RRF и convex combination, четыре способа нормализации, перебор весов | hybrid_rrf / hybrid_cc с weight_range |
Реранкеры | Подключить каждую модель, батчинг, сравнить с вариантом «без реранкера» | список модулей, pass_reranker как контроль |
Подбор top_k | Цикл экспериментов на каждый этап | top_k: [5, 10] |
Оценка промптов | Прогон LLM по всему датасету, батчинг, ретраи, подсчёт метрик ответов | prompt_maker + generator_modules |
Связка этапов | Передавать результаты лучшего модуля в следующий этап, кешировать промежуточные результаты | автоматически |
Отчёты | Сводные таблицы, графики, история экспериментов | summary.csv по каждому узлу + autorag dashboard |
Воспроизводимость | Свой формат конфигов и логов экспериментов | YAML конфиг + папка трайла |
Выбор победителя | Скрипт, который собирает итоговый пайплайн | extract_best_config |
Даже наш небольшой конфиг — это 3 модели эмбеддингов, 2 токенизатора BM25, десятки гибридных вариантов, 4 варианта реранкера, 3 промпта. Самописный стенд для такого сравнения — это недели работы и отдельный проект, который тоже нужно поддерживать. С AutoRAG это один YAML-файл и вечер ожидания.
Ещё важнее, что исчезает соблазн «посмотреть глазами на пару запросов». Добавить модель в сравнение — одна строка, поэтому проверить гипотезу дешевле, чем спорить о ней.
Что AutoRAG за вас не сделает
Чтобы не создавать ложных ожиданий:
Качество QA-датасета на вас. AutoRAG оптимизирует пайплайн под ваши вопросы. Если вопросы не похожи на реальные запросы агента, вы получите лучший пайплайн для чужой задачи. Поэтому типы запросов в QA_PROMPT повторяют то, как агент ищет рецепты.
Русский язык требует внимания. Встроенных промптов генерации QA на русском нет, porter_stemmer и rouge рассчитаны на латиницу.
Бизнес-логику не оптимизирует. Аллергены, тип блюда, калорийность и исключение недавних блюд — это фильтры Qdrant, и их пишете вы.
Деплой в свою инфраструктуру. AutoRAG умеет поднимать свой API-сервер, но если сервер уже есть, лучший пайплайн придётся перенести самостоятельно и проверить метриками, что ничего не потерялось при переносе.
Свежие модели появляются в библиотеке не сразу. Для gpt-5-nano пришлось дописать лимит контекста в таблицу AutoRAG:
def patch_gpt5():
from autorag.nodes.generator import openai_llm
openai_llm.MAX_TOKEN_DICT.setdefault("gpt-5-nano", 400_000)Итог
Простая индексация «в лоб» — честный первый шаг: два скрипта, и RAG работает. Но работает он «как-то», и никто не знает, насколько хорошо. Все решения в нём: модель, top_k = 40, полный текст рецепта, только dense-поиск — приняты по умолчанию, а не по данным.
С AutoRAG разработчик делает то, что умеет лучше всего: готовит данные, пишет фильтры, переносит результат в прод и покрывает его проверкой. А перебор моделей, гибридов, реранкеров, размеров контекста и промптов с честными метриками берёт на себя фреймворк. В результате gpt-5-nano получает 5–10 подходящих рецептов вместо 40 случайных, а за выбор каждого параметра можно отчитаться цифрами.
И прежде чем попрощаться с вами, ещё раз напомню, что весь код из статьи доступен как готовые решения для экспериментов в репозитории на github.
Глоссарий
Векторный поиск и обработка данных
Эмбеддинг (Embedding) — числовой вектор (массив чисел) фиксированной длины, в который специальная модель (embedding model) превращает текст, кодируя его смысловое значение для последующего сопоставления.
Dense-поиск (Плотный / векторный поиск) — поиск документов по смыслу с использованием математического сравнения плотных векторов (эмбеддингов) запроса и документов.
Лексический поиск (Lexical retrieval / BM25) — классический поиск по точным ключевым словам и их частотности в документах, без учёта скрытого семантического смысла.
Гибридный поиск (Hybrid retrieval) — комбинированный метод, сочетающий векторный (dense) и лексический (sparse/BM25) поиск для повышения точности выдачи.
Векторная БД (Vector Database) — специализированная база данных (в статье используется Qdrant), предназначенная для эффективного хранения векторов (эмбеддингов) и быстрого поиска похожих объектов.
Чанкинг (Chunking) — процесс разделения длинного исходного текста на небольшие смысловые фрагменты (чанки) для их последующей векторизации и добавления в контекст.
Top-k — параметр, определяющий количество наиболее релевантных документов или фрагментов, которые система извлекает из базы данных для передачи в модель.
Реранкер (Reranker / Passage reranker) — дополнительная нейросетевая модель, которая повторно оценивает и более точно переупорядочивает список документов, уже найденных на первом этапе поиска, отсекая лишний шум.
Метрики оценки качества
Recall (Полнота) — метрика поиска, показывающая, какую долю из всех действительно релевантных документов поисковая система смогла найти и выдать пользователю.
MRR (Mean Reciprocal Rank) — метрика, оценивающая позицию первого правильного ответа в поисковой выдаче (чем выше и ближе к первому месту релевантный документ, тем выше балл).
nDCG (Normalized Discounted Cumulative Gain) — метрика качества ранжирования, которая учитывает не только наличие правильных документов в выдаче, но и порядок их сортировки по степени полезности.
BLEU / ROUGE / METEOR — алгоритмические метрики для оценки качества генерации текста, которые сравнивают сгенерированный моделью ответ с эталонным (правильным) ответом из датасета.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.