Files
2026-07-13 21:36:42 +08:00

24 KiB
Raw Permalink Blame History

RAG 评估


评估框架概览

框架 侧重点 优势 适用场景
RAGAS RAG 专用指标 忠实度、相关性 生产环境 RAG 评估
TruLens LLM 应用可观测性 追踪、反馈函数 调试与监控
LangSmith LangChain 生态系统 追踪、数据集、测试 LangChain 项目
自定义 特定需求 完全掌控 领域特定需求

核心指标

检索指标

指标 公式 衡量内容
Precision@k 前 k 个结果中相关数 / k 检索到的文档是否相关?
Recall@k 前 k 个结果中相关数 / 总相关数 是否检索到了所有相关文档?
MRR 1 / 首个相关文档的排名 找到相关文档的速度有多快?
NDCG@k DCG@k / IDCG@k 排序顺序是否正确?
命中率 前 k 个结果中含相关文档的查询数 / 总查询数 二值成功率

生成指标

指标 衡量内容
忠实度 答案是否基于检索到的上下文?
答案相关性 答案是否回答了问题?
上下文相关性 检索到的上下文是否与问题相关?
上下文利用率 实际使用了多少上下文?

实现核心指标

Precision、Recall 与命中率

from dataclasses import dataclass
from typing import Set

@dataclass
class RetrievalMetrics:
    precision_at_k: float
    recall_at_k: float
    hit_rate: float
    mrr: float

def calculate_retrieval_metrics(
    retrieved_ids: list[str],
    relevant_ids: set[str],
    k: int
) -> RetrievalMetrics:
    """计算核心检索指标。"""
    top_k = retrieved_ids[:k]
    top_k_set = set(top_k)

    # Precision@k:前 k 个结果中相关数 / k
    relevant_in_top_k = len(top_k_set & relevant_ids)
    precision = relevant_in_top_k / k if k > 0 else 0

    # Recall@k:前 k 个结果中相关数 / 总相关数
    recall = relevant_in_top_k / len(relevant_ids) if relevant_ids else 0

    # 命中率:前 k 个结果中有相关文档则为 1,否则为 0
    hit_rate = 1.0 if relevant_in_top_k > 0 else 0.0

    # MRR1 / 首个相关结果的排名
    mrr = 0.0
    for i, doc_id in enumerate(top_k, 1):
        if doc_id in relevant_ids:
            mrr = 1.0 / i
            break

    return RetrievalMetrics(
        precision_at_k=precision,
        recall_at_k=recall,
        hit_rate=hit_rate,
        mrr=mrr
    )

# 使用示例
retrieved = ["doc1", "doc2", "doc3", "doc4", "doc5"]
relevant = {"doc2", "doc5", "doc7"}  # 真实标注

metrics = calculate_retrieval_metrics(retrieved, relevant, k=5)
print(f"Precision@5: {metrics.precision_at_k:.2f}")  # 2/5 = 0.40
print(f"Recall@5: {metrics.recall_at_k:.2f}")        # 2/3 = 0.67
print(f"MRR: {metrics.mrr:.2f}")                     # 1/2 = 0.50

NDCG(归一化折损累计增益)

import numpy as np

def dcg_at_k(relevance_scores: list[float], k: int) -> float:
    """计算折损累计增益。"""
    relevance_scores = np.array(relevance_scores[:k])
    if len(relevance_scores) == 0:
        return 0.0

    # DCG = sum(rel_i / log2(i + 1)) for i in 1..k
    discounts = np.log2(np.arange(2, len(relevance_scores) + 2))
    return np.sum(relevance_scores / discounts)

def ndcg_at_k(
    retrieved_ids: list[str],
    relevance_scores: dict[str, float],
    k: int
) -> float:
    """
    计算 NDCG@k。
    relevance_scores:将文档 ID 映射到相关性分数的字典(例如 0、1、2、3)
    """
    # 获取检索到的文档的相关性分数
    retrieved_relevance = [
        relevance_scores.get(doc_id, 0)
        for doc_id in retrieved_ids[:k]
    ]

    # 计算检索顺序的 DCG
    dcg = dcg_at_k(retrieved_relevance, k)

    # 计算理想 DCG(完美排序)
    ideal_relevance = sorted(relevance_scores.values(), reverse=True)[:k]
    idcg = dcg_at_k(ideal_relevance, k)

    return dcg / idcg if idcg > 0 else 0.0

# 使用示例(分级相关性)
retrieved = ["doc1", "doc2", "doc3", "doc4", "doc5"]
relevance = {
    "doc1": 0,   # 不相关
    "doc2": 3,   # 高度相关
    "doc3": 1,   # 部分相关
    "doc5": 2,   # 相关
    "doc7": 3,   # 高度相关(未检索到)
}

