跳过正文
RAG 评估体系:RAGAS 指标与幻觉检测实践

RAG 评估体系:RAGAS 指标与幻觉检测实践

·1391 字·7 分钟·
目录
AI 工程化实战 - 这篇文章属于一个选集。
§ : 本文

我们团队的 RAG 系统上线三个月后,产品经理过来说:「感觉最近回答质量变差了。」这句话让我非常被动——「感觉」是无法量化的,我也没办法证明「其实没变差」,更没办法定位是哪个环节出了问题。

这次经历让我下决心建立系统化的 RAG 评估体系。这篇文章记录了我们从「靠感觉」到「靠数据」的转型过程。

为什么 RAG 系统需要系统化评估
#

RAG 系统的质量由多个环节共同决定:

用户问题 → 检索 → 上下文拼装 → LLM 生成 → 最终回答

每个环节都可能出问题:

  • 检索环节:相关文档没被找到(召回率低)
  • 检索环节:检索到了不相关的文档(精确率低)
  • 生成环节:LLM 没有基于检索内容回答(幻觉)
  • 生成环节:回答没有针对用户问题(相关性差)

主观评估的问题

  1. 无法追踪趋势——每次改动后无法知道质量是提升还是下降
  2. 评估者的主观标准不一致,A 觉得好 B 觉得差
  3. 无法支撑 A/B 测试——不知道改进方案是否真的有效
  4. 无法大规模评估——人工评估 100 个问题就已经很费力了

RAGAS 解决的问题:提供可量化、可自动化的评估指标,让评估可以持续运行、可以集成进 CI/CD、可以用数据驱动优化决策。

RAGAS 四大指标详解
#

RAGAS(Retrieval Augmented Generation Assessment)提供了四个核心评估指标:

指标 1:Faithfulness(忠实度)
#

衡量什么:生成的回答是否忠实于检索到的上下文,即有没有「编造」上下文中不存在的信息。

计算方式

  1. 把回答分解成一组原子性陈述(claims)
  2. 对每个陈述,用 LLM 判断它是否能从上下文中推断出来
  3. Faithfulness = 可以从上下文推断的陈述数 / 总陈述数

分数范围:0 到 1,越高越好。低 Faithfulness 意味着高幻觉风险。

示例:

  • 上下文:「产品 A 的价格是 299 元,支持 7 天退换货。」
  • 回答:「产品 A 价格是 299 元,支持 7 天退换货,并且提供两年保修。」
  • 「两年保修」这个陈述无法从上下文推断 → Faithfulness < 1

指标 2:Answer Relevancy(回答相关性)
#

衡量什么:回答是否针对了用户的问题,有没有答非所问或者废话连篇。

计算方式

  1. 让 LLM 根据回答反向生成 N 个可能的问题
  2. 计算这些反向问题和原始问题的相似度(Embedding 余弦相似度)
  3. 取平均值作为 Answer Relevancy

分数范围:0 到 1。注意:Answer Relevancy 不衡量事实准确性,只衡量相关性——如果回答很相关但内容是错的,分数依然高。

指标 3:Context Precision(上下文精确率)
#

衡量什么:检索到的上下文中,有多少比例是真正有用的(signal vs noise)。

计算方式

  • 对每个检索到的文档块,判断它是否对生成正确回答有帮助
  • Context Precision = 有用的文档块数 / 总检索文档块数

低 Context Precision 意味着检索引入了太多噪声,可能让 LLM 被无关内容干扰。

指标 4:Context Recall(上下文召回率)
#

衡量什么:ground truth 回答中的关键信息,有多少比例能在检索到的上下文中找到。

计算方式

  • 把 ground truth 回答分解成原子性陈述
  • 对每个陈述,判断它是否能从检索到的上下文中归因
  • Context Recall = 能在上下文中找到来源的陈述数 / 总陈述数

需要 ground truth,适合有标注数据集的场景。

四个指标的关系总结:

指标评估对象需要 ground truth?解决的问题
Faithfulness生成质量检测幻觉
Answer Relevancy生成质量检测答非所问
Context Precision检索质量检测检索噪声
Context Recall检索质量检测检索遗漏

如何构建评估数据集
#

评估数据集是整个评估体系的基础。构建方式分两类:

