Откуда берётся неправильный ответ
Пользователь спрашивает у помощника по документации, как вернуть деньги за заказ. В справочнике есть инструкция «Возврат средств», но поиск её пропускает: в запросе и документе нет общих слов. Если нужный текст не попал в найденные материалы, языковой модели не на что опереться, даже если она умеет правильно формулировать ответы.
Такую ошибку можно обнаружить до генерации. Передайте запрос поисковому механизму, получите идентификаторы найденных фрагментов в порядке выдачи и сравните их с документами, заранее признанными релевантными. Вы увидите, есть ли нужная информация среди первых результатов и на каком месте она расположена.
В исходной работе о Retrieval-Augmented Generation (RAG) описана генерация с опорой на найденные внешние данные. Поиск можно проверять отдельно от языковой модели: для этого не требуются LLM или ключи к её API.
Разберём пример из десяти коротких документов и шести запросов. Напишем простой поиск на Python, зададим эталонную релевантность, рассчитаем три метрики и оформим проверки в тесты. При этом будем оценивать только поиск; правильность итоговых ответов остаётся отдельной задачей.
Документы, запросы и эталон релевантности
Для проверки нужны документы с постоянными идентификаторами. Возьмём десять коротких вымышленных инструкций об учётных записях, HTTP-ошибках и заказах. Каждая инструкция здесь представляет один результат поиска с собственным doc_id.
| Документ | О чём инструкция |
|---|---|
d01 | Сброс пароля через страницу входа |
d02 | Восстановление доступа по временному коду |
d03 | Срок действия токенов API |
d04 | Ошибка 401 и заголовок Authorization |
d05 | Ошибка 403 и недостаток прав |
d06 | Ошибка 429 и ограничение запросов |
d07 | Выгрузка данных в CSV |
d08 | Выгрузка данных перед удалением учётной записи |
d09 | Отмена заказа до отправки |
d10 | Возврат оплаты за выполненный заказ |
Теперь для каждого запроса отметим документы, содержащие полезную для ответа информацию. Такой набор оценок называется qrels (query relevance judgments, оценки релевантности). Он нужен как эталон для сравнения результатов поиска. В материалах TREC/NIST релевантность связывают с информационной потребностью пользователя, а сами оценки — с конкретным набором документов.
| ID | Запрос | Релевантные doc_id |
|---|---|---|
q01 | сброс пароля | d01 |
q02 | код ошибки 401 | d04 |
q03 | выгрузить данные перед удалением | d07, d08 |
q04 | ограничение запросов 429 | d06 |
q05 | как вернуть деньги | d10 |
q06 | квантовый двигатель | нет |
У q03 два релевантных фрагмента: d07 объясняет, где получить выгрузку, а d08 — почему её делают перед удалением учётной записи. Для q05 подходит d10: в нём говорится о возврате средств, хотя слова «вернуть деньги» там не используются. Запрос q06 оставлен без релевантных документов: в нашем корпусе нет сведений о квантовом двигателе.
Разметку нужно составить по содержанию документов, до просмотра выдачи. Иначе легко объявить найденные алгоритмом тексты единственно правильными и перестать замечать пропуски. Все десять фрагментов в этом примере доступны для чтения, поэтому можно проверить каждый doc_id. В большой коллекции потребуются отдельная процедура разметки и контроль её полноты.
Полные тексты лежат в documents.json, запросы и обоснования релевантности — в qrels.json. При изменении состава документов эти файлы нужно пересмотреть вместе: от разметки зависит и расчёт полноты поиска.
Простой поиск по совпадению слов
Чтобы было видно, откуда берётся порядок результатов, используем алгоритм без внешних библиотек. Он приводит текст к одному регистру, выделяет уникальные слова и считает, сколько слов запроса встретилось в документе. Чем больше совпадений, тем выше документ в выдаче. Документы без совпадений исключаются; при равных оценках порядок определяется doc_id.
Функция из retriever.py:
import re
from collections.abc import Sequence
from fixtures import Document
def tokens(text: str) -> set[str]:
return set(re.findall(r"[^\W_]+", text.casefold(), flags=re.UNICODE))
def search(query: str, documents: Sequence[Document], k: int = 3) -> list[str]:
if k < 1:
raise ValueError("k must be positive")
query_tokens = tokens(query)
ranked = []
seen_ids: set[str] = set()
for doc in documents:
if doc["id"] in seen_ids:
raise ValueError("duplicate document ID")
seen_ids.add(doc["id"])
score = len(query_tokens & tokens(doc["text"]))
if score > 0:
ranked.append((-score, doc["id"]))
ranked.sort()
return [doc_id for _, doc_id in ranked[:k]]
Для запроса q02 («код ошибки 401») поиск возвращает d04, d05 и d06. В d04 совпадает больше слов, поэтому нужная инструкция стоит первой. Два других документа тоже содержат «код ошибки», но описывают статусы 403 и 429.
Для q05 алгоритм не находит ничего: слова «как вернуть деньги» не пересекаются со словами инструкции d10 о возврате средств. doc_id есть в эталоне релевантности, но отсутствует в выдаче — именно такой пропуск должны показывать тесты.
Этот поиск выбран ради прозрачности проверки. Он не учитывает формы слов и синонимы и поэтому не служит примером качественного поиска по смыслу. Если позже заменить его другим алгоритмом, сравнивать результаты можно будет с той же разметкой.
Как посчитать Precision@3, Recall@3 и MRR@3
Пусть R(q) — множество релевантных документов для запроса, а top3(q) — не более трёх первых результатов поиска. В этом примере метка релевантности двоичная: документ либо полезен для ответа, либо нет. Определения Precision, Recall и оценки ранжированных списков разобраны в материалах NIST/TREC.
- Recall@3 (полнота) — доля всех релевантных документов, оказавшихся в первой тройке. Знаменатель равен
|R(q)|. - Precision@3 (точность) — число релевантных документов среди первых трёх позиций, делённое на 3. Если алгоритм вернул только один документ, две незанятые позиции по принятому здесь правилу не добавляют совпадений.
- RR@3 (обратный ранг) —
1 / rankпервого релевантного результата, если он есть в первой тройке; иначе0. MRR@3 — среднее RR@3 по запросам. Эта метрика учитывает только первый релевантный результат, а не все найденные документы.
Например, у q03 есть два релевантных документа — d07 и d08. Поиск находит только d08 и ставит его первым. Значит, Precision@3 = 1/3 ≈ 0,333, Recall@3 = 1/2 = 0,5, RR@3 = 1. Первый результат полезен, но половина размеченных материалов в выдачу не попала.
Реализация в metrics.py:
def evaluate(ranked: list[str], relevant: set[str], k: int = 3) -> tuple[float, float, float]:
if not relevant:
raise ValueError("no relevant documents: evaluate negative queries separately")
if k < 1:
raise ValueError("k must be positive")
selected = ranked[:k]
if len(selected) != len(set(selected)):
raise ValueError("duplicate document IDs in ranking")
hits = sum(doc_id in relevant for doc_id in selected)
first_rank = next((i for i, doc_id in enumerate(selected, 1) if doc_id in relevant), None)
return hits / k, hits / len(relevant), 1 / first_rank if first_rank else 0.0
Отдельно обработаем q06. Поскольку в корпусе нет релевантного документа для этого запроса, формула Recall@3 потребовала бы деления на ноль. Мы не включаем q06 в средние метрики и проверяем, что поиск возвращает пустой список. Это один контрольный пример, а не доказательство того, что поиск способен распознать любой запрос без ответа.
Запускаем проверки и разбираем результат
Проверим отдельно вычисление метрик и работу поиска на нашем корпусе.
Первый набор тестов использует небольшие списки с заранее известным ответом. Например, если релевантны d1 и d2, а выдача равна [noise, d1, d2], то Precision@3 = 2/3, Recall@3 = 1 и RR@3 = 1/2. Так можно обнаружить ошибку в формуле, даже если поисковый алгоритм работает правильно.
Второй набор подаёт настоящие запросы из qrels.json в search(), получает упорядоченные doc_id и сверяет их с разметкой. Здесь проверяются также устойчивый порядок при равных оценках, отсутствие дубликатов и поведение при разных значениях k.
Клонируйте учебный проект XNova и переключитесь на проверенную версию e03759534dbd3f794a3a0544a7378d29e1f44bee:
git clone https://github.com/xnova-tech/rag-retrieval-tests-example
cd rag-retrieval-tests-example
git checkout e03759534dbd3f794a3a0544a7378d29e1f44bee
В проекте находятся исходники поиска и метрик, десять документов, разметка шести запросов и тесты. Для самого поиска достаточно Python 3.12 или новее без сторонних пакетов. Тесты используют pytest из requirements-dev.txt. Запустите команды из корня проекта:
python -I run_checks.py
python -m pip install -r requirements-dev.txt
python -m pytest -q
Первая команда проверяет весь корпус и печатает результаты по каждому запросу. Для приведённого корпуса получился следующий результат:
q01: top3=d01 P@3=0.333 R@3=1.000 RR@3=1.000
q02: top3=d04,d05,d06 P@3=0.333 R@3=1.000 RR@3=1.000
q03: top3=d08 P@3=0.333 R@3=0.500 RR@3=1.000
q04: top3=d06 P@3=0.333 R@3=1.000 RR@3=1.000
q05: top3=- P@3=0.000 R@3=0.000 RR@3=0.000
q06: top3=[] no_relevant_documents
macro(5): P@3=0.267 R@3=0.700 MRR@3=0.800
Команда pytest выполнила 17 тестов без ошибок. Среди них есть проверка намеренно ухудшенного порядка выдачи и отдельный тест на отсутствие ответа для q06. Способ запуска через python -m pytest описан в документации pytest.
Средние показатели рассчитаны только по пяти запросам с релевантными документами. Для них Precision@3 = 0,267, Recall@3 = 0,700, MRR@3 = 0,800. Например, средняя полнота получается как (1 + 1 + 0,5 + 1 + 0) / 5 = 0,7. q06 проверяется отдельно и в среднее не входит.
Обратите внимание: успешные тесты не означают, что поиск нашёл всё нужное. Сейчас тест для q05 подтверждает известный недостаток лексического алгоритма — пустую выдачу при наличии релевантного d10. Проверки показывают, что реализация и расчёт метрик воспроизводимы. Требования к приемлемому качеству поиска придётся задать отдельно.
Как обнаружить ухудшение поиска
После изменения алгоритма одни запросы могут улучшиться, а другие — потерять нужные документы. Поэтому сохраняйте выдачу и метрики по каждому запросу, а не только средние значения. Для сравнения двух версий используйте один и тот же корпус и одну разметку qrels.
В тестах есть пример намеренного ухудшения: единственный релевантный документ d1 сначала стоит первым в списке [d1, x, y], а затем опускается на четвёртое место в [x, y, z, d1]. При k=3 полнота меняется с 1 на 0. Тест проверяет, что метрика реагирует на потерю документа из первых трёх результатов. Это искусственный контроль, а не изменение выдачи нашего десятидокументного корпуса.
Другой случай — q05. Документ d10 размечен как релевантный, но исходный поиск его не находит из-за несовпадения слов. Это известное ограничение текущего алгоритма. Если вы замените его поиском, понимающим близкие по смыслу формулировки, правильным результатом станет нахождение d10. Эталон релевантности при этом менять не нужно, а тест, закрепляющий старую пустую выдачу, придётся обновить.
Если результаты не совпали с приведёнными в статье, проверьте последовательно:
- Взяты ли
documents.jsonиqrels.jsonиз одной версии проекта и существуют ли все указанныеdoc_id. - Совпадают ли правила приведения текста к регистру, разбиения на слова и исключения документов с нулевой оценкой.
- Используется ли сортировка по
doc_idпри равных оценках и одинаковое значениеk. - Не изменился ли знаменатель
Precision@3: в нашем примере он всегда равен трём.
После исправления повторите запуск поиска и тестов. Если ошибка обнаружена в самой разметке, исправляйте её на основании текста документа с объяснением причины, а не подгоняйте под новую выдачу.
На что распространяются результаты
Мы проверили десять вымышленных документов и шесть запросов. Релевантность размечал один редактор, повторной независимой оценки не было. Такой набор позволяет проверить формулы, устойчивость ранжирования и конкретные случаи пропуска документов. Он не показывает, как поиск поведёт себя на большом корпусе и реальных пользовательских запросах.
В примере один doc_id соответствует одному короткому документу. В RAG нередко ищут по фрагментам больших файлов. Тогда эталонная разметка должна относиться именно к тем единицам, которые возвращает поиск. Иначе результаты будут зависеть от разбиения и дублирования текста, а метрики окажутся несопоставимыми.
Пустая выдача для q06 объясняется отсутствием совпадающих слов. Это не доказательство способности распознавать все вопросы без подходящего ответа: посторонний по смыслу документ тоже может содержать слова запроса. Кроме того, MRR@3 = 0,800 не означает, что найдены все нужные фрагменты, и ничего не говорит о точности будущего ответа LLM.
Для системы в эксплуатации потребуются более разнообразные запросы, проверенная разметка и отдельные тесты генерации. Если меняется корпус, нарезка документов или единица поиска, сначала убедитесь, что новую и старую метрики вообще можно сравнивать.
Что мы проверили
Проверку поиска в RAG можно выполнять отдельно от генерации. Для этого достаточно фиксированного корпуса, запросов с заранее размеченными релевантными документами и поисковой функции, возвращающей результаты в определённом порядке.
В нашем примере средняя Recall@3 составила 0,700, а MRR@3 — 0,800. Эти числа нужно читать вместе с результатами отдельных запросов: для q03 найден один из двух полезных документов, а для q05 пропущена инструкция о возврате средств. Так становится видно, какую информацию генератор мог бы получить, а какой ему не хватило бы.
Когда вы меняете способ поиска, сохраняйте корпус и разметку, сравнивайте результаты каждого запроса и разбирайте ухудшения. После проверки найденных материалов можно переходить к другой задаче — оценке ответов языковой модели.
Источники
- Публичный учебный репозиторий XNova с кодом, корпусом, разметкой и тестами
- Первоначальная работа RAG: объединение поиска по внешним данным и генерации
- Материал NIST/TREC по Precision, Recall и оценке ранжированных списков
- TREC/NIST: обратный ранг первого релевантного результата и MRR
- TREC/NIST: релевантные документы, qrels и соответствие разметки корпусу
- Официальная документация по запуску pytest через python -m pytest