Тестирование RAG: как понять, что сломалось — поиск или генерация
Как тестировать RAG: метрики поиска (hit rate, recall, MRR) и генерации (faithfulness), эксперимент с идеальным контекстом, влияние чанков и top-k. Код на Python.
Как понять, что сломалось в RAG: поиск или генерация? Краткий ответ
Проверяйте поиск и генерацию по отдельности. Для поиска нужен набор вопросов с известными нужными фрагментами и метрики hit rate@k, recall@k и MRR. Для генерации подставьте в промпт идеальный фрагмент (oracle context) и оцените ответ на фактичность и релевантность. Если с идеальным контекстом ответ хороший, виноват поиск; если плохой, проблема в промпте или модели.
Как устроен RAG и где он ломается
RAG (Retrieval-Augmented Generation) — подход, при котором модель отвечает не только по своим знаниям, но и по найденным во внешней базе фрагментам [1]. Схема почти всегда одна: документы режут на чанки, индексируют, на вопрос находят top-k ближайших чанков, подставляют их в промпт и генерируют ответ. Ошибка на любом шаге превращается в плохой ответ, и по самому ответу не видно, на каком.
Симптомы и вероятные причины
| Что видит пользователь | Вероятная причина | Куда смотреть в первую очередь |
|---|---|---|
| Бот отвечает «не знаю», хотя документ в базе есть | Поиск не нашёл нужный чанк | Recall@k, размер чанков, качество эмбеддингов |
| Ответ по теме, но устаревший или из другой версии | В индексе старые документы или дубли | Актуальность и метаданные индекса |
| Бот уверенно называет факт, которого нет в документах | Модель выдумывает поверх контекста | Faithfulness, формулировка промпта, temperature |
| Ответ верный, но не про то, что спросили | Промпт или поиск приносят соседнюю тему | Answer relevance, context precision |
| Хорошо отвечает на короткие вопросы, плохо на длинные и составные | Несколько нужных фактов лежат в разных чанках | Recall по составным вопросам, значение top-k |
| Качество упало без изменений в коде | Обновили базу знаний, модель или промпт | Сравнение прогонов, версии артефактов |
Как тестировать поиск отдельно
Поиск можно проверять вообще без языковой модели: он либо принёс нужный фрагмент, либо нет. Для этого нужен небольшой набор, в котором для каждого вопроса заранее известно, в каком чанке лежит ответ. Собирать его удобно из реальных вопросов пользователей и документов, к которым эксперты привязывают правильный источник.
- ▸Hit rate@k — доля вопросов, для которых нужный чанк попал в top-k. Самая простая метрика: нашли или нет.
- ▸Recall@k — доля нужных чанков, найденных в top-k. Важна, когда для ответа требуется несколько фрагментов.
- ▸Precision@k — доля релевантных среди найденных. Низкая precision означает, что в промпт попадает много мусора.
- ▸MRR (mean reciprocal rank) — среднее значение 1/позиция первого нужного чанка. Показывает, насколько высоко в выдаче стоит нужное.
1# evals/test_retrieval.py
2import json
3
4from myapp.rag import retrieve # retrieve(question, k) -> list[Chunk], у чанка есть .id
5
6K = 5
7
8# Каждая строка: {"question": "...", "relevant_ids": ["kb-12#3", "kb-40#1"]}
9with open("evals/retrieval_set.jsonl", encoding="utf-8") as f:
10 CASES = [json.loads(line) for line in f]
11
12
13def positions(case) -> list[int]:
14 """Позиции (с 1) нужных чанков в выдаче top-K."""
15 ids = [chunk.id for chunk in retrieve(case["question"], k=K)]
16 return [ids.index(rid) + 1 for rid in case["relevant_ids"] if rid in ids]
17
18
19def test_hit_rate_at_k():
20 hits = sum(1 for case in CASES if positions(case))
21 assert hits / len(CASES) >= 0.90, f"hit rate@{K} = {hits / len(CASES):.0%}"
22
23
24def test_recall_at_k():
25 recalls = [len(positions(c)) / len(c["relevant_ids"]) for c in CASES]
26 assert sum(recalls) / len(recalls) >= 0.80
27
28
29def test_mrr():
30 rr = []
31 for case in CASES:
32 pos = positions(case)
33 rr.append(1 / min(pos) if pos else 0)
34 assert sum(rr) / len(rr) >= 0.70Пороги здесь стартовые: подберите их по своим данным и цене ошибки. Ценность набора не только в пороге, но и в списке проваленных вопросов: он прямо показывает, каких документов и формулировок поиску не хватает.
Как тестировать генерацию отдельно: идеальный контекст
Чтобы проверить, умеет ли модель отвечать по контексту, уберите поиск из уравнения: подставьте в промпт заведомо правильный фрагмент. Этот приём называют oracle context. Если и при идеальном контексте ответ плохой, виноваты промпт, параметры или модель, а не поиск.
1# evals/test_generation.py
2import json
3
4import pytest
5
6from evals.judge import judge # LLM-судья, см. статью про LLM-as-a-Judge
7from myapp.rag import generate_answer # generate_answer(question, chunks: list[str]) -> str
8
9# {"id", "question", "oracle_chunks": ["..."], "bucket": "happy_path"}
10with open("evals/generation_set.jsonl", encoding="utf-8") as f:
11 CASES = [json.loads(line) for line in f]
12
13
14@pytest.mark.parametrize("case", CASES, ids=lambda c: c["id"])
15def test_answer_is_grounded_in_oracle_context(case):
16 answer = generate_answer(case["question"], case["oracle_chunks"])
17 verdict = judge({**case, "context": "\n".join(case["oracle_chunks"])}, answer)
18 assert verdict["verdict"] == "pass", verdict["reasoning"]Диагностика по шагам: блок-схема
Соберём два эксперимента в одну последовательность. Она работает и как тест, и как инструкция для разбора плохого фидбека от пользователя.
- ▸Ответ с идеальным контекстом хороший — виноват поиск. Идите в метрики поиска, размер чанков, top-k и индекс.
- ▸Ответ плохой и не опирается на контекст — проблема в промпте или модели. Проверяйте faithfulness, температуру, версию модели.
- ▸Ответ опирается на контекст и верен, но не отвечает на вопрос — проверьте answer relevance и то, как промпт формулирует задачу.
Чанки и top-k: параметры, которые ломают качество
Два параметра поиска чаще всего объясняют «странное» поведение RAG: размер чанка и число возвращаемых фрагментов k (не путайте с top-k в семплировании модели, о нём отдельная статья). Оба влияют на качество в противоположных направлениях, поэтому их подбирают экспериментом на вашем наборе, а не по чужим рекомендациям.
| Параметр | Слишком маленькое значение | Слишком большое значение |
|---|---|---|
| Размер чанка | Факт разрезан на части, чанк теряет контекст, поиск находит обрывки | В чанке много лишнего, эмбеддинг «размывается», релевантность падает |
| Overlap между чанками | Смысл на границах теряется | Много дублей, поиск выдаёт почти одинаковые чанки |
| k (число чанков) | Нужный фрагмент не попадает в выдачу, recall низкий | В промпт попадает мусор, растёт цена и задержка, модель отвлекается |
Есть ещё эффект, о котором часто забывают: модели хуже используют информацию из середины длинного контекста и лучше из начала и конца [3]. Значит, порядок чанков в промпте влияет на ответ. Проверить это можно тестом, который двигает нужный чанк по позициям среди отвлекающих.
1# evals/test_position.py
2import json
3
4import pytest
5
6from evals.judge import judge
7from myapp.rag import generate_answer
8
9# {"id", "question", "oracle_chunk": "...", "distractors": ["...", "...", "...", "..."]}
10with open("evals/position_set.jsonl", encoding="utf-8") as f:
11 CASES = [json.loads(line) for line in f]
12
13
14@pytest.mark.parametrize("position", [0, 2, 4])
15@pytest.mark.parametrize("case", CASES, ids=lambda c: c["id"])
16def test_answer_does_not_depend_on_chunk_position(case, position):
17 chunks = list(case["distractors"])
18 chunks.insert(position, case["oracle_chunk"]) # двигаем нужный чанк по выдаче
19
20 answer = generate_answer(case["question"], chunks)
21 verdict = judge({**case, "context": "\n".join(chunks)}, answer)
22 assert verdict["verdict"] == "pass", f"позиция {position}: {verdict['reasoning']}"Метрики RAG: что измерять на каждом шаге
Названия метрик чаще всего берут из фреймворка RAGAS, который предложил оценивать RAG без ручной разметки эталонных ответов [2]. Для практики достаточно четырёх:
| Метрика | Слой | Вопрос, на который отвечает |
|---|---|---|
| Context recall | Поиск | Нашли ли мы всё, что нужно для ответа? |
| Context precision | Поиск | Нет ли среди найденного лишнего, и стоит ли нужное выше? |
| Faithfulness | Генерация | Каждое ли утверждение ответа подтверждается контекстом? |
| Answer relevance | Генерация | Отвечает ли ответ на заданный вопрос? |
Типичные ошибки
- Оценивать только итоговый ответ
Плохой ответ не показывает, сломался поиск или генерация, и вы чините наугад
- Набор для поиска собран из тех же документов, по которым настраивали поиск
Метрики завышены. Берите реальные вопросы пользователей и держите часть набора закрытой
- Менять размер чанка и k без замеров
Оба параметра дают противоположные эффекты, и оптимум зависит от ваших документов
- Не пересчитывать метрики после обновления базы знаний
Новые документы и дубли меняют выдачу при неизменном коде
- Проверять только вопросы, ответ на которые есть в базе
Нужна корзина вопросов вне базы: бот должен честно сказать, что не знает
FAQ
Как понять, что виноват поиск, а не модель?
Подставьте в промпт правильный фрагмент вручную (oracle context). Если ответ стал хорошим, поиск не принёс нужное, и чинить надо его: размер чанков, k, эмбеддинги, индекс. Если ответ по-прежнему плохой, проблема в промпте или модели.
Какие метрики использовать для оценки поиска в RAG?
Hit rate@k (нашли ли нужный чанк в top-k), recall@k (какую долю нужных чанков нашли), precision@k (сколько релевантного среди найденного) и MRR (как высоко стоит нужный чанк). Для оценки нужен набор вопросов с известными релевантными чанками.
Что такое faithfulness в RAG?
Это доля утверждений в ответе, которые подтверждаются найденным контекстом. Низкая faithfulness означает, что модель выдумывает факты поверх контекста. Обычно её считает LLM-судья: разбивает ответ на утверждения и проверяет каждое по контексту.
Как выбрать размер чанка и значение k?
Универсального значения нет: слишком мелкие чанки теряют контекст, слишком крупные размывают релевантность, а большое k приносит мусор. Подберите значения экспериментом на своём наборе, измеряя hit rate, recall и precision, и пересматривайте при смене документов или модели эмбеддингов.
Влияет ли порядок чанков в промпте на ответ?
Да. Исследования показывают, что модели лучше используют информацию в начале и конце длинного контекста и хуже из середины. Проверьте это тестом, который двигает нужный чанк по позициям среди отвлекающих.
Источники
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks — Lewis P., Perez E., Piktus A. и др., NeurIPS 2020 (arXiv:2005.11401)
- Ragas: Automated Evaluation of Retrieval Augmented Generation — Es S., James J., Espinosa-Anke L., Schockaert S., arXiv:2309.15217, 2023
- Lost in the Middle: How Language Models Use Long Contexts — Liu N. F., Lin K., Hewitt J. и др., TACL, 2023 (arXiv:2307.03172)