16 мин чтения

Оценка качества RAG на .NET: Recall@5, MRR и гейт релиза

Как измерять качество RAG на .NET: Golden Set из 50–100 вопросов, расчёт Recall@5 и MRR, измерение faithfulness и eval-как-гейт-релиза в CI.

Виктор Бузин · Founder & Principal Engineer, AnetLab
16+ лет в enterprise-разработке: .NET, AI/LLM-системы, RAG, MCP. Автор материала и практик в студии.

Пилот, который работает, ничего не доказывает

Пилот на десятке документов почти всегда выглядит хорошо. Ответы осмысленные, цитаты на месте, заказчик доволен. Проблема возникает, когда документов десятки тысяч, а вопросы приходят не из демо-сценария, а из реальной поддержки.

Настоящая проблема даже не в качестве ответа — в том, что у команды нет способа узнать, стало ли хуже. Поменяли чанкер — стало лучше или чуть хуже? Заменили модель — просядет стройность формулировок? Добавили реранкер поверх гибридного поиска — поможет или помешает?

Все эти изменения ощущаются одинаково: «вроде работает». Разница между ними видна только на фиксированном наборе вопросов с известными правильными ответами — 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 пунктов для продакшна.

CTA

Если ваш RAG работает, а решения принимаются на глаз, начните с одного шага: выберите 50 вопросов из реальной поддержки, зафиксируйте эталонные чанки и посчитайте Recall@5 и MRR. Дальше каждое изменение в пайплайне станет проверяемым, а не ставкой.

Разобрать свой пайплайн на реальных данных — напишите нам. Покажем, где именно ломается: в чанкинге, в retrieval, в генерации или в самом способе измерять качество.

Читайте также

Заявка

Обсудить задачу

Оставьте имя и email — отвечу в течение рабочего дня. Если задача срочная, проще написать напрямую наproject@anetlab.ru.