Метрики оценки RAG: retrieval, реранкинг, генерация

Автоматический перевод Эта статья была автоматически переведена с оригинальной английской версии.

RAG-система со сломанными фильтрами может работать месяцами, не вызывая ни одного операционного алерта. Она по-прежнему возвращает ответы и укладывается в целевую латентность, но эти ответы опираются на неполные свидетельства. Recall@k относительно исходного gold set показывает эту потерю. Дашборды латентности и доступности — нет.

Для инженеров, которые эксплуатируют или оценивают многоэтапные RAG-системы, этот справочник сопоставляет сбои в парсинге документов, фильтрации, retrieval, реранкинге и генерации с метриками, позволяющими их выявить, а затем показывает, где именно такие измерения должны использоваться в release- или monitoring-gate.

Хотите сразу перейти к запуску кода?

В репозитории slavadubrov/rag-evals-demo можно запустить метрики на SciFact. make eval запускает весь набор, а make benchmark сравнивает конфигурации чанкинга, эмбеддингов и LLM. Ноутбуки 00–09 изолированно показывают работу каждой метрики. В демо используется встроенный Qdrant, поэтому Docker не требуется.

Коротко

  • Этап без метрики — это этап, который может отказать незаметно.
  • Полезный стек оценки охватывает ingestion, retrieval, граундинг генерации, соответствие онтологии и системные сигналы. RAGAS, TruLens, DeepEval, Arize Phoenix и TREC 2024 RAG Track предоставляют инструменты. Но метрики за вас они не выбирают.
  • Для RAG, основанного на метаданных и онтологии, неправильный тег или хрупкий hard predicate может обнулить recall. Стандартный Recall@k фиксирует потерю, если сохраняет исходный gold set. Метрика false-exclusion для фильтров выявляет причину. Faithfulness всё ещё может оценивать утверждения относительно неполного контекста, но не способна диагностировать проблему фильтра или retrieval. Пустой отказ может не содержать утверждений и давать NaN — в зависимости от реализации.

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


Таблица принятия решений для оценки RAG

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

ВопросСемейство метрикИспользуйте, когдаНа что обратить внимание
Сохранил ли парсинг исходный документ?Полнота извлечения, покрытие таблиц/рисунковВ корпус попадают PDF, слайды, сканы и HTML-страницыЧисто выглядящий текст всё равно может потерять подписи, сноски или структуру таблиц
Нашёл ли retrieval нужные свидетельства?Recall@k, nDCG@k, MRR, precision/recall контекстаМожно разметить релевантные чанки или документыHard metadata filter может удалить правильный документ до начала ранжирования
Улучшил ли реранкинг shortlist?Uplift реранкера, Precision@1, дельта nDCGПосле retrieval используются cross-encoder или LLM-rankerИзмеряйте латентность и стоимость вместе с приростом качества
Использовал ли ответ найденные свидетельства?Faithfulness, groundedness, поддержка цитатОтвет цитирует документы или утверждает факты из контекстаFaithfulness не диагностирует плохой парсинг или плохой retrieval
Стабильна ли система в production?Drift, regeneration, fallback, p95 latency, стоимость ответаПосле запуска меняется трафикProduction-телеметрии нужна выборочная проверка людьми для сохранения калибровки

Краткое сравнение инструментов см. в статье Лучшие инструменты оценки RAG: Ragas, DeepEval и TruLens.

Часть 1: Определите критерии успеха до архитектуры

Составьте eval set до схемы архитектуры. Он задаст измеримую цель для выбора каждого последующего компонента.

Нельзя выбрать между BM25 и dense retrieval, рекурсивным и семантическим чанкингом или Cohere Rerank и BGE, пока не определено, что именно вы оптимизируете. «Лучшие ответы» — не метрика. Пример контракта: «faithfulness ≥ 0,85 на golden set из 200 запросов, охватывающем три главных интента, при p95 latency < 1,5 с и false-exclusion rate фильтра < 2%». Числа здесь условны; важно, что качество, покрытие, латентность и фильтрация имеют явные gate.

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

Три слоя пайплайна и два режима запуска

Современный RAG — это пайплайн, поэтому и оценка должна быть пайплайном. Одно число не выявит каждый режим отказа.

У production-оценки есть три слоя. Оценка ingestion проверяет, сохранили ли корпус и индекс исходный материал. Оценка на этапе запроса проверяет, нашли ли query rewriting, фильтрация, retrieval, реранкинг и сборка контекста нужные свидетельства. Оценка ответа и production проверяет, использовал ли ответ эти свидетельства и сохраняется ли качество под live-трафиком. Если свести слои к одному score, баг нормализации может скрыться внутри приемлемого score ответа.

Три места, где RAG-система может потерять свидетельства: корпус и индекс, путь retrieval, ответ и live-трафикТри места, где RAG-система может потерять свидетельства: корпус и индекс, путь retrieval, ответ и live-трафик

Эти слои описывают, где происходит сбой. Offline и online описывают, когда и относительно каких данных выполняется проверка. Offline-оценка использует фиксированный датасет с известной ground truth; она воспроизводима и нужна для выбора компонентов, A/B-сравнений и CI-gate. Online-оценка оценивает выборку live-трафика и фиксирует regeneration, dwell time, явный feedback и реальный query drift. Она более шумная и сложнее для инструментирования.

Каждый слой пайплайна может содержать offline- и online-проверки. Фиксированный корпус ingestion выявляет регрессии парсера до release, а мониторы свежести и ошибок парсинга покрывают live-обновления. Фиксированный набор запросов измеряет retrieval до release, а выборочные live-трейсы показывают production drift. Только offline пропускает изменения в live-среде; только online затрудняет воспроизведение регрессий.

Метрики компонентов и end-to-end

Есть две распространённые ошибки. Оценка только end-to-end показывает, что система сломана, но не указывает где. Оценка только компонентов может показать, что каждый компонент проходит проверку, хотя полная система всё равно не работает. Решение — несколько ключевых end-to-end-метрик для go/no-go и метрики компонентов для диагностики. Метрики retrieval выявляют регрессии retriever. Метрики генерации выявляют регрессии generator. Корректность ответа end-to-end выявляет интеграционные сбои.

Обзор основных фреймворков

ФреймворкЛучше всего подходит дляОграничения
RAGASМой критерий выбора: единый словарь для faithfulness, answer relevancy и precision/recall контекста (метрики)Стоимость LLM-judge; непрозрачные компоненты score при отладке; изменения версий
ARESМой критерий выбора: task-specific classifier judge оправдывает затраты на обучение и разметку (статья); заявленная precision привязана к бенчмаркуБолее тяжёлая настройка; модели нужно действительно обучать
TruLensМой критерий выбора: feedback-функции, связанные с трейсами, и интеграция с OpenTelemetry важнее каталога RAG-метрик (проект)Меньше готовых RAG-метрик, чем в RAGAS
DeepEvalМой критерий выбора: интеграция с test runner и custom metrics важнее дефолтов фреймворка (проект)Активное использование LLM-judge приводит к скачкам стоимости
Arize PhoenixМой критерий выбора: трейсинг и визуализация эмбеддингов помогают проверять гипотезу о drift (проект)Определения метрик нужно предоставлять самостоятельно
TREC 2024 RAG TrackПубличный бенчмарк для оценки nuggets (AutoNuggetizer), поддержки и fluency на MS MARCO Segment v2.1Не runtime-инструмент; бенчмарк для калибровки

Мой дефолтный стек: RAGAS для словаря метрик, DeepEval для CI-gate, Phoenix для production-трейсинга плюс собственный код для метрик, специфичных для онтологии. Со временем любой выбранный стек перестанет покрывать ваши задачи. Выбирайте фреймворк, в котором легко добавлять custom metrics.

Для бенчмарков используйте BEIR (Thakur et al., NeurIPS 2021) для zero-shot-обобщения retrieval, MTEB для общей оценки качества эмбеддингов, MIRACL для мультиязычного retrieval и TREC 2024 RAG Track для end-to-end-оценки RAG.


Часть 2: Нанесите точки оценки на пайплайн

Production RAG-система — это не просто «получить эмбеддинги документов, найти чанки, вызвать LLM». Каждый этап между получением документа и выдачей ответа может отказать.

Пайплайн RAG, сгруппированный по ingestion, этапу запроса и пути ответа, с диагностическими метриками рядом с каждым этапомПайплайн RAG, сгруппированный по ingestion, этапу запроса и пути ответа, с диагностическими метриками рядом с каждым этапом

У каждого этапа на схеме есть как минимум одна метрика. Этап без метрики может отказать так, что никто этого не заметит.

Три дорожки соответствуют местам, где могут теряться свидетельства. Дорожка ingestion охватывает парсинг, очистку, чанкинг, генерацию эмбеддингов и индексацию. Дорожка этапа запроса — rewriting, фильтрацию, retrieval, реранкинг и сборку контекста. Дорожка ответа и production — faithfulness, проверку цитат, пользовательские сигналы, drift, латентность и стоимость.

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


Часть 3: Оценка ingestion

Многие production-сбои RAG начинаются на ingestion. На чистых тестовых документах система работает, а затем ломается на реальных PDF, сканах, таблицах и неаккуратно оформленных страницах корпуса.

Получение и парсинг документов