ndcg = ndcg_at_k(retrieved, relevance, k=5)
print(f"NDCG@5: {ndcg:.3f}")

RAGAS 框架

安装与设置

# pip install ragas

from ragas import evaluate
from ragas.metrics import (
    faithfulness,
    answer_relevancy,
    context_precision,
    context_recall,
    context_utilization,
)
from datasets import Dataset

# 准备评估数据集
eval_data = {
    "question": [
        "法国的首都是哪里?",
        "如何安装 Python"
    ],
    "answer": [
        "法国的首都是巴黎。",
        "你可以从 python.org 下载安装 Python。"
    ],
    "contexts": [
        ["巴黎是法国的首都和最大城市。"],
        ["Python 可以从官方网站 python.org 安装。",
         "你也可以使用 brew 或 apt 等包管理器。"]
    ],
    "ground_truth": [
        "巴黎是法国的首都。",
        "从 python.org 安装 Python,或使用包管理器。"
    ]
}

dataset = Dataset.from_dict(eval_data)

# 运行评估
results = evaluate(
    dataset,
    metrics=[
        faithfulness,
        answer_relevancy,
        context_precision,
        context_recall,
    ]
)

print(results)
# {'faithfulness': 0.95, 'answer_relevancy': 0.88, ...}

自定义 RAGAS 评估

from ragas.metrics import Metric
from ragas.llms import LangchainLLM
from langchain_openai import ChatOpenAI

# 使用自定义 LLM
custom_llm = LangchainLLM(llm=ChatOpenAI(model="gpt-4o-mini"))

# 使用自定义设置进行评估
results = evaluate(
    dataset,
    metrics=[faithfulness, answer_relevancy],
    llm=custom_llm,
    raise_exceptions=False  # 出错时继续执行
)

# 逐样本得分
for i, row in enumerate(results.to_pandas().itertuples()):
    print(f"Q{i+1}: Faithfulness={row.faithfulness:.2f}, "
          f"Relevancy={row.answer_relevancy:.2f}")

RAGAS 指标详解

"""
RAGAS 核心指标:

1. 忠实度(0-1):
   - 衡量答案是否基于上下文
   - LLM 从答案中提取声明,并与上下文进行验证
   - 高分 = 答案没有幻觉

2. 答案相关性(0-1):
   - 衡量答案是否回应了问题
   - 从答案生成问题,与原始问题进行比较
   - 高分 = 答案切题

3. 上下文精确度(0-1):
   - 衡量检索到的上下文是否相关
   - 按相关性对上下文排序,计算每个排序位置的精确度
   - 高分 = 靠前的上下文最相关

4. 上下文召回率(0-1):
   - 衡量所有真实标注信息是否都在上下文中
   - 检查真实标注的句子是否被上下文支持
   - 高分 = 上下文包含所需信息
"""

# 诊断低分原因
def diagnose_ragas_scores(results_df):
    """识别有问题的样本。"""
    issues = []

    for idx, row in results_df.iterrows():
        if row.get('faithfulness', 1) < 0.5:
            issues.append({
                "index": idx,
                "issue": "忠实度过低——答案可能包含幻觉",
                "question": row['question'],
                "answer": row['answer'][:200]
            })

        if row.get('context_recall', 1) < 0.5:
            issues.append({
                "index": idx,
                "issue": "上下文召回率过低——检索遗漏了相关文档",
                "question": row['question']
            })

    return issues

TruLens 评估

设置与基本用法

# pip install trulens-eval

from trulens_eval import Tru, TruChain, Feedback
from trulens_eval.feedback import Groundedness
from trulens_eval.feedback.provider import OpenAI as fOpenAI

# 初始化 TruLens
tru = Tru()

# 创建反馈提供器
provider = fOpenAI()

# 定义反馈函数
f_groundedness = Feedback(
    provider.groundedness_measure_with_cot_reasons,
    name="Groundedness"
).on(
    TruChain.select_context().node.text  # 检索到的上下文
).on_output()

f_relevance = Feedback(
    provider.relevance_with_cot_reasons,
    name="Answer Relevance"
).on_input().on_output()

f_context_relevance = Feedback(
    provider.context_relevance_with_cot_reasons,
    name="Context Relevance"
).on_input().on(
    TruChain.select_context().node.text
)

# 包装你的 RAG 链
from langchain.chains import RetrievalQA

rag_chain = RetrievalQA.from_chain_type(
    llm=llm,
    retriever=vector_store.as_retriever()
)