方法一:LLM 自动生成(快速启动)
#

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_llm = ChatOpenAI(model="gpt-4o")
critic_llm = ChatOpenAI(model="gpt-4o")
embeddings = OpenAIEmbeddings()

generator = TestsetGenerator.from_langchain(
    generator_llm,
    critic_llm,
    embeddings
)

# 生成测试集
testset = generator.generate_with_langchain_docs(
    documents,
    test_size=50,
    distributions={
        simple: 0.5,        # 简单问题(50%)
        reasoning: 0.25,    # 需要推理的问题(25%)
        multi_context: 0.25 # 需要多文档综合的问题(25%)
    }
)

# 转换为 DataFrame 查看
df = testset.to_pandas()
print(df[["question", "ground_truth", "context"]].head())

# 保存
df.to_csv("evaluation_dataset.csv", index=False)

方法二:人工标注(高质量)
#

LLM 生成的测试集质量参差不齐,最好做一轮人工审核:

import pandas as pd
import json

def create_annotation_template(questions: list[str]) -> pd.DataFrame:
    """创建人工标注模板"""
    return pd.DataFrame({
        "question": questions,
        "ground_truth": [""] * len(questions),  # 标注人填写
        "reference_docs": [""] * len(questions),  # 相关文档路径
        "difficulty": ["medium"] * len(questions),  # easy/medium/hard
        "category": ["general"] * len(questions),  # 问题分类
        "notes": [""] * len(questions)
    })

# 标注规范
ANNOTATION_GUIDE = """
标注规范:
1. ground_truth:写完整、准确的参考答案,不要太简短
2. reference_docs:填写这个问题答案来源的文档路径(可多个,逗号分隔)
3. difficulty:easy(直接查找)/ medium(需要理解)/ hard(需要推理或多文档综合)
4. 如果问题本身有歧义,在 notes 中说明
"""

测试集质量检查
#

def validate_testset(df: pd.DataFrame) -> dict:
    """检查测试集质量"""
    issues = []

    # 检查 ground_truth 是否太短
    short_answers = df[df["ground_truth"].str.len() < 20]
    if len(short_answers) > 0:
        issues.append(f"{len(short_answers)} 条 ground_truth 过短(<20字符)")

    # 检查重复问题
    duplicates = df[df["question"].duplicated()]
    if len(duplicates) > 0:
        issues.append(f"{len(duplicates)} 条重复问题")

    # 检查问题多样性(简单用长度分布)
    q_lengths = df["question"].str.len()
    print(f"问题长度分布: min={q_lengths.min()}, median={q_lengths.median():.0f}, max={q_lengths.max()}")

    return {
        "total": len(df),
        "issues": issues,
        "quality_score": 1 - len(issues) / 10  # 粗略质量分
    }

用 RAGAS 跑评估:完整代码示例
#

import asyncio
import pandas as pd
from datasets import Dataset
from ragas import evaluate
from ragas.metrics import (
    faithfulness,
    answer_relevancy,
    context_precision,
    context_recall
)
from ragas.llms import LangchainLLMWrapper
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic

# 你的 RAG 系统
class YourRAGSystem:
    def __init__(self, vector_store, llm):
        self.vector_store = vector_store
        self.llm = llm

    async def retrieve(self, question: str, k: int = 5) -> list[str]:
        """检索相关文档"""
        docs = await self.vector_store.asimilarity_search(question, k=k)
        return [doc.page_content for doc in docs]

    async def generate(self, question: str, contexts: list[str]) -> str:
        """基于上下文生成回答"""
        context_text = "\n\n".join(contexts)
        prompt = f"""基于以下参考资料回答问题。如果资料中没有相关信息,请说明无法从资料中找到答案。

参考资料:
{context_text}

问题:{question}
"""
        response = await self.llm.ainvoke(prompt)
        return response.content

    async def query(self, question: str) -> tuple[str, list[str]]:
        contexts = await self.retrieve(question)
        answer = await self.generate(question, contexts)
        return answer, contexts