Что измерять:

  • Полнота извлечения текста: extracted_chars / expected_chars на размеченной выборке, отдельно для каждого класса документов. Канонического пакета нет — напишите небольшой харнесс, сравнивающий результат парсера с вручную очищенным эталоном. Следите за пропавшими сносками, заголовками и подписями.

  • Точность OCR: CER (Character Error Rate) и WER (Word Error Rate) — стандартные метрики для speech/OCR:

    CER=S+D+IN,WER=Sw+Dw+IwNw\text{CER} = \frac{S + D + I}{N}, \qquad \text{WER} = \frac{S_w + D_w + I_w}{N_w}

    где SS, DD, II — замены, удаления и вставки на уровне символов, а NN — количество символов в эталоне (для версии на уровне слов используется нижний индекс ww). Не применяйте один порог CER ко всему корпусу. Калибруйте его по классу документов и потере качества downstream-ответа. Для печатного текста, рукописного текста и мультиязычных материалов характерны разные профили ошибок. Считайте метрики с помощью jiwer (jiwer.cer(refs, hyps), jiwer.wer(refs, hyps)) или evaluate от Hugging Face. Для eval-корпусов доступны публичные бенчмарки FUNSD и SROIE.

    from jiwer import cer, wer
    
    refs = ["Mars has two moons, Phobos and Deimos."]
    hyps = ["Mars has two m00ns, Phobos and Deirnos."]
    
    print(f"CER = {cer(refs, hyps):.3f}")  # CER = 0.105
    print(f"WER = {wer(refs, hyps):.3f}")  # WER = 0.286
  • Fidelity извлечения таблиц: TEDS (Tree-Edit-Distance-based Similarity) измеряет, насколько предсказанное HTML-дерево таблицы близко к эталону, с нормализацией по размеру большего дерева. По Zhong et al., 2020 (PubTabNet):

    TEDS(Ta,Tb)=1EditDist(Ta,Tb)max(Ta,Tb)\text{TEDS}(T_a, T_b) = 1 - \frac{\text{EditDist}(T_a, T_b)}{\max(|T_a|, |T_b|)}

    TEDS учитывает и структуру (строки, столбцы, spans), и содержимое ячеек. TEDS-S удаляет содержимое и оценивает только структуру. Эталонная реализация: teds.py в PubTabNet (под капотом использует apted). Для eval-корпусов см. PubTabNet, FinTabNet и SciTSR. Наивные парсеры часто плохо работают с таблицами. Проведите бенчмарк до того, как начинать им доверять.

  • Сохранение layout/структуры: порядок заголовков, целостность списков, порядок чтения в многостолбцовых PDF. Для размеченного бенчмарка используйте DocLayNet. Сравнение «из коробки» можно провести между element parser, например unstructured, PDF-библиотекой, например pymupdf, и VLM-парсером, например docling.

Сравнивайте разные семейства парсеров: например, baseline на Tesseract, VLM-based OCR-модель и кандидата от вендора. Используйте стратифицированную выборку реальных классов документов при фиксированном DPI, включая чистые сканы, фотографии, таблицы, мультиязычный текст, математические формулы и рукописный текст. Для каждого класса отчётливо показывайте CER или WER, а для страниц с таблицами — TEDS.

Очистка и нормализация

  • Точность удаления boilerplate: precision/recall относительно размеченных людьми фрагментов boilerplate. Агрессивное удаление теряет релевантное содержимое; слишком слабое загрязняет эмбеддинги. Инструменты для сравнения: trafilatura, jusText, Resiliparse. В Barbaresi (2021) эти инструменты сравниваются напрямую.

  • Нормализация Unicode: процент документов, для которых NFC- и NFKC-результаты идентичны (вычисляется стандартной библиотекой unicodedata.normalize), — полезный сигнал drift. Несовпадения приводят к тому, что zero-width joiners и похожие символы незаметно снижают recall retrieval.

  • Точность определения языка: F1 на размеченной мультиязычной выборке. Это критично для мультиязычных индексов. Используйте fasttext-langdetect (Facebook’s lid.176), lingua-py или cld3. FLORES-200 предоставляет тексты для оценки на 200 языках, но тестовую выборку должен определять языковой состав вашего production-трафика.

  • Эффективность дедупликации (MinHash / LSH): precision/recall детектора near-duplicate относительно вручную размеченного набора. Идея такова: оценить сходство Жаккара J(A,B)=ABABJ(A, B) = \frac{|A \cap B|}{|A \cup B|} между множествами шинглов документов с помощью kk хешей случайных перестановок (Broder, 1997), а затем объединить near-duplicates с помощью banding в LSH (Indyk & Motwani, 1998). Переберите число хешей и порог Jaccard на своём корпусе. Отдельно отслеживайте false-merge rate (портит ответы) и missed-merge rate (зря расходует место в индексе). Реализацию предоставляет datasketch; её параметры ниже приведены для иллюстрации:

    from datasketch import MinHash, MinHashLSH
    
    def shingles(text: str, k: int = 5) -> set[str]:
        text = text.lower()
        return {text[i:i + k] for i in range(len(text) - k + 1)}
    
    def to_minhash(text: str, num_perm: int = 128) -> MinHash:
        m = MinHash(num_perm=num_perm)
        for s in shingles(text):
            m.update(s.encode("utf-8"))
        return m
    
    docs = {
        "d1": "Mars has two moons, Phobos and Deimos.",
        "d2": "Mars has two moons, Phobos and Deimos!",   # near-dup
        "d3": "Curiosity rover landed on Mars in 2012.",
    }
    
    lsh = MinHashLSH(threshold=0.8, num_perm=128)
    for did, text in docs.items():
        lsh.insert(did, to_minhash(text))
    
    print(sorted(lsh.query(to_minhash(docs["d1"]))))  # ['d1', 'd2']
  • Очистка PII: precision и recall, рассчитанные отдельно для каждого типа сущности (email, SSN, имена, адреса). Ошибки recall создают compliance-риск; ошибки precision ухудшают качество ответов. Рабочую точку определяйте совместно с legal-командой. Возможные инструменты: Microsoft Presidio, scrubadub или файн-тюненная NER-модель на размеченном наборе.

Чанкинг определяет качество retrieval

Чанкинг может создать многоточечный разрыв в recall, даже если embedding-модель остаётся неизменной. В vendor-бенчмарке NVIDIA 2025 года чанкинг на уровне страниц дал максимальную точность и минимальную дисперсию для постраничных документов. Рассматривайте этот результат как свидетельство для протестированного корпуса, а не как универсального победителя.

Семантический чанкинг объединяет соседние предложения по сходству эмбеддингов и разделяет их на несхожих границах. LangChain SemanticChunker и LlamaIndex SemanticSplitterNodeParser реализуют эту стратегию. Она может повысить recall по сравнению с фиксированными окнами, если важны тематические границы.

Рекурсивное разбиение по символам сначала пытается разделить текст по абзацам, затем по предложениям, затем по словам, пока каждый чанк не поместится в целевой размер. LangChain RecursiveCharacterTextSplitter реализует эту последовательность. Выберите диапазон значений окна и overlap, соответствующий структуре документов, а финальные значения определите по golden set.

Отслеживайте такие метрики:

  • Когерентность чанка: coherence=cos(si,sj)withincos(si,sj)across boundary\text{coherence} = \overline{\cos(s_i, s_j)}_{\text{within}} - \overline{\cos(s_i, s_j)}_{\text{across boundary}}, где sis_i — эмбеддинги предложений. Здоровые чанки внутренне похожи, но отличаются на границах. Считайте метрику с помощью sentence-transformers и cosine_similarity из scikit-learn.
  • Качество границ: ручная разметка «является ли это разумным местом разреза?» на выборке плюс структурная проверка того, что чанки не разделяют таблицы, списки и нумерованные разделы.
  • Оптимальный размер чанка: переберите размеры в токенах (128, 256, 512, 1024) и постройте график Recall@k в зависимости от размера на golden set. Выберите точку перегиба. Не берите значение только потому, что его указали в туториале.
  • Эффективность overlap: проведите ablation для нескольких долей overlap и измерьте Recall@k. Прекратите увеличивать overlap, когда локальная кривая recall выровняется или стоимость дублирования превысит прирост.
  • Fidelity атрибуции чанка: процент чанков, сохраняющих проверяемый указатель на источник (номер страницы, anchor раздела, ID документа). Это необходимо для auditability.
  • Поздний и ранний чанкинг: late chunking (Günther et al., 2024) сначала строит эмбеддинг полного документа, а затем сегментирует его, сохраняя глобальный контекст (эталонная реализация в jina-embeddings-v3). Contextual Retrieval (Anthropic, 2024) добавляет к каждому чанку контекст, сгенерированный LLM. Оба подхода увеличивают стоимость. Проведите бенчмарк на своём корпусе, прежде чем внедрять какой-либо из них.

Моё мнение: структурный чанкинг (разбиение по заголовкам, таблицам и разделам — с помощью парсеров вроде unstructured.io или обхода AST, который уже создал ваш парсер) используется недостаточно. Если в документах есть структура, задействуйте её до добавления эвристик сходства. Рекурсивное разбиение по символам — baseline; семантический чанкинг оправдывает накладные расходы главным образом на неструктурированной прозе.

