Spaces:
Runtime error
A newer version of the Gradio SDK is available: 6.15.2
RAG 评测模块构建总结
本文档用于面试时说明:为什么需要 RAG 评测、如何设计 retrieval eval、如何接入公开数据集和自建 PDF 测试集,以及如何判断 RAG 优化是否真的有效。
背景问题
在优化 RAG 系统时,仅靠主观查看回答效果不稳定,也很难判断 PDF 解析、chunk 切分、embedding、reranker 或检索参数的改动是否真的带来提升。
因此我先搭建了一个独立的 RAG retrieval evaluation 模块,用固定测试集和固定指标来做 before/after 对比。
目标是:
- 能快速验证检索链路是否跑通。
- 能用公开 benchmark 做横向参考。
- 能用金融相关数据集贴近业务场景。
- 能用自己的期权 PDF 测试集验证 PDF 解析、公式抽取和章节切分是否有效。
- 每次改动后可以一条命令自动跑评测并生成报告。
数据集接入顺序
我按照由易到难、由通用到业务的顺序接入了 4 类测试集。
1. BEIR/scifact
scifact 是 BEIR 中比较小的科学事实检索数据集,适合快速跑通 retrieval eval。
接入它的目的不是追求业务贴合,而是验证:
- 数据下载和解析是否正常。
- corpus、query、qrels 能否正确对齐。
- 向量索引是否能构建。
- 检索指标是否能稳定输出。
2. BEIR/fiqa
fiqa 是金融问答相关数据集,比 scifact 更贴近金融场景。
接入它的目的:
- 验证金融语义检索能力。
- 检查 embedding 对金融术语、问答表达的适配情况。
- 作为后续期权 PDF 场景前的公开金融 benchmark。
3. Open RAGBench
Open RAGBench 更接近长文档、PDF、报告类 RAG 场景。
我选择了其中的 pdf/arxiv 子集,用来验证:
- 长文档解析后的检索效果。
- 多章节、多段落文档下的 chunk 检索表现。
- RAG 系统在 PDF-like 文档上的泛化能力。
4. 自建期权 PDF 测试集
最后补充自己的期权 PDF 测试集,因为公开 benchmark 无法完全覆盖当前项目中的业务难点。
自建测试集重点覆盖:
- 期权定价概念。
- PDF 中的公式内容。
- 章节标题和上下文定位。
- 公式编号、页码、章节等 metadata 是否能帮助检索。
模块设计
评测模块放在 eval/ 目录下,核心文件包括:
eval/rag_eval.py:单数据集 retrieval eval 入口。eval/run_eval_suite.py:批量评测多个数据集的 suite runner。eval/local_options_eval.jsonl:自建期权 PDF 测试集。eval/README.md:调用示例和使用说明。
整体流程如下:
加载数据集
-> 构造 documents / queries / qrels
-> 构建 Chroma 向量索引
-> 执行 top-k retrieval
-> 按 doc_id 去重
-> 计算 hit@k / MRR / NDCG@K
-> 生成 JSON 和 Markdown 报告
为什么只先做 retrieval eval
RAG 的最终效果由两部分组成:
RAG = Retrieval + Generation
如果检索阶段没有找到正确上下文,后面的 LLM 生成很容易幻觉。因此我先评估 retrieval:
- 问题对应的正确文档有没有被找回来。
- 正确文档排在第几名。
- top-k 结果排序是否合理。
这样可以先把问题定位在“检索是否正确”,再进一步评估生成答案。
指标设计
Hit@K
Hit@K 表示前 K 个结果里是否包含正确文档。
例如 Hit@5 = 1,表示正确文档出现在前 5 个检索结果中。
它适合判断:
- 正确上下文有没有被召回。
- top-k 设大以后召回是否提升。
MRR
MRR 是 Mean Reciprocal Rank,关注第一个正确结果出现的位置。
如果正确结果排第 1,得分是 1。
如果正确结果排第 2,得分是 1/2。
如果正确结果排第 5,得分是 1/5。
它适合判断:
- 正确文档是否排得足够靠前。
- 检索排序质量是否提升。
NDCG@K
NDCG@K 衡量前 K 个结果的排序质量。
计算方式是:
DCG@K = rel_1 / log2(2) + rel_2 / log2(3) + ... + rel_K / log2(K + 1)
NDCG@K = DCG@K / IDCG@K
其中 rel_i = 1 表示第 i 个结果相关,rel_i = 0 表示不相关。
NDCG 越接近 1,说明相关结果越靠前。
关键实现细节
1. 统一数据格式
不同数据集格式不同,因此我统一抽象成:
documents = [
{
"doc_id": "...",
"title": "...",
"text": "...",
"metadata": {...}
}
]
queries = [
{
"query_id": "...",
"question": "...",
"relevant_doc_ids": [...]
}
]
qrels = {
"query_id": {"doc_id"}
}
这样后续索引构建和指标计算可以复用同一套逻辑。
2. 小样本评测必须包含 gold 文档
在做 smoke test 时,如果只取 corpus 前 N 篇文档,可能会出现 query 的正确文档不在测试 corpus 里,导致评测不公平。
所以我在加载 BEIR 和 Open RAGBench 时,会先读取 qrels,确定当前 query 需要哪些 gold documents,再优先把这些文档纳入 corpus。
这样小样本测试可以稳定评估检索能力,而不是被采样问题干扰。
3. 检索结果按 doc_id 去重
一个文档会被切成多个 chunk,检索时可能同一篇文档的多个 chunk 同时出现在 top-k 中。
如果不去重,会导致:
- 指标被重复 chunk 影响。
- NDCG 可能异常偏高。
- top-k 实际上不是 top-k documents,而是 top-k chunks。
因此评测时内部会多取一些 chunk,然后按 doc_id 去重,再计算 top-k 文档级指标。
4. 支持 rebuild
如果修改了:
- PDF 解析逻辑
- chunk 切分方式
- embedding 模型
- metadata 构造
- reranker 或检索参数
必须使用 --rebuild 重建索引,否则会复用旧索引,评测结果不能代表最新代码。
自动化评测脚本
单数据集评测:
uv --cache-dir .uv-cache run python -m eval.rag_eval \
--dataset local-options \
--max-queries 3 \
--top-k 5 \
--rebuild
批量评测:
uv --cache-dir .uv-cache run python -m eval.run_eval_suite --rebuild
只跑指定数据集:
uv --cache-dir .uv-cache run python -m eval.run_eval_suite \
--datasets local-options,beir/fiqa \
--top-k 5 \
--max-queries 20 \
--rebuild
对比不同 chunk 设置:
uv --cache-dir .uv-cache run python -m eval.run_eval_suite \
--datasets local-options \
--chunk-size 384 \
--chunk-overlap 64 \
--output-name local_options_chunk384 \
--rebuild
报告会输出到:
eval/reports/
包括:
- 每个数据集的 JSON 报告。
- 每个数据集的 Markdown 报告。
- suite 级别的汇总报告。
遇到的问题和解决方案
问题 1:公开数据集需要联网下载
BEIR 和 Open RAGBench 都需要从公网下载数据。
解决方法:
- 第一次运行时下载并缓存到
eval/data/。 - 后续运行直接复用本地数据。
- 数据和索引分开存放,便于排查问题。
问题 2:Open RAGBench 实际目录结构和预期不一致
最开始预设路径是 official/pdf/arxiv,但实际下载后路径是 pdf/arxiv。
解决方法:
- loader 中兼容两种路径。
- 优先尝试
pdf/arxiv,不存在时再回退到official/pdf/arxiv。
问题 3:小样本采样会漏掉 gold document
如果 max_corpus_docs 很小,直接截取 corpus 前 N 条可能不包含 qrels 中的正确文档。
解决方法:
- 先根据 qrels 选择 query。
- 再把对应 gold documents 强制纳入 corpus。
- 最后补充其他文档作为干扰项。
问题 4:chunk 重复导致指标异常
同一篇文档的多个 chunk 可能同时命中,导致 NDCG 等指标不合理。
解决方法:
- 检索时多取一些 chunk。
- 评估时按
doc_id去重。 - 最终以 document-level top-k 计算指标。
问题 5:不重建索引可能复用旧结果
如果代码改了但没有 --rebuild,Chroma 可能复用旧索引。
解决方法:
- 文档中明确说明改动后必须加
--rebuild。 - suite runner 支持统一传入
--rebuild。 - 用
--output-name固定报告名,方便 before/after 对比。
问题 6:RAG 只是独立模块,没有真正接入 Agent
最开始 RAG 已经能单独查询知识库,但主 CodeAgent 的 tools 里没有注册知识库工具。这样在真实对话里,agent 实际只能查行情和时间,不能主动调用本地期权知识库。
解决方法:
- 将
QueryKnowledgeTool注册进主 agent。 - 优化 tool description,让模型知道它应该在期权概念、波动率、Greeks、策略、公式编号和书籍引用问题上调用该工具。
- 控制 tool 输出长度,只返回来源、页码、section、分数和截断后的片段,避免检索结果占满上下文。
面试可以强调:
RAG 不是只要能单独跑 query 就算完成,必须作为 agent 的一个可调用工具接入主工作流。否则用户问期权概念时,agent 不一定会查知识库,仍然可能凭模型参数记忆回答。
问题 7:知识库目录和代码目录耦合
早期知识库放在 tools/knowledge_base 下,代码、原始资料和 Chroma 数据库混在一起。随着知识库变大,这种结构不利于维护,也不利于后续把工具代码、数据和缓存分开管理。
解决方法:
- 将知识库统一到项目根目录:
OptionAgent/knowledge_base/
raw/
chroma_db/
- 工具代码中使用
PROJECT_ROOT / "knowledge_base"作为主路径。 - 保留旧路径 fallback,避免迁移时旧数据立刻失效。
面试可以强调:
我把知识库从工具目录迁到项目根目录,并保留 legacy fallback。这样既完成了结构治理,也避免了迁移时破坏已有索引和原始文档。
问题 8:全量 rebuild 成本高
只要文档、解析方法或 embedding 模型变化,就全量重建索引。书籍变多后,这会浪费大量时间,而且不方便频繁更新笔记。
解决方法:
- 每个 chunk metadata 中保留:
source_file
file_hash
embedding_model
extraction_method
- 启动时扫描当前 raw 文件,和 Chroma 中已有 metadata 对比:
新增文件 -> 只入库新增文件
修改文件 -> 删除该文件旧 chunks,再重新入库
删除文件 -> 删除该文件对应 chunks
embedding/extraction 版本变化 -> 触发对应文件更新
面试可以强调:
我没有只依赖 collection 是否为空,而是基于 source_file、file_hash、embedding_model 和 extraction_method 做增量更新。这样文档更新后索引不会脏,也不用每次全量 rebuild。
问题 9:纯向量检索对公式编号和专有名词不稳定
期权书里有很多精确查询,例如:
Equation 21.23
WITH ZERO CORRELATION
Black-Scholes-Merton
vega
gamma
这类问题不只是语义相似,还需要字面命中。纯 dense embedding 对概念解释很强,但对公式编号、章节标题、专有名词有时不如关键词检索稳定。
解决方法:
- 增加轻量 BM25 检索。
- 查询时同时跑:
dense vector retrieval
BM25 keyword retrieval
- 使用 reciprocal-rank merge 合并结果。
- 再交给 cross-encoder reranker 做最终排序。
最终链路:
query
-> dense top-k
-> BM25 top-k
-> merge / deduplicate
-> reranker
-> top results with citations
面试可以强调:
我做 hybrid search 是因为金融和期权文档里存在大量公式编号、章节名、ticker-like token 和专有名词。Dense retrieval 负责语义召回,BM25 负责精确词命中,reranker 负责最终排序。
问题 10:本地评测集太小
最初 local-options 只有 3 条 case,容易出现指标过高但不可泛化的问题。比如小样本里 Hit@5 为 1,并不代表系统在真实问题上稳定。
解决方法:
- 新增
eval/generate_local_options_eval.py。 - 从已解析的 PDF/MD 文档中随机抽样 chunk。
- 优先覆盖:
- 公式问题。
- 章节定位问题。
- 期权关键词问题。
- 波动率、Greeks、风险中性、策略等业务术语。
- 过滤前言、索引页、表格/图注噪声,避免生成低质量 query。
- 将本地 eval 扩充到 40 条。
面试可以强调:
我没有只手写少量 happy path case,而是做了一个本地 eval case generator,从真实 chunk 中抽样生成问题,并对噪声标题做过滤。这样可以更稳定地评估 PDF 解析和检索策略的变化。
Hybrid Search 和 Reranker 对比实验
扩充到 40 条 local-options case 后,我做了三组对比:
dense-only:
MRR 0.4708
NDCG@5 0.3468
Hit@1 0.4250
Hit@3 0.5250
Hit@5 0.5250
hybrid:
MRR 0.4833
NDCG@5 0.3190
Hit@1 0.4250
Hit@3 0.5250
Hit@5 0.5750
hybrid + reranker:
MRR 0.7125
NDCG@5 0.4717
Hit@1 0.7000
Hit@3 0.7250
Hit@5 0.7250
结果解释:
- Hybrid search 单独提升了 Hit@5,说明 BM25 补充了召回,尤其对精确术语和公式编号有帮助。
- Hybrid 的 NDCG 略降,说明召回增加后排序还不够好。
- 加上 reranker 后,MRR、NDCG、Hit@1、Hit@5 都明显提升,说明 reranker 有效改善了排序质量。
面试可以这样总结:
单独加 BM25 后,召回有提升但排序不一定更好;这符合预期,因为 BM25 会把更多字面相关结果拉进候选集。最终效果最好的是 dense + BM25 扩召回,再用 cross-encoder reranker 排序。这个实验也说明我不是凭感觉加组件,而是用 Hit@K、MRR 和 NDCG 验证每一步是否真的有效。
当前评测结果示例
早期小规模 smoke test 的结果示例:
BEIR/scifact:
MRR = 0.9000
NDCG@5 = 0.9262
Hit@1 = 0.8000
Hit@5 = 1.0000
BEIR/fiqa:
MRR = 0.8000
NDCG@5 = 0.6582
Hit@1 = 0.8000
Hit@5 = 0.8000
local-options:
MRR = 1.0000
NDCG@5 = 0.7162
Hit@1 = 1.0000
Hit@5 = 1.0000
这些结果主要用于验证评测流程和小样本趋势,不能直接代表完整 benchmark 成绩。正式对比时需要扩大 max_queries 和 max_corpus_docs。
面试回答话术
可以这样回答:
我在优化 RAG 系统时发现,单纯看回答效果很难判断改动是否真的有效,所以先搭了一个 retrieval evaluation 模块。我的思路是先用 BEIR/scifact 快速跑通标准检索评测,再接 BEIR/fiqa 贴近金融场景,然后接 Open RAGBench 验证长文档和 PDF-like 场景,最后补自己的期权 PDF 测试集,用来覆盖项目里公式、章节和金融术语这些业务难点。
如果面试官问为什么先评估 retrieval:
因为 RAG 的生成质量高度依赖检索质量。如果检索阶段没有召回正确上下文,后面 LLM 很容易幻觉。所以我先用 Hit@K、MRR、NDCG@K 衡量正确文档是否被召回以及排序是否靠前,把 retrieval 问题和 generation 问题分开定位。
如果面试官问如何保证评测可靠:
我做了几个处理。第一,所有数据集统一成 documents、queries、qrels 三类结构。第二,小样本 smoke test 会优先把 qrels 需要的 gold document 放进 corpus,避免因为采样漏掉正确文档导致评测不公平。第三,检索结果按 doc_id 去重,避免同一篇文档多个 chunk 重复命中导致指标虚高。第四,修改解析、chunk、embedding 或检索逻辑后必须 rebuild 索引,保证评测对应的是最新系统。
如果面试官问这个模块怎么用:
我提供了单数据集入口和 suite 入口。单数据集可以用
python -m eval.rag_eval --dataset local-options --rebuild,批量评测可以用python -m eval.run_eval_suite --rebuild。它会自动跑多个数据集,输出 JSON 和 Markdown 报告,便于做 before/after 对比。
如果面试官问为什么要做 hybrid search:
因为期权和金融文档里有两类查询。一类是语义型,比如“为什么临近到期 gamma 风险变大”,dense embedding 很适合;另一类是精确匹配型,比如
Equation 21.23、WITH ZERO CORRELATION、Black-Scholes-Merton,这些 BM25 更稳定。所以我用 dense retrieval 负责语义召回,BM25 负责关键词召回,然后合并候选,再用 cross-encoder reranker 排序。
如果面试官问 hybrid 是否真的提升了:
我用扩充后的 40 条 local-options eval 做了对比。Dense-only 的 Hit@5 是 0.525,MRR 是 0.471;加入 hybrid 后 Hit@5 提升到 0.575,说明召回变好,但 NDCG 有一点下降,说明排序还不够好;再加 reranker 后 Hit@5 到 0.725,MRR 到 0.713,Hit@1 到 0.700,说明 dense + BM25 + reranker 的组合最稳。
如果面试官问为什么不能每次全量 rebuild:
全量 rebuild 在文档少的时候可以,但参考书和笔记变多后成本会越来越高。我在 metadata 里记录 source_file、file_hash、embedding_model 和 extraction_method,启动时对比当前文件状态和 Chroma 中已有 metadata。新增文件只入库新增部分,修改文件只删除并重建该文件对应 chunks,删除文件同步清理旧 chunks。这样既保证索引新鲜,也避免无意义的全量重建。
如果面试官问 RAG 和 agent 怎么结合:
我把 RAG 封装成
QueryKnowledgeTool注册到主CodeAgent,而不是只做一个独立脚本。tool description 明确告诉模型在期权概念、波动率、Greeks、策略和公式编号问题上调用它。返回结果包含 source、page、section、content_type、score 和 excerpt,方便 agent 带引用地回答,而不是凭模型记忆回答。
如果面试官问如何避免本地 eval 过拟合:
早期我只有几条手写 case,很容易高估效果。后来我写了 local eval generator,从真实 PDF chunks 中抽样生成问题,同时过滤前言、索引、表格和图注噪声。这样测试集覆盖公式、章节、概念和金融术语,能更真实地暴露 retrieval 的召回和排序问题。
后续可扩展方向
后续还可以继续扩展:
- 增加 reranker 前后的对比实验。
- 增加 answer-level evaluation,评估最终回答是否正确。
- 增加 citation accuracy,判断引用来源是否准确。
- 增加公式检索专门测试集。
- 增加表格类 query 测试集。
- 对不同 chunk 策略、embedding 模型、top-k 参数做批量实验。
- 将报告接入 CI 或定期任务,防止 RAG 效果回退。