tru_recorder = TruChain(
    rag_chain,
    app_id="rag-v1",
    feedbacks=[f_groundedness, f_relevance, f_context_relevance]
)

# 记录运行结果
with tru_recorder as recording:
    response = rag_chain.invoke({"query": "如何配置身份验证?"})

# 查看结果
tru.run_dashboard()  # 打开 Web UI
# 或通过编程方式获取
records = tru.get_records_and_feedback(app_ids=["rag-v1"])

自定义反馈函数

from trulens_eval import Feedback, Select

def custom_citation_check(response: str, context: str) -> float:
    """检查响应是否引用了上下文中的来源。"""
    # 从响应中提取引用(例如 [1]、[Source: X]
    import re
    citations = re.findall(r'\[[\d\w\s:]+\]', response)

    if not citations:
        return 0.0  # 没有引用

    # 验证引用是否确实指向实际上下文
    valid_citations = sum(1 for c in citations if c.lower() in context.lower())
    return valid_citations / len(citations)

f_citation = Feedback(
    custom_citation_check,
    name="Citation Accuracy"
).on_output().on(Select.RecordCalls.retriever.get_relevant_documents.rets.page_content)

构建自定义评估流水线

LLM 作为评判者的评估

from openai import OpenAI
from dataclasses import dataclass
from typing import Literal

client = OpenAI()

@dataclass
class EvalResult:
    score: float
    reasoning: str
    criteria: str

def evaluate_with_llm(
    question: str,
    answer: str,
    context: str,
    criteria: Literal["faithfulness", "relevance", "completeness"]
) -> EvalResult:
    """使用 LLM 作为评判者进行评估。"""

    criteria_prompts = {
        "faithfulness": """
            评估答案是否完全由提供的上下文支持。
            如果答案中的每个声明都能从上下文中得到验证,则得 1.0 分。
            如果大多数声明有支持但部分没有,则得 0.5 分。
            如果答案包含大量无法支持的声明,则得 0.0 分。
        """,
        "relevance": """
            评估答案是否直接回应了问题。
            如果答案完全回应了问题,则得 1.0 分。
            如果答案部分回应了问题,则得 0.5 分。
            如果答案离题或未回应问题,则得 0.0 分。
        """,
        "completeness": """
            评估答案是否涵盖了问题的所有方面。
            如果答案全面且完整,则得 1.0 分。
            如果答案涵盖了主要要点但遗漏了细节,则得 0.5 分。
            如果答案明显不完整,则得 0.0 分。
        """
    }

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": f"""你是一名 RAG 系统的专家评估员。
                {criteria_prompts[criteria]}

                请以 JSON 格式回复:
                {{"score": <0.0-1.0>, "reasoning": "<解释>"}}"""
            },
            {
                "role": "user",
                "content": f"""问题:{question}

上下文:
{context}

答案:{answer}

