Головна / Статті

Дебагування процесу отримання даних у 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, інший – великі обсяги даних, а ще один – як скасувати експорт. На перший погляд здається, що система працює належним чином. Але це не так – вона знайшла уривки на правильну тему, проте жоден з них не містив необхідної інформації.

Звичайною реакцією на погану відповідь 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."
        ),
    },
]

ID схожий на бухгалтерський запис, але це допомагає уникнути проблем у майбутньому. У наборі також було 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. Він перевершив семантичний базовий варіант, показав кращі результати, ніж хтось міг очікувати, і, що дратує, був бездоганним на цьому невеликому наборі даних. Звучання заголовка на кшталт „embeddings мертві“ може бути спокусливим, але дані це не підтверджують. Це показує, що цей набір для оцінки сильно сприяє точному відповідному використанню термінології. Набір, наповнений перефразованими запитаннями, міг би обрати іншого переможця. Головний висновок — наскільки важливим було лексичне зіставлення для цього корпусу, що й є причиною створення взагалі набору для оцінки.

Гібридний пошук із фузією рангів

Відмова від семантичного пошуку все ще здавалася передчасною. Обидві методи працюють по-різному, тому їхні рейтинги були об’єднані за допомогою методу Reciprocal Rank Fusion. Важливим є один аспект реалізації: кожен пошуковий інструмент надає лише обмежену кількість кандидатів. Порядкування величезного корпусу двічі та об’єднання всього в 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]
    ]

Конвеєр у кінцевому підсумку має просту двоступеневу структуру.

Алгоритм спочатку отримує великий набір кандидатів, а потім переранжує їх так, щоб до ШІ потрапляли лише найкращі елементи. Після переранжування невеликий тестовий набір показав ідеальний результат 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 низький, немає сенсу витрачати годину на перефразування речення „Відповідайте лише, використовуючи наданий контекст“ шістьма все більш суворими способами, адже контекст вже є неправильним.

Зміна запитів все одно здається швидшою процедурою: ви редагуєте, запускаєте знову та миттєво бачите нову відповідь. Робота з отриманням інформації є повільнішою та менш задовільною: потрібно читати фрагменти, позначати їхню релевантність, перевіряти рейтинги, сперечатися щодо меж фрагментів, а іноді виявляється, що старомодний лексичний пошуковик працює краще, ніж складніші системи. Проте те, чого він навчає, є більш надійним.

Найкорисніше запитання — це не те, чому ШІ відповіло неправильно. Це питання про те, чи отримувало ШІ коли-небудь необхідну інформацію. Це здається настільки очевидним, що не варто ним займатися, проте його часто ігнорують.

Пов’язана література