Оценка качества RAG на .NET: Recall@5, MRR и гейт релиза
Как измерять качество RAG на .NET: Golden Set из 50–100 вопросов, расчёт Recall@5 и MRR, измерение faithfulness и eval-как-гейт-релиза в CI.
Пилот, который работает, ничего не доказывает
Пилот на десятке документов почти всегда выглядит хорошо. Ответы осмысленные, цитаты на месте, заказчик доволен. Проблема возникает, когда документов десятки тысяч, а вопросы приходят не из демо-сценария, а из реальной поддержки.
Настоящая проблема даже не в качестве ответа — в том, что у команды нет способа узнать, стало ли хуже. Поменяли чанкер — стало лучше или чуть хуже? Заменили модель — просядет стройность формулировок? Добавили реранкер поверх гибридного поиска — поможет или помешает?
Все эти изменения ощущаются одинаково: «вроде работает». Разница между ними видна только на фиксированном наборе вопросов с известными правильными ответами — Golden Set. Пока такого набора нет, любое изменение в пайплайне остаётся ставкой.
Eval превращает ставку в проверку: фиксированный набор, фиксированные метрики, сравнение «до и после». Дальше — конкретный конвейер: что лежит в наборе, как считаются четыре метрики и где прогон блокирует релиз.
Golden Set: 50 вопросов, а не 5
Golden Set — это набор вопросов из реальной поддержки, для каждого из которых заранее известен чанк или чанки, содержащие правильный ответ. Не «тестовые вопросы», а те, что пользователи задают на самом деле.
Первое правило: вопросы берутся из реальной поддержки, а не пишутся разработчиками. Синтетический вопрос формулируется в терминах документа: его пишет команда, которая этот документ уже понимает. Пользователь спрашивает про свою задачу и спотыкается там, где слова документа и слова вопроса не совпадают.
Второе правило — размер. Пять вопросов не дают метрики: один неудачный кейс — это минус 20 процентных пунктов, и порог Recall@5 = 0.85 перестаёт что-либо значить. Пятьдесят вопросов превращают тот же промах в два процентных пункта — это уже сигнал, а не шум. Рабочий диапазон — 50–100 вопросов: точное число зависит от поставленной задачи, состава контента и направления компании, поэтому диапазон задаёт порядок величины, а не измеренную константу. Суть в том, что набор покрывает десятки сценариев, а не единицы.
Покрытие важнее количества. В наборе должны быть разные типы запросов:
- факт из конкретного документа;
- точный идентификатор — номер договора, артикул, код ошибки; их надёжнее находит полнотекстовый поиск;
- вопрос, ответ на который собирается из двух-трёх чанков разных документов;
- вопрос, ответа на который в корпусе нет: правильное поведение — «не нашла», а не выдумка; эталонного чанка у такого кейса нет, поэтому в Recall@5 и MRR он не входит и проверяется отдельно.
Кейс — это структура, а не строка в списке. Набор лежит в JSONL, одна строка — один кейс:
{"id":"sup-0142","question":"Какой SLA у клиента по договору 44-А/2024?","expectedChunkIds":["doc-441/ch-06"],"category":"contract","source":"ticket-88213"}
Поля: id — стабильный идентификатор кейса, question — вопрос дословно, expectedChunkIds — эталонные чанки, category — класс вопроса для срезов, source — ссылка на исходный тикет.
Здесь первое техническое решение, которое снимает ручную работу при каждом пере-чанкинге: reference id привязан к структуре документа, а не к смещению в нём.
плохо: doc-441#offset-5120..5632 — ломается при каждом пере-чанкинге
хорошо: doc-441/ch-06 — переживает пере-чанкинг, пока структура та же
Формат — id документа, путь заголовков и номер раздела. При смене чанкера меняются границы и номера разделов, но путь остаётся, и старые reference id продолжают указывать на нужное место. Как выглядит архитектура, к которой привязан набор, — в RAG на .NET: архитектура, которая переживает продакшн.
Набор не пересобирают молча. Триггеры — новый корпус, изменение схемы чанкинга, смена модели эмбеддингов, правка промпта. Старые id мапятся на новые по паре «документ + путь заголовков», а кейс, у которого эталонный чанк исчез, помечается needs-review и не удаляется: если удалить, набор тихо сожмётся, и метрика станет лучше просто потому, что вопросы пропали. Новые id фиксируются отдельным коммитом, чтобы diff набора был виден на ревью.
Версионируется набор как пара: файл golden.v3.jsonl и манифест рядом — дата, чек-сумма, кто пересобирал, версии индекса, эмбеддингов и чанкинга. Старые версии не удаляются: по ним видно, что менялось.
Метрики и пороги
Четыре метрики, четыре порога. Первые две — про retrieval, вторые — про генерацию:
| Метрика | Что показывает | Как считается | Порог |
|---|---|---|---|
| Recall@5 | Нашёл ли retrieval нужный чанк в первых пяти | доля эталонных чанков в top-5, среднее по кейсам | > 0.85 |
| MRR | Насколько высок ранжирован первый правильный ответ | среднее 1/ранг по кейсам, 0 если эталон не найден | > 0.75 |
| Faithfulness | Есть ли в ответе утверждения, которых нет в контексте | judge по рубрике, claim-level: доля утверждений, следующих из переданного контекста | > 0.90 |
| Answer relevancy | Отвечает ли на вопрос, а не на смежную тему | тот же judge, answer-level: две оси по шкале 1..5, нормированы в 0..1 | > 0.85 |
Почему четыре метрики, а не шесть: Precision@k и nDCG, которые рядом с ними часто приводят в оценке RAG, отвечают на вопрос о доле релевантного в выдаче и о распределении позиций — он содержателен, когда у вопроса много равноценных правильных ответов, а в нашем гейте важны попадание нужного чанка в первые пять и его позиция.
Порог — не цель, а граница допуска. Ниже Recall@5 = 0.85 retrieval сломан независимо от того, насколько блестяще сформулирован ответ: модель не спасёт контекст, которого в выдаче нет. Faithfulness с порогом 0.90 — самая жёсткая из четырёх: выдуманный пункт договора хуже, чем честное «не нашла».
Пороги калибруются на своём наборе и своём judge: переносить их из другого проекта нельзя — и рубрика, и состав набора меняют значения метрик.
Расчёт retrieval-метрик детерминирован — код ниже. Метрики генерации считает judge, и он не видит эталонный ответ: faithfulness проверяет опору на контекст, а эталон нужен при ручной разметке.
Как это считается
// Золотой набор: вопрос из реальной поддержки и id чанка с правильным ответом.
public sealed record GoldenCase(
string Id,
string Question,
IReadOnlyList<string> ExpectedChunkIds,
string Category);
// Результат одного прогона: что вернула выдача и в каком порядке.
public sealed record CaseRun(
string CaseId,
IReadOnlyList<string> RetrievedChunkIds,
double LatencyMs);
GoldenCase — вход прогона, CaseRun — выход retrieval для одного кейса. Порядок в RetrievedChunkIds важен: из него считается позиция, а не только сам факт попадания.
public sealed record RetrievalMetrics(double RecallAtK, double Mrr, int Cases);
public static class RetrievalEvaluator
{
public static RetrievalMetrics Evaluate(
IReadOnlyList<GoldenCase> goldenSet,
IReadOnlyList<CaseRun> runs,
int k = 5)
{
var byCase = runs.ToDictionary(r => r.CaseId);
double recallSum = 0;
double reciprocalRankSum = 0;
foreach (var testCase in goldenSet)
{
if (testCase.ExpectedChunkIds.Count == 0)
throw new InvalidOperationException(
$"Кейс {testCase.Id} без эталонных чанков");
if (!byCase.TryGetValue(testCase.Id, out var run))
throw new InvalidOperationException(
$"Нет прогона для кейса {testCase.Id}");
var top = run.RetrievedChunkIds.Take(k).ToHashSet();
// Recall@k: доля эталонных чанков, попавших в первые k.
// Если эталонный чанк один, это 0 или 1 —
// то есть Recall@k совпадает с hit rate.
var found = testCase.ExpectedChunkIds.Count(id => top.Contains(id));
recallSum += (double)found / testCase.ExpectedChunkIds.Count;
// MRR: 1 / позиция первого эталонного чанка в выдаче.
// Ноль означает «эталон не найден»: дефолтный кортеж пуст.
var rank = run.RetrievedChunkIds
.Select((id, index) => (id, rank: index + 1))
.FirstOrDefault(x => testCase.ExpectedChunkIds.Contains(x.id))
.rank;
reciprocalRankSum += rank > 0 ? 1.0 / rank : 0.0;
}
return new RetrievalMetrics(
RecallAtK: recallSum / goldenSet.Count,
Mrr: reciprocalRankSum / goldenSet.Count,
Cases: goldenSet.Count);
}
}
Среднее считается по кейсам, а не по документам: на маленьком наборе метрика скачет, и гейт срабатывает на шум.
Faithfulness проверяется в два слоя, и инструменты у них разные. Первый слой — детерминированный код: он ловит два класса дефектов — утверждение без ссылки на чанк из переданного контекста и ссылку на несуществующий чанк. Оба — автоматический провал, и слой стоит ноль токенов.
// Детерминированная часть оценки: каждое утверждение ответа обязано
// ссылаться на чанк, который реально был передан в контекст.
public sealed record GroundingCheck(int Claims, int Cited, int ForeignCitations)
{
// Доля утверждений с корректной ссылкой на чанк из контекста.
// Это не семантическая опора: содержание утверждения не проверяется.
public double CitedRatio =>
Claims == 0 ? 0 : (double)Cited / Claims;
}
public static class GroundingChecker
{
public static GroundingCheck Check(
string answer,
IReadOnlyList<string> contextChunkIds)
{
var known = contextChunkIds.ToHashSet(StringComparer.Ordinal);
// Абзац — одно утверждение. Строка со списком источников не считается.
var claims = answer
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(line => !line.StartsWith("Источники:", StringComparison.Ordinal))
.ToList();
var cited = claims.Count(claim =>
{
var ids = SourceIds(claim).ToList();
return ids.Count > 0 && ids.All(known.Contains);
});
// Ссылка на чанк, которого в контексте не было, — выдуманный источник.
var foreign = claims.SelectMany(SourceIds).Count(id => !known.Contains(id));
return new GroundingCheck(claims.Count, cited, foreign);
}
// Формат ссылки в ответе: [chunk-id].
private static IEnumerable<string> SourceIds(string claim)
{
var start = claim.IndexOf('[');
while (start >= 0)
{
var end = claim.IndexOf(']', start + 1);
if (end < 0) yield break;
yield return claim[(start + 1)..end].Trim();
start = claim.IndexOf('[', end + 1);
}
}
}
Второй слой — judge. Он оценивает не ответ целиком, а отдельные утверждения: смешанный вердикт по частям — нормальный, «частично правдивый» ответ плохой сигнал гейта. Рубрика claim-level: вердикт выносится только по переданному контексту — знание модели подтверждением не считается. Утверждение, для которого в контексте нет подтверждающей формулировки, неподтверждённое. Противоречие контексту — всегда неподтверждённое утверждение, даже если оно правдоподобно.
// Judge оценивает не ответ целиком, а отдельные утверждения:
// «частично правдивый» ответ — плохой сигнал гейта.
public sealed record ClaimJudgement(string ClaimId, bool Supported, string Reason);
// Ответ оценивается по шкале 1..5 по двум осям.
// Полнота считается справочно и в метрику не входит.
public sealed record AnswerJudgement(
int RespondsToQuestion,
int OnTopic,
int Complete,
string Reason);
public sealed record JudgeVerdict(
IReadOnlyList<ClaimJudgement> Claims,
AnswerJudgement Answer)
{
// Faithfulness: доля утверждений, подтверждённых переданным контекстом.
public double Faithfulness =>
Claims.Count == 0 ? 0 : Claims.Count(c => c.Supported) / (double)Claims.Count;
// Answer relevancy: среднее двух осей, приведённое к 0..1.
// Оценка 1 → 0.0, оценка 5 → 1.0.
public double AnswerRelevancy =>
((Answer.RespondsToQuestion + Answer.OnTopic) / 2.0 - 1.0) / 4.0;
}
Answer relevancy считается тем же вызовом judge, но по другой оси рубрики: RespondsToQuestion — отвечает ли ответ на вопрос, OnTopic — не ушёл ли в смежную тему. Третья ось, полнота, в метрику не входит.
Ключевое различие: детерминированный код ловит ссылку, но не содержание. Утверждение со ссылкой на правильный чанк, но не следующее из него, проходит детерминированную проверку — поймать его может только judge. Считать одно другим — ошибка: метрика зелёная, а ответы мусорные.
Eval как гейт релиза
Прогон eval — это не отчёт, который кто-то посмотрит. Это шаг Azure DevOps pipeline с кодом возврата: нарушил порог — релиз не вышел.
| # | Шаг | Что считается | Блокирует |
|---|---|---|---|
| 0 | Индекс собран офлайн-батчем, у него есть версия | — | нет индекса — прогон не стартует |
| 1 | Retrieval: вопрос → гибридный поиск (вектор + FTS, RRF K = 60) → пост-ретривальная фильтрация по правам → список chunk id | Recall@5, MRR | — |
| 2 | Проверка порогов retrieval | Recall@5 > 0.85, MRR > 0.75 | ниже порога — прогон останавливается, до генерации не доходим |
| 3 | Генерация: retrieved-контекст → ответ с цитированием [chunk-id] |
секунды и токены на запрос | — |
| 4 | Детерминированная проверка цитирования | утверждения, корректные ссылки, выдуманные источники | выдуманный источник — падение до judge |
| 5 | Judge по рубрике | faithfulness, answer relevancy | ниже порога — прогон падает |
| 6 | Сбор отчёта | метрики, разбивка по кейсам, пять версий | — |
| 7 | Гейт | пороги и baseline | код возврата ≠ 0 — пайплайн красный |
Порядок не декоративный. Шаг 2 стоит перед шагом 3 по экономике: нет нужного чанка в выдаче — ответ всё равно неверный. Шаг 4 — перед шагом 5: дешёвый фильтр за ноль токенов.
public sealed record GateRule(string Metric, double Minimum, double? Baseline = null);
// Итог по одному кейсу: попал ли эталон в выдачу и какое правило нарушено.
public sealed record CaseResult(
string CaseId,
bool Passed,
string? FailedRule);
public sealed record EvalReport(
IReadOnlyDictionary<string, double> Metrics,
IReadOnlyList<CaseResult> Cases);
public static class ReleaseGate
{
// Гейт — не «метрика выше порога», а «выше порога и не хуже baseline».
// Метрика может остаться выше порога и упасть — порог такое не поймает.
public static bool Passes(EvalReport report, IReadOnlyList<GateRule> rules)
{
foreach (var rule in rules)
{
if (!report.Metrics.TryGetValue(rule.Metric, out var value))
return false;
if (value < rule.Minimum)
return false;
if (rule.Baseline is { } baseline && value < baseline)
return false;
}
return true;
}
}
В гейте два условия: порог и baseline. Порог ловит провал, но не ловит деградацию внутри допуска — метрика может остаться выше порога и при этом упасть. Поэтому у правила есть baseline, и сравнение возможно только внутри одной версии набора и индекса.
// Пороги гейта живут в одном месте.
public static class Rules
{
public static readonly IReadOnlyList<GateRule> All =
[
new("recall_at_5", 0.85),
new("mrr", 0.75),
new("faithfulness", 0.90),
new("answer_relevancy", 0.85)
];
}
// Детализация по кейсу: кейс из набора, выдача и вердикты judge.
public sealed record CaseDetail(
GoldenCase TestCase,
IReadOnlyList<string> RetrievedChunkIds,
string Answer,
IReadOnlyList<ClaimJudgement> Verdicts);
// Артефакт сборки: агрегаты плюс детализация по кейсам.
public sealed record EvalArtifact(
EvalReport Report,
IReadOnlyList<CaseDetail> Cases);
// Прогон eval в Azure DevOps pipeline: один шаг, один код возврата.
public static async Task<int> RunEvalAsync(EvalArtifact artifact)
{
await File.WriteAllTextAsync(
"artifacts/eval-report.json",
JsonSerializer.Serialize(artifact));
// 0 — гейт пройден, 1 — нарушен порог или регресс к baseline.
return ReleaseGate.Passes(artifact.Report, Rules.All) ? 0 : 1;
}
Каждый прогон фиксирует пять версий: IndexVersion, EmbeddingVersion, PromptVersion, ChunkingVersion, GoldenSetVersion. Без них дельта между прогонами нечитаема: непонятно, что именно изменилось.
Отчёт — артефакт сборки, и по нему видно, какой кейс сломался. EvalReport несёт агрегаты и по кейсу итог passed / failed с нарушенным правилом — этого достаточно гейту. Детализация собирается отдельно в EvalArtifact: вопрос, категория, эталонные и retrieved chunk id, текст ответа и вердикт judge по каждому утверждению с причиной. Плюс diff к baseline: какие кейсы упали, какие добавились. Без этого артефакта команда видит только «красный прогон».
Минимальный обязательный вариант — один полный гейт перед релизом, шаги 0–7. Практичное развитие — два уровня: на каждый pull request прогонять только retrieval-часть (шаги 0–2 и 6–7), без LLM и judge — дёшево, а полный гейт остаётся на релиз-кандидате.
Offline и online: что где меряется
Offline-прогон по Golden Set воспроизводим: он идёт перед релизом и ловит регрессии в чанкинге, эмбеддингах, промптах, реранкере и сборе контекста. Там же считается стоимость и задержка до релиза.
Eval-прогон идёт с отключённым кэшем: кэш по вектору запроса закрывает 30–50% запросов, и если прогон идёт через него, метрика измеряет кэш, а не пайплайн.
Но offline-прогон не видит запросов, которых нет в наборе: он покрывает типовые сценарии и молчит обо всём остальном. Поэтому в проде нужен второй контур — online-мониторинг. Он даёт то, чего у Golden Set нет:
- cache hit rate: доля запросов, закрытых кэшем по вектору запроса; в контуре, где мы это делали, до генерации не доходили 30–50% запросов;
- счёт за запрос: маршрутизация по сложности снижает его в 3–4 раза;
- p95 latency: задержка ответа, а не стоимость, поэтому читается отдельно от счёта;
- доля отсечённых кандидатов по правам — 10–20%; если доля растёт, права и корпус разошлись и пора разбирать качество источника, а не крутить порог;
- доля запросов без ответа — первая сигнализация о том, что retrieval перестал находить;
- доля запросов, ушедших к человеку: если растёт, система перестала закрывать вопрос сама;
- drift: распределение категорий живых вопросов против состава золотого набора — расхождение показывает, какие классы вопросов ещё не закрыты.
Отдельно стоит сказать: judge нужен не только offline. В проде он идёт на сэмпле ответов и на всех ответах без цитат: offline даёт метрику на размеченном, online ловит дрейф на неразмеченном.
Ключевое различие, которое стоит проговорить прямо: у online-метрик нет меток. У живого запроса нет эталона, поэтому Recall@5 в проде посчитать не с чем — online-мониторинг это прокси, сэмплирование и обратная связь. Offline — единственное место, где качество измеряется по-настоящему.
Где мы ошиблись
Три решения, которые мы пересмотрели после выхода системы в прод.
Фильтр по правам внутри запроса к векторному индексу → post-retrieval trimming. Сначала доступ фильтровался прямо в запросе к индексу. Схема тихая: сужение кандидатной выборки снижает recall, и в логах нет ни одной ошибки — просто «стало чуть хуже». Если права не совпадают с корпусом и отсекается, скажем, половина кандидатов, поиск по индексу в предельном случае превращается в почти полное сканирование. Сейчас кандидаты извлекаются с запасом и режутся по правам уже после retrieval. Отсекается 10–20% кандидатов: права в основном совпадают с корпусом, поэтому основная работа ушла не на фильтрацию, а на индексацию.
Эмбеддинги в рантайме → офлайн-батч. На десятках тысяч документов и десятках ГБ пересчёт эмбеддингов на лету не проходит по времени ответа. Эмбеддинги считаются офлайн-батчем, пользователь получает поиск по готовому индексу — время ответа перестаёт зависеть от пересчёта корпуса.
Фиксированное окно 512 токенов → чанкинг по структуре. Документ, разрезанный по длине, теряет смысл на стыках: половина ответов не находила контекст, который был в соседнем чанке. Сейчас режем по структуре — для договоров стороны, предмет, сроки, ответственность, приложения; для технической документации разделы и подсекции, блоки кода целиком. Выбор чанкера влияет на качество ответа примерно на 80% — это главный рычаг. Именно поэтому reference id в Golden Set привязаны к структуре документа, а не к смещениям: иначе каждая смена чанкера ломала бы набор.
Что мы оставили: гибридный поиск с RRF в Azure AI Search (K = 60) поверх векторной и полнотекстовой выдачи, маршрутизация по сложности и семантический кэш по вектору запроса — измеримый эффект без переделки. Подробнее про сам конвейер — Собираем RAG-пайплайн на .NET: практический разбор.
Чек-лист перед продакшном
- Golden Set из 50–100 вопросов из реальной поддержки, с версией, эталонными чанками и категорией по каждому кейсу
- Reference id привязаны к структуре документа, а не к смещениям
- Recall@5 выше 0.85 на наборе
- MRR выше 0.75 на наборе
- Faithfulness выше 0.90 на наборе
- Answer relevancy выше 0.85 на наборе
- Прогон eval выполняется в Azure DevOps pipeline и блокирует релиз при падении порога
- Гейт проверяет baseline, а не только порог
- Отчёт по прогону сохраняется как артефакт сборки, с разбивкой по кейсам
- Eval-прогон идёт с отключённым кэшем
- Чанкинг по структуре, а не по фиксированному окну
- Кейсы с исчезнувшим эталоном помечаются
needs-review, а не удаляются - В проде видны cache hit rate, счёт за запрос, доля отсечённых кандидатов и p95 latency
Развёрнутый чек-лист для продакшна — Production RAG: чек-лист из 50 пунктов для продакшна.
Related Articles
- Собираем RAG-пайплайн на .NET: практический разбор
- RAG на .NET: архитектура, которая переживает продакшн
- Production RAG: чек-лист из 50 пунктов для продакшна
- Архитектура enterprise AI: четыре решения, которые всё определяют
- .NET и Azure для AI-систем: референсная архитектура
CTA
Если ваш RAG работает, а решения принимаются на глаз, начните с одного шага: выберите 50 вопросов из реальной поддержки, зафиксируйте эталонные чанки и посчитайте Recall@5 и MRR. Дальше каждое изменение в пайплайне станет проверяемым, а не ставкой.
Разобрать свой пайплайн на реальных данных — напишите нам. Покажем, где именно ломается: в чанкинге, в retrieval, в генерации или в самом способе измерять качество.
Production RAG: чек-лист из 50 пунктов для продакшна
Чек-лист вывода RAG в продакшн: безопасность, оценка качества, observability, контроль стоимости и управление жизненным циклом контента.
.NET и Azure для AI-систем: референсная архитектура
Как собрать production-ready AI-систему на .NET и Azure: async-пайплайн, RAG, Polly, observability и контроль стоимости — с рабочим кодом.