The Jerusalem PostEl Al unveils new bedding, Jo Malone kits in passenger service overhaulESPNHurts out for season after blood clots discovered in Eagles QB's lungsESPN DeportesNBA 2026-27: Rankings, predicciones sobre los 30 equiposInquirerDizon: 3 nabbed for stealing parts of Diokno Pumping StationPunchN3bn power line: Scrap dealers demand proof of cable theft from ZulumRTP DesportoChampions: Farioli quer começar a "escalar a montanha" frente ao PSVDaily MaverickTHE CONVERSATION: Why are we so obsessed with true crime? It’s not just morbid curiosityFootball ItaliaAllegri: ‘I question myself every day, Napoli players must be defended’Anime News NetworkSTEEL BALL RUN JoJo's Bizarre Adventure ‒ Episodes 1-4GIGAZINE規制が厳しい現代社会に「オトナ」なアニメ表現を届ける気概と工夫を男2人が密室でみっちり語り倒す『ダークサモナーとデキている』『彼女の友達』プロデューサートーク濃密レポート
The Daily Newsstand · Free, Always
Monday, October 19, 2026

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

Translate

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.