Р. Аллахвердиев
RAGОценка качестваСерия «Оценка RAG» · часть 2

Метрики поиска в RAG: Recall@K, MRR и NDCG на практике, плюс как выбирать чанкинг

Как отделить ошибки retriever от ошибок генерации: формулы Recall@K, Precision@K, MRR и NDCG, реализация на 30 строках Python, эксперимент с размером чанков и честные ограничения метрик.

опубликовано · 10 минc81dbec
Обложка: Метрики поиска в RAG: Recall@K, MRR и NDCG на практике, плюс как выбирать чанкинг
Содержание

Коротко

  • Если RAG отвечает плохо, первым делом проверьте, был ли нужный фрагмент среди найденных. Это вопрос к retriever, и ответить на него можно без LLM-судьи.
  • Recall@K: нашли ли нужное в первых K результатах. MRR: насколько высоко стоит первый правильный результат. NDCG@K: хорошо ли упорядочена вся выдача, включая градации релевантности.
  • Метрики поиска дёшевы, детерминированы и не требуют вызовов модели, если в датасете есть разметка эталонных источников.
  • Чанкинг и top-k подбирайте экспериментом на своём датасете, а не по чужим рекомендациям.
  • Хороший поиск не гарантирует хороший ответ, но плохой поиск гарантирует плохой. Поэтому эти метрики идут первыми.

Зачем отдельно мерить поиск

Четыре метрики RAG используют LLM-судью, и в двух из них (Contextual Precision и Recall) он оценивает найденный контекст. Это удобно, но дорого и подвержено шуму судьи. Если у вас есть разметка «в каких документах лежит ответ» (поле expected_sources из golden-датасета), метрики поиска можно посчитать чистой арифметикой. Они ответят на вопрос: принёс ли retriever то, что нужно?

Это сильно сокращает диагностику. Если Recall@5 низкий, нет смысла править промпт: модель не может использовать то, чего ей не дали.

Определения

Пусть для вопроса есть множество релевантных фрагментов R (по разметке), а retriever вернул упорядоченный список из результатов.

Precision@K. Доля релевантных среди первых K результатов: |R ∩ top_K| / K. Показывает чистоту выдачи, не показывает, всё ли нашли.

Recall@K. Доля найденных релевантных от всех существующих: |R ∩ top_K| / |R|. Для вопроса с одним нужным фрагментом это просто «попал или нет».

MRR (Mean Reciprocal Rank). Для каждого вопроса берём позицию первого релевантного результата rank, считаем 1/rank и усредняем по вопросам. Первый на первом месте даёт 1, на втором — 0,5, на пятом — 0,2. Если релевантного нет в выдаче, вклад 0.

NDCG@K. Учитывает порядок и градации релевантности. Сначала считается DCG: Σ rel_i / log2(i + 1) по позициям i от 1 до K, где rel_i — оценка релевантности результата. Затем DCG делится на идеальный DCG (если бы результаты были отсортированы лучшим образом), получается число от 0 до 1. Метрика полезна, когда фрагменты бывают «полностью» и «частично» релевантны.

Какая метрика когда нужна:

Ситуация Метрика
Ответ собирается из нескольких фрагментов Recall@K
Модель читает только первый-два результата MRR
Контекст ограничен, важна чистота Precision@K
Есть градации полезности фрагментов NDCG@K

Реализация на Python

Всё считается без внешних библиотек:

import math

def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
    if not relevant:
        return float("nan")  # для no_answer вопросов метрика не определена
    return len(set(retrieved[:k]) & relevant) / len(relevant)

def precision_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
    return len(set(retrieved[:k]) & relevant) / k

def reciprocal_rank(retrieved: list[str], relevant: set[str]) -> float:
    for i, doc_id in enumerate(retrieved, start=1):
        if doc_id in relevant:
            return 1.0 / i
    return 0.0

def ndcg_at_k(retrieved: list[str], gains: dict[str, float], k: int) -> float:
    """gains: id фрагмента -> градация релевантности (например 0, 1, 2)."""
    dcg = sum(gains.get(d, 0.0) / math.log2(i + 1)
              for i, d in enumerate(retrieved[:k], start=1))
    ideal = sorted(gains.values(), reverse=True)[:k]
    idcg = sum(g / math.log2(i + 1) for i, g in enumerate(ideal, start=1))
    return dcg / idcg if idcg > 0 else 0.0