请评估答案的{criteria}"""
            }
        ],
        response_format={"type": "json_object"}
    )

    import json
    result = json.loads(response.choices[0].message.content)

    return EvalResult(
        score=result["score"],
        reasoning=result["reasoning"],
        criteria=criteria
    )

# 使用示例
eval_result = evaluate_with_llm(
    question="如何配置 OAuth2",
    answer="在 config.yaml 中设置 client_id 和 client_secret 即可配置 OAuth2。",
    context="OAuth2 配置需要在 config.yaml 中设置 client_id、client_secret 和 redirect_uri。",
    criteria="faithfulness"
)
print(f"忠实度:{eval_result.score:.2f}")
print(f"推理过程:{eval_result.reasoning}")

批量评估流水线

import asyncio
from tqdm.asyncio import tqdm_asyncio

async def evaluate_batch(
    test_cases: list[dict],
    retriever,
    generator,
    metrics: list[str] = ["precision", "faithfulness", "relevance"]
) -> dict:
    """对测试用例运行批量评估。"""

    results = {
        "per_sample": [],
        "aggregated": {}
    }

    async def evaluate_single(case: dict) -> dict:
        # 检索
        retrieved = await retriever.aretrieve(case["question"])
        retrieved_ids = [r.id for r in retrieved]

        # 生成
        answer = await generator.agenerate(
            question=case["question"],
            context=[r.text for r in retrieved]
        )

        # 计算指标
        sample_result = {
            "question": case["question"],
            "answer": answer,
            "retrieved_ids": retrieved_ids
        }

        if "relevant_ids" in case and "precision" in metrics:
            retrieval_metrics = calculate_retrieval_metrics(
                retrieved_ids,
                set(case["relevant_ids"]),
                k=5
            )
            sample_result["precision@5"] = retrieval_metrics.precision_at_k
            sample_result["recall@5"] = retrieval_metrics.recall_at_k

        if "faithfulness" in metrics:
            faith_eval = evaluate_with_llm(
                case["question"],
                answer,
                "\n".join([r.text for r in retrieved]),
                "faithfulness"
            )
            sample_result["faithfulness"] = faith_eval.score

        return sample_result

    # 并发运行评估
    tasks = [evaluate_single(case) for case in test_cases]
    results["per_sample"] = await tqdm_asyncio.gather(*tasks)

    # 聚合结果
    for metric in ["precision@5", "recall@5", "faithfulness"]:
        scores = [r.get(metric) for r in results["per_sample"] if r.get(metric) is not None]
        if scores:
            results["aggregated"][metric] = {
                "mean": sum(scores) / len(scores),
                "min": min(scores),
                "max": max(scores)
            }

    return results

诊断检索效果不佳

检索诊断

def diagnose_retrieval(
    query: str,
    retrieved_docs: list,
    expected_docs: list,
    embedding_model
) -> dict:
    """诊断检索可能失败的原因。"""

    query_embedding = embedding_model.encode(query)
    retrieved_embeddings = [embedding_model.encode(d) for d in retrieved_docs]
    expected_embeddings = [embedding_model.encode(d) for d in expected_docs]

    from sklearn.metrics.pairwise import cosine_similarity
    import numpy as np

    diagnosis = {
        "query": query,
        "issues": []
    }

    # 检查查询-文档相似度
    for i, (doc, emb) in enumerate(zip(retrieved_docs, retrieved_embeddings)):
        sim = cosine_similarity([query_embedding], [emb])[0][0]
        if sim < 0.5:
            diagnosis["issues"].append({
                "type": "low_similarity",
                "doc_index": i,
                "similarity": float(sim),
                "doc_preview": doc[:100]
            })

    # 检查预期文档是否得分更高
    for i, (doc, emb) in enumerate(zip(expected_docs, expected_embeddings)):
        sim = cosine_similarity([query_embedding], [emb])[0][0]
        retrieved_max_sim = max(
            cosine_similarity([query_embedding], [e])[0][0]
            for e in retrieved_embeddings
        )

        if sim > retrieved_max_sim:
            diagnosis["issues"].append({
                "type": "missed_better_doc",
                "expected_doc_index": i,
                "expected_sim": float(sim),
                "best_retrieved_sim": float(retrieved_max_sim),
                "doc_preview": doc[:100]
            })

    # 检查词汇不匹配
    query_terms = set(query.lower().split())
    for i, doc in enumerate(retrieved_docs):
        doc_terms = set(doc.lower().split())
        overlap = query_terms & doc_terms
        if len(overlap) < len(query_terms) * 0.3:
            diagnosis["issues"].append({
                "type": "vocabulary_mismatch",
                "doc_index": i,
                "query_terms": list(query_terms),
                "overlapping_terms": list(overlap)
            })

    return diagnosis

# 使用示例
diagnosis = diagnose_retrieval(
    query="如何配置 OAuth 身份验证",
    retrieved_docs=retrieved_texts,
    expected_docs=expected_texts,
    embedding_model=sentence_transformer
)

for issue in diagnosis["issues"]:
    print(f"问题:{issue['type']}")
    print(f"详情:{issue}")

查询分析

def analyze_query_performance(
    query_logs: list[dict],
    threshold_precision: float = 0.6
) -> dict:
    """分析查询模式以发现系统性问题。"""

    analysis = {
        "total_queries": len(query_logs),
        "low_performing": [],
        "patterns": {}
    }

    for log in query_logs:
        if log.get("precision@5", 1.0) < threshold_precision:
            analysis["low_performing"].append(log)

    # 分析表现不佳的查询
    if analysis["low_performing"]:
        # 检查常见模式
        low_perf_queries = [l["query"] for l in analysis["low_performing"]]

        # 查询长度分析
        avg_length = sum(len(q.split()) for q in low_perf_queries) / len(low_perf_queries)
        analysis["patterns"]["avg_low_perf_query_length"] = avg_length

        # 失败查询中的常见词汇
        from collections import Counter
        all_terms = []
        for q in low_perf_queries:
            all_terms.extend(q.lower().split())
        analysis["patterns"]["common_failing_terms"] = Counter(all_terms).most_common(10)

        # 问题类型分析
        question_words = ["how", "what", "why", "when", "where", "who"]
        question_types = Counter()
        for q in low_perf_queries:
            for qw in question_words:
                if q.lower().startswith(qw):
                    question_types[qw] += 1
                    break
            else:
                question_types["other"] += 1
        analysis["patterns"]["failing_question_types"] = dict(question_types)

    return analysis

持续监控

生产指标仪表板

import time
from dataclasses import dataclass, field
from collections import deque
from threading import Lock

@dataclass
class RAGMetricsCollector:
    """在生产环境中收集和追踪 RAG 指标。"""

    window_size: int = 1000
    _latencies: deque = field(default_factory=lambda: deque(maxlen=1000))
    _retrieval_scores: deque = field(default_factory=lambda: deque(maxlen=1000))
    _generation_scores: deque = field(default_factory=lambda: deque(maxlen=1000))
    _lock: Lock = field(default_factory=Lock)

    def record_query(
        self,
        latency_ms: float,
        retrieval_score: float | None = None,
        generation_score: float | None = None
    ):
        """记录单个查询的指标。"""
        with self._lock:
            self._latencies.append(latency_ms)
            if retrieval_score is not None:
                self._retrieval_scores.append(retrieval_score)
            if generation_score is not None:
                self._generation_scores.append(generation_score)

    def get_summary(self) -> dict:
        """获取当前指标摘要。"""
        with self._lock:
            import numpy as np

            summary = {
                "queries_in_window": len(self._latencies),
                "latency": {
                    "p50": np.percentile(self._latencies, 50) if self._latencies else 0,
                    "p95": np.percentile(self._latencies, 95) if self._latencies else 0,
                    "p99": np.percentile(self._latencies, 99) if self._latencies else 0,
                },
                "retrieval_score": {
                    "mean": np.mean(self._retrieval_scores) if self._retrieval_scores else 0,
                    "std": np.std(self._retrieval_scores) if self._retrieval_scores else 0,
                },
                "generation_score": {
                    "mean": np.mean(self._generation_scores) if self._generation_scores else 0,
                    "std": np.std(self._generation_scores) if self._generation_scores else 0,
                }
            }

            return summary

# 使用示例
metrics = RAGMetricsCollector()

# 在 RAG 端点中
start = time.time()
response = rag_pipeline.query(question)
latency = (time.time() - start) * 1000

metrics.record_query(
    latency_ms=latency,
    retrieval_score=response.get("retrieval_score"),
    generation_score=response.get("generation_score")
)

# 定期检查
print(metrics.get_summary())

质量退化告警

class RAGQualityMonitor:
    """监控 RAG 质量并在退化时发出告警。"""

    def __init__(
        self,
        baseline_precision: float = 0.8,
        alert_threshold: float = 0.1,  # 下降超过 10% 即告警
        window_size: int = 100
    ):
        self.baseline = baseline_precision
        self.threshold = alert_threshold
        self.window_size = window_size
        self.recent_scores = deque(maxlen=window_size)

    def record_score(self, precision: float) -> dict | None:
        """记录分数,如果质量退化则返回告警。"""
        self.recent_scores.append(precision)

        if len(self.recent_scores) < self.window_size // 2:
            return None  # 数据不足

        current_mean = sum(self.recent_scores) / len(self.recent_scores)
        degradation = self.baseline - current_mean

        if degradation > self.threshold:
            return {
                "alert": "QUALITY_DEGRADATION",
                "baseline": self.baseline,
                "current": current_mean,
                "degradation": degradation,
                "window_size": len(self.recent_scores)
            }

        return None

# 使用示例
monitor = RAGQualityMonitor(baseline_precision=0.85)

for query_result in production_queries:
    alert = monitor.record_score(query_result["precision@5"])
    if alert:
        send_alert(alert)  # 发送至 Slack、PagerDuty 等

评估最佳实践

实践 描述
黄金测试集 维护 50-200 个带有真实标注的精选问答对
分层采样 测试集中包含多样化的查询类型
人工基线 将 LLM 评判者与人工标注者进行比较
版本控制 将评估结果与模型版本一同追踪
定期重新评估 每次检索变更时重新运行黄金测试
A/B 测试 在线上流量中比较新的检索策略

快速参考

目标 指标 目标值
文档是否相关? Precision@5 > 0.7
是否获取了所有文档? Recall@5 > 0.8
排序是否良好? NDCG@5 > 0.7
答案是否基于上下文? 忠实度 > 0.9
答案是否切题? 答案相关性 > 0.8
上下文是否有用? 上下文相关性 > 0.7
框架 最适合
RAGAS 快速 RAG 专用评估
TruLens 生产监控与追踪
自定义 LLM 评判者 领域特定标准
人工标注 真实标注创建

相关技能

  • RAG 架构师 - 系统设计
  • ML 流水线 - 评估自动化
  • 数据科学家 - 统计分析
  • 监控专家 - 生产可观测性