Извлечение и обогащение метаданных

  • Precision/recall/F1 для NER: отдельно для каждого типа сущности, на размеченном подмножестве. Используйте стандартный стиль CoNLL/MUC. Считайте метрики с помощью seqeval (from seqeval.metrics import f1_score) для версии с поддержкой BIO/IOB-тегов или scikit-learn для сравнения множеств spans. CoNLL-2003 и OntoNotes 5.0 — канонические эталонные корпуса.
  • F1 извлечения отношений: для систем, основанных на онтологии, эта метрика ещё важнее. Вручную разметьте выборку, стратифицированную по типу отношения и классу документа. Публичные бенчмарки: TACRED и DocRED; реализации можно найти в relation-пайплайнах opennre и spaCy.
  • Точность извлечения заголовка/heading: exact match плюс нормализованное сходство Левенштейна (1edit_dist(a,b)max(a,b)1 - \frac{\text{edit\_dist}(a, b)}{\max(|a|, |b|)}) с ground truth — python-Levenshtein или rapidfuzz дают оба показателя одним вызовом.
  • Сохранение иерархических метаданных: процент чанков, корректно сохраняющих родительский раздел, родительский документ и путь предков. Именно эта метрика определяет, способен ли ваш RAG ответить на вопросы вроде «что говорит child политики X?».

Генерация эмбеддингов

  • Бенчмарки выбора модели: используйте результаты задач MTEB (ключевая метрика — nDCG@10; Python-пакет MTEB позволяет воспроизвести leaderboard локально), BEIR для zero-shot-обобщения и MIRACL для мультиязычного retrieval. Перенос результатов с English MTEB на язык с малым количеством ресурсов рассматривайте как гипотезу, которую нужно проверить на размеченном наборе этого языка.
  • Оценка в домене: не считайте место в общем бенчмарке результатом для конкретного домена. Сформируйте domain golden set по матрице покрытия и уровню неопределённости, который допустим для вашего решения. Затем переоцените на нём candidate-модели с помощью ranx или pytrec_eval. Доменный набор может изменить порядок моделей в leaderboard, поэтому публикуйте с результатом срез датасета, протокол retrieval и доверительный интервал.
  • Детектирование drift эмбеддингов: отслеживайте распределительный KL или model-based drift между фиксированным reference window и rolling production-эмбеддингами; также измеряйте стабильность nearest neighbors на фиксированном probe set. evidently и alibi-detect реализуют model-based и статистические детекторы. Сравнительное исследование Evidently — один из vendor-анализов; сравнивайте методы на известных сдвигах собственных эмбеддингов.
  • Multi-vector и single-vector: late interaction сохраняет представления на уровне токенов вместо свёртки каждого документа в один вектор; ColBERT — канонический дизайн, а эталонные реализации доступны в RAGatouille и PyLate. Более богатое представление увеличивает стоимость индекса и retrieval. Сравните качество, объём хранения и латентность с single-vector baseline на одном и том же domain set.

Построение индекса

  • Recall@k при аппроксимации: сравните ANN-индекс approximate-nearest-neighbour с точным brute-force baseline при одинаковом k — в FAISS это IndexHNSWFlat (или IndexIVFFlat) против IndexFlatIP/IndexFlatL2. Допустимую потерю recall задайте из бюджета downstream-качества. Проект ann-benchmarks отслеживает Pareto-кривые recall–QPS для разных библиотек.
  • Настройка HNSW: HNSW (Hierarchical Navigable Small World) — многоуровневый граф близости; см. Malkov & Yashunin, 2018. Он реализован в hnswlib, IndexHNSWFlat из FAISS и большинстве vector DB. У HNSW есть три параметра: M (fan-out графа), efConstruction (ширина набора кандидатов при построении) и efSearch (ширина набора кандидатов при запросе). Начните с документированных дефолтов библиотеки, затем перебирайте параметры, пока кривая recall–latency не будет соответствовать требованиям eval set.
  • Настройка IVF: IVF (Inverted File index — разбиение векторов k-means на nlist ячеек с последующим сканированием при запросе nprobe ближайших ячеек; см. IndexIVFFlat и IndexIVFPQ в FAISS). Сравнивайте nlist и nprobe с recall и латентностью exact search. Фильтрованные запросы тестируйте отдельно, поскольку разные семейства индексов и vector DB по-разному реализуют обход с фильтрами.
  • Lag свежести обновлений: время от commit документа до его доступности для retrieval. Отслеживайте p50 и p99. Для систем с регуляторными требованиями дополнительно измеряйте процент запросов, обслуженных устаревшими индексами.

Часть 4: Оценка на этапе запроса

Дорожка этапа запроса содержит метрики, диагностирующие путь retrieval. Одного Recall@k недостаточно, чтобы определить, что вызвало сбой: rewriting, фильтрация, реранкинг или сборка контекста.

Понимание и rewriting запроса

  • Качество query expansion: uplift Recall@k на golden set для expanded query по сравнению с raw query. До тестирования задайте минимальный полезный прирост и его неопределённость. Если expansion не проходит этот локальный gate, он не оправдывает свою латентность и стоимость. Классические baseline PRF (pseudo-relevance feedback), такие как RM3 и Bo1, всё ещё полезны для sanity check; LLM-based expansion должен их превзойти.
  • Оценка HyDE: HyDE (Gao et al., 2022) генерирует гипотетический ответ с помощью LLM, строит его эмбеддинг и выполняет retrieval по нему. Это добавляет latency генерации и новый класс отказов. Отдельно измеряйте Recall@10 на in-domain, out-of-domain и low-confidence срезах, затем решите, должен ли HyDE быть в default path, fallback или нигде.
  • Генерация multi-query: Recall@k объединения N rewrites по сравнению с одним запросом. Переберите N и выберите точку на frontier recall–latency. Реализации: MultiQueryRetriever в LangChain и QueryFusionRetriever в LlamaIndex.
  • Точность классификации интента: стандартные precision/recall/F1 для каждого интента (считайте с помощью sklearn.metrics.classification_report), но рабочая метрика — корректность роутинга: был ли вызван правильный downstream-пайплайн?
  • Адаптивный роутинг: Adaptive-RAG (Jeong et al., NAACL 2024) показывает, что каждому запросу не нужна одна и та же стратегия retrieval. Отслеживайте точность роутера как задачу классификации на размеченном наборе «retrieval не нужен / one-shot / iterative».

Метрики retrieval

Это базовые метрики. Если их не отслеживать, невозможно понять, улучшается ли retrieval.

МетрикаЧто измеряетКогда использовать
Recall@kДоля релевантных документов запроса, возвращённых в top kКогда важно не потерять ни одну часть релевантного набора
Precision@kПроцент релевантных документов среди top-kКогда узким местом является context window
MRRСреднее значение 1/rank первого релевантного документаКогда пользователи смотрят только top-1 или top-3
nDCG@kGain с учётом позиции и степени релевантностиСтандартная метрика retrieval для graded relevance
MAPСреднее по запросам значение average precisionКогда важен весь ранжированный список
Hit Rate@kПоявляется ли хотя бы один релевантный документ в top kДля быстрого sanity check: усредните бинарный результат по запросам
CoverageПроцент golden docs, когда-либо найденных по всем запросамВыявляет систематические пробелы в индексе

Формулы для справки (бинарная релевантность с релевантным множеством RqR_q для запроса qq и reli=1\text{rel}_i = 1, если документ, извлечённый на позиции ii, входит в RqR_q):

Recall@k=Rq{d1,,dk}Rq,Precision@k=Rq{d1,,dk}k\text{Recall@k} = \frac{|R_q \cap \{d_1, \dots, d_k\}|}{|R_q|}, \quad \text{Precision@k} = \frac{|R_q \cap \{d_1, \dots, d_k\}|}{k} RRq=1rank of first relevant doc,MRR=1QqQRRq\text{RR}_q = \frac{1}{\text{rank of first relevant doc}}, \quad \text{MRR} = \frac{1}{|Q|} \sum_{q \in Q} \text{RR}_q DCG@k=i=1k2reli1log2(i+1),nDCG@k=DCG@kIDCG@k\text{DCG@k} = \sum_{i=1}^{k} \frac{2^{\text{rel}_i} - 1}{\log_2(i + 1)}, \quad \text{nDCG@k} = \frac{\text{DCG@k}}{\text{IDCG@k}}

Для graded relevance используется reli{0,1,2,}\text{rel}_i \in \{0, 1, 2, \dots\}; бинарный nDCG — частный случай, применённый в коде ниже. MAP — среднее по запросам от APq=1Rqi:reli=1Precision@i\text{AP}_q = \frac{1}{|R_q|}\sum_{i: \text{rel}_i = 1} \text{Precision@}i. Вывод формул см. в Manning, Raghavan, Schütze, Introduction to Information Retrieval, глава 8.

Для production-кода используйте ranx, pytrec_eval или ir_measures — они реализуют всё семейство метрик TREC и корректно работают с graded relevance. Задавайте release-target на реалистичном golden set, downstream-качестве ответа и стоимости пропуска. Не переносите пороги из туториала.

Харнесс для этих метрик короткий. Его можно запустить из ноутбука ещё до выбора vector DB.

from math import log2
from statistics import mean

# synthetic gold set: query_id -> set of relevant doc ids
gold = {
    "q1": {"d3"},
    "q2": {"d7", "d2"},
    "q3": {"d11"},
    "q4": {"d5"},
}

