RAGу для программиста. AutoRAG.. AutoRAG. llm.. AutoRAG. llm. python.. AutoRAG. llm. python. rag.. AutoRAG. llm. python. rag. Блог компании Контур.. AutoRAG. llm. python. rag. Блог компании Контур. векторный поиск.. AutoRAG. llm. python. rag. Блог компании Контур. векторный поиск. искусственный интеллект.. AutoRAG. llm. python. rag. Блог компании Контур. векторный поиск. искусственный интеллект. обработка данных.. AutoRAG. llm. python. rag. Блог компании Контур. векторный поиск. искусственный интеллект. обработка данных. Программирование.. AutoRAG. llm. python. 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 требуется две вещи:

  1. Найти действительно подходящие рецепты;

  2. Не завалить модель лишним текстом. 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 — алгоритмические метрики для оценки качества генерации текста, которые сравнивают сгенерированный моделью ответ с эталонным (правильным) ответом из датасета.

Автор: 2union

Источник