Автоматический перевод
Эта статья была автоматически переведена с оригинальной английской версии.
Оценка RAG: метрики для каждого этапа production-системы RAG
Часть 1 серии о Production RAG
RAG-система со сломанными фильтрами может работать месяцами, прежде чем кто-то это заметит. Пайплайн возвращает ответы, дашборды latency остаются зелёными, и единственный признак проблемы — сами ответы слегка неверны. «Слегка неверно» никого не пейджит.
Лучшие логи это не поймают. Оценка поймает — но только если она покрывает каждый этап пайплайна собственной метрикой. Эта статья — тот справочник, который мне хотелось иметь, когда я разбирался, какие метрики действительно важны.
Хотите сразу перейти к коду?
Я упаковал метрики из этой статьи в исполняемый companion repo: slavadubrov/rag-evals-demo. make eval запускает полный набор — retrieval metrics, hybrid + RRF, reranker uplift, filter false-exclusion, faithfulness, lost-in-the-middle, LLM-as-judge с mitigation bias, latency — на корпусе SciFact. make benchmark прогоняет chunking × embedding × LLM и пишет markdown-отчёт. Ноутбуки 00–09 разбирают каждую метрику по отдельности; та же терминология, что и в статье, реальные числа, без Docker (встроенный Qdrant).
TL;DR
- Оценка определяет систему. Этап без метрики — это этап, который падает молча.
- Полезный evaluation stack покрывает ingestion, retrieval, grounding генерации, соответствие онтологии и системные сигналы. RAGAS, TruLens, DeepEval, Arize Phoenix и TREC 2024 RAG Track дают вам инструменты. Но метрики за вас они не выбирают.
- Для metadata- и ontology-grounded RAG самый частый сбой — тихий фильтр. Неверный тег или хрупкий жёсткий предикат обнуляет recall. Faithfulness при этом всё ещё может выглядеть нормально, потому что модель добросовестно сказала: «Я не знаю».
В последующих статьях я подробно разберу отдельные разделы. Эту используйте как индекс.
Часть 1 — Почему сначала evaluation
Сигнал senior-уровня
В RAG-проекте диаграмма архитектуры не должна быть первым артефактом. Первым должен быть eval set.
Нельзя выбрать между BM25 и dense retrieval, recursive и semantic chunking или Cohere Rerank и BGE, пока вы не понимаете, что именно оптимизируете. «Ответы получше» — не метрика. «Faithfulness ≥ 0.85 на golden set из 200 запросов, покрывающем три наших основных интента, при p95 latency < 1.5s и filter false-exclusion rate < 2%» — метрика.
Определите harness до того, как начнёте писать retrieval code. Первый harness будет неверным, и вы его пересмотрите. Пересмотреть метрику намного дешевле, чем пересматривать систему, которую вы уже выкатили.
Три слоя, а не одно число
Современный RAG — это пайплайн, поэтому и evaluation должно быть пайплайном. Ни одно число не ловит все режимы отказа.
Production evaluation состоит из трёх слоёв: offline (корректно ли подготовлена база знаний?), online (нашлись ли и были ли использованы правильные свидетельства для этого запроса?) и post-generation (верен ли ответ источникам и можно ли его проверить?). Каждый слой задаёт свой вопрос. Сведите их к одному score — и пропустите базовые сбои, вроде бага нормализации, который убивает recall.
Это же разделение хорошо объясняет разницу между online и offline evaluation. Offline работает на фиксированном датасете с известной ground truth. Оно воспроизводимо, дёшево в итерациях и подходит для выбора компонентов, A/B-сравнений и CI-гейтов. Online работает на живом трафике. Оно ловит сигналы, которые невозможно сымитировать офлайн: regeneration rate, dwell time, лайки/дизлайки и реальный дрейф запросов. Но оно шумное, и его сложнее хорошо инструментировать.
Нужны оба режима. Только offline — значит пропустить живой дрейф. Только online — значит сделать регрессии плохо воспроизводимыми. Делать и то и другое сложнее, но только такой сетап даёт полезную обратную связь до и после запуска.
Component-level против end-to-end
Есть две типичные ошибки. Evaluation только end-to-end говорит, что система сломана, но не показывает где. Evaluation только на уровне компонентов может показать, что каждая часть проходит, хотя система целиком всё равно проваливается. Решение — несколько headline end-to-end метрик для go/no-go решений плюс component metrics для диагностики. Retrieval metrics ловят регрессии retriever-а. Generation metrics ловят регрессии generator-а. Сквозная correctness ответа ловит интеграционные сбои.
Эталонные фреймворки (субъективный обзор)
| Framework | В чём лучше всего | Где проседает |
|---|---|---|
| RAGAS | Reference-free метрики для RAG (faithfulness, answer relevancy, context precision/recall); де-факто общий словарь | Стоимость LLM-judge; непрозрачные компоненты score при отладке; English-centric defaults |
| ARES | Обучаемые classifier judges для каждого пайплайна; требует меньше разметки, чем подходы в стиле RAGAS; высокая точность для близких систем | Более тяжёлый setup; модели реально нужно обучать |
| TruLens | Компонуемые feedback functions с хорошей explainability; OpenTelemetry traces; дружелюбен к production | Меньше готовых RAG-специфичных метрик, чем в RAGAS |
| DeepEval | Unit tests в стиле Pytest для LLM-выходов; G-Eval, custom metrics, native поддержка CI/CD | Активное использование LLM-judge = всплески стоимости |
| Arize Phoenix | Сильный tracing и визуализация embedding-ов; визуально выявляет embedding drift; OTEL-native | Определения метрик вы приносите сами |
| TREC 2024 RAG Track | Публичный benchmark для nugget evaluation (AutoNuggetizer), support evaluation и fluency на MS MARCO Segment v2.1 | Не runtime-инструмент, а benchmark для калибровки |
Мой дефолтный стек — RAGAS для словаря метрик, DeepEval для CI-гейтов, Phoenix для production tracing и кастомный код для ontology-specific metrics. Что бы вы ни выбрали на старте, со временем вы это перерастёте. Выбирайте фреймворк, в котором легко писать свои метрики.
Для benchmark-ов используйте BEIR (Thakur et al., NeurIPS 2021) для zero-shot retrieval generalization, MTEB для общего качества embedding-ов, MIRACL для multilingual retrieval и TREC 2024 RAG Track для end-to-end RAG evaluation.
Часть 2 — Пайплайн с точками оценки
Production RAG-система больше, чем «эмбеддим документы, ищем чанки, вызываем LLM». Каждый этап между получением документа и доставкой ответа может сломаться.
У каждого этапа на схеме есть хотя бы одна метрика. Этап без метрики может упасть так, что никто этого не заметит.
Три дорожки соответствуют тому, где происходят сбои. Offline-дорожка покрывает всё до появления запроса: parsing, cleaning, chunking, embedding, indexing. Online-дорожка покрывает всё после прихода запроса: rewriting, retrieval, reranking, context assembly. Post-generation-дорожка покрывает проверки после того, как модель написала ответ: faithfulness, citation verification, drift signals и production telemetry.
Ошибки накапливаются вниз по цепочке. Плохой parsing ограничивает chunking. Плохой chunking ограничивает retrieval. Плохой retrieval ограничивает reranking. Плохой reranking ограничивает generation. Faithfulness измеряет только финальный ответ, но никогда не показывает причину выше по пайплайну.
Часть 3 — Offline-оценка ingestion
Многие production-сбои RAG начинаются на ingestion. На чистых тестовых документах система работает, а затем разваливается на реальных PDF, сканах, таблицах и грязных страницах корпуса.
Получение и парсинг документов
Что измерять:
- Полнота извлечения текста:
extracted_chars / expected_charsна размеченной выборке, отдельно по классам документов. Канонического пакета для этого нет — напишите небольшой harness, который сравнивает вывод парсера с вручную очищенным эталоном. Следите за пропавшими сносками, заголовками, подписями. -
Точность OCR: CER (Character Error Rate) и WER (Word Error Rate), стандартные метрики из speech/OCR:
\[ \text{CER} = \frac{S + D + I}{N}, \qquad \text{WER} = \frac{S_w + D_w + I_w}{N_w} \]где \(S\), \(D\), \(I\) — посимвольные замены, удаления и вставки, а \(N\) — число символов в референсе (индекс \(w\) — версия по словам). CER 1–2% — хороший уровень для печатного текста; >10% — уже непригодно. Для рукописного или multilingual-материала даже ≤20% может быть рабочим. Считайте через
jiwer(jiwer.cer(refs, hyps),jiwer.wer(refs, hyps)) или HuggingFaceevaluate. В качестве evaluation corpora используйте 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.077 print(f"WER = {wer(refs, hyps):.3f}") # WER = 0.286 -
Качество извлечения таблиц: TEDS (Tree-Edit-Distance-based Similarity) измеряет, насколько предсказанное HTML-дерево таблицы близко к референсу, нормированное на размер большего дерева. Из Zhong et al., 2020 (PubTabNet):
\[ \text{TEDS}(T_a, T_b) = 1 - \frac{\text{EditDist}(T_a, T_b)}{\max(|T_a|, |T_b|)} \]TEDS использует и структуру (строки, столбцы, spans), и содержимое ячеек; TEDS-S убирает содержимое и оценивает только структуру. Эталонная реализация: PubTabNet's
teds.py(внутри используетapted). Для evaluation corpora см. PubTabNet, FinTabNet и SciTSR. Наивные парсеры часто проваливаются на таблицах; не доверяйте им без benchmark-а. -
Сохранение layout / структуры: порядок заголовков, целостность списков, порядок чтения в многоколоночных PDF. Используйте DocLayNet как размеченный benchmark; для сравнения готовых парсеров
unstructured,pymupdfи VLM-парсер вродеdoclingпокрывают большую часть пространства решений.
Моё мнение: прогоните benchmark на трёх парсерах (baseline на Tesseract, VLM-OCR-модель и вашего vendor-кандидата) на стратифицированной выборке реальных классов документов (чистые сканы, фото, страницы с множеством таблиц, multilingual, математика, рукопись) при фиксированном DPI. Репортите CER/WER по каждому классу плюс TEDS для страниц с таблицами. Без этого вы просто гадаете.
Очистка и нормализация
- Точность удаления boilerplate: precision/recall по сравнению с размеченными человеком boilerplate spans. Агрессивное удаление убивает релевантный контент; ленивое удаление загрязняет embedding-и. Инструменты для сравнения:
trafilatura,jusText,Resiliparse. Barbaresi (2021) сравнивает их head-to-head. - Unicode-нормализация: процент документов, у которых выходы NFC и NFKC совпадают (посчитано через stdlib
unicodedata.normalize), — полезный drift signal. Именно такие несовпадения позволяют zero-width joiner-ам и lookalike-символам уничтожать retrieval recall. - Точность определения языка: F1 на размеченной multilingual-выборке. Критично для multilingual-индексов. Используйте
fasttext-langdetect(Facebook'slid.176),lingua-pyилиcld3; FLORES-200 — стандартный benchmark для low-resource languages. -
Эффективность дедупликации (MinHash / LSH): precision/recall вашего детектора near-duplicate по сравнению с размеченным вручную набором. Базовая идея: оценить Jaccard similarity \(J(A, B) = \frac{|A \cap B|}{|A \cup B|}\) между множествами шинглов документов через \(k\) случайных permutation hashes (Broder, 1997) и сгруппировать near-duplicates через LSH banding (Indyk & Motwani, 1998). Стандартный MinHash с 128 hash functions и LSH banding, настроенным на порог Jaccard 0.7–0.85, — хороший дефолт; подбирайте на своих данных, потому что правильный порог зависит от корпуса. Отдельно отслеживайте false-merge rate (портит ответы) и missed-merge rate (тратит место в индексе).
datasketch— канонический Python package: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(lsh.query(to_minhash(docs["d1"]))) # ['d1', 'd2'] -
Очистка PII: precision и recall, считаемые отдельно по типам сущностей (emails, SSNs, names, addresses). Ошибки recall создают compliance-риск; ошибки precision ухудшают качество ответов. Рабочую точку выбирайте вместе с legal team. Инструменты: Microsoft Presidio (самый полный),
scrubadubили fine-tuned NER-модель на размеченном наборе.
Chunking — этап, который тихо определяет retrieval
Chunking — одно из самых влияющих решений в RAG. Неверная стратегия может дать разницу в recall на несколько пунктов при тех же embedding-ах. Бенчмарки NVIDIA 2024 года показали, что page-level chunking даёт максимальную точность при наименьшей дисперсии для пагинированных документов; semantic chunking (кластеризация соседних предложений по embedding similarity и разбиение по непохожим границам — реализовано в LangChain's SemanticChunker и LlamaIndex's SemanticSplitterNodeParser) может улучшить recall по сравнению с fixed-window chunking; recursive character splitting (сначала пытается разбивать по абзацам, затем по предложениям, затем по словам, пока каждый chunk не уложится в target size — см. LangChain's RecursiveCharacterTextSplitter) на 400–512 токенов с overlap 10–20% остаётся хорошим дефолтом для общего текста.
Метрики, которые стоит отслеживать:
- Chunk coherence: \(\\text{coherence} = \\overline{\\cos(s_i, s_j)}_{\\text{within}} - \\overline{\\cos(s_i, s_j)}_{\\text{across boundary}}\), где \(s_i\) — embeddings предложений. У здоровых chunks высокая внутренняя схожесть и низкая схожесть на границе. Считайте через
sentence-transformersплюсscikit-learn'scosine_similarity. - Boundary quality: человеческая разметка вида «это разумный разрез?» на выборке плюс структурная проверка, что chunks не рвут таблицы, списки или нумерованные разделы (ваш самый частый production-баг).
- Оптимальный размер chunk-а: прогоните token sizes (128, 256, 512, 1024) и постройте Recall@k vs. size на вашем golden set. Выберите knee point. Не берите то, что написано в туториале.
- Эффективность overlap: абляция overlap fraction (0%, 10%, 20%, 30%) и измерение Recall@k. На большинстве корпусов после ~20% отдача быстро снижается.
- Chunk attribution fidelity: процент chunks, сохраняющих проверяемый source pointer (номер страницы, section anchor, doc ID). Для auditability это обязательно.
- Late vs. early chunking: late chunking (Günther et al., 2024) сначала эмбеддит весь документ, а затем сегментирует его, сохраняя глобальный контекст (reference implementation в
jina-embeddings-v3). Contextual Retrieval (Anthropic, 2024) добавляет к каждому chunk LLM-сгенерированный контекст. Оба подхода добавляют cost. Прежде чем брать любой из них, прогоните benchmark на своём корпусе.
Моё мнение: structural chunking (разбиение по заголовкам, таблицам и секциям — реализуется парсерами вроде unstructured.io или обходом AST, который ваш парсер уже построил) используется слишком редко. Если в документах есть структура, используйте её до добавления similarity-эвристик. Recursive character splitting — baseline; semantic chunking оправдан в основном на неструктурированной прозе.
Извлечение и обогащение metadata
- NER precision/recall/F1: по каждому типу сущностей на размеченном подмножестве. Стандартный формат CoNLL/MUC. Считайте через
seqeval(from seqeval.metrics import f1_score) для BIO/IOB-aware версии или через scikit-learn для сравнения наборов spans. CoNLL-2003 и OntoNotes 5.0 — канонические reference corpora. - Relation extraction F1: для ontology-grounded систем это даже важнее. Разметьте вручную 200 документов. TACRED и DocRED — публичные benchmarks; для production code
opennreиspaCyrelation pipelines — разумные стартовые точки. - Точность извлечения title / heading: exact match плюс нормализованная Levenshtein similarity (\(1 - \\frac{\\text{edit\\_dist}(a, b)}{\\max(|a|, |b|)}\)) по сравнению с ground truth —
python-Levenshteinилиrapidfuzzдадут обе метрики одним вызовом. - Сохранение иерархических metadata: процент chunks, которые правильно сохраняют родительскую секцию, родительский документ и ancestry path. Именно эта метрика решает, сможет ли ваш RAG отвечать на вопросы типа «что говорит дочерний раздел policy X?».
Генерация embedding-ов
- Бенчмарки выбора модели: MTEB для общей способности (headline-метрика — nDCG@10; MTEB Python package позволяет воспроизвести leaderboard локально), BEIR для zero-shot generalization, MIRACL для multilingual. Лучшие retrieval-модели группируются в узком диапазоне nDCG@10, но английские MTEB-результаты плохо предсказывают качество на low-resource languages.
- Domain-specific evaluation: не доверяйте общим benchmark-ам на доменных корпусах. Соберите domain golden set из 200–500 query/doc pairs и переоцените на нём кандидатные модели через
ranxилиpytrec_eval. Я много раз видел, как модель с #5 в MTEB обгоняла модель с #1 на 15+ пунктов в конкретном домене. - Детекция embedding drift: отслеживайте распределительный KL или model-based drift между фиксированным reference window и rolling production embeddings; устойчивость nearest neighbors на фиксированном probe set — самый простой практически полезный сигнал.
evidentlyиalibi-detectреализуют и model-based, и statistical drift detectors. Сравнительное исследование Evidently рекомендует model-based drift detection как default. - Multi-vector vs. single-vector: late-interaction (ColBERT / ColBERTv2 — см. Khattab & Zaharia, 2020; эталонные реализации в RAGatouille и PyLate) обычно выигрывает out-of-domain ценой хранения в 6–10× больше (с PLAID-style compression; без сжатия ещё больше). Имеет смысл, когда ваш корпус далёк от train distribution embedding-модели. Иначе лучше single-vector.
Построение индекса
- Recall@k при аппроксимации: сравнивайте approximate-nearest-neighbour (ANN) индекс с точным brute-force baseline при одинаковом k — в FAISS это
IndexHNSWFlat(илиIndexIVFFlat) противIndexFlatIP/IndexFlatL2. Цель — ≥95% recall@10 относительно flat. Проектann-benchmarksотслеживает Pareto-кривые recall–QPS в разных библиотеках. - Настройка HNSW: HNSW (Hierarchical Navigable Small World — многослойный граф близости; см. Malkov & Yashunin, 2018, реализован в
hnswlib, FAISS'sIndexHNSWFlatи большинстве vector DB) имеет три параметра:M(fan-out графа),efConstruction(ширина кандидатов при построении),efSearch(ширина кандидатов при запросе). Практичные defaults: M=16–32, efConstruction=150, efSearch начиная со 100 и выше, пока recall не перестанет расти. На датасете в 10M vectors при efSearch=500 можно получить 98% recall за 5ms; при efSearch=100 — 85% за 1ms. Выбирайте точку recall, которую требует ваш evaluation set. - Настройка IVF: IVF (Inverted File index — разбивает vectors через k-means на
nlistячеек, затем при запросе сканируетnprobeближайших ячеек; см. FAISS'sIndexIVFFlatиIndexIVFPQ). Используйтеnlist≈ √N как стартовую эвристику и настраивайтеnprobeво время выполнения. IVF обычно эффективнее, чем HNSW, справляется с filtered search, а это важно для ontology-grounded систем с большим количеством metadata predicates. - Лаг свежести обновлений: время от commit документа до момента, когда он становится retrievable. Отслеживайте p50 и p99. Для систем с регуляторными требованиями дополнительно отслеживайте процент запросов, обслуженных по устаревшим индексам.
Часть 4 — Online-оценка inference
В online-дорожке живёт большая часть production-метрик. Многие команды останавливаются на Recall@k. Этого недостаточно.
Понимание и rewriting запроса
- Качество query expansion: uplift Recall@k на вашем golden set, expanded query против raw. Если на сложных запросах это не даёт хотя бы +5%, ваш expander вредит больше, чем помогает. Классические baseline-ы PRF (pseudo-relevance feedback), такие как RM3 и Bo1, всё ещё полезны как sanity check; LLM-based expansion должно их обыгрывать.
- Оценка HyDE: HyDE (Gao et al., 2022) генерирует гипотетический ответ через LLM, эмбеддит его и ищет по нему — полезный инструмент, который добавляет latency и поверхность для hallucination. Оценивайте его по uplift Recall@10 на out-of-domain запросах (там он силён) и убедитесь, что нет деградации на in-domain запросах (там он может мешать). Используйте как fallback при низкой retrieval confidence, а не как default. Ниже по пайплайну добавляйте cross-encoder reranker, чтобы валидировать retrieval, инициированный гипотетическим ответом.
- Multi-query generation: union Recall@k для N rewrites против одиночного запроса. После 3–4 rewrites отдача быстро падает. Реализации: LangChain's
MultiQueryRetriever, LlamaIndex'sQueryFusionRetriever. - Точность intent classification: стандартные precision/recall/F1 по каждому intent (считайте через
sklearn.metrics.classification_report), но рабочая метрика здесь — routing correctness: действительно ли был вызван правильный downstream-пайплайн? - Adaptive routing: Adaptive-RAG (Jeong et al., NAACL 2024) убедительно показывает, что не каждый запрос заслуживает одинаковую retrieval-стратегию. Отслеживайте точность router-а как задачу классификации на размеченном наборе «не нужен retrieval / one-shot / iterative».
Retrieval metrics
Это базовые метрики. Если вы их не отслеживаете, вы не сможете понять, улучшается retrieval или нет.
| Metric | Что измеряет | Когда использовать |
|---|---|---|
| Recall@k | процент запросов, где хотя бы один релевантный doc в top k | самая важная retrieval-метрика для RAG; если она низкая, дальше уже ничего не важно |
| Precision@k | процент релевантных документов в top-k | полезно, когда bottleneck — размер context window |
| MRR | среднее 1/rank для первого релевантного doc | когда пользователи смотрят только top-1 или top-3 |
| nDCG@k | gain с discount по позиции и весами по grade relevance | стандартная retrieval-метрика для graded relevance |
| MAP | среднее по запросам average precision | когда важен весь ranked list |
| Hit Rate@k | бинарная версия Recall@k | быстрая sanity-метрика |
| Coverage | процент golden docs, которые вообще хоть раз извлекались по всем запросам | ловит систематические дыры в индексе |
Формулы для справки (бинарная релевантность с релевантным множеством \(R_q\) для запроса \(q\), и \(\\text{rel}_i = 1\), если \(i\)-й извлечённый документ входит в \(R_q\)):
Для graded relevance \(\\text{rel}_i \\in \\{0, 1, 2, \\dots\\}\); бинарный nDCG — это частный случай, используемый в коде ниже. MAP — это среднее по запросам от \(\\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 code используйте ranx, pytrec_eval или ir_measures — они реализуют всё семейство метрик TREC и корректно работают с graded relevance. Разумные стартовые цели: Recall@10 ≥ 0.85, MRR ≥ 0.6, nDCG@10 ≥ 0.7. Их нужно задавать по реалистичному golden set, а не брать из туториала.
Harness для этого короткий. Его можно запустить из ноутбука ещё до того, как вы вообще выбрали vector database.
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. Подключите его к golden set из 200 запросов и гоняйте на каждом PR. Если один из трёх показателей деградирует — блокируйте merge и чините регрессию.
Companion repo фиксирует точные числа выше (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 harness лежит в evaluation/retrieval.py.
Hybrid retrieval и reciprocal rank fusion
BM25 (классический sparse lexical scorer из Robertson & Walker, 1994 — точное совпадение терминов с TF-IDF-подобным взвешиванием и нормализацией по длине, доступен в rank_bm25, Elasticsearch/OpenSearch и большинстве search engines) плюс dense fusion через Reciprocal Rank Fusion (Cormack, Clarke, Buettcher, SIGIR 2009) с k=60 — сильный default. RRF score-agnostic, поэтому он обходит проблемы нормализации score, которые возникают при линейной интерполяции. Если у вас есть 50+ размеченных query pairs, попробуйте convex combination и подберите α. Hybrid плюс cross-encoder reranker обычно выигрывают у dense-only или sparse-only retrieval на технических, логовых и code-корпусах. На корпусах с сильной семантикой выигрыш может быть небольшим. Измеряйте на своих данных; плохая конфигурация fusion может проиграть dense-only.
Реализация умещается в несколько строк.
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 scores. Dense retriever может вернуть cosine 0.98, а BM25 lane — score 17.4, но сравнивать их напрямую нельзя. Если нормализовать их через z-scores или min-max scaling, можно случайно отдать приоритет lane с наибольшей дисперсией в текущем батче.
RRF использует только rank. Если retriever ставит документ на позицию 2, этот голос стоит 1 / (60 + 2) независимо от raw score, который к этому привёл.
Hybrid + RRF на SciFact: notebook 02 сравнивает dense vs BM25 vs RRF с per-query delta. Production-shaped fuser лежит в retrieval/hybrid_rrf.py; tests/test_rrf.py фиксирует канонический порядок d3 / d2 / d1 при k=60.
Reranking
- ΔnDCG / ΔMRR: единственная честная метрика reranker-а — uplift относительно варианта без rerank на вашем golden set и на той глубине, которую реально использует приложение. Считайте её, прогоняя retrieval metrics с reranker-ом и без него на идентичных candidate set.
- Cross-encoder vs. bi-encoder: bi-encoder эмбеддит query и doc независимо (по одному vector с каждой стороны) и считает score через dot product; cross-encoder конкатенирует query+doc и делает один forward pass с совместным attention по обоим. Cross-encoder почти всегда выигрывает по relevance ценой одного forward pass на каждого candidate. Эталонная реализация:
sentence-transformersCrossEncoder. В опубликованных benchmark-ах BGE-reranker-v2-m3 даёт ~80ms на 100 candidates на GPU и ~350ms на CPU и по качеству сопоставим с Cohere Rerank при нулевой операционной стоимости. Воспринимайте эти цифры как порядок величины — ваше железо и batch size их сдвинут. - Listwise vs. pointwise: pointwise оценивает каждую пару (query, doc) независимо; listwise оценивает весь список candidates совместно, чтобы модель напрямую оптимизировала ranking objective. Listwise (BGE, ZeRank-2 с калиброванными выходами) обычно выигрывает по nDCG; pointwise проще threshold-ить. Калиброванные вероятности ZeRank-2 позволяют использовать простые thresholds по
score > 0.7; сырые scores BGE/MiniLM требуют настройки по корпусу.
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}")
Reranker часто оказывается самым ценным дополнением к базовому RAG-пайплайну. На большинстве корпусов, что я видел, он двигает Precision@1 на 15–40%. Если у вас в RAG его нет, добавьте его раньше, чем тратить время на более мелкие retrieval-твики.
ΔnDCG и ΔPrecision@1 от cross-encoder-а на SciFact: notebook 03; модуль: retrieval/reranker.py.
Построение контекста и lost-in-the-middle
Именно здесь возникают многие сбои вида «retrieval хороший, ответ плохой».
- Context relevance: per-chunk relevance score из RAGAS
ContextRelevancyили от cross-encoder-а, агрегированный как среднее и как процент chunks ниже порога. - Context utilization: из chunks, помещённых в контекст, сколько реально были процитированы или использованы в ответе. Считайте как \(\\frac{|\\text{cited chunks}|}{|\\text{retrieved chunks}|}\) на размеченной выборке. Низкая утилизация (< 30%) означает, что вы платите за лишние токены.
- Детекция lost-in-the-middle: синтетическая evaluation, где gold chunk помещается в позиции {first, middle, last} внутри длинного контекста, после чего измеряется correctness ответа. U-shaped деградация реальна и задокументирована в Liu et al. (TACL 2023). Современные модели лучше моделей эпохи 2023 года, но bias сохраняется. Mitigation: rerank, затем reorder top-k так, чтобы chunk с максимальным score был первым или последним (LangChain's
LongContextReorderделает именно это), либо агрессивно сжимайте средние chunks. Измеряйте через position-stratified eval, а не только одним aggregate score. Рабочий position-stratified eval есть в notebook 06 (модуль:evaluation/lost_in_middle.py). - Context compression: репортите compression ratio (input tokens / output tokens) вместе с answer correctness. Инструменты: LangChain's
ContextualCompressionRetriever, LongLLMLingua. Если compression снижает correctness больше чем на 2 пункта, вы зашли слишком далеко.
Часть 5 — Filter False-Exclusion Rate
Эта метрика заслуживает отдельный раздел, потому что большинство команд её пропускает, а она реально вызывает production-сбои.
Жёсткий metadata filter вроде tenant_id = X AND product = Y AND locale = en-US может обнулить effective recall, не изменив стандартные retrieval metrics. Gold doc исключается до начала ранжирования. Recall@k считается по выжившему candidate set, поэтому может выглядеть нормально. Faithfulness считается относительно извлечённого контекста, поэтому тоже может выглядеть нормально; модель добросовестно сказала: «Я не знаю».
Красная ветка на дереве — типовой отказ: правильный документ существует, но фильтр удаляет его до retrieval.
Метрика
filter_false_exclusion_rate =
(# queries where gold doc was excluded by metadata filter) /
(# queries with at least one gold doc)
Чтобы её посчитать, нужны (a) ground-truth doc IDs для каждого eval query и (b) instrumentation, которая логирует применённые filter predicates, а не только финальные результаты. Разумная цель — < 2% на production traffic. Если значение выше, ваша логика фильтра убивает recall.
Ниже рабочая реализация, которая одновременно показывает, почему этот сбой невидим для наивно написанного retrieval harness.
# A small worked example that drops recall to zero silently.
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 — silently 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%
# The trap: a recall harness that only iterates over the SURVIVORS will
# either skip the empty-gold queries or report perfect recall on a doomed set.
def naive_recall_over_survivors(queries, docs, k=10):
recalls = []
for q in queries:
survivors = [d for d in docs if q["filter"](d)][:k]
survivor_ids = {d["id"] for d in survivors}
denom = len(q["gold"] & {d["id"] for d in docs})
if denom == 0:
continue # silently drops the query
visible_gold = q["gold"] & survivor_ids
recalls.append(len(visible_gold) / denom)
return sum(recalls) / len(recalls) if recalls else 0.0
print(f"naive recall (filtered universe) = {naive_recall_over_survivors(queries, docs):.2%}")
# naive recall (filtered universe) = 50.00%
assert rate == 0.5
Половина запросов теряет свой gold doc из-за фильтра. Наивный recall harness сообщает 50%, и вы вините retriever. Exclusion rate показывает реальную проблему: это баг предиката. У двух запросов ответ был удалён до запуска retriever-а. Ни одна модель не восстановит документ, который был отфильтрован.
Показанный выше уровень 50% воспроизводится как unit test в companion repo: tests/test_filter_exclusion.py::test_50_percent_exclusion_rate. Notebook 04 запускает это на SciFact с синтетическими metadata, чтобы можно было увидеть, как реальный фильтр обнуляет recall; runtime-метрика (вместе с companion-метриками predicate precision/recall) лежит в evaluation/filter_exclusion.py.
Сопутствующая метрика: predicate precision и recall
Когда фильтрация динамическая (например, LLM извлекает filter predicates из запроса), рассматривайте predicate extractor как классификационную модель и оценивайте его соответствующим образом. Predicate precision/recall на размеченном наборе пар (query, correct predicate). Если extractor ошибается в 8% случаев и применяет жёсткие фильтры, у вас появляется жёсткий потолок recall около 92%, и никакой reranking это не исправит.
Soft boost против hard filter
Эта метрика вынуждает принять архитектурное решение. Используйте hard filters, когда correctness бинарна: legal jurisdiction, ACL boundaries, published-versus-draft. Используйте soft boosts, когда relevance градуирована: locale preference, recency, version. Без измерения 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.
ε в диапазоне 1–2% — разумно; для high-stakes доменов ниже. Отдельный пост в этой серии разберёт этот trade-off подробнее.
Часть 6 — Оценка генерации
Retrieval metrics показывают, что система могла ответить правильно. Они не показывают, что она ответила правильно. Эту дыру закрывают generation metrics.
Faithfulness и groundedness
RAGAS faithfulness раскладывает ответ на atomic claims (короткие самодостаточные фактические утверждения), а затем проверяет каждое из них относительно извлечённого контекста через LLM judge:
Score — это доля поддержанных утверждений. Сама структура полезнее любого одного числа, потому что она показывает, какие именно claims не подтверждены. Production code живёт в пакете ragas — использование выглядит так:
from datasets import Dataset
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision
samples = Dataset.from_dict({
"question": ["How many moons does Mars have?"],
"answer": ["Mars has two moons, Phobos and Deimos."],
"contexts": [["Mars has two moons named Phobos and Deimos."]],
"ground_truth": ["Mars has two moons."],
})
result = evaluate(samples, metrics=[faithfulness, answer_relevancy, context_precision])
print(result)
Ниже тот же loop, развернутый с детерминированным stand-in 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. Остальная часть harness остаётся той же: extract, verify, aggregate.
End-to-end extraction и verification claims на сгенерированных ответах SciFact: notebook 05; модуль: evaluation/faithfulness.py. Repo также прогоняет verifier в стиле HHEM cross-family в том же loop, чтобы вы могли увидеть, какие семейства judges совпадают между собой.
Целенаправленная альтернатива LLM-as-judge — HHEM-2.1-Open (Hughes Hallucination Evaluation Model, Vectara), classifier размером 600 MB, fine-tuned для детекции hallucination. Порог по умолчанию обычно 0.5 (>0.5 = factual, ≤0.5 = hallucinated), но его стоит калибровать на собственной размеченной выборке. Модель работает на CPU и, по сообщениям, обгоняет generic LLM judges на AggreFact и RAGTruth. Более новые коммерческие HHEM-2.3 и FaithJudge сейчас находятся на Pareto frontier в leaderboard Vectara. Перед принятием решения прогоните benchmark заново; leaderboards дрейфуют.
Оценка atomic facts
FActScore (Min et al., EMNLP 2023) раскладывает длинные поколения на atomic facts, извлекает evidence для каждого факта, помечает каждый как supported / not-supported и репортит поддержанную долю:
Эталонная реализация: shmsw25/FActScore. Подход хорошо работает для биографий, саммари и других длинных ответов. Осторожно: повторяющиеся тривиальные факты могут искусственно завышать score, а атаки «MontageLie» (истинные факты в обманчивом порядке) могут его обходить. VeriScore корректно работает с claims, где важны необходимые модификаторы; фильтр Core помогает бороться с fact-padding.
Точность цитирования
Отслеживайте citation precision (процитированные spans действительно поддерживают claim) и citation recall (claims, которые должны быть процитированы, действительно процитированы):
Академический стандарт здесь — support evaluation из TREC 2024 RAG Track. Upadhyay et al. (SIGIR 2025) сообщают, что GPT-4o совпадает с human judges в 56% случаев при ручной оценке с нуля; с post-editing LLM-предсказаний показатель растёт до 72%. Это полезный force multiplier, но не замена human assessment в high-stakes сценариях. В качестве автоматизированного приближения ALCE (Gao et al., EMNLP 2023) реализует citation precision/recall через NLI-based verification.
Correctness, полнота и отказ от ответа
- Answer correctness vs. ground truth: если ground truth есть, используйте exact match или token-F1 для short-answer задач (
evaluate.load("squad")), semantic similarity для open-ended (bert-score, embedding cosine черезsentence-transformersили RAGASAnswerCorrectness). - Полнота через nuggets: «nugget» — это один атомарный фрагмент информации, который должен быть в любом правильном ответе (например, для вопроса «Когда была основана компания?» nuggets могут быть
{year: 1994, founder: Jane Doe}). AutoNuggetizer из TREC извлекает gold nuggets правильного ответа из референса, а затем оценивает, какую долю покрывает система — сильная корреляция с ручной оценкой на 21 теме × 45 прогонов в TREC 2024. - Поведение отказа: запросы, на которые в корпусе нет ответа, должны приводить к abstention, а не к hallucination. Отслеживайте abstention precision (отказы были корректны) и abstention recall (out-of-scope запросы действительно вызвали отказ). NoMIRACL — публичный benchmark; в собственном домене размечайте срез out-of-scope запросов и отслеживайте точность abstention.
Post-generation verification
Самые дешёвые приросты надёжности часто приходят не от больших моделей, а от детерминированных post-checks.
- Проверка grounding сущностей: каждая именованная сущность в ответе должна присутствовать в извлечённом контексте (или выводиться из него). Простая проверка regex + exact match (или
spaCy'sentsпо нормализованной строке контекста) ловит удивительно большую долю hallucination. - Верификация claims: извлеките claims, прогоните NLI по контексту, отклоните или пометьте всё, что ниже порога. 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): сэмплируйте N=5 generations при temperature > 0; репортите agreement rate (например, долю generations, совпавших с модальным ответом, или pairwise BERTScore); ответы с низким agreement отправляйте на human review.
- Калибровка confidence: собирайте verbalized confidence («Насколько ты уверен, 0–1?») и сравнивайте с реальной correctness на eval set. Стройте calibration curve и считайте Expected Calibration Error: \(\\text{ECE} = \\sum_{m=1}^{M} \\frac{|B_m|}{n} |\\text{acc}(B_m) - \\text{conf}(B_m)|\), где \(B_m\) — confidence bins. Реализации:
netcal,torchmetrics.CalibrationError. Модель, которая говорит 0.9, должна быть права в 90% случаев. Почти никогда так не бывает.
Часть 7 — Оценка ontology-grounded RAG
Стандартные метрики выше покрывают open-corpus RAG. Ontology-grounded системам нужно больше. Если ваш RAG ищет по структурированной ontology, taxonomy или knowledge graph (товары в каталоге, состояния в SNOMED, компоненты в BOM, техники безопасности в MITRE ATT&CK), стандартные RAG-метрики необходимы, но недостаточны. Нужно ещё измерять ontology layer.
Точность entity linking
Первая задача — сопоставить mention из запроса с сущностью ontology («Aspirin» → wikidata:Q18216, «the 737» → aircraft:Boeing_737).
- Mention-level precision/recall/F1: стандартно, по gold mention spans (считайте через
seqevalили comparator по span-set). - Disambiguation accuracy: для корректно найденных mentions — какая доля сопоставлена с правильным entity ID? Публичные reference-системы: ReFinED, REL и GENRE; benchmarks вроде AIDA-CoNLL и BELB показывают end-to-end F1 в диапазоне 60–90% в зависимости от системы и домена.
- Обработка NIL: precision/recall для случая «entity отсутствует в ontology». Именно здесь большинство production EL-систем тихо ломается. Они пере-связывают сущность с похожей, но неверной, вместо abstention.
Hierarchy-aware evaluation
Обычная accuracy считает ошибку «предсказали Sedan, а truth — Hatchback» такой же, как «предсказали Sedan, а truth — Submarine». Это неравноценные ошибки.
-
Hierarchical precision/recall/F1 (Kosmopoulos et al., 2015): начисляйте credit за предков и потомков в ontology DAG. Пусть \(\\hat{P}_q\) — предсказанная нода плюс все её предки, а \(T_q\) — истинная нода плюс все её предки:
\[ hP = \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} \]Реализуется примерно в 30 строк с
networkxна ontology graph; см.hierarchical-classifier-metricsкак reference. -
Wu-Palmer similarity между предсказанной и gold-сущностью в taxonomy (Wu & Palmer, 1994):
\[ \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 — lowest common ancestor в taxonomy. Для WordNet доступно из коробки в NLTK (
from nltk.corpus import wordnet as wn; wn.synset("car.n.01").wup_similarity(wn.synset("truck.n.01"))); для кастомных taxonomy вычисляйте LCA черезnetworkx. -
Sibling/parent confusion rate: отдельно отслеживайте ошибки в siblings, parents и children —
count_sibling / total_errors,count_parent / total_errors,count_descendant / total_errors. Confusion со sibling обычно означает неоднозначные mentions; confusion с parent — что модель «страхуется» вверх по иерархии.
Filter false-exclusion rate (снова, теперь уже критично)
В ontology-grounded системах жёсткие фильтры часто приходят из самой ontology («извлекать только документы с category X»). Метрика exclusion rate (определена в Части 5) становится основным сигналом correctness. Неверное предсказание категории может тихо обнулить recall.
Constrained generation conformance
Когда output должен соответствовать ontology (каждое имя сущности в ответе должно быть валидным членом ontology; каждый predicate должен приходить из закрытого словаря), измеряйте:
- Schema validity rate: процент outputs, которые парсятся и валидируются относительно ontology schema. Валидируйте через
jsonschemaилиpydantic. JSONSchemaBench — публичный benchmark для general structured output; для ontology-specific schemas валидатор придётся строить самому. - Vocabulary conformance: процент именованных сущностей в output, которые являются валидными ontology ID — однострочная проверка set-membership по закрытому словарю.
- Semantic conformance: валидность необходима, но недостаточна. Синтаксически валидный output может выбрать неправильную, но допустимую сущность. Поэтому conformance нужно парить с downstream answer correctness.
Фреймворки constrained decoding (Outlines, XGrammar, Guidance, OpenAI Structured Outputs) позволяют приблизиться к ~100% schema validity с умеренной добавкой latency. Согласно JSONSchemaBench, Guidance сейчас лидирует на Pareto frontier по эффективности × покрытию × качеству.
Auditability
Для ontology-grounded систем, чьи ответы проходят review:
- Citation completeness: процент фактических claims, имеющих хотя бы одну проверяемую citation.
- Provenance depth: процент citation, которые разворачиваются до source document со стабильным ID, а не только до hash chunk-а.
- Reproducibility rate: повторный запуск того же запроса на фиксированном snapshot возвращает тот же ответ (с поправкой на temperature). Если это не ~100% при temp=0, значит недетерминированность сидит где-то ещё в пайплайне.
Часть 8 — System-level evaluation
Холистическое качество ответа
- LLM-as-judge (Zheng et al., NeurIPS 2023): доминирующий подход. G-Eval (протокол LLM-judge, в котором модель сначала генерирует собственную chain-of-thought rubric, а потом оценивает по ней) автоматически строит rubric из критерия на естественном языке, а затем выставляет score с log-prob-weighted output. Сильная корреляция с human judgment у judges класса GPT-4.
- Pairwise preference: judge получает ответ A и ответ B и выбирает предпочтительный. Это снимает проблемы калибровки абсолютных score. На уровне GPT-4 даёт примерно 80% согласия с human judges, что сопоставимо с согласованностью людей между собой.
LLM-as-judge имеет реальные bias:
- Position bias: judge предпочитает первый или второй ответ независимо от качества. Mitigation: рандомизируйте порядок либо прогоняйте оба порядка и усредняйте.
- Verbosity bias: judges предпочитают более длинные ответы. Исследования 2025–2026 годов показывают более тонкую картину. Современные instruction-tuned judges штрафуют пустословие в length-controlled тестах, но вознаграждают реальную полноту на truncation pairs. Даже так явно указывайте judge-у, как трактовать длину, и рассмотрите length-controlled win rate.
- Self-preference bias: GPT-4 предпочитает outputs GPT-4; bias коррелирует с perplexity output-а (judges предпочитают текст, который им знаком). Mitigation: используйте семейство judge-моделей, отличное от оцениваемой системы. Никогда не давайте модели судить саму себя.
Практический рецепт: GPT-4o или Claude в роли judge, случайный порядок, замаскированные model identities, явная политика по длине в rubric и усреднение по нескольким запускам. Для high-stakes evaluation используйте двух judges и анализируйте расхождения.
Schema-Guided Reasoning для judges
Free-form output judge-а — главная причина, по которой judge runs плохо воспроизводимы. Два прогона на одном и том же ответе могут дать разные scores не потому, что judge изменил мнение, а потому что он иначе организовал свои рассуждения. Решение — зажать judge-а в структурированную rubric — то, что я называю Schema-Guided Reasoning (SGR): определить шаги рассуждения как Pydantic schema, запускать с constrained decoding (Outlines, XGrammar, structured outputs в vLLM, OpenAI's response_format), и тогда judge обязан выводить каждое поле по порядку. Никаких пропущенных шагов, никакого скрытого bias в сторону длинных ответов.
Для 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
После того как judge ограничен этой формой, меняются три вещи. Score восстанавливается из структурированных полей (len(supported) / len(extracted)), поэтому у position bias и verbosity bias остаётся меньше пространства для манёвра. Расхождения между двумя judges становятся диагностируемыми — видно, какой именно claim каждый judge пометил. И поскольку rubric теперь и есть schema, её можно version-ить как код: изменение rubric — это diff Pydantic, а не переписывание prompt-а.
Это работает для любого judge-а на основе rubric, не только для faithfulness. Pairwise preference, citation support и correctness отказов тоже выигрывают от такого подхода.
Harness для G-Eval / pairwise / position-bias / cross-family judge лежит в notebook 07; модуль: evaluation/llm_judge.py. Benchmark sweep (make benchmark в repo) подключает три frontier-tier модели — gpt-5-mini, claude-haiku-4-5, gemini-2.5-flash — в rotating-judge pairwise A/B, так что каждая модель судит две другие, а self-preference становится видна численно.
Latency и cost
- p50, p95, p99 на каждом этапе пайплайна. Для большинства приложений p95 — правильная цель SLO (service level objective); p99 — то, на что надо алертить.
- Time-to-first-token против total generation time. Для streaming UX пользователей интересует именно TTFT.
- Stage breakdown: retrieval, reranking, generation, post-processing. Самые большие всплески p95 почти всегда приходят от reranker-ов, работающих на CPU.
- Общий $/query = embedding + retrieval + rerank + generation + amortized storage. Отслеживайте p50 и p99; длинный хвост и съедает бюджет.
- Cache hit rates на уровне embedding cache, retrieval cache и KV-cache. Для повторяющихся workloads обычно достижим hit rate 30%+, и это самая дешёвая единичная оптимизация стоимости.
Per-stage p50/p95/p99 со stage breakdown встроены в notebook 08 и runner в evaluation/latency.py; benchmark report объединяет latency и faithfulness в одной матрице, которую можно повторно запускать через make benchmark.
A/B testing
- Единица рандомизации: per-user или per-session, никогда не per-query (один и тот же пользователь, видящий нестабильное качество, — хуже, чем любая из систем по отдельности).
- Primary, guardrails, exploratory metrics: зафиксируйте их заранее. Primary обычно — прокси удовлетворённости (thumbs / regenerations / dwell). Guardrails — latency и cost. Exploratory — всё остальное.
- Размер выборки: проведите power analysis до запуска. Большинство RAG A/B тестов недомощны, объявляют ложные победы и выкатывают регрессии.
Часть 9 — Построение test set
Метрика хороша ровно настолько, насколько хорош test set, на котором она запускается. Если ваш golden set покрывает три интента, а production traffic — двенадцать, то ваше число Recall@10 — это измерение трёх интентов в маске. Хуже того, test set, переобученный на простые вопросы («Какова политика возврата компании?»), тихо одобрит систему, которая ломается на сложных («Подходит ли частичная отмена под возврат по EU Digital Services Act 2023, если оплата в EUR и источник — Ireland?»). Число растёт, дашборд зеленеет, а система уходит в прод сломанной.
Та же проблема бьёт по ground truth. Если SME разметили очевидные документы, но пропустили релевантный long tail, Recall@k будет недооценивать retriever, который их действительно нашёл. Вы оптимизируете под метки, а не под истину.
Поэтому правильный порядок такой: сначала построить test set, который отражает реальное распределение и реальную сложность; затем выбрать метрики, чувствительные к нужным вам режимам отказа; и только потом тюнить систему.
Синтетическая генерация запросов
Используйте LLM, чтобы генерировать вопросы по вашему корпусу:
- Per-chunk: «Сгенерируй 3 вопроса, которые пользователь мог бы задать и на которые отвечает этот chunk».
- Multi-hop: возьмите два chunks и сгенерируйте вопрос, требующий оба.
- Adversarial: генерируйте вопросы с distractor-entities, near-duplicate phrasing, ambiguous mentions.
RAGAS имеет встроенное распределение типов вопросов (reasoning, conditional, multi-context); более новая работа DataMorgana генерирует более разнообразные synthetic benchmarks через многоосевую категоризацию пользователей и вопросов. Синтетические данные полезны для cold start и тестирования покрытия. Но они не заменяют реальные пользовательские запросы.
Построение golden dataset
Самый сильный dataset — это human-curated набор.
- Возьмите выборку реальных пользовательских запросов (или смоделированных, если продукт ещё не запущен), стратифицированную по intent.
- Попросите SME ответить на каждый вопрос и указать, в каком документе(ах) содержится ответ.
- Цель — минимум 200–500 запросов; покрытие важнее размера.
- Переобновляйте набор ежеквартально. Распределения дрейфуют.
Adversarial test sets
- Counterfactuals: замените ключевые сущности в запросе. Извлекает ли система правильные chunks для изменённого запроса?
- Distractors: запросы, где корпус содержит правдоподобный, но неверный ответ, который извлекать не нужно. Именно это стресс-тестирует RGB (Chen et al., AAAI 2024): noise robustness, negative rejection, information integration и counterfactual robustness.
- Negation и quantifiers: запросы с «not», «except» и «only». Dense retriever-ы часто с этим плохо справляются.
- Out-of-scope: запросы, на которые в корпусе нет ответа. Система должна говорить «Я не знаю», а не hallucinate. Здесь живёт NoMIRACL. Для большинства production-моделей abstention нужно оценивать явно.
Coverage и continuous evaluation
- Постройте coverage matrix: query intent × document type × ontology branch. Стремитесь иметь ≥1 запрос на ячейку. Пустые ячейки — это неотслеживаемые зоны, где прячутся регрессии.
- Regression suite запускается на каждом PR на маленьком быстром подмножестве (~50 запросов).
- Full eval запускается ночью или на release candidate, на полном golden set.
- Drift eval запускается еженедельно на скользящей выборке production-запросов (запросы с thumbs-down стоит взвешивать сильнее).
Часть 10 — Мониторинг в production
Eval suite, который вы выкатываете, описывает систему в момент запуска. После этого production traffic меняется.
Неявная и явная обратная связь
- Click-through / open rate на cited sources (если UI их показывает).
- Dwell time на ответе.
- Regeneration rate: процент ответов, которые пользователь переспрашивает или просит систему переделать. В большинстве продуктов это самый сильный неявный сигнал неудовлетворённости.
- Copy / share / export rates — сильный положительный сигнал.
- Паттерны follow-up: «Вы уверены?» или «А что насчёт X?» обычно означают недоверие.
- Thumbs up/down с необязательными категориями причин (ошибка, неполно, не по теме, harmful, медленно). Inline edits, если UI их позволяет, — самый информативный сигнал обратной связи из всех.
Детекция drift
- Query drift: отслеживайте распределение query embedding-ов относительно reference window через KL divergence, MMD или model-based detector. При shift-е алертьте, затем дебажьте по сегментам.
- Embedding drift: зафиксируйте probe set из документов; периодически переэмбеддивайте его и измеряйте cosine к исходным embedding-ам. Даже небольшой drift между версиями провайдерской модели тихо ломает retrieval. Самая дешёвая mitigation — versioned embedding storage (immutable snapshots по версиям).
- Performance drift: отслеживайте production-equivalent metrics (например, regeneration rate по intent) во времени. Резкие скачки означают, что что-то сломалось; медленные дрейфы — что изменился мир.
Shadow evaluation и human-in-the-loop
Запускайте кандидатную систему параллельно с production, сравнивайте outputs офлайн и не показывайте их пользователям. Это ловит регрессии до релиза. Дополнительный inference cost есть, но customer impact отсутствует.
Для human-in-the-loop (HITL) review:
- Отправляйте в очередь на review выходы с низкой confidence.
- Случайно отбирайте 1–2% всего production traffic для blind review.
- Сильно повышайте вес outputs с thumbs-down.
- Используйте проверенные outputs для расширения golden set.
Минимальный набор guardrails
Алертить стоит по этим сигналам, в порядке приоритета:
- Faithfulness/HHEM score ниже порога на скользящей production-выборке.
- p95 latency выше SLO.
- Filter false-exclusion rate выше порога (оценка по выборке).
- Regeneration rate выше baseline + 2σ.
- Cost/query выше бюджета.
Если алерт срабатывает без соответствующего изменения кода или модели, у вас, вероятно, drift. Если он срабатывает после изменения — вероятно, регрессия. В обоих случаях вы получаете сигнал до того, как придут тикеты в поддержку.
Caveats
- Целевые значения иллюстративны, а не универсальны. «Recall@10 ≥ 0.85» и «filter false-exclusion < 2%» — разумные defaults из систем, с которыми я работал. Калибруйте их под свой домен, stakes и ожидания пользователей. Medical RAG с 95% faithfulness небезопасен; brainstorm-assistant RAG с 70% — возможно, вполне.
- Пространство фреймворков быстро меняется. Конкретные числа (latency BGE, топовые scores MTEB, версии HHEM, названия метрик в RAGAS) верны на момент написания в мае 2026 и будут дрейфовать. Перед принятием решений прогоняйте benchmark заново.
- Числа согласия LLM-as-judge с людьми идут со звёздочкой. Показатель 80% для GPT-4-vs-human взят из условий MT-Bench / Chatbot Arena. На niche-доменах и adversarial-кейсах согласие резко падает. Используйте judges как force multiplier, а не как замену spot-checking.
- Vendor benchmark uplifts часто нельзя независимо воспроизвести. Воспроизводите всё на собственных данных, прежде чем верить числам — особенно для новых reranker-ов и OCR-систем.
- Ни одна метрика не заменяет просмотр реальных outputs. Садитесь командой на 30 минут в неделю и читайте 50 случайных production-ответов. Метрики масштабируют эту практику; они её не заменяют.
Что дальше в этой серии
Это был индекс. Вот follow-up статьи, которые я планирую:
- Soft Boosts vs. Hard Filters: подробный разбор filter false-exclusion rate с кодом, реальными production-примерами и decision framework.
- Chunking Is the Hidden Variable: контролируемый эксперимент по recursive, semantic, late и structural chunking на трёх корпусах.
- Выбор reranker-а в 2026: BGE vs. Cohere vs. ZeRank vs. актуальные cross-encoder models, head-to-head по cost, latency и uplift.
- Ontology-Grounded RAG: End-to-End Walkthrough: построение полного evaluation harness для entity-grounded retrieval system.
- LLM-as-Judge Without the Self-Preference Trap: практические рецепты unbiased automated evaluation.
- Online Evaluation in Production: паттерны instrumentation, политики alerting и дашборды, которые ловят реальные регрессии.
Ключевые выводы
- Начинайте с eval set, а не с архитектуры. Определите в числах, что значит «лучше», до выбора дизайна системы.
- Используйте три слоя оценки. Offline для корпуса и индекса. Online для retrieval и generation. Post-generation verification плюс production telemetry. Каждый слой ловит свой класс сбоев.
- Отслеживайте filter false-exclusion rate. Неверный predicate или хрупкий hard filter обнуляет recall до начала ранжирования, и стандартные retrieval metrics этого не увидят.
- Faithfulness измеряет последнее звено цепи. Оно не обнаружит parsing bug, chunking bug, embedding drift или filter exclusion. У каждого этапа должна быть своя метрика.
- Hybrid retrieval с RRF — сильный default. Score-agnostic, иммунен к катастрофам нормализации, k=60 из оригинальной статьи Cormack. Hybrid плюс cross-encoder reranker обыгрывает каждую из дорожек по отдельности на большинстве корпусов.
- Добавьте reranker раньше, чем тюнить что-либо ещё. На большинстве корпусов он двигает Precision@1 на 15–40%, больше, чем любое другое одиночное изменение.
- У LLM-as-judge есть реальные bias. Position, verbosity, self-preference. Рандомизируйте порядок, скрывайте identity, никогда не давайте модели судить саму себя и используйте двух judges в high-stakes evaluation.
- Production дрейфует. Shadow eval, HITL-очереди и rolling production samples поддерживают актуальность launch-eval suite по мере изменения трафика.
References
Frameworks and benchmarks
- Es et al., Ragas: Automated Evaluation of Retrieval Augmented Generation, 2023.
- RAGAS documentation и GitHub.
- Saad-Falcon et al., ARES: An Automated Evaluation Framework for Retrieval-Augmented Generation Systems, NAACL 2024.
- TruLens, DeepEval, Arize Phoenix.
- Thakur et al., BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models, NeurIPS 2021.
- MTEB Leaderboard.
- TREC 2024 RAG Track.
- Pradeep et al., Initial Nugget Evaluation Results for the TREC 2024 RAG Track with the AutoNuggetizer Framework, 2024.
Retrieval and ranking
- Cormack, Clarke, Buettcher, Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods, SIGIR 2009.
- Gao et al., Precise Zero-Shot Dense Retrieval Without Relevance Labels (HyDE), 2022.
- Jeong et al., Adaptive-RAG: Learning to Adapt Retrieval-Augmented Large Language Models through Question Complexity, NAACL 2024.
- Anthropic, Introducing Contextual Retrieval, September 2024.
- Günther et al., Late Chunking: Contextual Chunk Embeddings Using Long-Context Embedding Models, 2024.
- Sarthi et al., RAPTOR: Recursive Abstractive Processing for Tree-Organized Retrieval, 2024.
Generation, faithfulness, judges
- Min et al., FActScore: Fine-grained Atomic Evaluation of Factual Precision in Long Form Text Generation, EMNLP 2023.
- Liu et al., Lost in the Middle: How Language Models Use Long Contexts, TACL 2023.
- Chen et al., Benchmarking Large Language Models in Retrieval-Augmented Generation (RGB), AAAI 2024.
- Vectara, HHEM-2.1-Open hallucination evaluation model.
- Zheng et al., Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena, NeurIPS 2023.
- Upadhyay et al., Support Evaluation for the TREC 2024 RAG Track: Comparing Human versus LLM Judges, SIGIR 2025.
- Thakur et al., NoMIRACL: Knowing When You Don't Know for Robust Multilingual Retrieval-Augmented Generation, 2023.
- Geng et al., JSONSchemaBench: A Rigorous Benchmark of Structured Outputs for Language Models, 2025.
- Kosmopoulos et al., Evaluation Measures for Hierarchical Classification: a unified view and novel approaches, 2015.
Drift and production
- Evidently, Embedding drift detection methods compared.
Companion code
slavadubrov/rag-evals-demo— исполняемый harness для каждой метрики из этой статьи на корпусе SciFact, плюс benchmark sweep по chunking × embedding × LLM. Ноутбуки 00–09, unit tests, фиксирующие разобранные выше примеры, и embedded-Qdrant index, так что всё запускается без Docker.