Главная / Статьи

Отладка процесса извлечения данных в RAG: Recall@K, BM25, гибридное объединение и повторная классификация

Небольшой эксперимент с пайплайном RAG, в котором правильный фрагмент занял четвертое место, демонстрирующий, как разбиение на фрагменты, алгоритм BM25, слияние рангов и повторная оценка влияют на показатели Recall@3 и MRR.

Издатель: REACTAPP.TOP11 мин чтения2556 слов

Источники: How to Fix a RAG System That Keeps Retrieving the Wrong Context

Вопрос, на который система ответила неверно

Возьмём простую систему обработки информации и зададим ей вопрос, который должен быть тривиальным: как долго остаётся действительным ссылка на экспорт в формате CSV? В документации указан ответ — ссылка на загрузку готового файла CSV остаётся активной в течение семи дней. Однако система поставила этот отрывок на четвёртое место, и поскольку к языковой модели передаются только три лучших фрагмента, она так и не получает вопрос.

Раздражает то, что три лучших результата не кажутся неверными. Один из них описывает процесс обработки экспорта в формате CSV, другой — работу с большими объёмами данных, а третий — способ отмены экспорта. На первый взгляд кажется, что система работает нормально. Но это не так: она нашла отрывки на правильную тему, но ни в одном из них не было необходимой информации.

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

Это становится ещё важнее по мере совершенствования системы. После этапа демонстрации плохой поиск превращается в проблему, которую трудно заметить по окончательному ответу. Модель может казаться уверенной в себе, контекст — отчасти релевантным, но настоящая причина кроется на нескольких этапах раньше. Лучшей практикой является разделение причин сбоев: отсутствие доказательств, слабая структуризация данных, плохая оценка релевантности или проблемы на этапе генерации. Тогда порядок отладки будет следующим:

Сначала неверный ответ, затем проверка процесса поиска, затем его оценка, затем исправление, и только после этого рассмотрение процесса генерации.

Небольшой эксперимент показывает, насколько важны ранние этапы.

Корпус, достаточно маленький для прочтения

В эксперименте использовался вымышленный набор документации о ограничениях скорости, резервных копиях, экспорте в формат CSV, ошибках API, аутентификации, мониторинге и журналах аудита. Он не был впечатляющим, и это было сделано намеренно: достаточно маленьким, чтобы человек мог прочитать каждый найденный отрывок, но в то же время достаточно запутанным, чтобы несколько фрагментов могли казаться релевантными для одного вопроса. Каждому фрагменту присваивался стабильный идентификатор:

chunks = [
    {
        "id": "audit_03",
        "source": "audit-logs",
        "text": (
            "Enterprise audit logs are retained for 365 days "
            "before automatic deletion."
        ),
    },
    {
        "id": "errors_07",
        "source": "api-errors",
        "text": (
            "NX-204 means the requested resource exists but is not "
            "available in the caller's current region."
        ),
    },
    {
        "id": "exports_01",
        "source": "csv-exports",
        "text": (
            "CSV exports run asynchronously and appear in the exports "
            "panel when processing completes."
        ),
    },
    {
        "id": "exports_04",
        "source": "csv-exports",
        "text": (
            "A completed CSV download link remains active for seven days."
        ),
    },
]

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

eval_cases = [
    {
        "query": "How long are enterprise audit logs kept?",
        "relevant": {"audit_03"},
    },
    {
        "query": "What does error NX-204 mean?",
        "relevant": {"errors_07"},
    },
    {
        "query": "How long is a CSV export link usable?",
        "relevant": {"exports_04"},
    },
]

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

Намеренно простая база сравнения

В качестве базы сравнения использовались методы TF-IDF, сокращённый SVD и косинусное сходство. В реальном проекте, скорее всего, была бы выбрана современная модель вкладки, но её добавление привело бы к появлению ещё одной переменной, не помогая решить поставленную задачу. Система поиска не нуждалась в сложных функциях — ей требовалось что-то, что можно было бы нарушить, изучить и измерить.

