В прошлой статье серии мы сжимали эмбеддинги и замеряли, насколько изменится выдача относительно полного fp32-поиска. Там это был правильный вопрос: ломает ли квантизация уже существующий retrieval.
Но у такого замера есть неприятное слепое пятно. Можно аккуратно сохранить 99% выдачи исходного эмбеддера и все равно плохо находить нужные документы на своем домене. Внешний бенчмарк, даже сильный, этого не гарантирует. BEIR как раз был создан, чтобы показать, насколько результаты retrieval меняются между разными задачами и доменами; один усредненный скор там не заменяет проверку на собственных данных.
Нужен свой eval set. И тут обычно возникает ложная развилка:
либо размечаем тысячу вопросов и ждем квартал;
либо генерируем десяток вопросов моделью, получаем
recall@10 = 0.91и объявляем поиск готовым
Между этими крайностями есть рабочее место: первые 100-300 вручную отобранных кейсов. Это не универсальный бенчмарк, не датасет для обучения и не повод рисовать точность до второго знака. Зато такого набора хватает, чтобы перестать менять chunking, embedder и reranker вслепую.
В статье разберу, как собрать этот набор, какие поля в нем хранить, чем отдельно мерить retrieval и ответ, а главное - как читать результат так, чтобы один средний скор не спрятал поломку в критичном сценарии.
Сначала разделим три разных набора
Слова dataset, benchmark и eval часто склеивают в одно. Отсюда и странные решения: синтетические вопросы используют как доказательство качества, а обучающие пары - как независимый тест.
У меня для RAG обычно есть три сущности
Набор | Размер | Для чего нужен | Чего он не доказывает |
|---|---|---|---|
Smoke-набор | 20-50 | Быстро поймать грубую регрессию перед релизом: пропал фильтр, сломался парсер, модель перестала отвечать на ключевой вопрос | Что новая конфигурация лучше старой в целом |
Ручной eval set | 100-300 | Выбирать chunking, поиск, reranker, фильтры и промпт на реальных сценариях | Статистическую истину про все будущие запросы |
Данные для обучения | От сотен до тысяч и дальше | Учить retriever, reranker или классификатор | Качество обученной на них системы, если они попали и в eval |
Smoke-набор должен жить рядом с CI и быть скучным. В нем лежат запросы, на которых система уже однажды ошиблась, плюс несколько критичных happy path. Он короткий специально: его надо прогонять на каждом заметном изменении.
Ручной eval set - инструмент выбора. Его задача - помогать с конкретными решениями: «нужен ли hybrid search», «помогает ли reranker именно на длинных регламентах», «сломали ли фильтры поиск по старой версии документа».
А данные для обучения нельзя тихо тащить в этот набор. Если hard negative или вопрос влиял на fine-tune, ему нужен отдельный holdout. Иначе получится знакомая магия: после обучения метрика выросла, потому что система снова увидела экзаменационные билеты.