Обратите внимание на два момента. Для вопросов без ответа в базе (no_answer) Recall не определён, их исключают из усреднения и проверяют отдельно (retriever не должен тащить уверенный мусор, а генерация — отказывать). И результат нужно усреднять по вопросам, показывая срезы по типам: общая цифра скрывает провал на multi_hop.

Прогон по датасету выглядит так:

import csv, statistics
from retrieval import search  # ваша функция: вопрос -> список id фрагментов

def evaluate(path: str, k: int = 5):
    rows = [r for r in csv.DictReader(open(path, encoding="utf-8", newline=""))
            if r["type"] != "no_answer"]
    recalls, rrs = [], []
    for r in rows:
        relevant = set(filter(None, r["expected_sources"].split(";")))
        retrieved = search(r["question"], k=k)
        recalls.append(recall_at_k(retrieved, relevant, k))
        rrs.append(reciprocal_rank(retrieved, relevant))
    print(f"Recall@{k}: {statistics.mean(recalls):.2f}  MRR: {statistics.mean(rrs):.2f}  N={len(rows)}")

Идентификаторы в разметке и в выдаче должны совпадать по формату. Если вы режете документы на чанки заново, expected_sources придётся пересопоставить: удобнее размечать на уровне документа или раздела, а не номера чанка.

Эксперимент: как выбирать чанкинг

Чанкинг — разбиение документов на фрагменты — главный рычаг качества поиска. Параметры: размер чанка, перекрытие, способ разбиения (по символам, по абзацам, по заголовкам). Общая картина такая:

  • Слишком мелкие чанки теряют контекст: ответ разрезан пополам, Recall падает.
  • Слишком крупные размывают смысл и тащат шум: растёт стоимость и падает точность, а модель хуже использует длинный контекст.
  • Перекрытие снижает риск разрезать ответ, но увеличивает дубли в выдаче.

Универсального оптимума нет, он зависит от документов и вопросов. Поэтому подбор делают сеткой:

for size in (200, 400, 800):
    for overlap in (0, 50):
        index = build_index(docs, chunk_size=size, overlap=overlap)  # ваша сборка индекса
        for k in (3, 5, 10):
            evaluate_with(index, "golden.csv", k)

Таблицу результатов сохраняйте в отчёт. Условный вид (цифры иллюстративные, на вашем датасете будут другие):

chunk_size overlap Recall@5 MRR
200 0 0.62 0.48
400 50 0.78 0.61
800 50 0.74 0.55

Как читать такую таблицу: выберите вариант по Recall@K для вашего K (столько фрагментов вы реально подаёте в модель), проверьте MRR, затем посмотрите на стоимость токенов контекста. Если разница между вариантами мала в пределах шума, берите более дешёвый.

Другие рычаги, которые измеряются теми же метриками: гибридный поиск (векторный + BM25), reranker, переписывание запроса, фильтры по метаданным. Каждое изменение проверяйте на одном датасете и сравнивайте с базой.

Связь с метриками генерации

Полезно собрать диагностику в одну таблицу по провальным вопросам:

Recall@K Faithfulness Вывод
низкий любая чиним поиск: чанкинг, эмбеддинги, гибридный поиск
высокий низкая чиним генерацию: промпт, модель, шум в контексте
высокий высокая, ответ неверный проверяем эталон и актуальность документов

Так вы не тратите время на правку промпта, когда проблема в чанкинге.

Ограничения

  • Метрики требуют разметки эталонных источников, а разметка стоит времени и может ошибаться. Если релевантных фрагментов несколько и разметчик отметил не все, Recall занижается.
  • Идентификатор в разметке привязан к схеме чанкинга. Смена схемы ломает сопоставление.
  • Recall@K не знает, понятен ли фрагмент модели. Нужный абзац может прийти, но быть бесполезным без соседних.
  • Метрики не оценивают актуальность документа и противоречия между документами.
  • На маленьком датасете цифры шумят. Не делайте выводов по разнице в 1–2 пункта.

Что дальше