import numpy as np

from sklearn.decomposition import TruncatedSVD
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.preprocessing import normalize


class LsaRetriever:
    def __init__(self, chunks, dims=16):
        self.chunks = chunks

        self.tfidf = TfidfVectorizer(
            stop_words="english",
            ngram_range=(1, 2),
            sublinear_tf=True,
        )

        term_matrix = self.tfidf.fit_transform(
            chunk["text"] for chunk in chunks
        )

        # The corpus is tiny. SVD doesn't need dimensions it cannot use.
        dims = min(
            dims,
            term_matrix.shape[0] - 1,
            term_matrix.shape[1] - 1,
        )

        if dims < 1:
            raise ValueError("Need more text to build the LSA index.")

        self.svd = TruncatedSVD(
            n_components=dims,
            random_state=0,
        )

        self.index = normalize(
            self.svd.fit_transform(term_matrix)
        )

    def search(self, query, limit=None):
        query_vec = self.tfidf.transform([query])
        query_vec = normalize(self.svd.transform(query_vec))

        similarity = cosine_similarity(
            query_vec,
            self.index,
        )[0]

        ranked = np.argsort(similarity)[::-1]

        if limit is not None:
            ranked = ranked[:limit]

        return [
            (self.chunks[i], float(similarity[i]))
            for i in ranked
        ]

Первый этап разбиения документации производился по предложениям. После задания вопроса в формате CSV получился следующий результат:

1. 0.879  exports_01
   CSV exports run asynchronously and appear in the exports panel...

2. 0.843  exports_02
   Large exports are split into multiple compressed files.

3. 0.830  exports_03
   Users can cancel an export while it is still queued...

4. 0.772  exports_04
   A completed CSV download link remains active for seven days.

При взгляде назад максимальный балл 0,879 кажется почти смешным, ведь создаётся впечатление, что система точно знает, что делает. Но когда читаешь полученный фрагмент, в нём нет никаких ответов. Именно в этот момент следует перестать рассматривать показатель сходства как гарантию качества.

Сбой происходит ещё до генерации. Механизм поиска выбирает правдоподобный, но бесполезный контекст, и большая языковая модель видит лишь то, что проходит фильтр top-k.

Вывести полученный результат

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

def show_hits(retriever, query, limit=5):
    print(f"\n{query}\n")

    for position, (chunk, score) in enumerate(
        retriever.search(query, limit),
        start=1,
    ):
        print(
            f"{position:>2}. {score:.3f}  "
            f"{chunk['id']} ({chunk['source']})"
        )
        print(f"    {chunk['text']}\n")

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

Он также подчеркивает то, что легко упустить из виду: система поиска принимает важное решение. То, что выживает на этом этапе, становится версией реальности для модели, и это заслуживает более тщательного анализа, чем обычно.

Оценка по ID, а не по совпадению строк

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

if answer_hint in chunk["text"]:
    ...

Удобно, но ненадежно. Небольшое смещение границ фрагментов, изменение знаков препинания или перефразирование могут привести к тому, что правильный результат поиска будет выглядеть неверным. Что ещё хуже, это связывает оценку с формулировкой синтетического ответа, а не с критерием релевантности, который имеет значение. Именно поэтому фрагментам с самого начала присваиваются идентификаторы, чтобы оценщик мог проверять их напрямую:

def evaluate_retriever(retriever, cases, k=3):
    recall_scores = []
    reciprocal_ranks = []

    for case in cases:
        hits = retriever.search(case["query"])
        relevant = case["relevant"]

        relevant_positions = [
            position
            for position, (chunk, _) in enumerate(hits, start=1)
            if chunk["id"] in relevant
        ]

        found_in_top_k = sum(
            position <= k
            for position in relevant_positions
        )

        recall_scores.append(
            found_in_top_k / len(relevant)
        )

        reciprocal_ranks.append(
            1 / relevant_positions[0]
            if relevant_positions
            else 0.0
        )

    return {
        f"recall@{k}": float(np.mean(recall_scores)),
        "mrr": float(np.mean(reciprocal_ranks)),
    }