async def run_evaluation(rag_system: YourRAGSystem, testset_path: str) -> dict:
    """运行 RAGAS 评估"""
    # 加载测试集
    df = pd.read_csv(testset_path)
    print(f"评估测试集大小: {len(df)} 条")

    # 对每个问题运行 RAG 系统,收集结果
    results = []
    for _, row in df.iterrows():
        question = row["question"]
        ground_truth = row.get("ground_truth", "")

        answer, contexts = await rag_system.query(question)
        results.append({
            "question": question,
            "answer": answer,
            "contexts": contexts,
            "ground_truth": ground_truth
        })

    # 转换为 RAGAS Dataset 格式
    eval_dataset = Dataset.from_list(results)

    # 配置评估用的 LLM(可以和 RAG 系统用不同的模型)
    evaluator_llm = LangchainLLMWrapper(ChatOpenAI(model="gpt-4o"))

    # 运行评估
    metrics_to_use = [faithfulness, answer_relevancy, context_precision]
    if df["ground_truth"].notna().all() and (df["ground_truth"] != "").all():
        metrics_to_use.append(context_recall)

    result = evaluate(
        eval_dataset,
        metrics=metrics_to_use,
        llm=evaluator_llm,
    )

    # 输出结果
    scores = result.to_pandas()
    summary = {
        "faithfulness": scores["faithfulness"].mean(),
        "answer_relevancy": scores["answer_relevancy"].mean(),
        "context_precision": scores["context_precision"].mean(),
    }
    if "context_recall" in scores.columns:
        summary["context_recall"] = scores["context_recall"].mean()

    return summary, scores

# 运行
async def main():
    rag = YourRAGSystem(vector_store, llm)
    summary, detailed = await run_evaluation(rag, "evaluation_dataset.csv")

    print("\n=== 评估结果 ===")
    for metric, score in summary.items():
        status = "✓" if score > 0.7 else "✗"
        print(f"{status} {metric}: {score:.3f}")

    # 保存详细结果
    detailed.to_csv("eval_results.csv", index=False)

asyncio.run(main())

幻觉检测:判断答案是否基于检索内容
#

除了 RAGAS 的 Faithfulness 指标,实际应用中还需要一个更轻量的幻觉检测机制——能在运行时实时检测,而不只是离线评估。

import anthropic
import json
from typing import Literal

client = anthropic.Anthropic()

def detect_hallucination(
    question: str,
    answer: str,
    contexts: list[str]
) -> dict:
    """
    检测回答是否存在幻觉
    返回: {hallucinated: bool, unsupported_claims: list, confidence: float}
    """
    context_text = "\n\n---\n\n".join(
        [f"[文档 {i+1}]\n{ctx}" for i, ctx in enumerate(contexts)]
    )

    prompt = f"""你是一个事实核查助手。请分析以下回答中的每个声明是否有文档支撑。

参考文档:
{context_text}

问题:{question}

回答:{answer}

请执行以下步骤:
1. 将回答分解为独立的事实声明(每句话或每个具体说法)
2. 对每个声明,判断它是否能从参考文档中找到依据
3. 标记无法从文档中找到依据的声明

以 JSON 格式返回:
{{
    "claims": [
        {{"text": "声明内容", "supported": true/false, "source_doc": 1 或 null}}
    ],
    "overall_faithfulness": 0.0到1.0,
    "has_hallucination": true/false,
    "unsupported_claims": ["无支撑的声明1", ...]
}}"""

    response = client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=2048,
        messages=[{"role": "user", "content": prompt}]
    )

    try:
        result = json.loads(response.content[0].text)
        return {
            "hallucinated": result["has_hallucination"],
            "unsupported_claims": result["unsupported_claims"],
            "faithfulness_score": result["overall_faithfulness"],
            "detailed_claims": result["claims"]
        }
    except json.JSONDecodeError:
        return {
            "hallucinated": None,
            "error": "解析失败",
            "raw_response": response.content[0].text
        }

# 使用示例
result = detect_hallucination(
    question="我们产品的退款政策是什么?",
    answer="我们支持 30 天无理由退款,并且提供免费上门取件服务。",
    contexts=["本产品支持 30 天内无理由退款,退款需通过官网申请。运费由买家承担。"]
)
print(f"存在幻觉: {result['hallucinated']}")
print(f"无支撑声明: {result['unsupported_claims']}")
# 输出: 存在幻觉: True
# 无支撑声明: ["提供免费上门取件服务"]

检索质量评估
#

除了端到端的 RAGAS 指标,检索环节本身也需要评估。常用指标:

