RAG 调参后"看起来更好"无法支撑工程决策:没有固定样本、不可追溯的索引和中间过程,任何结论都可能来自样本漂移或偶然输出。EDD(Eval-Driven Development)把改动放进可重复的 prepare -> index -> run -> compare 循环。本文基于本项目的 eval/、app/rag.py 和实际工件,搭建一套离线 RAG eval 系统,先测真实 pipeline,再用 Judge 评估最终答案。
本文使用 HotpotQA distractor validation 的固定数据:共享语料、200 条 retrieval 样本、其中确定性抽取的 20 条 generation 样本。参考答案和参考证据只供 Judge 使用,不会进入 RAG 的生成输入。
github仓库: https://github.com/NyaRu-Kiss/RAG-RAGAS.git
想了解更多的LLM应用eval知识,推荐看我往期的这篇博客:
AIE-AI Engineering三, 四章总结: 如何评估AI应用
项目架构
评测运行边界必须显式:业务索引与评测索引不共享表;应用生成模型、Ragas Judge、本地 embedding 分属不同调用边界;pipeline worker 和 Judge worker 分开控制。

技术选型
| 层 | 本项目实现 | 选择理由 |
|---|---|---|
| RAG 执行 | LlamaIndex + RagService |
复用线上检索、重排、上下文拼接和生成路径,避免评测实现与应用实现分叉 |
| 评测编排 | Python eval/cli.py、eval/runner.py |
将准备、预检、执行、汇总和工件写入拆成可测试步骤 |
| Judge | Ragas Faithfulness、ResponseRelevancy、FactualCorrectness |
以标准化单轮样本调用外部评审模型 |
| Judge 适配 | LangchainLLMWrapper + ChatOpenAI |
兼容 DeepSeek 和其他 OpenAI-compatible endpoint |
| Embedding | 本地 BAAI/bge-m3 |
RAG 向量和 Ragas relevancy embedding 不依赖远端 embedding API |
| 存储 | PostgreSQL + pgvector + Postgres docstore | 与应用一致,同时用三张 eval_* 表隔离评测状态 |
| 并发 | pipeline ThreadPoolExecutor;Judge RunConfig.max_workers |
两类请求的资源和限流策略不同,独立配置 |
| 工件 | manifest.json、JSONL、JSON、Markdown |
可进 CI、可审查、可按样本定位失败 |
当前应用生成模型由 app/config.py 的 LLM_PROVIDER、DEEPSEEK_MODEL 等配置决定;评测 Judge 由 eval/config.py 的 EVAL_JUDGE_* 或 DeepSeek/Gemini 配置决定,二者可以独立切换。
1. Eval 先定义可回答的问题
一次离线评测的输入是:固定数据集文件、固定共享语料、已验证的评测索引、应用 pipeline 配置、Judge 配置。输出是:每个样本的回答、检索上下文、引用、trace、指标状态和运行级汇总。
eval/runner.py::run_evaluation() 先加载完整数据,再执行标签和数量筛选:
python
loaded = load_dataset(dataset_file)
selected = filter_dataset(loaded, tags=tags, limit=limit)
生成评测要求严格 20 条。preflight_generation() 会检查数据集长度、manifest、corpus hash、index state、eval_ 表名、样本 ID 顺序、本地 embedding 和 Ragas import。任何一项不一致,在请求模型前失败,避免生成结果与错误索引混在一起。
质量目标至少包含四类:
- 生成质量:忠实性、回答相关性、事实正确性。
- pipeline 质量:召回上下文覆盖、排序、引用可追溯、query transform 实际结果。
- 稳定性:pipeline/Judge 失败率、超时、重试和 NaN/缺失分数。
- 成本与性能:检索、重排、生成耗时,context/request token,以及并发下的内存、连接和限流。
EDD 循环固定为:修改一个变量 -> 运行同一数据集 -> 检查 trace 和指标 -> 与 baseline 比较 -> 决定合入或回滚。评测集、语料和索引状态变化时,结果不具备直接可比性。
2. Pipeline 也必须被评测
最终回答正确不代表 pipeline 健康。召回文档可能已经退化,模型仍可能凭先验回答正确;上下文长度可能膨胀,延迟和成本已经上升;重排可能丢掉证据,错误会在生成层才暴露;引用可能与最终上下文错位。只评生成层无法定位这些变化。
本项目在 app/rag.py::evaluate_query_with_trace() 中执行一次完整链路:
python
source_nodes = self._retrieve_nodes(message, index)
retrieved_nodes = list(source_nodes)
if self.settings.reranker_enabled and self._reranker is not None:
source_nodes = self._reranker.postprocess_nodes(source_nodes, query_str=message)
response = Response(response=None, source_nodes=source_nodes)
generation_request = self._build_generation_request(message, response)
answer = self._generate_request(generation_request)
返回的 RAGPipelineResult 同时包含 answer、retrieved_contexts、citations、retrieval_trace 和 generation_request。trace 保存:
query、transformed_queries、retrieval_mode、top_k、fetch_k;- 重排前后的节点数量,以及每个节点的
node_id、score、文件、页码和原文; - 最终上下文的顺序和序列化文本;
- query transform、retrieve、rerank、generation 的毫秒耗时;
- system/user prompt hash、context token、request token。
_retrieve_nodes() 对 multi_query 记录真实生成的查询列表,对 hyde 记录真实 embedding_strs。Multi-query 会缓存已经生成的 query,随后复用同一列表执行检索,避免 trace 过程额外触发一次 LLM。
并发运行时,检索元数据放在 threading.local() 中:
python
def _set_retrieval_metadata(self, metadata):
trace_local = getattr(self, "_retrieval_trace_local", None)
if trace_local is None:
trace_local = threading.local()
self._retrieval_trace_local = trace_local
trace_local.metadata = metadata
这保证样本 A 的 query transform 不会被样本 B 覆盖。共享索引在 worker 启动前通过 _ensure_index() 预热一次;结果收集使用 as_completed(),最终按原始数据集顺序写回,兼顾并发和确定性。