Были отслеживаны два показателя. Показатель Recall@K показывает, попадает ли необходимое доказательство в окно контекста, а показатель Mean Reciprocal Rank различает правильный фрагмент на первой позиции и скрытый фрагмент на восьмой позиции. Результаты базового тестирования: Recall@1 — 0,750, Recall@3 — 0,917, MRR — 0,854.

Проблематичным является показатель Recall@3. При запланированном объеме контекста в три фрагмента некоторые вопросы уже теряли информацию, необходимую для правильного ответа, еще до начала генерации. Корректировка системного запроса на этом этапе была бы бесполезной.

Размер фрагмента: одна из причин проблем

Основным подозреваемым были фрагменты на уровне предложений, и они действительно отчасти виноваты. При использовании фрагментов из двух предложений показатель Recall@1 вырос с 0,750 до 0,917, Recall@3 достиг 1,000, а MRR — 0,958. Фрагменты из трех предложений дали почти идентичные результаты. Фрагменты целых страниц принесли идеальные результаты, что кажется замечательным примерно пять секунд.

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

Следовательно, урок заключался не в идеальном размере фрагментов, а в том, что первоначальное разделение на предложения было слишком агрессивным. Это менее яркий, но при этом полезный вывод. Это также вызывает сомнения относительно подобных настроек:

chunk_size = 500
chunk_overlap = 50

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

Точные термины несут смысл

В примере с CSV была ещё одна подсказка: каждый высокоранговый отрывок был семантически близок к экспортируемым данным, поэтому система поиска не ошиблась с доменом. Просто она недостаточно точно различала отрывки внутри него. Техническая документация усиливает этот эффект, поскольку точные строки несут огромное количество смысла. Код ошибки вроде NX-204 не просто семантически похож на соседний код; то же самое касается customer_id, конечной точки, номера версии или имени функции.

BM25 показал одинаковый результат 1,000 по показателям Recall@1, Recall@3 и MRR. Он превзошел семантический базовый вариант, дал лучшие результаты, чем ожидали многие, и, что раздражает, был безупречен на этом небольшом наборе данных. Звучание заголовка вроде «Эмбеддинги мертвы» может показаться заманчивым, но данные этого не подтверждают. Результаты показывают, что для точного совпадения терминологии этот набор данных оказался чрезвычайно полезным. Набор, состоящий из перефразированных вопросов, может выбрать другого победителя. Главный вывод заключается в том, насколько важно было лексическое совпадение для этого корпуса, и именно поэтому вообще создаются наборы для оценки.

Гибридный поиск с слиянием рангов

Отказ от семантического поиска пока казался преждевременным. Оба метода допускают ошибки по-разному, поэтому их рейтинги были объединены с использованием метода взаимного слияния рейтингов. Важен один технический аспект: каждый поисковик предоставляет лишь ограниченное количество кандидатов. Повторное ранжирование огромного корпуса данных и последующее объединение всех результатов на Python было бы странным подходом для копирования.

from collections import defaultdict


def fuse_rankings(vector_hits, bm25_hits, rrf_k=60):
    fused = defaultdict(float)
    chunks_by_id = {}

    for hits in (vector_hits, bm25_hits):
        for rank, (chunk, _) in enumerate(hits, start=1):
            chunk_id = chunk["id"]
            chunks_by_id[chunk_id] = chunk
            fused[chunk_id] += 1 / (rrf_k + rank)

    ranked_ids = sorted(
        fused,
        key=fused.get,
        reverse=True,
    )

    return [
        (chunks_by_id[chunk_id], fused[chunk_id])
        for chunk_id in ranked_ids
    ]

Оболочка вокруг него простая:

def hybrid_search(query, lsa, bm25, candidate_k=20):
    vector_hits = lsa.search(query, limit=candidate_k)
    bm25_hits = bm25.search(query, limit=candidate_k)

    return fuse_rankings(vector_hits, bm25_hits)