# ranked retrieval results: query_id -> ranked list of doc ids (top-10)
runs = {
    "q1": ["d8", "d3", "d1", "d4", "d2", "d9", "d6", "d10", "d12", "d13"],
    "q2": ["d2", "d6", "d4", "d7", "d1", "d3", "d8", "d11", "d5", "d9"],
    "q3": ["d11", "d2", "d3", "d4", "d1", "d6", "d7", "d8", "d10", "d12"],
    "q4": ["d1", "d2", "d3", "d6", "d8", "d9", "d10", "d12", "d13", "d14"],
}

def recall_at_k(ranked, gold_set, k):
    if not gold_set:
        return 0.0
    hit = sum(1 for d in ranked[:k] if d in gold_set)
    return hit / len(gold_set)

def reciprocal_rank(ranked, gold_set):
    # MRR contribution per query: 1/rank of the first relevant doc.
    for rank, d in enumerate(ranked, start=1):
        if d in gold_set:
            return 1.0 / rank
    return 0.0

def ndcg_at_k(ranked, gold_set, k):
    # binary relevance: rel ∈ {0, 1}
    gains = [1.0 if d in gold_set else 0.0 for d in ranked[:k]]
    dcg = sum(g / log2(i + 2) for i, g in enumerate(gains))
    # ideal DCG: all gold docs ranked first, capped by k
    n_gold_in_topk = min(k, len(gold_set))
    idcg = sum(1.0 / log2(i + 2) for i in range(n_gold_in_topk))
    return dcg / idcg if idcg else 0.0

K = 5
print(f"Recall@{K}: {mean(recall_at_k(runs[q], gold[q], K) for q in gold):.3f}")
print(f"MRR:       {mean(reciprocal_rank(runs[q], gold[q]) for q in gold):.3f}")
print(f"nDCG@{K}:  {mean(ndcg_at_k(runs[q], gold[q], K) for q in gold):.3f}")
# Recall@5: 0.750
# MRR:       0.625
# nDCG@5:    0.627

Это ваш retrieval CI-gate. Подключите coverage-driven fast subset к каждому PR, а полный golden set запускайте на более медленном release-gate. Блокируйте merge, если preregistered metric пересекает заданный regression budget.

Companion-репозиторий фиксирует приведённые выше числа (Recall@5 = 0.750, MRR = 0.625, nDCG@5 = 0.627) в unit test в tests/test_retrieval_metrics.py; notebook 01 перебирает Recall@k / MRR / nDCG на реальном SciFact-индексе, а production-shaped харнесс находится в evaluation/retrieval.py.

Hybrid retrieval и reciprocal rank fusion

BM25 — sparse lexical scorer, объединяющий сопоставление точных терминов, взвешивание терминов и нормализацию длины. Он доступен в rank_bm25, Elasticsearch, OpenSearch и большинстве поисковых систем.

Reciprocal Rank Fusion (Cormack, Clarke и Buettcher, SIGIR 2009) объединяет BM25- и dense-ранжирование по позициям. Исходная настройка k=60 — полезный baseline. RRF не зависит от score, поэтому ему не нужна междорожечная нормализация, требуемая при линейной интерполяции. Если размеченный набор достаточно велик для стабильной оценки дельты, также протестируйте выпуклую комбинацию и настройте α.

Моя гипотеза: hybrid retrieval в сочетании с cross-encoder-реранкером может помочь на технических, log-style- и code-корпусах. На преимущественно семантических корпусах прирост может быть небольшим. Сравнивайте результат с dense-only и sparse-only дорожками, поскольку плохая конфигурация fusion может оказаться хуже каждого из входов. Companion SciFact notebook — это один ограниченный тест, а не универсальный результат.

Реализация занимает несколько строк.

from collections import defaultdict

# two retrieval lanes: dense embeddings and BM25.
dense  = ["d3", "d7", "d1", "d4", "d2", "d9", "d10"]
sparse = ["d2", "d3", "d8", "d1", "d11", "d4", "d6"]

def rrf(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
    """Reciprocal Rank Fusion (Cormack et al., SIGIR 2009).

    score(d) = sum over rankings of 1 / (k + rank(d))
    Score-agnostic: only rank position matters. k=60 is the canonical default.
    """
    scores: dict[str, float] = defaultdict(float)
    for ranking in rankings:
        for rank, doc in enumerate(ranking, start=1):
            scores[doc] += 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda kv: kv[1], reverse=True)

fused = rrf([dense, sparse], k=60)
for doc, score in fused[:5]:
    print(f"{doc}  score={score:.5f}")
# d3  score=0.03252   <- rank 1 dense, rank 2 sparse
# d2  score=0.03178   <- rank 5 dense, rank 1 sparse
# d1  score=0.03150

Обратите внимание, чего RRF не делает: он никогда не смотрит на исходные similarity score. Dense retriever, вернувший cosine 0,98, и BM25-дорожка со score 17,4 напрямую несопоставимы. Если нормализовать их z-score или min-max scaling, можно случайно отдать предпочтение дорожке с большей дисперсией в этом batch.

RRF использует только rank. Если retriever поставил документ на позицию 2, его голос стоит 1 / (60 + 2) независимо от исходного score, который привёл к этой позиции.

Hybrid + RRF на SciFact: notebook 02 сравнивает dense, BM25 и RRF с дельтами по каждому запросу. Production-shaped fuser находится в retrieval/hybrid_rrf.py; tests/test_rrf.py фиксирует канонический порядок d3 / d2 / d1 на k=60.

Реранкинг

  • ΔnDCG / ΔMRR: uplift относительно варианта без реранкинга на golden set и на глубине, которую реально использует приложение. Считайте retrieval-метрики с реранкером и без него на идентичных наборах кандидатов.
  • Cross-encoder и bi-encoder: bi-encoder независимо строит эмбеддинги запроса и документа (по одному вектору с каждой стороны) и считает score через dot product; cross-encoder конкатенирует query+doc и выполняет один forward pass с совместным attention по обоим. Cross-encoder обменивает forward pass для каждого кандидата на более богатое взаимодействие query–document. Эталонная реализация: sentence-transformers CrossEncoder. Сравнивайте relevance и latency на конкретном hardware, batch size и глубине кандидатов; не переносите результат одной модели или managed service в другую среду.
  • Listwise и pointwise: pointwise оценивает каждую пару (query, doc) независимо; listwise совместно оценивает весь список кандидатов, чтобы модель могла сравнить их. Тестируйте оба подхода на одинаковых наборах кандидатов. Порог score калибруйте отдельно для каждой модели и корпуса, а не считайте опубликованный пример переносимым.
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

query = "How do I rotate database credentials in production?"
candidates = [
    "Production database credentials are rotated via Vault every 30 days.",
    "The new logo was unveiled at the all-hands meeting.",
    "To rotate prod DB creds, run the `rotate-secrets` GitHub Action.",
]

scores = reranker.predict([(query, c) for c in candidates])
ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
for doc, score in ranked:
    print(f"{score:+.3f}  {doc}")

Реранкер часто помогает базовому RAG-пайплайну, но гарантированного выигрыша нет. Измерьте ΔPrecision@1 и ΔnDCG на golden set, а затем оставляйте реранкер только если прирост укладывается в бюджет латентности и стоимости. Сравните измеренный прирост с более мелкими изменениями retrieval перед выбором следующей оптимизации.

ΔnDCG и ΔPrecision@1 для cross-encoder на SciFact: notebook 03; модуль: retrieval/reranker.py.

Сборка контекста и lost-in-the-middle

Многие случаи «хороший retrieval, плохой ответ» начинаются при сборке контекста.

  • Релевантность контекста: score релевантности каждого чанка из RAGAS ContextRelevance или cross-encoder, агрегированный как mean и как процент чанков ниже порога.
  • Использование контекста: сколько из помещённых в контекст чанков действительно было процитировано или использовано в ответе. Считайте как cited chunksretrieved chunks\frac{|\text{cited chunks}|}{|\text{retrieved chunks}|} на размеченной выборке. Рабочий порог определяйте по качеству ответа и стоимости токенов, а не используйте универсальный процент.
  • Детектирование lost-in-the-middle: synthetic eval, в котором gold chunk помещается на позиции {first, middle, last} длинного контекста, после чего измеряется корректность ответа. В цитируемом исследовании Liu et al. (TACL 2023) сообщается U-образное ухудшение в условиях длинного контекста. Такой же паттерн в актуальной модели нужно рассматривать как гипотезу для проверки. Митигации: сначала выполнить реранкинг, затем переставить top-k так, чтобы чанк с максимальным score оказался первым или последним (LangChain LongContextReorder делает именно это), либо агрессивно сжать средние чанки. Используйте position-stratified eval, а не только aggregate score. Рабочий запускаемый position-stratified eval находится в notebook 06 (модуль: evaluation/lost_in_middle.py).
  • Сжатие контекста: вместе с корректностью ответа показывайте compression ratio (input tokens / output tokens). Инструменты включают ContextualCompressionRetriever из LangChain и LongLLMLingua. Заранее определите максимально допустимую потерю корректности с учётом риска приложения и token budget, затем отклоняйте конфигурации, которые её превышают.

Часть 5: false-exclusion rate фильтра

Эта метрика заслуживает отдельного раздела, потому что aggregate retrieval score не может связать пропуск с фильтром.