import numpy as np
from typing import Optional

def hit_rate(retrieved_ids: list[str], relevant_ids: list[str]) -> float:
    """Hit Rate:检索到的文档中是否包含至少一个相关文档"""
    retrieved_set = set(retrieved_ids)
    relevant_set = set(relevant_ids)
    return 1.0 if retrieved_set & relevant_set else 0.0

def mrr(retrieved_ids: list[str], relevant_ids: list[str]) -> float:
    """Mean Reciprocal Rank:第一个相关文档出现在第几位(越前越好)"""
    relevant_set = set(relevant_ids)
    for i, doc_id in enumerate(retrieved_ids):
        if doc_id in relevant_set:
            return 1.0 / (i + 1)
    return 0.0

def ndcg(retrieved_ids: list[str], relevant_ids: list[str], k: Optional[int] = None) -> float:
    """Normalized Discounted Cumulative Gain:综合考虑相关性和排名"""
    relevant_set = set(relevant_ids)
    if k:
        retrieved_ids = retrieved_ids[:k]

    dcg = sum(
        1.0 / np.log2(i + 2)
        for i, doc_id in enumerate(retrieved_ids)
        if doc_id in relevant_set
    )

    # 理想 DCG:所有相关文档都排在前面
    ideal_hits = min(len(relevant_ids), len(retrieved_ids))
    idcg = sum(1.0 / np.log2(i + 2) for i in range(ideal_hits))

    return dcg / idcg if idcg > 0 else 0.0

def evaluate_retrieval(testset: list[dict]) -> dict:
    """
    评估检索质量
    testset: [{"question": str, "retrieved_ids": list, "relevant_ids": list}, ...]
    """
    hit_rates, mrrs, ndcgs = [], [], []

    for sample in testset:
        retrieved = sample["retrieved_ids"]
        relevant = sample["relevant_ids"]

        hit_rates.append(hit_rate(retrieved, relevant))
        mrrs.append(mrr(retrieved, relevant))
        ndcgs.append(ndcg(retrieved, relevant, k=5))

    return {
        "hit_rate@5": np.mean(hit_rates),
        "mrr@5": np.mean(mrrs),
        "ndcg@5": np.mean(ndcgs)
    }

# 评估示例
testset = [
    {
        "question": "产品退款政策",
        "retrieved_ids": ["doc_003", "doc_007", "doc_001", "doc_012", "doc_005"],
        "relevant_ids": ["doc_003", "doc_015"]  # ground truth 相关文档
    },
    # ...更多测试用例
]

metrics = evaluate_retrieval(testset)
print(f"Hit Rate@5: {metrics['hit_rate@5']:.3f}")
print(f"MRR@5:      {metrics['mrr@5']:.3f}")
print(f"NDCG@5:     {metrics['ndcg@5']:.3f}")

CI 集成:每次改动自动跑评估
#

把评估集成进 CI/CD,确保每次改动不会导致质量退化:

# .github/workflows/rag-eval.yml
name: RAG Quality Evaluation

on:
  pull_request:
    paths:
      - 'rag/**'          # RAG 代码变更触发
      - 'prompts/**'      # Prompt 变更触发
      - 'embeddings/**'   # Embedding 模型变更触发

jobs:
  evaluate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: pip install ragas langchain-openai pytest

      - name: Run RAG evaluation
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: |
          python scripts/run_eval.py \
            --testset data/eval_testset.csv \
            --output eval_results.json \
            --baseline metrics/baseline.json

      - name: Check quality gates
        run: python scripts/check_quality_gates.py eval_results.json

      - name: Comment on PR
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const results = JSON.parse(fs.readFileSync('eval_results.json'));
            const body = `## RAG 评估结果
            | 指标 | 当前值 | 基准值 | 状态 |
            |------|--------|--------|------|
            | Faithfulness | ${results.faithfulness.toFixed(3)} | ${results.baseline.faithfulness.toFixed(3)} | ${results.faithfulness >= results.baseline.faithfulness * 0.95 ? '✅' : '❌'} |
            | Answer Relevancy | ${results.answer_relevancy.toFixed(3)} | ${results.baseline.answer_relevancy.toFixed(3)} | ${results.answer_relevancy >= results.baseline.answer_relevancy * 0.95 ? '✅' : '❌'} |
            | Context Precision | ${results.context_precision.toFixed(3)} | ${results.baseline.context_precision.toFixed(3)} | ${results.context_precision >= results.baseline.context_precision * 0.95 ? '✅' : '❌'} |
            `;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: body
            });