3. 评测数据集:样本、语料与标准答案分离
eval/dataset.py::EvalSample 是生成评测的最小契约:
python
class EvalSample(BaseModel):
id: str
source_sample_id: str
user_input: str
reference: str
reference_contexts: list[str] = []
tags: list[str] = []
difficulty: str | None = None
question_type: str | None = None
load_dataset() 读取 JSONL,拒绝空文件、非法 JSON、Pydantic 校验失败和重复 source_sample_id。filter_dataset() 只负责 tags 和 limit;生成集的 20 条要求在 preflight_generation() 中再次强制检查。
eval/prepare.py::prepare_hotpotqa_records() 采用两层抽样:先用固定 seed 从 validation split 选 200 条 retrieval records,再用 seed + 1 从这 200 条中选 20 条 generation records。所有 query 共享同一份去重 corpus;段落 ID 使用 title + paragraph_index 的 canonical JSON SHA-256,内容冲突直接失败。
python
retrieval_records = _selected_records(records, seed=seed, count=retrieval_count)
generation_records = _selected_records(
retrieval_records, seed=seed + 1, count=generation_count
)
生成输入只使用 user_input。reference 和 reference_contexts 在 eval/runner.py 构造 SingleTurnSample 时才交给 Judge。输入来源审计由 find_generation_input_leaks() 完成:它检查 generation request 的 provenance 字段,而不比较文本内容;检索结果与参考证据文本相同是正常情况,按文本相等判定会误报。
数据 provenance 写进 dataset_manifest.json:Hub ID、URL、config、split、license、版本、下载时间、raw SHA-256、抽样 seed、样本 ID hash、corpus SHA-256 和三个评测表名。这样数据变化会被 manifest 和 baseline 兼容性检查捕获。
4. 评测索引与业务索引隔离
rebuild_eval_index() 读取 manifest,验证 corpus hash,然后将共享 corpus 物化到 data/eval_uploads/hotpotqa-distractor/,再用 Settings.model_copy() 覆盖评测服务配置:
python
table_names = eval_storage_table_names(HOT_POT_DATASET)
settings = base.model_copy(
update={"upload_dir": materialized_dir, "embed_batch_size": 1, **table_names}
)
rag_service = RagService(settings)
chunk_count = rag_service.rebuild_index()
表名固定为:
eval_hotpotqa_distractor:pgvector;eval_hotpotqa_distractor_docstore:LlamaIndex docstore;eval_hotpotqa_distractor_file_index:文件索引。
validate_eval_table_name() 只允许 eval_[a-z0-9_]+。index_state.json 保存 corpus hash、文档数、chunk 数、表名和 index config hash。build_eval_rag_service() 在创建服务前验证这些状态;preflight_generation() 再验证 generation 数据的 source ID 顺序。
index_state.json:
json
{
"dataset": "hotpotqa-distractor",
"table_name": "eval_hotpotqa_distractor",
"storage_tables": {
"pg_table": "eval_hotpotqa_distractor",
"docstore_table": "eval_hotpotqa_distractor_docstore",
"file_index_table": "eval_hotpotqa_distractor_file_index"
},
"corpus_sha256": "sha256:60ce6398751d7498a64bc3c9c6c4ef9bad38fcdaf793c26f445478b626f66236",
"document_count": 1992,
"chunk_count": 1992,
"indexed_at_utc": "2026-08-18T05:31:35.933059+00:00",
"index_config_sha256": "sha256:a3e6f6e7949abfaefb4d251929b936cc2e9fbebaaa0b6c301e08790f83f85a6c"
}
复用业务表会引入三个问题:评测重建可能破坏线上数据,索引变更无法与运行绑定,业务数据中的额外文档会掩盖召回退化。评测表白名单和 corpus hash 是最小隔离边界,不需要额外设计一套向量数据库。
eval专用表:

