tiered-rag-service 从 10-31 到 11-11 的更新复盘。上一篇讲的是"怎么把结论变成可测的",这一篇讲这半个月我实际动了什么——包括一件我花了四轮实验然后决定不做的事。
三条主线:放弃表格混合解析、三个静默失效的兜底、一轮 60 题 RAGAS 加上一次对裁判的审计。
只写已经发生的事:提交进仓库的改动、跑完的评测、以及明确还没接进链路的部分。
一、放弃:不做 PDF 混合表格解析了
这是这次最花时间、结论最干脆的一块。完整记录在 docs/adr/0013-放弃-pdf-混合表格解析.md。
动因:上一轮把父子分块做完之后,我意识到评测语料里没有任何带表格的文档。于是一个很自然的想法冒出来——"pypdf 提正文 + pdfplumber 认表格转 Markdown",看起来是标准的工程解法。
做法:先造了 4 份带真实 <table> 标记的语料(3 份自编 + 1 份公开一手摘录),围绕表格解析连着做了四轮验证,最后一轮刻意走出合成语料,拿两份公开的真实 arXiv 论文(15 页 / 26 页)做外部验证。
结果一句话:合成语料全绿,真实文档全废。
| 检查项 | 合成语料(4 份 / 13 张表) | 真实文档 |
|---|---|---|
| 表格召回 | 逐张核对,span 几何推导与作者意图差值 0 | 一篇 26 页 RAG 综述文中有 6 张带标题的表,pdfplumber 默认策略检出 0 张 |
| 表格误检 | — | 一篇实际约 4 张表的论文,默认策略检出 10 张;换 vertical_strategy="text" 变 15 张 / 749 行,等于把整页正文当表 |
| 正文质量 | 双栏正文 10/10 完整 | 同一页 pdfplumber 只抽出 37 个字符且单词粘连(Table1:ComparisonbetweenRAG…),pypdf 正常 |
| 行完整性收益 | 两方案同为 20/20、47/47、26/26 | 难版式两边都是 9/12 —— 表格化没有带来收益 |
| 载体成本 | Markdown vs HTML:92/92 个子块超过 200 字符(<thead> 前缀吃满预算),子块总字符是 Markdown 的 4.5 倍,一次回答能带的行数从 11 掉到 5 | — |
| 耗时 | — | 表格文档 ×3.0–4.4;无表格文档也要 ×3.8(pdfplumber 每页都得开);加上吸附与展开后 ×5.2–9.2 |
根本原因很朴素:我合成语料里的表格都有框线,而真实技术文档的表格恰恰没有。booktabs 风格只有横线、没有竖线,pdfplumber 的默认策略在这种版式上召回直接归零。我的收益建立在版式巧合上。
所以决定是:不引入 pdfplumber 依赖,不改 requirements.txt / CI / 镜像,表格继续以纯文本入库,legacy/ 里的实验实现标注"超出 src/ 能力范围"停止迭代。
按这个项目一直用的纪律——每个功能要带 1 个测试 + 1 个数字,并且经得起 20 分钟追问——一个"在我的合成语料上完美、在真实 PDF 上召回为 0"的能力,是负资产。ADR 里也写死了重选条件(必须同时满足三条:有真实用户场景报"表读不出来"、存在在 ≥2 份真实文档上召回不为 0 的检测配置、表格能力带来的命中提升能在评测集上量出来)。
顺带说一句:这次放弃省下的不只是依赖,还有三条只在一个正例/一个反例上验证过的启发式(合并三道条件、表头前缀判定、网格吸附)。n=1 的规则进了主链路,就是未来某个 bug 的种子。
二、三个"静默失效"的兜底
这三个缺陷有个共同点:它们都不会报错,只会悄悄给出错误结果。这类问题比崩溃危险得多,所以这次改的都是"让它吵出来"。
1. 一页坏,整个上传 500
同批真实文档顺手暴露了一个老 bug:一份 26 页的 arXiv PDF 让 pypdf 在第 1 页抛 AssertionError,而旧实现没有任何异常保护,整个 /upload 直接 500。
改成逐页 try/except + 计数之后,同一份文档正常入库:65,043 字、pages_failed=1、failed_pages=[1];干净文档 pages_failed=0,无回归。
关键不只是"跳过坏页",而是把跳过这件事回报出来。静默跳过等于让用户以为整份文档都进了库。
2. 两个文件同时上传,其中一个从 BM25 里消失
这个是我最不愿意遇到的那种:直到下次重启才恢复。
RetrieverPool 原本只有一把 _lock 保护"读/换引用"那一瞬。两个并发上传会各自从对方写入之前的快照重建,后完成的那个把先完成的文件静默抹掉——没有异常,没有日志,检索就是少一个文件。
改法是两把锁分管两件事:
_lock:只保护"读引用 / 换引用"这一瞬,/chat不被上传阻塞;_build_lock:把add()/replace_source()的读-改-写整个流程串起来。
tests/test_retrieval_pool.py 用两个线程重传两个文件来守住它。这类"丢了但没报错"的 bug,只能靠测试锁住——它不会在任何一次手工点测里出现。
3. 模型超时:默认值能让你干等一小时
这是未提交那批里的主要工作,起因是"仓库里为什么没有限流与重试机制"这个问题——但真正的问题比"少个限流"严重得多。
init_chat_model 原本只传了 model / provider / api_key / base_url,一个超时参数都没传。等于用 OpenAI SDK 的默认值:
DEFAULT_TIMEOUT = 600 秒
DEFAULT_MAX_RETRIES = 2 → 一共试 3 次
而一次 /chat 最多调 2 次模型(条件重写 + 生成)。所以最坏情况:
600 秒 × 3 次 × 2 调用 = 3600 秒 = 一小时
真实后果不是"慢",是:阿里云那边卡住 → Gradio 界面转圈两分钟报"连接断开" → 你以为自己代码崩了 → 服务器还在傻等、还在默默重试烧你的额度 → 日志里什么都没有。
改成了 timeout=45 / max_retries=0(Settings.llm_kwargs(),两个值都进环境变量)。几个细节值得单独说:
- 为什么
max_retries=0:这是交互式问答路径。再等 45 秒远不如直接告诉调用方"重试一次"。批量任务(比如重建 embedding)才需要重试,注释里写清楚了。 - 为什么返回 504 不是 500:500 是"我的代码坏了",504 是"上游超时"。前端和看日志的人需要能区分这两种。
- 流式接口的坑:响应一旦开始,状态码就已经是 200 了。所以改成先把第一个 chunk 拉出来再开响应,这样首 token 前超时还能正常返回 504。
- 半截答案不写历史:流中途超时,会往正文里补一行说明,并且不把半截答案写入会话历史——否则下一轮的多轮对话会拿一段残缺的回答当上文。
tests/test_service_timeout.py 覆盖了 7 条:504 映射、服务端不重试、半截流不写历史、首 token 前超时仍是 504 等。这 7 条需要 fastapi / langchain,在只装 pytest+ruff 的最小 CI 环境里会 skip——README 里把这个差异写明了,两边都全绿才算过。
4. 附带:一个切分缺陷
表格长行只有靠分隔符列表末尾的 \n 才切得开。这不是大 bug,但它解释了为什么之前某些表格文档的子块会超长。现在有一条测试专门守这件事。
三、评测:60 题跑完,抓到一个真正的区分点
上一轮的 60 题基准评的是"答案子串有没有进 Prompt",0 次调用、可进 CI。这次补的是生成层:用 RAGAS 在同样 10 篇公开语料、同样 60 题上跑四个指标。
先说清楚评的是什么:跑的是 legacy/app11-02.py(本地实验快照,基线 7b2262f),不是仓库的 src/。快照里还留着限流和已废弃的 pdfplumber 函数,所以这份分数不能当作仓库主干的成绩。
成本:566 次调用(780,683 prompt tokens + 58,519 completion tokens),每题约 9 次(1 次生成 + 8 次裁判)。延迟 P50 13.2 s / P95 15.9 s(纯 CPU 重排的账单),平均上下文 1,699 字。
| 指标 | 全体 60 | exact 30 | paraphrase 15 | cross_document 15 |
|---|---|---|---|---|
| 忠实度 faithfulness | 0.913 | 0.911 | 0.922 | 0.907 |
| 答案相关性 answer_relevancy | 0.845 | 0.900 | 0.805 | 0.774 |
| 上下文精确率 context_precision | 0.813 | 0.978 | 1.000 | 0.294 |
| 上下文召回率 context_recall | 0.975 | 1.000 | 1.000 | 0.900 |
| Prompt 命中(gold 是否进上下文) | 0.950 | 1.000 | 1.000 | 0.800 |
这一列 0.294 就是全部信息量。 其他所有数字都在 0.8–1.0 之间,只有跨文档题的 CP 掉到 0.294。而 prompt 命中是 0.95(57/60:普通+改写 45/45 找齐,跨文档 12/15)——也就是说大部分时候证据找到了,但 CP 依然判它没用。
两个解释方向,这次都验证了:一半是评分口径(每段上下文要独立"有用"),一半是我的评测脚本自己填错了输入。见下一节。
四、审裁判:把低分当结论之前,先检查裁判
60 题跑完,最省事的做法是写一句"跨文档题偏弱,后续优化"。但我上一轮正因为不检查评委,把一个 n=3 的抖动当成了结论。所以这次做了一件更基本的事:把同一批固定输入再评一遍,看分数会不会变。
方法:从原始记录里挑 3 题(q03 / q18 / q21),不重新检索、不重新生成,只把记录下来的回答和上下文重新送进裁判,每题评两次。共 46 次请求。
1. 同一份答案,同一个模型,三次不同的分
| 题 | 指标 | 原始 | 复测第 1 次 | 复测第 2 次 |
|---|---|---|---|---|
| q03 | faithfulness | 0.833 | 1.000 | 1.000 |
| q18 | faithfulness | 0.000 | 0.500 | 0.500 |
| q21 | faithfulness | 0.714 | 0.857 | 0.857 |
| q21 | context_recall | 1.000 | 0.500 | 0.500 |
| q03 | answer_relevancy | 0.6774752269 | 0.6774752269 | 0.6774752269 |
最后一行是这次审计里最有用的一条对照:answer_relevancy 三次逐位相同(它中间那步是 embedding 距离计算,确定性的),而纯靠 LLM 判断的 faithfulness / context_recall 每次都能变。q18 更极端——同一份输入,0.0 和 0.5 都出现过。
结论不是"裁判很差",而是:在这个粒度和这个模型上,「忠实度」这类指标的分辨率大约就是 ±0.15~0.5,单次运行的小数位没有意义。 上一轮我说"3 题测不出差异",这次可以说得更准确:单次 RAGAS 运行的组间差异,必须大于抖动,否则不算差异。
2. 但有一类低分是稳定的——那就要查到底
q21 的 context_precision 是 0 / 0 / 0,三次完全一致。稳定的 0 不是抖动,是系统性原因。
顺着 judge_traces.jsonl 查下去,找到了一条硬证据:评测脚本在组装 judge 输入时,把 answer 字段填成了参考答案,而不是模型生成的回答。 于是 CP 实际在问的问题是"这两段上下文能不能完整支撑参考答案",而不是"这两段上下文对回答这个问题有没有用"。
再叠加 RAGAS 的 CP 是逐段评分:q21 是跨文档题,两段上下文各承担一半信息,各被判 0 —— 可两段合起来其实是有用的。
所以这一列 0.294 的读法要改成:
- 跨文档题的 CP 系统性偏低,是因为"逐段独立有用"这个口径和"多段合起来才够用"这个题型不匹配;
- 其中至少一部分被脚本的字段错置放大了;
- 它是评测口径问题,不能直接翻译成"检索没找到"——prompt 命中 0.8 摆在那里。
这次我真正抓到的不是模型的短板,是评测脚本的 bug。 而它之所以能被抓到,只是因为我在意"为什么这个 0 这么稳定"。
五、这次更新真正改变了什么
一、能力的边界是"真实文档"划的,不是合成语料划的。 合成语料全绿、真实文档全废——这不是运气差,是合成语料天然带着我的假设(表格有框线)。以后凡是"只在自己的语料上验证过"的能力,我都按未验证处理。
二、静默失效比崩溃更贵。 坏页静默跳过、并发上传静默丢文件、超时静默干等一小时、半截答案静默进历史——这四个问题的共同点是都不报错。这次统一改成一个原则:任何降级都必须可见(计数、504、日志、测试锁)。
三、先审裁判,再信分数。 这一轮如果直接引用 0.294,我会得出"跨文档检索很弱"的结论并去优化检索;实际上主要问题是评测口径和一个字段错置。指标本身也需要被测试。
四、放弃也是产出。 ADR-0013 大概是这个仓库里最"负"的一份文档:82 行,结论是"不做"。但它同时固定了数据(真实文档召回 0)、固定了原因(无框线表格)、固定了重选条件。没有这份文档,半年后我一定会再花四轮重做一遍。
附录:这次引用的数字与它们的限制
RAGAS 60 题(data/ragas_legacy/runs/20261110T134207Z-967b72b0/,gitignored):
- 566 次请求(上限 700)、780,683 + 58,519 tokens、P50 13,180 ms / P95 15,926 ms、平均上下文 1,699 字。
- 被测对象是
legacy/app11-02.py快照,不是src/;语料是 10 篇公开自编 PDF。 - 脚本自己声明的限制:合成语料不能外推到真实私有 PDF;参考答案是标注的 gold 片段、不是独立审核过的答案;裁判与生成是同一个模型(qwen-plus),存在自我偏好;CP/CR 每问最多 3 块,粒度很粗;单次运行、无方差估计;延迟不含模型加载;请求数是尝试次数,不是账单证明。
裁判复测(20261111T121412Z-replay-3f7424ef):3 题 × 2 次、46 次请求、固定记录输入、不重新检索。它是诊断性子集,不是 60 题的无偏估计。
单测:88 passed(完整依赖环境);最小 CI 环境会 skip 需要 fastapi/langchain 的 7 条。
表格实验:合成语料 4 份 / 13 张表;真实文档仅 2 份(15 页 / 26 页)。「假合并」「双行表头」「合并单元格」各只有 1 个正例或反例——这就是所有结论的天花板。A 股年报、政府公文、扫描件、无框线中文表格一律未测。