质量门禁脚本:

# scripts/check_quality_gates.py
import json
import sys

def check_quality_gates(results_path: str):
    with open(results_path) as f:
        results = json.load(f)

    # 绝对值门禁:低于这个值直接失败
    ABSOLUTE_THRESHOLDS = {
        "faithfulness": 0.70,
        "answer_relevancy": 0.65,
        "context_precision": 0.60,
    }

    # 相对退化门禁:相比 baseline 退化超过 5% 失败
    REGRESSION_THRESHOLD = 0.05

    failures = []

    baseline = results.get("baseline", {})
    for metric, threshold in ABSOLUTE_THRESHOLDS.items():
        current = results.get(metric, 0)

        # 绝对值检查
        if current < threshold:
            failures.append(
                f"{metric} ({current:.3f}) 低于最低阈值 ({threshold})"
            )
            continue

        # 相对退化检查
        if baseline.get(metric):
            regression = (baseline[metric] - current) / baseline[metric]
            if regression > REGRESSION_THRESHOLD:
                failures.append(
                    f"{metric} 相比 baseline 退化 {regression*100:.1f}%"
                )

    if failures:
        print("❌ 质量门禁未通过:")
        for f in failures:
            print(f"  - {f}")
        sys.exit(1)
    else:
        print("✅ 所有质量门禁通过")

if __name__ == "__main__":
    check_quality_gates(sys.argv[1])

评估结果如何指导 RAG 优化
#

评估数据是优化的地图。根据不同的指标问题,优化方向不同:

问题指标表现优化方向
检索到的文档不相关Context Precision 低优化 Embedding 模型、调整检索策略(混合检索)、添加元数据过滤
关键文档没被检索到Context Recall 低优化分块策略(chunk size/overlap)、改进查询重写、添加关键词检索
LLM 编造了上下文没有的信息Faithfulness 低优化 System Prompt(明确要求基于文档回答)、添加引用要求
回答与问题关联度低Answer Relevancy 低优化 Prompt 模板、添加问题理解步骤
全面偏低所有指标重新检查整体流程,可能是测试集质量问题

一个实用的「诊断优先」原则:先看检索指标,再看生成指标。如果 Context Precision/Recall 都很好,但 Faithfulness 低,那是生成环节的问题;如果检索指标本身就差,改 Prompt 没有用。

评估体系建一次累一次,之后每次优化都能拿数据说话,再也不用和 PM 扯"感觉"。

Wenzhuo Huang
作者
Wenzhuo Huang
搞运维的工程师,写代码的运维人。专注 Kubernetes、AWS、GitOps 与基础设施可靠性。这个博客既是我的技术笔记本,也是我踩过的坑的受害者档案。
AI 工程化实战 - 这篇文章属于一个选集。
§ : 本文

相关文章

RAG 系统设计与实战:检索增强生成完全指南

·1157 字·6 分钟
RAG(检索增强生成)是目前企业落地 LLM 最主流的方式。本文覆盖 RAG 系统的完整设计:文档处理管线、分块策略、向量检索与关键词混合检索、Rerank 重排序、上下文压缩,以及用 RAGAS 框架评估 RAG 质量,最后分享生产环境踩坑记录。

Milvus 向量数据库实战:从部署到生产应用

·895 字·5 分钟
覆盖向量数据库选型对比(Milvus/Qdrant/Weaviate/pgvector)、Milvus Standalone与Cluster部署、Collection Schema设计、HNSW/IVF_FLAT索引调优、混合搜索实战,以及生产环境常见问题处理。

大模型核心概念:工程师需要理解的 LLM 基础

·786 字·4 分钟
同事第一次用 GPT-4 API 写代码时问我:为什么我发了一段中文,token 消耗比英文多那么多?为什么模型有时候会一本正经地胡说八道?这篇文章把我认为工程师必须理解的 LLM 概念系统整理了一遍,不涉及 Transformer 数学,只讲对你写代码有帮助的部分。