Spaces:
Runtime error
Runtime error
| # 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 效果回退。 | |