有时候放弃也是一种收获

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 的默认值:

ini 复制代码
DEFAULT_TIMEOUT     = 600 秒
DEFAULT_MAX_RETRIES = 2      → 一共试 3 次

而一次 /chat 最多调 2 次模型(条件重写 + 生成)。所以最坏情况:

yaml 复制代码
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 股年报、政府公文、扫描件、无框线中文表格一律未测。

相关推荐
shengjk135 分钟前
从一条 Bash 命令看懂 LLM Agent:为什么大模型会执行命令?
人工智能
愚坤1 小时前
前端真有意思,JS还能独立做项目?
前端·javascript·github
Yuzhiyuxia1 小时前
【AI Agent面试系列】AI Agent可观测、评测、安全相关的知识点
人工智能·安全·面试
dong_junshuai2 小时前
每天一个开源项目#157 Modern Web Guidance:让Agent按需查浏览器能力
github
dozenyaoyida2 小时前
AI与大模型新闻日报 | 2026-11-11
人工智能·chatgpt·大模型·新闻
xsd202411182 小时前
把品牌官网做成一个 3D 世界:scroll-world
人工智能
孟健2 小时前
我用Claude Skills把出海视频月账单从$86打到了$16
人工智能·ai编程·claude
泯泷3 小时前
同样是多 Agent,为什么有的稳,有的却天天翻车?
人工智能·llm·agent
一起学安全技术3 小时前
拆大模型 06|Prompt 工程不靠玄学:上下文要当程序写
人工智能·语言模型·prompt·aigc