Гибридный метод поиска позволил повысить показатель MRR до 0,958 и устранил проблему с низким значением Recall@3, но он всё равно уступил BM25. Эксперимент не подвергался доработкам до тех пор, пока не победила более сложная архитектура, поскольку это противоречило бы цели исследования. Увеличение количества компонентов автоматически не означает повышения релевантности.

Дешёвый инструмент для переранкинга

Поскольку процесс поиска должен охватывать широкий диапазон данных, имеет смысл, чтобы он был дешёвым. Как только появляется 10–20 подходящих кандидатов, становится возможным провести более тщательную проверку релевантности. В рамках этого локального эксперимента такой инструментом служил скорер на основе n-грамм символов. Это дешёвый экспериментальный инструмент для переранкинга, а не рекомендация для промышленного использования. В реальной системе следовало бы рассмотреть использование обученного кросс-кодера.

def cheap_local_rerank(query, candidates, limit=5):
    """
    Good enough for this experiment.
    I'd use a learned reranker for a real deployment.
    """
    candidate_text = [
        chunk["text"]
        for chunk, _ in candidates
    ]

    tfidf = TfidfVectorizer(
        analyzer="char_wb",
        ngram_range=(3, 5),
        min_df=1,
    )

    matrix = tfidf.fit_transform(
        [query, *candidate_text]
    )

    relevance = cosine_similarity(
        matrix[0],
        matrix[1:],
    )[0]

    reranked = sorted(
        zip(candidates, relevance),
        key=lambda row: row[1],
        reverse=True,
    )

    return [
        (chunk, float(score))
        for ((chunk, _), score) in reranked[:limit]
    ]

В итоге конвейер имеет простую двухэтапную структуру.

Алгоритм сначала собирает большой набор кандидатов, затем переранжирует их так, чтобы только наиболее качественные элементы попадали в LLM. После переранжирования небольшой тестовый набор показал идеальные показатели 1,000 по метрикам Recall@1, Recall@3 и MRR. Эта цифра не должна фигурировать на слайде, заявляющем о решении проблемы поиска, поскольку двенадцать запросов способствуют разработке, но мало что говорят об общей надежности. Тем не менее эксперимент выполнил свою функцию. Вот как сравниваются все этапы:

Если бы историю было подготовлено до эксперимента, каждая строка улучшилась бы в чёткой последовательности. Но всё произошло иначе. BM25, простой вариант, сравнялся по результатам с окончательной системой обработки. Гибридный поиск улучшил начальный семантический уровень, но не занял первое место. Более крупные фрагменты решили одну проблему, но возникли новые вопросы относительно ограничений по контексту. Эта нерегулярность делает результат более достоверным и показала конкретную особенность данной задачи: точность терминологии имела огромное значение. Это лучше, чем победа в конкурсе популярности архитектур.

Исходный вопрос был следующим:

How long is a CSV export link usable?

До изменений рейтинг выглядел так:

1. CSV exports run asynchronously...
2. Large exports are split...
3. Users can cancel an export...
4. A completed CSV download link remains active for seven days.

Позже:

1. A completed CSV download link remains active for seven days.
2. Users can cancel an export while it is still queued...
3. CSV exports run asynchronously...

Исходные документы остались прежними, как и вопрос, поэтому более совершенного ЯИ не требовалось. Изменились доказательства.

Порядок отладки некорректных ответов RAG

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

Практический алгоритм сначала проверяет полученные доказательства, а затем определяет, связана ли ошибка с процессом извлечения информации или с её генерацией. Именно этот момент принятия решения является ключевым. Когда показатель Recall@3 низкий, бессмысленно тратить час на перефразирование фразы «ответьте, используя только предоставленный контекст», причём делать это шестью всё более строгими способами, ведь контекст уже неверен.

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

Самый важный вопрос — не в том, почему ЯИИ дал неверный ответ, а в том, получил ли он вообще необходимую информацию. Это кажется настолько очевидным, что его часто игнорируют.

Связанные материалы