# 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`:调用示例和使用说明。 整体流程如下: ```text 加载数据集 -> 构造 documents / queries / qrels -> 构建 Chroma 向量索引 -> 执行 top-k retrieval -> 按 doc_id 去重 -> 计算 hit@k / MRR / NDCG@K -> 生成 JSON 和 Markdown 报告 ``` ## 为什么只先做 retrieval eval RAG 的最终效果由两部分组成: ```text 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 个结果的排序质量。 计算方式是: ```text 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. 统一数据格式 不同数据集格式不同,因此我统一抽象成: ```python 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` 重建索引,否则会复用旧索引,评测结果不能代表最新代码。 ## 自动化评测脚本 单数据集评测: ```bash uv --cache-dir .uv-cache run python -m eval.rag_eval \ --dataset local-options \ --max-queries 3 \ --top-k 5 \ --rebuild ``` 批量评测: ```bash uv --cache-dir .uv-cache run python -m eval.run_eval_suite --rebuild ``` 只跑指定数据集: ```bash 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 设置: ```bash 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 ``` 报告会输出到: ```text 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 数据库混在一起。随着知识库变大,这种结构不利于维护,也不利于后续把工具代码、数据和缓存分开管理。 解决方法: - 将知识库统一到项目根目录: ```text OptionAgent/knowledge_base/ raw/ chroma_db/ ``` - 工具代码中使用 `PROJECT_ROOT / "knowledge_base"` 作为主路径。 - 保留旧路径 fallback,避免迁移时旧数据立刻失效。 面试可以强调: > 我把知识库从工具目录迁到项目根目录,并保留 legacy fallback。这样既完成了结构治理,也避免了迁移时破坏已有索引和原始文档。 ### 问题 8:全量 rebuild 成本高 只要文档、解析方法或 embedding 模型变化,就全量重建索引。书籍变多后,这会浪费大量时间,而且不方便频繁更新笔记。 解决方法: - 每个 chunk metadata 中保留: ```text source_file file_hash embedding_model extraction_method ``` - 启动时扫描当前 raw 文件,和 Chroma 中已有 metadata 对比: ```text 新增文件 -> 只入库新增文件 修改文件 -> 删除该文件旧 chunks,再重新入库 删除文件 -> 删除该文件对应 chunks embedding/extraction 版本变化 -> 触发对应文件更新 ``` 面试可以强调: > 我没有只依赖 collection 是否为空,而是基于 source_file、file_hash、embedding_model 和 extraction_method 做增量更新。这样文档更新后索引不会脏,也不用每次全量 rebuild。 ### 问题 9:纯向量检索对公式编号和专有名词不稳定 期权书里有很多精确查询,例如: ```text Equation 21.23 WITH ZERO CORRELATION Black-Scholes-Merton vega gamma ``` 这类问题不只是语义相似,还需要字面命中。纯 dense embedding 对概念解释很强,但对公式编号、章节标题、专有名词有时不如关键词检索稳定。 解决方法: - 增加轻量 BM25 检索。 - 查询时同时跑: ```text dense vector retrieval BM25 keyword retrieval ``` - 使用 reciprocal-rank merge 合并结果。 - 再交给 cross-encoder reranker 做最终排序。 最终链路: ```text 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 后,我做了三组对比: ```text 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 的结果示例: ```text 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 效果回退。