Почему MTEB и синтетика не отвечают на этот вопрос
Публичные бенчмарки нужны. Иначе мы бы вообще не могли сравнивать модели до первого запуска. MTEB и BEIR отвечают на вопрос «как модель ведет себя на наборе публичных задач». Проверку пункта 4.7 актуальной версии вашего регламента, который пользователь назвал своими словами, они на себя не берут.
В доменном retrieval обычно болят вещи, которые почти не видны в среднем скоре:
точные сущности: артикулы, номера договоров, коды ошибок, названия полей;
версия и дата документа;
длинные разделы, где ответ и условие разнесены на несколько абзацев;
таблицы, у которых при парсинге потерялась шапка;
конфликтующие источники: инструкция обновилась, а старый FAQ остался в корпусе;
вопрос, на который в корпусе честно нет ответа;
человеческая формулировка, не похожая на заголовок документа.
Синтетика ускоряет старт: ее используют и исследовательские фреймворки вроде ARES. Но она опасна как единственный источник вопросов. Модель, которая видит один и тот же документ при генерации вопроса и при проверке, часто делает слишком аккуратную формулировку. В живом логе люди не пишут «опишите порядок оформления служебной записки согласно разделу 3.2». Они пишут «кому нести заявку, если доступ вчера закрыли?».
Поэтому синтетику я оставляю вспомогательным слоем: закрыть редкий тип документа, придумать перефразирование, подготовить кандидатов. Финальное решение о попадании кейса в eval принимает человек, который понимает домен и цену ошибки.
Сначала решить, какие решения будет принимать набор
Самая частая ошибка - сразу начать со столбца question в CSV. Вопросов набирается много, но потом выясняется, что все они про одно и то же: короткий FAQ, один стиль формулировки, одна версия документа. Средний recall@10 получается хорошим, только он ни на что не влияет.
До разметки стоит выписать 6-8 срезов, на которых будут приниматься решения. Ориентир здесь - реальные риски системы.
Вот пример для внутреннего помощника по технической документации:
Срез | Что проверяем | Почему важен |
|---|---|---|
Exact entity | Код ошибки, имя API, артикул, номер пункта | Dense-поиск часто уступает лексическому совпадению |
Перефразирование | Пользователь говорит не словами документа | Проверяем, найдет ли смысловой поиск вопрос без совпадения с заголовком |
Длинный раздел | Ответ разбросан по нескольким кускам | Проверяем chunking, |
Таблицы | Ответ зависит от колонки и строки | Ловим ошибки ingestion до эксперимента с моделью |
Версии и конфликты | В корпусе есть старый и новый документ | Проверяем metadata, фильтры и свежесть индекса |
Нет ответа | В корпусе нет достаточной информации | Проверяем, умеет ли система не выдумывать |
Плохая формулировка | Опечатка, жаргон, неполный вопрос | Приближаем eval к живому вводу |
Чувствительный сценарий | Ошибка дорого стоит | Вводим отдельный release gate, а не растворяем его в среднем |
Не обязательно заполнять каждый срез поровну. Если 70% реального трафика - простые вопросы по одному документу, это должно быть видно в наборе. Но редкий сценарий с высокой ценой ошибки тоже нельзя оставить в трех случайных строках: ему нужна минимальная квота и отдельный порог качества.
Практичный старт на 120 кейсов
Чтобы не зависнуть над идеально репрезентативной выборкой, можно начать так:
Источник | Кейс | Зачем |
|---|---|---|
Обезличенные логи или обращения | 45 | Самый близкий к реальности язык пользователей |
Документация и changelog | 30 | Версии, таблицы, длинные разделы, редкие, но важные темы |
Интервью с domain expert | 25 | Вопросы, которые знают только люди из процесса |
Синтетические кандидаты после ручного ревью | 20 | Закрыть пустые срезы и варианты формулировок |
Это не норма индустрии, а просто разумная первая итерация: по 12-20 кейсов на важный срез уже позволяют увидеть крупную проблему, а набор еще можно дочитать руками за вечер-два.
Как выглядит один кейс
Минимальная строка «вопрос - правильный документ» годится для первого наброска. Но потом вы захотите понять, что именно сломалось, и одного gold_doc_id не хватит.
Я храню eval set в JSONL, один объект на кейс. Документ и место с доказательством лучше идентифицировать стабильным document_id и section_id либо диапазоном символов в исходнике. chunk_id стабилен только внутри одного индекса: поменяли chunking - старый gold-label уже не с чем сравнивать.
{ "id": "ops-047", "query": "После обновления агент перестал отвечать. Где посмотреть код ошибки E204?", "scenario": "exact_entity", "difficulty": "medium", "answerability": "answerable", "expected_document_ids": ["runbook-errors-v3"], "gold_evidence": [ {"document_id": "runbook-errors-v3", "section_id": "errors/e204", "relevance": 2}, {"document_id": "release-notes-2-4", "section_id": "known-issues", "relevance": 1} ], "expected_facts": [ "E204 означает недоступность upstream API", "Нужно проверить статус upstream и повторить запрос" ], "acceptable_answer": "Короткая инструкция с источником; без причин, которых нет в документации", "why_it_matters": "Это частый вопрос первой линии поддержки", "source": "support_log", "reviewed_by": "domain_expert", "dataset_version": "2026-08-14" }
Здесь есть несколько неслучайных полей.
answerability заставляет явно хранить вопросы без ответа. Без них система почти никогда не учится говорить «в документации этого нет», потому что весь eval требует любой ценой что-то найти.
expected_document_ids и gold_evidence отделяют два уровня. Runner для каждой версии индекса знает mapping chunk_id -> document_id + source span и отмечает chunk релевантным, если он пересекается с нужным фрагментом. Так можно менять chunking и не переписывать весь eval set. Иногда правильный документ найден, но нужный фрагмент потерян из-за chunking. Иногда в top-5 есть нужный фрагмент, но выше него лежит мусор. Это разные поломки и разные фиксы.
expected_facts не превращает задачу в экзамен на дословное совпадение ответа. Это опорные утверждения для ручной проверки полноты и для LLM-as-a-judge, если он появится позже.
why_it_matters кажется лишним до первого спора о метрике. Потом оказывается самым полезным полем: оно напоминает, почему один кейс нельзя списать как «редкий edge case».
Размечаем retrieval отдельно от ответа
RAG состоит хотя бы из двух систем: поиск выбирает контекст, генератор строит ответ по контексту. Если мерить только финальный текст, причины смешиваются.
Рассмотрим три ситуации:
В top-5 нет нужного чанка. Промпт здесь почти точно ни при чем: надо смотреть ingestion, chunking, фильтры, embedder, BM25 или reranker
Нужный chunk есть, но ответ додумал факт. Retrieval сработал; проблема в генерации, инструкции, цитировании или валидации
В top-5 только старый документ, а ответ честно следует ему. Причину нужно искать в metadata и политике свежести
Именно поэтому Microsoft Foundry выделяет retrieval-оценку в отдельный класс и считает ее по размеченной релевантности. Groundedness, completeness и relevance ответа там остаются отдельными измерениями. RAGAS устроен по тому же принципу: качество контекста, верность контексту и полезность ответа - разные оси, которые не стоит сводить к одному числу
Метрики retrieval
Для первого eval set обычно хватает трех метрик.
Recall@k. Доля кейсов, где среди первых k найден хотя бы один обязательный релевантный chunk. Это страховочная метрика: если нужный контекст не попал в окно модели, финальный ответ уже почти не спасти
Recall@k = кейсы, где есть relevant chunk в top-k / все answerable кейсы
MRR@k. Обратный ранг первого обязательного чанка. Полезен, когда у вопроса есть один канонический ответ и важен его порядок. Если документ попадает пятым, он формально спасает Recall@5, но может утонуть среди четырех нерелевантных кусков
nDCG@k. Нужен, если вы размечаете релевантность градациями: 2 - отвечает прямо, 1 - полезный фон, 0 - нерелевантен. Для документов с несколькими допустимыми источниками это честнее, чем требовать один «золотой» chunk
Не обязательно заводить все три в первый день. Для многих инженерных решений достаточно Recall@5 плюс ручной просмотр top-5. Но если вы сравниваете reranker или порядок результатов, nDCG@5 быстро окупается.
Для среза нет ответа обычный Recall бессмысленен. Там я отдельно считаю, сколько раз система все-таки подложила нерелевантный контекст или выдала уверенный ответ. Удобный release gate звучит: «на 20 вопросах вне корпуса не больше двух уверенных ответов без пометки о нехватке данных».
Метрики ответа
Когда retrieval уже измерен, можно добавлять второй слой:
groundedness / faithfulness: каждое существенное утверждение ответа опирается на переданный контекст;
completeness: ответ закрыл ожидаемые факты, а не только первый удобный пункт;
answer relevance: ответ отвечает на вопрос, а не пересказывает весь найденный документ;
correct abstention: на вопрос без достаточного контекста система не выдумывает уверенный ответ.
LLM-as-a-judge здесь экономит время. Перед использованием его стоит проверить на ручной подвыборке, особенно на тех классах, где он будет принимать решение. Иначе вы оптимизируете предпочтения судьи. В исследованиях ARES и в индустриальных подходах автоматическую оценку сочетают с человеческими метками.
Baseline: как сравнивать конфигурации, а не идеи
В eval легко утонуть в комбинаторике: другой chunk size, overlap, embedding model, BM25, filter, reranker, top_k, prompt. Менять все одновременно бессмысленно: новый скор будет, а причины - нет.
Сначала фиксируем baseline. Например:
Корпус: документация продукта, 18 400 чанков Chunking: по заголовкам, максимум 450 токенов, overlap 60 Поиск: dense, top-20 кандидатов Финальная выдача: top-5 Модель: <версия эмбеддера> Индекс: <версия и дата сборки>
После этого каждая строка эксперимента меняет одну понятную гипотезу:
dense -> hybrid: точные сущности и редкие термины найдутся лучше;hybrid -> hybrid + reranker: полезные документы поднимутся выше в top-5;450 -> 700 токенов: длинные процедуры перестанут рваться, но таблицы и короткие ответы могут стать шумнее;фильтр по версии: старые регламенты перестанут выигрывать поиск.
вот результат на наборе из 120 кейсов. Он показывает форму таблицы, она может отличаться в зависимости от ваших потребностей или домена
Конфигурация | Recall@5 | nDCG@5 | Median latency, ms | Что видно |
|---|---|---|---|---|
Dense | 0.717 | 0.632 | 74 | Базовая семантика есть, но часть точных совпадений теряется |
Dense + BM25 (hybrid) | 0.792 | 0.694 | 86 | Заметный прирост без дорогого reranker |
Hybrid + reranker | 0.808 | 0.748 | 163 | Качество порядка выросло, но latency почти удвоилась |
На первый взгляд победил reranker. Но среднее здесь говорит не все.
Срез | Кейсов | Метрика | Dense | Hybrid | Hybrid + reranker |
|---|---|---|---|---|---|
Exact entity | 18 | Recall@5 | 0.500 | 0.778 | 0.778 |
Перефразирование | 22 | Recall@5 | 0.818 | 0.773 | 0.864 |
Длинные разделы | 17 | Recall@5 | 0.647 | 0.706 | 0.824 |
Версии и конфликты | 15 | Recall@5 | 0.533 | 0.667 | 0.867 |
Таблицы | 16 | Recall@5 | 0.625 | 0.625 | 0.750 |
Нет ответа | 20 | Корректное воздержание | 0.550 | 0.600 | 0.700 |
Плохая формулировка | 12 | Recall@5 | 0.667 | 0.750 | 0.750 |