Hard metadata filter вроде tenant_id = X AND product = Y AND locale = en-US может обнулить эффективный recall. Корректно реализованный Recall@k фиксирует потерю, поскольку его denominator остаётся исходным множеством релевантных документов. Но он не показывает, что стало причиной пропуска: фильтр, retriever или ranker. Faithfulness оценивает утверждения относительно retrieved context. Она всё ещё может засчитать утверждения, поддержанные этим неполным контекстом, но не способна диагностировать причину в фильтре или retrieval. Пустой отказ может не содержать утверждений и давать NaN — в зависимости от реализации; не считайте это свидетельством того, что faithfulness одобрила отказ.

Подсвеченная ветка — типичный сбой: правильный документ существует, но фильтр удаляет его до retrieval. Recall@k фиксирует падение; только exclusion rate связывает его с predicate.

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

Метрика

filter_false_exclusion_rate =
    (# queries where all gold docs were excluded by metadata filter) /
    (# queries with at least one gold doc)

Это определение на уровне запроса считает катастрофические исключения: ни один релевантный документ не прошёл фильтр. Для запросов с несколькими gold-документами стандартный Recall@k всё равно показывает частичную потерю; если эта граница важна, добавьте exclusion rate на уровне документа. Для расчёта любой из метрик нужны (a) ground-truth doc IDs для каждого eval-запроса и (b) instrumentation, которая логирует применённые filter predicates, а не только финальные результаты. Цель задайте по стоимости исключения корректного ответа и доверительному интервалу production-выборки.

Ниже приведена рабочая реализация. Она сравнивает корректный стандартный recall с некорректным evaluаtor, который переопределяет relevance после фильтрации.

# A small worked example where hard filters remove relevant documents.
docs = [
    {"id": "d1", "tenant": "acme",   "locale": "en-US"},
    {"id": "d2", "tenant": "acme",   "locale": "en-GB"},
    {"id": "d3", "tenant": "globex", "locale": "en-US"},
    {"id": "d4", "tenant": "acme",   "locale": "en-US"},
    {"id": "d5", "tenant": "acme",   "locale": "de-DE"},
]

queries = [
    # the gold doc lives in en-GB but the dynamic filter forced en-US
    {"qid": "q1", "gold": {"d2"}, "filter": lambda d: d["locale"] == "en-US"},
    # the gold doc is correctly within the tenant filter
    {"qid": "q2", "gold": {"d4"}, "filter": lambda d: d["tenant"] == "acme"},
    # the gold doc is in a different tenant and gets dropped
    {"qid": "q3", "gold": {"d3"}, "filter": lambda d: d["tenant"] == "acme"},
    # the gold doc passes the filter (de-DE locale match)
    {"qid": "q4", "gold": {"d5"}, "filter": lambda d: d["locale"] == "de-DE"},
]

def filter_false_exclusion_rate(queries, docs):
    n_with_gold, n_excluded = 0, 0
    for q in queries:
        if not q["gold"]:
            continue
        n_with_gold += 1
        survivors = {d["id"] for d in docs if q["filter"](d)}
        if not (q["gold"] & survivors):
            n_excluded += 1
    return n_excluded / n_with_gold if n_with_gold else 0.0

rate = filter_false_exclusion_rate(queries, docs)
print(f"filter_false_exclusion_rate = {rate:.2%}")
# filter_false_exclusion_rate = 50.00%

# Correct Recall@k keeps the original gold set as its denominator.
def standard_recall_at_k(queries, docs, k=10):
    recalls = []
    for q in queries:
        # demo only: the survivor set stands in for a ranked run.
        # A real harness ranks the survivors first, then slices to k.
        survivors = [d for d in docs if q["filter"](d)][:k]
        survivor_ids = {d["id"] for d in survivors}
        recalls.append(len(q["gold"] & survivor_ids) / len(q["gold"]))
    return sum(recalls) / len(recalls) if recalls else 0.0

print(f"standard recall@10 = {standard_recall_at_k(queries, docs):.2%}")
# standard recall@10 = 50.00%

# INVALID: rebuilding the gold set after filtering changes the question.
# It drops queries whose relevant documents did not survive, then scores 100%.
def invalid_recall_over_filtered_gold(queries, docs, k=10):
    recalls = []
    all_doc_ids = {d["id"] for d in docs}
    for q in queries:
        all_survivors = {d["id"] for d in docs if q["filter"](d)}
        filtered_gold = q["gold"] & all_doc_ids & all_survivors
        if not filtered_gold:
            continue
        top_k_ids = set(list(all_survivors)[:k])
        recalls.append(len(filtered_gold & top_k_ids) / len(filtered_gold))
    return sum(recalls) / len(recalls) if recalls else 0.0

invalid = invalid_recall_over_filtered_gold(queries, docs)
print(f"INVALID recall (filtered gold) = {invalid:.2%}")
# INVALID recall (filtered gold) = 100.00%

assert rate == 0.5
assert standard_recall_at_k(queries, docs) == 0.5
assert invalid == 1.0

У половины запросов фильтр удаляет gold-документ, поэтому корректный Recall@10 падает до 50%. Этот score фиксирует симптом, но не может связать его с причиной. False-exclusion rate показывает, что predicate удалил два ответа до запуска retriever. Намеренно некорректный evaluator сообщает 100% только потому, что исключает эти сбои из gold set. Ни одна модель не сможет восстановить документ, отфильтрованный заранее.

Приведённый выше показатель 50% воспроизводится в unit test companion-репозитория: tests/test_filter_exclusion.py::test_50_percent_exclusion_rate. Notebook 04 запускает этот тест на SciFact с синтетическими метаданными, чтобы можно было увидеть, как реальный фильтр обнуляет recall; runtime-метрика с companion-метриками predicate precision/recall находится в evaluation/filter_exclusion.py.

Companion-метрика: precision и recall predicate

Если фильтрация динамическая — например, LLM извлекает filter predicates из запроса, — рассматривайте extractor predicate как классификационную модель и оценивайте его соответствующим образом. Измеряйте precision и recall predicate на размеченном наборе пар (query, correct predicate). Error rate predicate не переводится напрямую в такую же потерю retrieval recall; измеряйте, как часто эти ошибки исключают gold-документ. После удаления gold-документа hard filter никакой реранкинг не поможет.

Soft boost и hard filter

Эта метрика заставляет принять архитектурное решение. Используйте hard filters, когда корректность бинарна: правовая юрисдикция, границы ACL, опубликованный документ против draft. Используйте soft boosts, когда relevance градуирована: предпочтение локали, свежесть, версия. Без измерения exclusion rate неправильный выбор трудно заметить.

Измеримое правило принятия решения:

For each filter predicate F:
  hard_recall_F  = retrieval_recall@k with F as a hard filter
  soft_recall_F  = retrieval_recall@k with F as a +0.X rerank boost
  hard_precision = relevant_in_top_k / k under hard filter
  soft_precision = relevant_in_top_k / k under soft boost
  exclusion_rate = % of queries where the gold doc was filtered out (hard)

Use hard filter only if exclusion_rate < ε AND hard_precision >> soft_precision.
Otherwise prefer soft boost.

Выбирайте ε с учётом вреда от false exclusion, выгоды от повышения precision и размера eval-выборки. Отдельный подробный материал об этом компромиссе запланирован; см. follow-ups в конце.


Часть 6: Оценка генерации

Метрики retrieval показывают, что система могла бы ответить правильно. Они не показывают, что она ответила правильно. Метрики генерации закрывают этот разрыв.

Faithfulness и groundedness

Faithfulness в RAGAS раскладывает ответ на атомарные утверждения (короткие, самодостаточные фактические высказывания), а затем проверяет каждое относительно retrieved context с помощью LLM-judge:

faithfulness=claims supported by contexttotal claims\text{faithfulness} = \frac{|\text{claims supported by context}|}{|\text{total claims}|}

Score — это процент поддержанных утверждений. Такая структура полезнее одного числа: она показывает, какие именно утверждения не поддержаны. Production-код находится в пакете ragas. Следующий пример — это legacy API sketch без запуска для RAGAS 0.4.3, а не программа, готовая к копированию. Для запуска зафиксируйте ragas==0.4.3, установите client провайдера, настройте credentials, создайте provider-backed RAGAS LLM и передайте его как evaluator_llm. Настройка намеренно вынесена за пределы блока и зависит от провайдера; функция принимает настроенный объект аргументом, не подразумевая дефолтный вариант. Для нового кода используйте актуальный collections-based API, который RAGAS описывает как путь миграции с legacy metrics API:

# No-run: legacy RAGAS 0.4.3 API shape.
# Before calling this function, configure a provider-backed RAGAS LLM.
# For example, with the provider credentials already set:
# from openai import AsyncOpenAI
# from ragas.llms import llm_factory
# evaluator_llm = llm_factory("gpt-4o-mini", client=AsyncOpenAI())
from ragas import EvaluationDataset, evaluate
from ragas.metrics import (
    Faithfulness,
    LLMContextPrecisionWithoutReference,
    ResponseRelevancy,
)

def run_legacy_ragas_043(evaluator_llm):
    dataset = EvaluationDataset.from_list([
        {
            "user_input": "How many moons does Mars have?",
            "response": "Mars has two moons, Phobos and Deimos.",
            "retrieved_contexts": ["Mars has two moons named Phobos and Deimos."],
            "reference": "Mars has two moons.",
        }
    ])

    return evaluate(
        dataset,
        metrics=[Faithfulness(), ResponseRelevancy(), LLMContextPrecisionWithoutReference()],
        llm=evaluator_llm,
    )

В RAGAS эти поля были переименованы в версии 0.2 (questionuser_input, answerresponse, contextsretrieved_contexts, ground_truthreference). Код, написанный для версии 0.1, ломается неочевидно: старые ключи отбрасываются, а не отклоняются, поэтому ошибка указывает на отсутствующие новые колонки.

Ниже тот же цикл, развёрнутый с детерминированным заменителем judge, чтобы было видно его end-to-end-структуру.

def extract_claims(answer: str) -> list[str]:
    # Production: an LLM call that decomposes the answer.
    # Demo: split on sentence-final punctuation.
    return [c.strip() for c in answer.replace("?", ".").replace("!", ".").split(".") if c.strip()]

def verify_claim(claim: str, context: str) -> bool:
    # Production: an NLI (natural-language inference) model or LLM judge.
    # Demo: a deterministic stand-in so the example runs offline.
    entailed_pairs = {
        "Mars has two moons": True,
        "Phobos and Deimos orbit Mars": True,
        "Mars has a thick atmosphere": False,  # unsupported by context
        "Curiosity landed in 2012": True,
    }
    for k, v in entailed_pairs.items():
        if k.lower() in claim.lower() or claim.lower() in k.lower():
            return v
    words = [w.lower() for w in claim.split() if len(w) > 3]
    return all(w in context.lower() for w in words) if words else False

context = (
    "Mars has two moons, Phobos and Deimos. NASA's Curiosity rover "
    "landed on Mars in 2012."
)
answer = (
    "Mars has two moons. Phobos and Deimos orbit Mars. "
    "Mars has a thick atmosphere. Curiosity landed in 2012."
)

claims = extract_claims(answer)
verdicts = [(c, verify_claim(c, context)) for c in claims]
faithfulness = sum(1 for _, ok in verdicts if ok) / len(verdicts)
for c, ok in verdicts:
    print(f"  [{'✓' if ok else '✗'}] {c}")
print(f"faithfulness = {faithfulness:.2f}")
# faithfulness = 0.75   (one unsupported claim about the atmosphere)

Структура важна. В production verify_claim становится NLI-моделью или вызовом LLM. Остальная часть харнесса не меняется: извлечь, проверить, агрегировать.

End-to-end извлечение и проверка утверждений в сгенерированных ответах SciFact: notebook 05; модуль: evaluation/faithfulness.py. Репозиторий прогоняет тот же цикл через два семейства judge — собственную модель generator и judge из другого семейства (RAG_EVALS_JUDGE_MODEL) — плюс детерминированный lexical baseline, чтобы показать, где семейства расходятся.

Специализированная альтернатива LLM-as-judge — HHEM-2.1-Open (Hughes Hallucination Evaluation Model, Vectara), classifier, дообученный для детектирования галлюцинаций. В model card описаны checkpoint, выдаваемый raw score от 0 до 1 и результаты balanced accuracy на AggreFact и RAGTruth. Дефолтная граница решения не опубликована, поэтому выбирать её нужно самостоятельно. Рассматривайте данные model card как свидетельства о модели, а не как гарантию для своего корпуса: калибруйте threshold на локальной разметке и сравнивайте его с выбранным judge до deployment.

Оценка атомарных фактов

FActScore (Min et al., EMNLP 2023) раскладывает длинные генерации на атомарные факты, извлекает evidence для каждого факта, присваивает каждому supported / not-supported и считает долю поддержанных фактов:

FActScore=supported atomic factstotal atomic facts\text{FActScore} = \frac{|\text{supported atomic facts}|}{|\text{total atomic facts}|}

Эталонная реализация: shmsw25/FActScore. Подход хорошо работает для биографий, саммари и других длинных ответов. Следите за тем, что повторяющиеся тривиальные факты могут завысить score, а атаки «MontageLie» (правдивые факты в обманчивом порядке) могут его обойти. VeriScore учитывает claims с необходимыми модификаторами; фильтр Core помогает предотвратить fact-padding.

Точность цитирования

Отслеживайте citation precision (действительно ли процитированные spans поддерживают утверждение) и citation recall (есть ли цитаты у утверждений, которые должны быть процитированы):

cite_precision=cited spans that support a claimcited spans,cite_recall=claims with at least one supporting cited spanclaims that should be cited\text{cite\_precision} = \frac{|\text{cited spans that support a claim}|}{|\text{cited spans}|}, \quad \text{cite\_recall} = \frac{|\text{claims with at least one supporting cited span}|}{|\text{claims that should be cited}|}

В TREC 2024 RAG Track определён воспроизводимый протокол support evaluation. Thakur et al. (SIGIR 2025) сообщают, что GPT-4o совпал с human judges в 56% случаев при ручной оценке с нуля и в 72% после post-editing LLM-предсказаний. Это полезный force multiplier в их условиях, но не замена human assessment в high-stakes-сценариях. Для automated approximation ALCE (Gao et al., EMNLP 2023) реализует citation precision/recall с NLI-based verification.

Корректность, полнота и отказ

  • Корректность ответа относительно ground truth: если ground truth доступна, используйте exact match или token-F1 для задач с короткими ответами (evaluate.load("squad")), semantic similarity для open-ended-задач (bert-score, embedding cosine через sentence-transformers или RAGAS AnswerCorrectness).
  • Полнота через nuggets: «nugget» — это одна атомарная единица информации, которая должна присутствовать в любом корректном ответе (например, для вопроса «Когда была основана компания?» nuggets могут быть {year: 1994, founder: Jane Doe}). AutoNuggetizer из TREC извлекает gold nuggets корректного ответа из reference, а затем оценивает, какую их долю покрывает система — метод хорошо коррелировал с ручной оценкой на 21 теме × 45 запусках в TREC 2024.
  • Поведение при отказе: запросы, для которых в корпусе нет ответа, должны приводить к abstention, а не к галлюцинации. Отслеживайте abstention precision (корректные отказы) и abstention recall (out-of-scope-запросы, вызвавшие отказ). NoMIRACL — публичный бенчмарк; в своём домене разметьте срез out-of-scope-запросов и отслеживайте abstention accuracy.

Постгенерационная проверка

Самые дешёвые приросты надёжности часто дают детерминированные post-checks, а не более крупные модели.

  • Проверка граундинга сущностей: каждая named entity в ответе должна присутствовать в retrieved context или выводиться из него. Простая проверка regex + exact match (или ents из spaCy относительно нормализованной строки контекста) выявляет неожиданно большую долю галлюцинаций.
  • Проверка утверждений: извлеките claims, запустите NLI относительно контекста, завершайте запрос ошибкой или помечайте claims ниже порога. NLI-as-faithfulness-модели: cross-encoder/nli-deberta-v3-large, MoritzLaurer/DeBERTa-v3-large-mnli-fever-anli-ling-wanli. Это увеличивает latency, но оправдано в high-stakes-доменах.
  • Self-consistency (Wang et al., ICLR 2023): сэмплируйте несколько генераций при temperature > 0; показывайте agreement rate (например, долю генераций, совпадающих с modal answer, или попарный BERTScore); число сэмплов выбирайте по кривой stability–cost, а ответы с низким agreement отправляйте на human review.
  • Калибровка confidence: собирайте verbalized confidence («Насколько вы уверены, 0–1?») и сравнивайте с фактической корректностью на eval set. Постройте calibration curve и покажите Expected Calibration Error: ECE=m=1MBmnacc(Bm)conf(Bm)\text{ECE} = \sum_{m=1}^{M} \frac{|B_m|}{n} |\text{acc}(B_m) - \text{conf}(B_m)|, где BmB_m — confidence bins. Реализации: netcal, torchmetrics.CalibrationError. Модель, сообщающая confidence 0,9, должна быть корректна примерно в 90% сопоставимых случаев; измеряйте разрыв, а не предполагайте, что calibration уже есть.

Часть 7: Оценка RAG, основанного на онтологии

Стандартные метрики выше покрывают open-corpus RAG. Если ваш RAG выполняет retrieval по структурированной онтологии, таксономии или knowledge graph, этих метрик необходимо, но недостаточно. Примеры: товары в каталоге, состояния в SNOMED, компоненты в BOM и техники безопасности в MITRE ATT&CK. Нужно также измерять слой онтологии.

Точность entity linking

Первая задача — сопоставить упоминание в запросе с сущностью онтологии («Aspirin» → wikidata:Q18216, «the 737» → aircraft:Boeing_737).

  • Precision/recall/F1 на уровне mention: стандартные метрики относительно gold spans упоминаний (считайте с помощью seqeval или comparator для множеств spans).
  • Точность disambiguation: среди корректно найденных mentions какая доля сопоставлена с правильным entity ID? Публичные reference-системы: ReFinED, REL и GENRE; бенчмарки вроде AIDA-CoNLL и BELB показывают, что результаты зависят от системы и домена.
  • Обработка NIL: precision/recall для случаев «сущности нет в онтологии». Отдельно измеряйте over-linking к близким, но неправильным сущностям и корректный abstention.

Оценка с учётом иерархии

Обычная accuracy считает ошибки «предсказан Sedan вместо Hatchback» и «предсказан Sedan вместо Submarine» одинаковыми. Это разные ошибки.

  • Hierarchical precision/recall/F1 (Kosmopoulos et al., 2015): учитывайте предков и потомков в DAG онтологии. Пусть P^q\hat{P}_q — предсказанный узел и все его предки, а TqT_q — истинный узел и все его предки:

    hP=qP^qTqqP^q,hR=qP^qTqqTq,hF1=2hPhRhP+hRhP = \frac{\sum_q |\hat{P}_q \cap T_q|}{\sum_q |\hat{P}_q|}, \quad hR = \frac{\sum_q |\hat{P}_q \cap T_q|}{\sum_q |T_q|}, \quad hF1 = \frac{2 \cdot hP \cdot hR}{hP + hR}

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

  • Сходство Wu–Palmer между предсказанной и gold-сущностью в таксономии (Wu & Palmer, 1994):

    WuP(c1,c2)=2depth(LCA(c1,c2))depth(c1)+depth(c2)\text{WuP}(c_1, c_2) = \frac{2 \cdot \text{depth}(\text{LCA}(c_1, c_2))}{\text{depth}(c_1) + \text{depth}(c_2)}

    Здесь LCA — ближайший общий предок в таксономии. Готовая реализация доступна в NLTK для WordNet (from nltk.corpus import wordnet as wn; wn.synset("car.n.01").wup_similarity(wn.synset("truck.n.01"))); для собственных таксономий вычисляйте LCA с помощью networkx.

  • Доля ошибок со sibling/parent: отдельно отслеживайте ошибки сопоставления с sibling, parent и child — count_sibling / total_errors, count_parent / total_errors, count_descendant / total_errors. На проверенных примерах выясните, происходят ли ошибки sibling из-за неоднозначных mentions, а ошибки parent — из-за чрезмерного обобщения.

False-exclusion rate фильтра — ещё раз, теперь критично

В системах, основанных на онтологии, hard filters часто порождаются самой онтологией («извлекать только документы с категорией X»). Метрика exclusion rate, определённая в части 5, становится основным сигналом корректности. Неправильное предсказание категории может обнулить recall; exclusion rate связывает потерю с фильтром.

Соответствие constrained generation

Если output должен соответствовать онтологии (каждое имя сущности в ответе должно быть допустимым членом онтологии; каждый predicate должен входить в закрытый словарь), измеряйте:

  • Schema validity rate: процент output, которые успешно парсятся и проходят валидацию относительно схемы онтологии. Валидируйте с помощью jsonschema или pydantic. JSONSchemaBench — публичный бенчмарк для общего structured output; для ontology-specific schemas создайте собственный validator.
  • Соответствие словарю: процент named entities в output, являющихся допустимыми ontology IDs — однострочная проверка вхождения в set закрытого словаря.
  • Семантическое соответствие: синтаксически валидный output всё ещё может выбрать неправильную, но допустимую сущность. Сопоставляйте conformance с downstream-корректностью ответа.

Фреймворки constrained decoding (Outlines, XGrammar, Guidance, OpenAI Structured Outputs) предназначены для принудительного соблюдения schema validity. JSONSchemaBench сравнивает efficiency, coverage и quality разных реализаций. Повторно запустите его cases, соответствующие вашим схемам и serving backend, поскольку coverage и latency зависят от обоих факторов.

Аудитируемость

Для ontology-grounded-систем, ответы которых проходят review:

  • Полнота цитирования: процент фактических утверждений хотя бы с одной проверяемой цитатой.
  • Глубина provenance: процент цитат, которые разрешаются вплоть до исходного документа со стабильным ID, а не только до hash чанка.
  • Воспроизводимость: при повторном запуске того же запроса на фиксированном snapshot возвращается тот же ответ. Зафиксируйте версию модели, runtime, конфигурацию decoding и seed, а требуемую долю повторяемости определите по требованиям workflow к auditability. Нулевой temperature сам по себе не гарантирует детерминизм. Ошибка может возникнуть при генерации, в serving runtime или на любом upstream-этапе.

Часть 8: Оценка системы целиком

Целостное качество ответа

  • LLM-as-judge (Zheng et al., NeurIPS 2023): масштабируемый model-based подход к оценке. G-Eval (Liu et al., EMNLP 2023) выводит rubric из критерия на естественном языке, а затем выставляет score с учётом log-prob. Согласие зависит от judge, задачи, промпта и calibration set.
  • Pairwise preference: покажите judge ответ A и ответ B и запишите предпочтение. Это позволяет избежать проблем с калибровкой абсолютного score. В MT-Bench сообщалось, что согласие GPT-4 judge превышало 80% как с предпочтениями людей, так и с согласованностью human–human в условиях этого бенчмарка; не переносите этот показатель в другой домен без калибровки.

У LLM-as-judge есть реальные biases:

  • Position bias: judge предпочитает первый или второй ответ независимо от качества. Митигация: рандомизируйте порядок или запускайте оба порядка и усредняйте.
  • Verbosity bias: judge может спутать длину с качеством. В контролируемом исследовании 2026 года обнаружилось неоднородное поведение на парах с расширением. Три judge предпочитали более длинные ответы, Claude — более краткие, а GPT-4o был примерно нейтрален. Все пять хорошо справились с контролями усечения. Эти результаты привязаны к бенчмарку, поэтому явно укажите judge, как учитывать полноту и filler, а затем покажите length-controlled performance на собственной rubric.
  • Self-preference bias: GPT-4 предпочитает output GPT-4; bias коррелирует с perplexity output (judge предпочитает знакомый ему текст). Митигация: используйте judge из другого model family, чем оцениваемая система. Не поручайте модели оценивать саму себя.

Практический рецепт: выберите judge на human-labeled calibration data, рандомизируйте порядок ответов, скройте identity моделей и зафиксируйте политику длины в rubric. Повторяйте cases только тогда, когда дополнительные samples существенно уменьшают неопределённость. Для high-stakes-оценок сравнивайте judge из разных model families и анализируйте расхождения относительно human labels.

Schema-Guided Reasoning для judge

Свободный output — один из источников вариативности при запусках judge. Два запуска на одном и том же ответе могут по-разному организовать rubric и выдать разные score. Schema-Guided Reasoning (SGR) делает rubric явным: задайте этапы оценки как Pydantic schema, затем используйте constrained output через Outlines, XGrammar, vLLM structured outputs или OpenAI response_format, чтобы каждый запуск возвращал одни и те же поля в одном и том же порядке.

Для RAG eval schema раскладывает judgment на явные и аудируемые поля, не позволяя модели сразу перескочить к числу:

from pydantic import BaseModel, Field
from typing import Literal

class FaithfulnessJudgment(BaseModel):
    extracted_claims: list[str] = Field(
        description="Atomic factual claims in the answer, one per item."
    )
    supported_claims: list[str] = Field(
        description="Subset of extracted_claims that are entailed by the context."
    )
    unsupported_claims: list[str] = Field(
        description="Subset that is NOT entailed by the context."
    )
    failure_mode: Literal[
        "none", "fabrication", "overgeneralization", "wrong_entity", "stale_fact"
    ]
    score: float = Field(ge=0.0, le=1.0)
    rationale: str

Структурированные поля позволяют восстановить score как len(supported) / len(extracted) и точно показать, по каким claims расходятся два judge. Pydantic-модель также превращает изменение rubric в видимый code diff. Constrained output гарантирует форму, но не беспристрастность вердикта, поэтому всё ещё нужны рандомизация позиции, cross-family judge и human calibration.

Это работает для любой rubric-based judge, не только для faithfulness. Pairwise preference, support цитат и correctness отказа получают те же преимущества.

Харнесс для G-Eval / pairwise / position-bias / cross-family judge находится в notebook 07; модуль: evaluation/llm_judge.py. Benchmark sweep (make benchmark в репозитории) подключает три модели (gpt-5-mini, claude-haiku-4-5, gemini-2.5-flash) к rotating-judge pairwise A/B, поэтому каждая модель оценивает две другие, а self-preference превращается в числовой сигнал.

Латентность и стоимость

  • p50, p95, p99 на каждом этапе пайплайна. Выбирайте percentile SLO и threshold алерта по пользовательскому сценарию, объёму трафика и error budget.
  • Time-to-first-token против общего времени генерации. Для streaming UX пользователям важен TTFT.
  • Разбивка по этапам: retrieval, реранкинг, генерация, post-processing. Используйте trace, чтобы найти tail, а не предполагайте, какой этап вызвал проблему; при сравнении запусков фиксируйте устройство реранкера и batch size.
  • Общая стоимость $/query = embedding + retrieval + rerank + generation + амортизированная стоимость хранения. Отслеживайте p50 и p99; именно long tail расходует бюджет.
  • Hit rate кэшей на уровнях embedding cache, retrieval cache и KV-cache. Отдельные targets задавайте на основе наблюдаемой повторяемости, политики invalidation и сэкономленной стоимости каждого слоя.

Разбивка p50/p95/p99 по этапам встроена в notebook 08 и runner в evaluation/latency.py; benchmark report объединяет latency и faithfulness в одной матрице, которую можно повторно запустить с помощью make benchmark.

A/B-тестирование

  • Единица рандомизации: выбирайте её по estimand, carryover и interference. Используйте назначение на пользователя или сессию, если повторное воздействие может менять поведение или создавать непоследовательный UX. Назначение на запрос допустимо только при пренебрежимо малых эффектах и если анализ моделирует повторные наблюдения.
  • Основные метрики, guardrails и exploratory metrics: preregister их. Primary metric выбирайте по продуктовым результатам; proxy удовлетворённости включают thumbs, regeneration и dwell. Latency и cost рассматривайте как guardrails, если они ограничивают UX.
  • Размер выборки: до запуска проведите power analysis на основе минимального обнаруживаемого эффекта, дисперсии baseline, единицы назначения и stopping rule.

Часть 9: Формирование test set

Метрика хороша ровно настолько, насколько хорош test set, на котором она запускается. Если golden set охватывает три интента, а production-трафик — двенадцать, Recall@10 измеряет только эти три интента. Ещё хуже, если test set переобучен под лёгкие вопросы («Какова политика возврата компании?»): такая система может пройти проверку и провалиться на сложных запросах («Имеет ли право пользователь на возврат при частичной отмене по EU Digital Services Act 2023, если счёт выставлен в EUR, а заказ оформлен в Ирландии?»). Aggregate score растёт, хотя система по-прежнему не справляется с важной частью production-трафика.

Та же проблема касается ground truth. Если SMEs разметили очевидные документы, но пропустили релевантные документы из long tail, Recall@k будет недооценивать retriever, который на самом деле их нашёл. Вы оптимизируетесь под labels, а не под истину.

Сначала формируйте test set по реальному распределению запросов и сложности. Затем выбирайте метрики, реагирующие на целевые режимы отказа, и настраивайте по ним систему.

Генерация синтетических запросов

Используйте LLM для генерации вопросов по корпусу:

  • Для каждого чанка: «Сгенерируй 3 вопроса, которые пользователь может задать и на которые отвечает этот чанк».
  • Multi-hop: выберите два чанка и сгенерируйте вопрос, требующий оба.
  • Adversarial: сгенерируйте вопросы с distractor entities, near-duplicate phrasing и неоднозначными mentions.

RAGAS имеет встроенное распределение типов вопросов (reasoning, conditional, multi-context). DataMorgana генерирует настраиваемые синтетические бенчмарки по категориям пользователей и вопросов. Synthetic data полезны для cold start и тестирования покрытия. Но они не заменяют реальные пользовательские запросы.

Формирование golden dataset

Данные, отобранные людьми, служат опорой golden set.

  1. Выберите реальные пользовательские запросы (или симулированные, если система ещё не запущена), стратифицировав их по интенту.
  2. Попросите SMEs ответить на каждый вопрос и указать документы, содержащие ответ.
  3. Определите размер набора по матрице покрытия и доверительному интервалу, необходимому для release-решений; coverage важнее заимствованного количества запросов.
  4. Проводите повторную кураторскую разметку, когда этого требуют cadence release, сигналы drift, риск домена и возможности annotators.

Adversarial test sets

  • Контрфактуалы: заменяйте ключевые сущности в запросе. Находит ли система правильные чанки для изменённого запроса?
  • Distractors: запросы, для которых в корпусе есть правдоподобный, но неправильный ответ, который не следует извлекать. Именно это проверяет RGB (Chen et al., AAAI 2024): устойчивость к шуму, rejection негативных примеров, интеграция информации и counterfactual robustness.
  • Отрицания и кванторы: запросы со словами «не», «кроме» и «только». Dense retriever часто испытывает с ними трудности.
  • Out-of-scope: запросы без ответа в корпусе. Система должна сказать «Я не знаю», а не галлюцинировать. Здесь находится NoMIRACL. Явно оценивайте abstention на типах запросов из production.

Coverage и непрерывная оценка

  • Постройте coverage matrix: query intent × document type × ontology branch. Стремитесь иметь ≥1 запрос на каждую ячейку. Пустые ячейки — не мониторируемые области, где прячутся регрессии.
  • Запускайте ограниченный быстрый regression subset на каждом PR, а полный suite — по более редкому расписанию.
  • Планируйте полную eval golden set исходя из release cadence и стоимости оценки; release candidate — естественный gate.
  • Планируйте drift evaluation по объёму трафика, ожидаемым изменениям и риску. Используйте rolling production sample и стратифицируйте его по feedback, не изменяя target distribution незаметно.

Часть 10: Production-мониторинг

Eval suite, который вы выпускаете, описывает систему на момент запуска. После этого production-трафик меняется.

Неявный и явный feedback

  • Click-through / open rate для процитированных источников, если UI их показывает.
  • Dwell time на ответе.
  • Regeneration rate: процент ответов, которые пользователь повторно запрашивает или просит переделать. Считайте это одним из сигналов неудовлетворённости и калибруйте по проверенным диалогам.
  • Copy / share / export rate — сильный positive signal.
  • Паттерны follow-up: вопросы «Вы уверены?» или «А что насчёт X?» могут указывать на недоверие.
  • Thumbs up/down с необязательными категориями причины (неправильно, неполно, не по теме, вредно, медленно). Inline edits, если UI их поддерживает, обычно несут больше информации, чем любой другой feedback signal.

Детектирование drift

  • Query drift: отслеживайте распределение query embeddings относительно reference window с помощью KL divergence, MMD или model-based detector. При shift создавайте alert, затем отлаживайте по сегментам.
  • Embedding drift: зафиксируйте probe set документов; периодически перестраивайте их эмбеддинги и измеряйте cosine относительно исходных. Даже небольшой drift между версиями provider model может незаметно сломать retrieval. Versioned embedding storage (immutable snapshots для каждой версии) — самая дешёвая митигация.
  • Performance drift: отслеживайте production-equivalent metrics (regeneration rate по интенту) во времени. Резкий скачок означает поломку; медленный drift — изменение внешнего мира.

Shadow evaluation и human-in-the-loop

Запускайте candidate-систему параллельно с production, сравнивайте output offline и не показывайте их пользователям. Так можно обнаружить регрессии до запуска. Это требует дополнительных inference-ресурсов, но не влияет на клиентов.

Для human-in-the-loop (HITL) review:

  • Отправляйте low-confidence output в review queue.
  • Добавляйте случайную выборку production-трафика для blind review; rate определяйте по объёму трафика, риску и возможностям reviewers.
  • Придавайте thumbs-down output повышенный вес.
  • Используйте проверенные output для расширения golden set.

Минимальный набор guardrail

Создавайте alert на следующие условия в порядке приоритета:

  1. Faithfulness/HHEM score ниже порога на rolling production sample.
  2. p95 latency выше SLO.
  3. False-exclusion rate фильтра выше порога (по выборке).
  4. Regeneration rate выходит за локально калиброванный control band, учитывающий размер окна, трафик, сезонность и бюджет ложных алертов.
  5. Cost/query выше бюджета.

Если alert сработал без соответствующего изменения кода или модели, вероятно, это drift. Если после изменения — вероятно, регрессия. В обоих случаях вы получите сигнал до того, как появятся тикеты в поддержку.


Ограничения

  • Targets локальны, а не универсальны. Любое число, названное в этом руководстве иллюстративным, — пример конфигурации или разобранный результат, а не release threshold. Калибруйте thresholds по домену, stakes, неопределённости eval set и ожиданиям пользователей.
  • Пространство фреймворков быстро меняется. Версии HHEM, названия метрик RAGAS, model cards и порядок моделей в leaderboard могут измениться после публикации. Перепроверяйте связанные источники и повторяйте бенчмарк до фиксации решения.
  • Числа согласия LLM-as-judge требуют оговорок. Показатель 80% для GPT-4 против людей получен в условиях MT-Bench / Chatbot Arena. В нишевых доменах и adversarial cases согласие резко падает. Используйте judge как force multiplier, а не как замену выборочной проверке.
  • Vendor benchmark uplift часто невозможно независимо воспроизвести. Проверяйте его на собственных данных, особенно для новых реранкеров и OCR-систем.
  • Ни одна метрика не заменяет просмотра output. Планируйте blind review случайной production-выборки по объёму трафика, риску и возможностям reviewers. Метрики масштабируют эту практику, но не заменяют её.

Продолжение серии

Это был индекс серии. Я планирую следующие материалы:

  • Soft Boosts против Hard Filters: подробный разбор false-exclusion rate фильтра, с кодом, реальными production-примерами и фреймворком принятия решений.
  • Чанкинг — скрытая переменная: контролируемый эксперимент с рекурсивным, семантическим, поздним и структурным чанкингом на трёх корпусах.
  • Выбор реранкера в 2026 году: BGE против Cohere, ZeRank и актуальных cross-encoder-моделей; head-to-head по стоимости, latency и uplift.
  • Ontology-Grounded RAG: end-to-end walkthrough: построение полного evaluation harness для системы retrieval, основанной на сущностях.
  • LLM-as-Judge без ловушки self-preference: практические рецепты unbiased automated evaluation.
  • Online Evaluation в Production: паттерны instrumentation, политики алертов и дашборды, выявляющие реальные регрессии.

Источники

Фреймворки и бенчмарки

Retrieval и ranking

Generation, faithfulness и judges

Drift и production

Companion code

  • slavadubrov/rag-evals-demo — запускаемый харнесс для каждой метрики из этой статьи на корпусе SciFact, плюс benchmark sweep chunking × embedding × LLM. Ноутбуки 00–09, unit tests, фиксирующие разобранные выше примеры, и индекс со встроенным Qdrant, который работает без Docker.