Spaces:
Runtime error
Runtime error
File size: 18,535 Bytes
8f1601b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 | # 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 效果回退。
|