Из этой таблицы уже получаются инженерные решения
Hybrid почти полностью решает проблему exact entity. Reranker там ничего не добавляет: платить лишние 77 ms за этот срез не за что. Зато reranker хорошо поднимает длинные разделы и конфликтующие версии. Но последние могут быть не заслугой reranker вообще: если в выдаче есть устаревший документ, сначала стоит проверить metadata-фильтр и индекс, а не радоваться тому, что дорогая модель иногда переставляет документы удачно.
Срез нет ответа напоминает еще одну вещь. Хороший retriever не обязан возвращать пустой top-5: многие векторные базы найдут что-то похожее всегда. Воздержание - это policy поверх скоринга, порогов, metadata и генерации. Его нужно проверять отдельно.
Почему разница в 1-2 пункта еще ничего не значит
Главная ценность маленького набора - диагностика, а не точность общего процента. На 120 кейсах разница 0.808 против 0.792 - это примерно два дополнительных успешных запроса. Для решения «включаем ли дорогой reranker на весь трафик» этого маловато.
Грубая оценка неопределенности для бинарной метрики выглядит так:
SE = sqrt(p * (1 - p) / n)
Если Recall@5 = 0.78 на 120 кейсах, 95% интервал по нормальному приближению будет примерно +/- 7.4 процентного пункта. На 200 кейсах при скоре 0.80 - все еще около +/- 5.5 пункта. Это не значит, что надо ждать 10 000 примеров. Это значит: не стоит объявлять победителем конфигурацию, которая выиграла один-два кейса в общей таблице.
Что делать вместо этого:
сохранять результат на каждом кейсе, а не только итоговый средний скор;
сравнивать конфигурации на одних и тех же запросах;
смотреть, какие конкретно кейсы изменились, а не только среднее;
для бинарного успеха использовать парный тест Мак-Немара или bootstrap разности метрик;
выпускать изменение, если оно улучшает критичный срез, не нарушает safety gate и укладывается в latency/стоимость.
Последний пункт важнее статистики. Если новый фильтр убрал выдачу старой инструкции на 14 из 15 запросов с конфликтующими версиями, а общий Recall почти не сдвинулся, это полезное изменение. Если reranker добавил 1.6 пункта в среднем, но съел половину latency-бюджета и не улучшил критичные срезы, его можно не включать.
Как собрать первые 120 кейсов без месяца ручной работы
Я разбиваю работу на короткие итерации.
1. Зафиксировать корпус
Сохранить версию исходных документов, правила очистки, chunking и mapping chunk_id -> document_id. Без этого через неделю нельзя будет понять, улучшил ли retrieval новый embedder или просто поменялась база.
2. Собрать smoke-набор из 20-50 вопросов
Взять самые частые, самые дорогие и самые хрупкие сценарии. Прогнать baseline, открыть top-5 руками. Здесь часто находятся проблемы, для которых вообще не нужна новая модель: дубликаты, неверная кодировка, распавшиеся таблицы, документы без даты.
3. Заполнить матрицу срезов
Не пытаться сразу написать 300 идеальных вопросов. Сначала пройтись по восьми срезам и положить в каждый хотя бы 10-15 строк. После первого прогона станет видно, где выборка однообразна и где система реально ломается.
4. Разметить в два прохода
Первый человек выбирает ожидаемые документы и факты. Второй быстро просматривает спорные и дорогие кейсы. Полный double annotation для всех 120 строк дорог; для начала лучше отдать второй проход тем местам, где ошибка будет менять продуктовую политику.
5. Заморозить v0 и не подчищать ее под новую модель
Очень хочется убрать «неудобный» запрос, потому что он странно сформулирован или ломает красивую таблицу. Так eval превращается в демо. Лучше оставить его, добавить пометку и завести отдельный кейс, если вопрос правда некорректен.
6. Запускать эксперименты карточками
Для каждого изменения достаточно одной записи:
Гипотеза: hybrid-поиск поднимет exact entity без ухудшения перефразирования. Изменение: BM25 + RRF, остальные параметры фиксированы. Проверяем: Recall@5, nDCG@5, срезы exact entity и перефразирование, p95 latency. Решение: принять / отклонить / повторить после исправления ingestion.
Это скучнее, чем десять веток с конфигами, зато через месяц все еще понятно, почему текущий стек выглядит именно так.
что стоит помнить и проверить до смены модели
Когда общий скор плохой, первая реакция обычно «давайте другой embedder». Иногда он правда нужен. Но сначала стоит открыть двадцать промахов и разложить их по причинам.
Симптом | Частая причина | Что проверить раньше новой модели |
|---|---|---|
Нужный текст не находится вообще | Документ не попал в индекс или распался при ingestion | Coverage корпуса, парсер, mapping документов |
Находится старое вместо нового | Нет версии, даты или фильтра свежести | Metadata, alias актуального документа, reindex |
Теряются коды и артикулы | Dense retrieval не любит точное совпадение | BM25, hybrid, нормализация токенов |
Ответ есть в документе, но не в chunk | Чанк разрезал таблицу или условие и действие | Structure-aware chunking, размер и overlap |
Нужный chunk в top-5, ответ все равно неверный | Генерация додумала факт или проигнорировала источник | Prompt, цитаты, constrained answer, judge |
Система уверенно отвечает вне корпуса | Нет явной policy на отсутствие ответа | Порог, проверка контекста, шаблон воздержания |
В корпоративном контуре к этому списку добавляются права доступа. Eval, где search возвращает документ, который конкретный пользователь видеть не должен, нельзя считать успешным retrieval. ACL-фильтрация и tenant isolation должны быть частью тестового сценария, а не внешним middleware, который забыли включить в локальном ноутбуке.
Минимальный runner
Для первого шага не нужен новый фреймворк. Достаточно, чтобы runner на каждый case_id сохранял:
{ "case_id": "ops-047", "config_id": "hybrid-rerank-v3", "index_version": "2026-08-14", "retrieved": [ {"chunk_id": "runbook-errors-v3#e204", "rank": 1, "score": 0.82}, {"chunk_id": "release-notes-2-4#known-issues", "rank": 2, "score": 0.71} ], "answer": "...", "latency_ms": 163, "prompt_version": "answer-v5" }
Без такого следа метрика почти бесполезна. Она скажет, что Recall упал с 0.79 до 0.72, но не скажет, что в индексе исчезла ветка документации или новый reranker начал опускать таблицы ниже лимита top_k.
Позже этот же trace станет основой для следующего шага серии: живой плохой запрос сначала разбирается, потом получает класс ошибки и только после этого попадает в eval как regression case.
Что считать успехом после первой итерации
Не Recall@10 = 0.95. Для одного домена это может быть мало, для другого - уже лишний latency и стоимость. Успех первого eval set гораздо прозаичнее:
у команды есть зафиксированная версия корпуса и понятная схема кейса;
в наборе есть реальные вопросы и случаи без ответа, а не только синтетические happy path;
retrieval измеряется отдельно от генерации;
каждый новый эксперимент отвечает на одну гипотезу;
по top-5 можно объяснить основные классы промахов;
критичные срезы имеют собственные пороги, а не растворены в среднем;
плохой production trace можно добавить как новый regression test.
После этого RAG перестает быть набором настроек, которые двигают «на ощущениях и субъективности». Можно честно сказать: вот где система помогает, вот где ломается, вот какую цену мы платим за следующий процент качества.
И это хороший момент, чтобы идти дальше. Даже аккуратный eval set не остается полным: пользователи регулярно приносят сценарии, которых в нем не было. В следующей статье разберем, как превращать жалобу «ответ странный» в trace, разметку и новый тест, а не в одноразовый тикет на смену промпта
В Telegram torch lab я собираю короткие разборы, схемы и рабочие заготовки, которые не всегда помещаются в статью: от trace до карточки эксперимента. Там же можно принести свой кейс или вопрос, приходите :)

