Метрики поиска в RAG: Recall@K, MRR и NDCG на практике, плюс как выбирать чанкинг
Как отделить ошибки retriever от ошибок генерации: формулы Recall@K, Precision@K, MRR и NDCG, реализация на 30 строках Python, эксперимент с размером чанков и честные ограничения метрик.
c81dbec
Содержание
Коротко
- Если 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 пункта.
Что дальше
- Разметьте
expected_sourcesв своём датасете и посчитайте Recall@5 и MRR. - Если метрики поиска в порядке, переходите к судье: калибровка LLM-судьи.
- Если хотите проверить безопасность базы знаний, прочитайте про инъекции через документы RAG.