5. 真实 RAG Adapter:一次调用贯穿 pipeline
eval/adapter.py::RagEvaluationAdapter.run() 只做适配,不重新实现检索:
python
def run(self, question: str) -> RAGRunResult:
pipeline_result = self._rag_service.evaluate_query_with_trace(question)
return RAGRunResult(
response=pipeline_result.answer,
retrieved_contexts=[c.text for c in pipeline_result.retrieved_contexts],
citations=[c.model_dump() for c in pipeline_result.citations],
retrieval_trace=pipeline_result.retrieval_trace,
generation_request=getattr(pipeline_result, "generation_request", None),
)
run_evaluation() 每个样本调用一次 adapter。评测层不先调用检索、再调用生成;这避免重复 LLM 请求、不同检索结果和双倍延迟。_sample_to_row() 将 response、contexts、citations、trace 和 generation request 写入内存行;之后 runner 才把 trace 分流到 retrieval_traces.jsonl。
生成 request 还承担防泄漏审计:如果 input_sources 包含 reference、reference_contexts 或 reference_images,样本被标记为 invalid,并记录 ReferenceLeakError。参考信息是否与检索文本相同不影响这项检查,审计对象是数据流来源。
6. 生成层与 Judge 指标
RAG pipeline 负责产生可评估的事实:回答、contexts、citations、trace。Judge 只对成功 pipeline 行创建 Ragas SingleTurnSample:
python
SingleTurnSample(
user_input=row["user_input"],
response=row["response"],
reference=row["reference"],
retrieved_contexts=row["retrieved_contexts"],
reference_contexts=row["reference_contexts"],
)
指标由 eval/metrics.py::build_metric_specs() 集中构造:
python
MetricSpec(key="faithfulness", metric=Faithfulness(llm=judge_llm))
MetricSpec(
key="response_relevancy",
metric=ResponseRelevancy(llm=judge_llm, embeddings=embeddings),
result_key="answer_relevancy",
)
MetricSpec(
key="factual_correctness",
metric=FactualCorrectness(llm=judge_llm),
result_key="factual_correctness(mode=f1)",
)
这里保留稳定的内部 key,同时声明 Ragas 返回列名:response_relevancy 对应 answer_relevancy,factual_correctness 对应 factual_correctness(mode=f1)。Ragas 版本升级时只需调整映射,不让报告 schema 跟着外部列名漂移。
Judge 使用 LangchainLLMWrapper 包装 ChatOpenAI,RunConfig 读取 timeout、max retries、max workers;请求显式设置 extra_body={"thinking": {"type": "disabled"}}。EVAL_JUDGE_TEMPERATURE=0 降低评审随机性。评测 embedding 使用本地 HuggingFaceEmbeddings,模型路径来自应用配置中的 BAAI/bge-m3。
每个指标有四种结果状态:scored(有限数值)、failed(Judge 异常或无分数)、skipped(指标不可用)和 nan。summarize_metric_outcomes() 只用 scored 值计算 mean、p50、p95,同时保留 attempted/scored/failed/skipped/nan 计数。缺失列不能静默转成 0,否则会把 Judge 故障伪装成质量下降。
samples.jsonl中一条样本的记录截图:



