XNOVA
← Все статьи

СТАТЬЯ / engineering

RAG без LLM: как проверить качество поиска отдельно от генерации

На десяти документах и шести запросах проверяем качество поиска без LLM: эталонную релевантность, порядок результатов, Precision@3, Recall@3 и MRR@3.

Автор: XNova

Откуда берётся неправильный ответ

Пользователь спрашивает у помощника по документации, как вернуть деньги за заказ. В справочнике есть инструкция «Возврат средств», но поиск её пропускает: в запросе и документе нет общих слов. Если нужный текст не попал в найденные материалы, языковой модели не на что опереться, даже если она умеет правильно формулировать ответы.

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

В исходной работе о 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код ошибки 401d04
q03выгрузить данные перед удалениемd07, d08
q04ограничение запросов 429d06
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. Эталон релевантности при этом менять не нужно, а тест, закрепляющий старую пустую выдачу, придётся обновить.

Если результаты не совпали с приведёнными в статье, проверьте последовательно:

  1. Взяты ли documents.json и qrels.json из одной версии проекта и существуют ли все указанные doc_id.
  2. Совпадают ли правила приведения текста к регистру, разбиения на слова и исключения документов с нулевой оценкой.
  3. Используется ли сортировка по doc_id при равных оценках и одинаковое значение k.
  4. Не изменился ли знаменатель Precision@3: в нашем примере он всегда равен трём.

После исправления повторите запуск поиска и тестов. Если ошибка обнаружена в самой разметке, исправляйте её на основании текста документа с объяснением причины, а не подгоняйте под новую выдачу.

На что распространяются результаты

Мы проверили десять вымышленных документов и шесть запросов. Релевантность размечал один редактор, повторной независимой оценки не было. Такой набор позволяет проверить формулы, устойчивость ранжирования и конкретные случаи пропуска документов. Он не показывает, как поиск поведёт себя на большом корпусе и реальных пользовательских запросах.

В примере один doc_id соответствует одному короткому документу. В RAG нередко ищут по фрагментам больших файлов. Тогда эталонная разметка должна относиться именно к тем единицам, которые возвращает поиск. Иначе результаты будут зависеть от разбиения и дублирования текста, а метрики окажутся несопоставимыми.

Пустая выдача для q06 объясняется отсутствием совпадающих слов. Это не доказательство способности распознавать все вопросы без подходящего ответа: посторонний по смыслу документ тоже может содержать слова запроса. Кроме того, MRR@3 = 0,800 не означает, что найдены все нужные фрагменты, и ничего не говорит о точности будущего ответа LLM.

Для системы в эксплуатации потребуются более разнообразные запросы, проверенная разметка и отдельные тесты генерации. Если меняется корпус, нарезка документов или единица поиска, сначала убедитесь, что новую и старую метрики вообще можно сравнивать.

Что мы проверили

Проверку поиска в RAG можно выполнять отдельно от генерации. Для этого достаточно фиксированного корпуса, запросов с заранее размеченными релевантными документами и поисковой функции, возвращающей результаты в определённом порядке.

В нашем примере средняя Recall@3 составила 0,700, а MRR@3 — 0,800. Эти числа нужно читать вместе с результатами отдельных запросов: для q03 найден один из двух полезных документов, а для q05 пропущена инструкция о возврате средств. Так становится видно, какую информацию генератор мог бы получить, а какой ему не хватило бы.

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

Источники