Почему стандартные метрики не работают
Для классических NLP-задач есть проверенные метрики: BLEU и ROUGE считают n-gram overlap между ответом и эталоном, accuracy — долю правильных ответов. В RAG всё сложнее: один и тот же факт можно выразить десятком разных формулировок, и ROUGE посчитает правильный ответ плохим — просто потому что использовали синонимы.
Но главная проблема глубже. RAG-система может «ломаться» в трёх независимых местах, и одна метрика не покажет, где именно:
RAGAS (RAG Assessment) — фреймворк, который вводит четыре ортогональные метрики. Каждая измеряет одну «точку отказа» и вместе они дают полную картину состояния системы.
Архитектура RAGAS: четыре метрики
Каждая метрика RAGAS принимает на вход одну или несколько из четырёх сущностей, которые производит RAG-система, и возвращает число от 0 до 1.
Вот что измеряет каждая метрика:
pip install ragas datasets langchain-openai
Faithfulness: нет ли галлюцинаций?
Представьте: пользователь спрашивает «Когда вышел GPT-4?», retriever находит правильный документ, но LLM отвечает «GPT-4 вышел в октябре 2022». Это галлюцинация — ответ звучит уверенно, но противоречит источнику.
Faithfulness проверяет: каждый факт в ответе — только ли то, что есть в retrieved контексте? Метрика не проверяет правильность самого контекста — только соответствие ответа ему.
Как работает
Ответ: «RAG вышел в 2020 году, его придумала Meta. Метод улучшает точность на 30%.»
Клеймы: «RAG появился в 2020» · «Meta создала RAG» · «точность улучшается на 30%»
✓ «RAG появился в 2020» — найдено в ctx[0]: «Lewis et al., 2020»
✓ «Meta создала RAG» — найдено в ctx[0]: «команда Facebook AI»
✗ «точность улучшается на 30%» — в контексте такого числа нет
Один клейм не подтверждён контекстом → есть галлюцинация
Реализация
import json
from openai import OpenAI
client = OpenAI()
CLAIMS_PROMPT = """Извлеки все фактические утверждения из ответа.
Каждое утверждение — отдельный атомарный факт, без интерпретации.
Ответ: {answer}
Верни JSON-массив: ["утверждение 1", "утверждение 2", ...]"""
VERIFY_PROMPT = """Можно ли вывести следующее утверждение из контекста?
Отвечай строго по контексту, не используй внешние знания.
Контекст:
{context}
Утверждение: {claim}
Верни JSON: {{"verdict": "yes" | "no", "reason": "кратко"}}"""
def extract_claims(answer: str) -> list[str]:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": CLAIMS_PROMPT.format(answer=answer)}],
response_format={"type": "json_object"},
)
data = json.loads(resp.choices[0].message.content)
# LLM может вернуть {"claims": [...]} или просто [...]
return data if isinstance(data, list) else data.get("claims", [])
def verify_claim(claim: str, context: str) -> bool:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": VERIFY_PROMPT.format(
context=context, claim=claim
)}],
response_format={"type": "json_object"},
)
result = json.loads(resp.choices[0].message.content)
return result.get("verdict") == "yes"
def faithfulness(answer: str, contexts: list[str]) -> float:
context_text = "\n\n---\n\n".join(contexts)
claims = extract_claims(answer)
if not claims:
return 1.0 # Нет клеймов — нечего проверять
verified = sum(1 for c in claims if verify_claim(c, context_text))
return verified / len(claims)
# Использование
score = faithfulness(
answer="RAG появился в 2020 году. Метод улучшает точность на 30%.",
contexts=["Lewis et al. (2020) из Facebook AI..."]
)
print(f"Faithfulness: {score:.2f}") # 0.50
from ragas.metrics import faithfulness
from ragas import evaluate
from datasets import Dataset
data = Dataset.from_dict({
"question": ["Когда вышел RAG?"],
"answer": ["RAG появился в 2020 году. Метод улучшает точность на 30%."],
"contexts": [["Lewis et al. (2020) из Facebook AI..."]],
"ground_truth": ["RAG появился в 2020 году в работе Lewis et al."],
})
result = evaluate(data, metrics=[faithfulness])
print(result) # {'faithfulness': 0.50}
Answer Relevancy: отвечает ли на вопрос?
Ответ может быть фактически точным, но уйти не туда. На вопрос «Как установить Docker?» LLM может дать длинное объяснение что такое Docker — технически верное, но нерелевантное. Answer Relevancy измеряет именно это: насколько ответ по существу.
Как работает: обратная генерация вопросов
Прямое сравнение вопроса и ответа не работает: они написаны в разных стилях. RAGAS использует хитрость: просит LLM придумать вопросы, на которые мог бы отвечать данный ответ, и затем измеряет их сходство с оригинальным вопросом.
Ответ: «Docker — это платформа контейнеризации. Контейнер изолирует приложение вместе с зависимостями.»
Сгенерированные вопросы:
q1: «Что такое Docker?» · q2: «Зачем нужны контейнеры?» · q3: «Как Docker изолирует приложения?»
Оригинал: «Как установить Docker?»
cos(orig, q1) = 0.72 · cos(orig, q2) = 0.61 · cos(orig, q3) = 0.68
Ответ уходит в объяснение «что такое», а не «как установить» → низкая релевантность
import numpy as np
from sentence_transformers import SentenceTransformer
embed_model = SentenceTransformer("intfloat/multilingual-e5-large")
REVERSE_Q_PROMPT = """По этому ответу придумай {n} разных вопросов, на которые он отвечает.
Вопросы должны быть разнообразными, но все — релевантными ответу.
Ответ: {answer}
Верни JSON-массив строк: ["вопрос 1", "вопрос 2", ...]"""
def cosine(a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
def answer_relevancy(question: str, answer: str, n: int = 3) -> float:
# 1. Генерируем обратные вопросы
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": REVERSE_Q_PROMPT.format(
answer=answer, n=n
)}],
response_format={"type": "json_object"},
)
data = json.loads(resp.choices[0].message.content)
gen_questions = data if isinstance(data, list) else list(data.values())[0]
# 2. Embeddings
q_emb = embed_model.encode(question, normalize_embeddings=True)
gen_embs = embed_model.encode(gen_questions, normalize_embeddings=True)
# 3. Среднее cosine similarity
sims = [cosine(q_emb, g) for g in gen_embs]
return float(np.mean(sims))
Context Recall: нашёл ли retriever нужное?
Если retriever не нашёл важный кусок — LLM не сможет использовать его в ответе, независимо от качества промпта. Context Recall измеряет, насколько полно retrieved контекст покрывает информацию из эталонного ответа.
Как работает: attribution по предложениям
Эталонный ответ (ground truth) разбивается на предложения. Для каждого предложения LLM определяет: можно ли его вывести из retrieved контекста? Recall — доля предложений, которые «покрыты» контекстом.
s1: «RAG улучшает качество ответов LLM.»
s2: «Метод использует vector search для поиска релевантных документов.»
s3: «Авторы: Lewis et al. из Facebook AI, 2020.»
✓ s1 — подтверждено в ctx[0]: «повышает точность генерации»
✓ s2 — подтверждено в ctx[1]: «retrieval с помощью ANN»
✗ s3 — в контексте нет упоминания авторов
Retriever не нашёл чанк с информацией об авторах → recall снизился
ATTRIBUTION_PROMPT = """Для каждого предложения из эталонного ответа определи,
можно ли его вывести из контекста. Используй только контекст.
Контекст:
{context}
Предложения эталонного ответа (JSON-массив):
{sentences}
Верни JSON-массив объектов:
[{{"sentence": "...", "attributed": true | false}}, ...]"""
def split_sentences(text: str) -> list[str]:
"""Простая разбивка по '. ' — для продакшена используй spacy/nltk."""
return [s.strip() for s in text.replace(".\n", ". ").split(". ") if s.strip()]
def context_recall(contexts: list[str], ground_truth: str) -> float:
sentences = split_sentences(ground_truth)
if not sentences:
return 1.0
context_text = "\n\n---\n\n".join(contexts)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": ATTRIBUTION_PROMPT.format(
context=context_text,
sentences=json.dumps(sentences, ensure_ascii=False),
)}],
response_format={"type": "json_object"},
)
data = json.loads(resp.choices[0].message.content)
results = data if isinstance(data, list) else list(data.values())[0]
attributed = sum(1 for r in results if r.get("attributed", False))
return attributed / len(results)
Context Precision: нет ли лишнего шума?
Retriever нашёл 5 чанков, но 3 из них — мусор. LLM теперь читает их все и «отвлекается» на нерелевантное. Эксперименты показывают: один нерелевантный чанк в топ-1 ухудшает ответ сильнее, чем нерелевантный чанк в топ-5. Context Precision учитывает это через взвешенную позиционную оценку.
Как работает: Average Precision
Каждый чанк оценивается: полезен ли он для ответа на вопрос (относительно GT)? Затем считается Average Precision — метрика из information retrieval, которая штрафует за нерелевантные чанки в топе.
c1 (rank=1): ✓ полезен — содержит ключевой факт
c2 (rank=2): ✗ шум — нерелевантно вопросу
c3 (rank=3): ✓ полезен — дополнительный факт
c4 (rank=4): ✗ шум
P@1 = 1/1 = 1.00 (c1 полезен)
P@2 = 1/2 = 0.50 (c2 пропускаем)
P@3 = 2/3 = 0.67 (c3 полезен)
Шумный c2 на второй позиции штрафует precision. Если бы c2 и c3 поменялись местами → 0.92
RELEVANCE_PROMPT = """Полезен ли этот фрагмент для ответа на вопрос,
принимая во внимание правильный ответ?
Вопрос: {question}
Правильный ответ: {ground_truth}
Фрагмент: {chunk}
Верни JSON: {{"useful": true | false, "reason": "кратко"}}"""
def is_chunk_useful(question: str, chunk: str, ground_truth: str) -> bool:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": RELEVANCE_PROMPT.format(
question=question, ground_truth=ground_truth, chunk=chunk
)}],
response_format={"type": "json_object"},
)
result = json.loads(resp.choices[0].message.content)
return result.get("useful", False)
def context_precision(question: str, contexts: list[str], ground_truth: str) -> float:
# Оцениваем каждый чанк
useful = [is_chunk_useful(question, ctx, ground_truth) for ctx in contexts]
if not any(useful):
return 0.0
# Average precision: считаем P@k только на позициях релевантных чанков
relevant_count = 0
precision_sum = 0.0
for k, is_rel in enumerate(useful, start=1):
if is_rel:
relevant_count += 1
precision_sum += relevant_count / k # precision@k
return precision_sum / relevant_count
Тестовый датасет: структура и генерация
RAGAS принимает на вход датасет в виде словаря с четырьмя полями. Два поля обязательны для всех метрик, два — только для context recall и precision.
Структура датасета
from datasets import Dataset
data = {
# Обязательно для всех метрик
"question": [
"Что такое RAG?",
"Как работает vector search?",
"Чем отличается dense от sparse retrieval?",
],
"answer": [
"RAG — это подход, который совмещает поиск по базе знаний и генерацию LLM.",
"Vector search находит похожие документы через cosine similarity эмбеддингов.",
"Dense retrieval использует нейронные эмбеддинги, sparse — TF-IDF и BM25.",
],
# Обязательно для всех метрик
"contexts": [
[
"Retrieval-Augmented Generation (RAG) — архитектура, предложенная Lewis et al.",
"RAG повышает точность LLM за счёт поиска релевантных документов.",
],
[
"ANN (Approximate Nearest Neighbor) позволяет искать похожие векторы за O(log n).",
"FAISS, Qdrant, Chroma реализуют vector search для эмбеддингов.",
],
[
"BM25 — статистический метод, не требующий обучения.",
"Dense retrieval требует предобученной модели эмбеддингов.",
],
],
# Нужен только для context_recall и context_precision
"ground_truth": [
"RAG — метод, сочетающий поиск документов и генерацию ответа языковой моделью.",
"Vector search использует ANN для поиска ближайших векторов по cosine similarity.",
"Dense retrieval — нейронные эмбеддинги; sparse — TF-IDF / BM25 без обучения.",
],
}
dataset = Dataset.from_dict(data)
Автоматическая генерация тестов
Собирать GT вручную для 100+ вопросов затратно. RAGAS предоставляет
TestsetGenerator, который читает ваши документы и генерирует
вопросно-ответные пары трёх типов сложности.
from ragas.testset.generator import TestsetGenerator
from ragas.testset.evolutions import simple, reasoning, multi_context
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.document_loaders import DirectoryLoader
# Загружаем документы
loader = DirectoryLoader("./docs/", glob="**/*.md")
documents = loader.load()
# Создаём генератор
generator = TestsetGenerator.with_openai(
generator_llm=ChatOpenAI(model="gpt-4o-mini"), # генерирует вопросы
critic_llm=ChatOpenAI(model="gpt-4o"), # оценивает качество
embeddings=OpenAIEmbeddings(),
)
# Генерируем тестовый датасет
testset = generator.generate_with_langchain_docs(
documents,
test_size=100,
distributions={
simple: 0.5, # Простые фактологические вопросы
reasoning: 0.25, # Требующие рассуждений
multi_context: 0.25, # Ответ в нескольких чанках одновременно
},
with_debugging_logs=True,
)
df = testset.to_pandas()
print(df[["question", "ground_truth", "evolution_type"]].head())
# question ground_truth evolution_type
# 0 Что такое chunking? Chunking — разбивка... simple
# 1 Почему важен overlap? Перекрытие позволяет... reasoning
Полный pipeline оценки
Все четыре метрики запускаются одним вызовом evaluate().
Под капотом RAGAS делает параллельные LLM-вызовы — каждый сэмпл датасета
обрабатывается независимо.
from ragas import evaluate
from ragas.metrics import (
faithfulness,
answer_relevancy,
context_recall,
context_precision,
)
from ragas.run_config import RunConfig
from datasets import Dataset
# Датасет с ответами вашей RAG-системы
dataset = Dataset.from_dict({
"question": [...],
"answer": [...], # ответы вашего RAG
"contexts": [...], # чанки, которые retriever вернул для каждого вопроса
"ground_truth": [...],
})
# RunConfig управляет параллелизмом и retry-политикой
run_config = RunConfig(
max_workers=16, # параллельных LLM-вызовов
max_wait=120, # таймаут в секундах на один запрос
max_retries=3, # повторных попыток при rate limit
)
result = evaluate(
dataset=dataset,
metrics=[faithfulness, answer_relevancy, context_recall, context_precision],
run_config=run_config,
raise_exceptions=False, # продолжать при ошибках отдельных сэмплов
)
print(result)
# {'faithfulness': 0.85, 'answer_relevancy': 0.79,
# 'context_recall': 0.91, 'context_precision': 0.73}
# Детальный анализ по каждому вопросу
df = result.to_pandas()
print(df[["question", "faithfulness", "answer_relevancy"]].sort_values("faithfulness"))
Интеграция с вашей RAG-системой
from dataclasses import dataclass
from typing import Any
import asyncio
@dataclass
class RAGSample:
question: str
answer: str
contexts: list[str]
ground_truth: str | None = None
async def run_rag(question: str, rag_pipeline) -> tuple[str, list[str]]:
"""Запускает RAG и возвращает (ответ, список контекстов)."""
contexts = await rag_pipeline.retrieve(question)
answer = await rag_pipeline.generate(question, contexts)
return answer, [c.page_content for c in contexts]
async def collect_eval_data(
questions: list[str],
ground_truths: list[str],
rag_pipeline,
) -> list[RAGSample]:
tasks = [run_rag(q, rag_pipeline) for q in questions]
results = await asyncio.gather(*tasks)
return [
RAGSample(
question=q,
answer=answer,
contexts=contexts,
ground_truth=gt,
)
for q, gt, (answer, contexts) in zip(questions, ground_truths, results)
]
def build_dataset(samples: list[RAGSample]) -> Dataset:
return Dataset.from_dict({
"question": [s.question for s in samples],
"answer": [s.answer for s in samples],
"contexts": [s.contexts for s in samples],
"ground_truth": [s.ground_truth or "" for s in samples],
})
# Запуск
async def evaluate_rag(rag_pipeline, test_questions, test_answers):
samples = await collect_eval_data(test_questions, test_answers, rag_pipeline)
dataset = build_dataset(samples)
result = evaluate(dataset, metrics=[faithfulness, answer_relevancy,
context_recall, context_precision])
return result
Интерпретация результатов
Абсолютные значения RAGAS не имеют смысла без контекста — важен тренд и диагноз. Тем не менее, вот практические пороги для ориентира:
| Метрика | < 0.5 — плохо | 0.5–0.7 — средне | 0.7–0.9 — хорошо | > 0.9 — отлично |
|---|---|---|---|---|
| Faithfulness | Много галлюцинаций | Частичные отклонения от контекста | Редкие галлюцинации | Практически без галлюцинаций |
| Answer Relevancy | Ответ не по теме | Частично уходит в сторону | Обычно по существу | Точно отвечает на вопрос |
| Context Recall | Retriever пропускает ключевое | Часть информации теряется | Хорошее покрытие | Находит всё нужное |
| Context Precision | Контекст полон шума | Заметный шум в топе | Преимущественно релевантен | Только нужные чанки |
Что делать с результатами
Шпаргалка
| Метрика | Входные данные | Вопрос | Метод | Нужен GT? |
|---|---|---|---|---|
| Faithfulness | Q, A, C | Нет ли галлюцинаций? | Клеймы → NLI-верификация | Нет |
| Answer Relevancy | Q, A | Ответ по существу? | Обратные вопросы → cosine sim | Нет |
| Context Recall | Q, C, GT | Нашёл ли retriever всё? | Attribution предложений GT | Да |
| Context Precision | Q, C, GT | Чист ли контекст? | Average Precision@k | Да |
Быстрый старт (без GT):
# Оценка без ground truth — для первого запуска
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy
from datasets import Dataset
result = evaluate(
Dataset.from_dict({
"question": questions,
"answer": answers,
"contexts": contexts,
}),
metrics=[faithfulness, answer_relevancy],
)
print(result) # {'faithfulness': 0.85, 'answer_relevancy': 0.79}
Ключевые принципы:
- Faithfulness и Answer Relevancy — reference-free, работают без GT
- Context Recall и Precision — требуют GT, но дают точный сигнал о retrieval
- Начинайте с 30–50 вопросов, масштабируйте после валидации
- RAGAS использует LLM как судью — качество оценки зависит от качества evaluation LLM
- Смотрите на тренд (до/после изменений), а не на абсолютные числа
- Используйте
result.to_pandas()для анализа проблемных вопросов поштучно
Практические задания
- Базовая оценка. Возьмите RAG-систему из предыдущих уроков, запустите 20 вопросов и оцените faithfulness и answer relevancy. Найдите 3 вопроса с наихудшим faithfulness — что общего в ответах? Это системная проблема или единичные случаи?
- Сравнение retrieval-стратегий. Запустите оценку для двух конфигураций: (a) simple top-5 retrieval, (b) hybrid search + reranking top-5. Сравните context_recall и context_precision. Какая стратегия лучше по каждой метрике?
-
Генерация тестового датасета. Используйте
TestsetGeneratorна 10–15 страницах вашей документации. Сгенерируйте 30 вопросов (50% simple, 25% reasoning, 25% multi_context). Просмотрите вручную — какие вопросы оказались плохого качества и почему?