7. 当前版本不测 MRR:指标需要 gold IDs
MRR、Hit@K、Recall@K 的比较对象是 gold document ID 或稳定的 gold node ID。当前 HotpotQA 适配数据的 reference_contexts 是参考文本,convert_hotpotqa_row() 没有提供能与索引 node 对齐的 gold ID 映射。
本版本不伪造 gold ID,也不把文本相似度包装成文档级检索指标。当前报告评估生成质量,并完整保存 pipeline trace,足以定位召回和排序变化,但不能声称已经完成标准 MRR 评测。后续迭代需要补充:原始 supporting-facts 到 corpus paragraph 的映射、稳定 node ID、gold ID 写入数据集和 retrieved_nodes 的对齐逻辑,再加入 MRR、Hit@K、Recall@K 及门槛。
8. Run Artifacts:把一次运行变成可审计对象
eval/reporting.py::AtomicRunWriter 先写入临时目录,只有必需文件齐全才将目录原子改名为最终 run ID。必需工件包括:
text
eval/reports/<run_id>/
├── manifest.json
├── samples.jsonl
├── retrieval_traces.jsonl
├── summary.json
├── summary.md
├── failures.json
└── comparison.json # 传入 --baseline 时生成
manifest.json 至少记录:Git SHA、数据集路径和 hash、source sample ID hash、样本数、pipeline retrieval/chunking/generation 配置、prompt hash、应用配置快照、Judge 配置、embedding 配置、隔离索引状态和并发配置。safe_config_snapshot() 递归删除 key、password、secret、token 等敏感字段;API key 只用于请求,不写入工件。
样本级记录区分 pipeline_succeeded、pipeline_failed 和 invalid。失败聚合按 stage、异常类型、消息和 sample ID 汇总,既能看总失败数,也能回到具体样本的 trace。summary 同时记录按 tag、difficulty、question_type 的分桶结果,避免总体平均数掩盖某类问题。
eval/reports/<run_id>/ 目录截图:



9. Baseline 比较与发布门槛
eval/comparison.py::compare_runs() 只比较已完成 run。比较前校验:dataset_sha256、corpus_sha256、source_sample_ids_sha256、sample_count 和指标列表;样本数不是 20 时直接标记不可比。配置差异按 app_config、pipeline_config、eval_config、embedding、prompt_config 展开。
可比时,按 source_sample_id 配对当前和 baseline 的有限分数,输出每个指标的 paired count、mean delta、样本级 delta、recovered、newly_failed 和 missing。发布门槛应同时覆盖:
- 质量:指标均值和关键样本不得低于阈值;
- 稳定性:pipeline/Judge 失败率、invalid 数和 NaN 数不得超限;
- 性能:p95 检索、重排、生成耗时和 request token 不得超预算;
- 可观测性:成功样本必须有完整 trace 和 citations。
质量平均值上涨但失败率或 p95 恶化时,不应直接合入;先检查 trace 和配置 diff。
10. 并发、超时与资源预算
.env.eval 中两类 worker 独立配置:
env
EVAL_PIPELINE_MAX_WORKERS=2
EVAL_MAX_WORKERS=1
EVAL_BATCH_SIZE=4
EVAL_TIMEOUT_SECONDS=120
EVAL_MAX_RETRIES=2
EVAL_PIPELINE_MAX_WORKERS 控制 ThreadPoolExecutor 中的真实 RAG 样本并发;EVAL_MAX_WORKERS 通过 Ragas RunConfig 控制 Judge 并发。pipeline 结果先异步收集,再按数据集顺序写 artifact;Judge 只处理 pipeline 成功行。
容量约束来自三个方向:本地 BGE-M3 的内存、PostgreSQL 连接/查询能力、DeepSeek 生成和 Judge endpoint 的限流。推荐顺序是:先用 1/1 验证正确性,再提升 pipeline worker,观察失败率、p95、内存和 API 限流,最后单独调整 Judge worker。并发数不是质量参数,不能通过增加 worker 修复召回或答案问题。
11. 从零运行一遍
以下命令对应 eval/cli.py 的真实子命令:
bash
source .venv/bin/activate
# 1. 下载固定 validation split
python -m eval.cli data fetch --dataset hotpotqa-distractor
# 2. 固定 seed,生成共享 corpus、retrieval 集和 20 条 generation 集
python -m eval.cli data prepare --dataset hotpotqa-distractor --seed 2025
# 3. 仅重建 eval_* 表,并写入 index_state.json
python -m eval.cli index rebuild --dataset hotpotqa-distractor
# 4. 预检后运行真实 RAG pipeline + Ragas Judge
python -m eval.cli run generation --dataset hotpotqa-distractor
# 5. 用某次已完成 run 重新生成 Markdown 报告
python -m eval.cli report --run eval/reports/<run_id>
# 6. 将当前运行与 baseline 比较
python -m eval.cli run generation \
--dataset hotpotqa-distractor \
--baseline eval/reports/<baseline_run_id>


单条 smoke test 可通过直接调用 run_evaluation(..., limit=1) 做开发期验证;正式 generation preflight 仍要求固定 20 条,避免把部分运行误当成可发布 baseline。真实 20 条运行会产生应用模型和 Judge API 请求,应在确认 .env.eval、数据库、embedding 文件和 API 配额后执行。
12. 最小实现路径与验收标准
实现顺序保持单向依赖:
- 定义
EvalSample、JSONL loader 和固定 20 条 generation 集。 - 下载原始数据,固定 seed 生成共享 corpus 与 provenance manifest。
- 用白名单表名重建隔离索引,生成
index_state.json。 - 让 adapter 调用真实
evaluate_query_with_trace(),保留 contexts、citations 和 trace。 - 接入三项 Judge 指标,处理 Ragas 列名映射和四种结果状态。
- 写入原子 run artifacts、汇总、失败报告和 baseline comparison。
- 先跑单条 smoke,再跑固定 20 条回归集。
验收必须同时满足:
- 评测只写
eval_三张表,业务索引数据不变; - manifest 的 corpus hash、样本 ID hash、Git SHA、模型、prompt 和并发配置可追溯;
- 20 条 generation 样本均有成功行或明确失败行,顺序稳定;
- 每条成功样本都有
retrieval_trace和 citations; - 每个指标都有分数,或有
failed/skipped/nan状态及原因; - baseline 在数据、语料、样本数和指标集合不兼容时拒绝比较;
- pipeline 和 Judge 并发可分别配置,线程间 trace 不串写;
pytest全量测试通过,真实 API 运行另行记录请求成本、耗时和限流情况。
结尾:把 Eval 放进 EDD
chunking、retriever、reranker、query transform、prompt 或模型每次变更,都运行同一数据集和隔离索引。用 pipeline trace 定位变化发生在哪一步,用 Judge 指标量化最终影响,用 baseline comparison 判断是否满足发布门槛。当前版本先建立生成层和 pipeline 的可追溯评测;补齐 gold IDs 后,再扩展 MRR 等标准检索指标,形成完整的 RAG EDD 闭环。