把 143 块拆到 45 块:一次 RAG 分块重构,和它揭出来的评测陷阱
把一句 RecursiveCharacterTextSplitter(1000, 200) 换成「清洗 → 画像 → 分层 → 校验 → 回退」的自适应分块器,全库 1959 → 1120 块、HTML 残留 38.3% → 0%、单篇最多块数 143 → 45。但改起来容易、证明它变好很难:A/B 显示六个检索配置全部正向,同时也让「多路召回 +11.1%」缩水到 +2.2pp——原来那部分增益是在补偿脏文本,不是互补召回。本文连同评测自身的三个陷阱(wall-clock 时间窗、judge 随机失败、n=18 时符号会翻)与一次生产语料重建的前置验证一起记录。
换掉一行分块代码是半小时的事;证明它真的变好了,是另一个量级的事。
而且更麻烦的是:当你终于把「验证」做扎实,它往往会先推翻你原本准备拿去讲的结论。
这篇文章记录一次 RAG 分块层的重构——从发现问题、借鉴、实现,到 A/B 验证、跨环境上线、语料重建。但真正的重点是中间那几步:评测本身错在哪里,以及为什么"数字好看"和"数字可信"是两件事。
一、病灶:一句分块代码能坏到什么程度
起点很朴素。分块层当时只有一句话:
self.splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
text = f"{entry['title']}\n\n{entry.get('summary', '')}".strip()
chunks = self.splitter.split_text(text)
没有清洗、没有画像、没有校验。我把它当成"标准做法"用了很久,直到把库里的 chunk 捞出来做统计:
| 症状 | 实测 |
|---|---|
| 含 HTML 标签的 chunk | 38.3% |
| 单篇最多块数 | 143 |
| 标题只出现在篇内前 1~2 块 | 第 2 块起脱离主语 |
症状一:HTML 未清洗,而不是"分块参数没调好"
一开始我以为是 chunk_size 调得不对。量完之后发现不是:
某一篇 release notes,正文 10.3 万字里 70.5% 是 HTML 标签。剥完标签只剩 3 万字。
原因很直接——RSS 源的 summary 字段本身就是富文本。<p>、<h2>、<ul><li>、<strong> 全都算进字符预算里。1000 字符的窗口里,可能有 700 字符是标签,真正的语义内容被挤到下一个窗口。
这也解释了为什么"巨块"和"脏块"是同一个病:标签把有效信息密度拉低,同样的窗口能容纳的正文更少,于是切出来的块更多、更碎、更脱离上下文。
症状二:短条目里,标题是唯一可靠信号
另一个方向的坏法。有一类中文信源的条目正文中位数只有 58 个字,正文里常常根本不出现模型名——标题才是唯一可检索的信号。
而当时的实现只把标题放在首块。短条目通常只有一块,所以没暴露;但一旦正文稍微长一点被切成两块,第二块就成了"没有主语的孤块",再好的向量模型也召不回它。
这一段是我后来才补上的教训:分块器改造完成后我复查时发现,四条策略路径里有三条加了标题前缀,唯独"短条目整条入库"那条忘了——而它恰恰是最需要标题的那一类。
二、借鉴:只取三样,其余主动不采纳
我参考了腾讯 WeKnora 的分块模块(Go 写的开源 RAG 框架)。它的架构是 清洗 → 画像 → 分层策略 → 校验 → 回退,比我们当时的"一句话切分"完整得多。
但照搬是危险的,尤其是跨语言/跨输入形态的照搬。下面是逐条取舍:
| WeKnora 的机制 | 我们的处理 |
|---|---|
| 归一化换行 + 清洗 | ✅ 采纳并加强:增加了 HTML→Markdown 结构转换、实体解码、空白折叠 |
| Tier 链(heading → heuristic → legacy) | ✅ 裁剪成四层:短条目 → heading → recursive → 字符硬切兜底 |
| 校验器(拒绝超长块、碎片化块) | ✅ 移植 2 条,去掉 1 条 |
| 分隔符表含中文标点 | ✅ 采纳并补全角 !?; |
| 条目式资料 overlap=0 | ✅ 采纳(短条目整条入库、不重叠) |
| 父子分块(chunk → parent) | ❌ 不采纳:报告链路的文章级归并已经是它的等价物 |
| heuristic tier(页眉页脚、换页符) | ❌ 不采纳:对新闻类信源无意义,是过度设计 |
最坑的一条:heading 策略差点变成死代码
WeKnora 的 heading tier 假设输入是 Markdown——按 #/## 切分。
我们照搬之后,第一次跑出来的策略命中是这样的:
short 247 / recursive 275 / title_only 49 / heading 1
heading 只命中 1 篇。 而库里明明有 20 篇结构清晰的 release notes。
查下去才明白:那些 release notes 是 HTML:
<h1>v0.29.0</h1>
<h2>Highlights</h2>
<ul><li><strong>...</strong></li></ul>
一个 # 都没有。 而清洗逻辑第一件事就是把标签剥掉——结构在切分之前就已经被抹平了。
修法不复杂,但顺序很讲究:在剥标签之前,先把 HTML 结构转成 Markdown。
# 1. 结构先行:<h2> → ##、<li> → -
t = _RE_H.sub(lambda m: "\n\n" + "#" * int(m.group(1)) + " " + _plain(m.group(2)) + "\n", t)
t = _RE_LI.sub("\n- ", t)
# 2. 块级边界:<p>a</p><p>b</p> 不能粘成 ab
t = _RE_TAG_BREAK.sub("\n", t)
# 3. 剥残余标签 + 解码实体
t = _RE_TAG.sub("", t)
t = html.unescape(t)
三步不能交换。实体解码必须放在剥标签之后——否则 <script> 这类转义内容会被当成真标签二次剥掉。
改完之后 heading 命中从 1 篇变成 19 篇。
这条教训比代码本身值钱:跨项目借鉴时,最难发现的不是"它多做了什么",而是"它假设了什么"。 它的 heading tier 隐含假设"输入是 Markdown",这个假设在它的语境里成立、在我们的语境里不成立,而失败方式是"静默只命中 1 篇",不是报错。
顺带修掉的两个小 Bug
空壳块:# v0.29.0 下面直接跟 ## Highlights,会产出一个 17 字的块(只有两级标题)。它会匹配任何含 v0.29.0 的查询,白占一个 top-k 槽位。修法是让空标题挂到下一块当面包屑,而不是独立成块。
标题重复:release notes 的 H1 通常就等于文档标题,而每块已经统一前缀了标题,于是块内出现两遍。去重时得注意——比较前必须剥掉 # 标记,否则 "# v0.29.0" 永远不等于 "v0.29.0"。
三、验证:六个配置全正向,但有一个反直觉发现
改造完成后,我用已有的评测框架做了一次只换语料、其余全冻结的对照:
- 同一基准日、同一 judge 缓存(判定结果按
query|url冻结在文章级) - 旧语料 = 重建前的备份;新语料 = 重建后的库
- 两边都跑到 18/18 成功(原因见第四节)
| 配置 | 旧分块 P@10 | 新分块 | Δ | MRR |
|---|---|---|---|---|
| A 生产链路(含时间窗) | 40.0% | 49.4% | +9.4pp | 0.644 → 0.704 |
| A0 去时间窗 + 文章级归并 | 50.6% | 56.5% | +5.9pp | 0.738 → 0.863 |
| AR + Rerank | 48.3% | 53.3% | +5.0pp | 0.741 → 0.750 |
| AB 多路召回 | 50.6% | 51.7% | +1.1pp | 0.722 → 0.778 |
| ARB 多路 + Rerank | 50.6% | 52.2% | +1.6pp | 0.713 → 0.750 |
| B 朴素检索 | 53.3% | 56.4% | +3.1pp | 0.722 → 0.852 |
六个配置全部正向。 到这里,故事看起来已经可以收尾了。
但换个角度看同一个表,会发现更重要的东西。
反直觉发现:辅助手段的"增益"大幅缩水
把上表改成"增益"视角:
| 对比 | 旧分块 | 新分块 |
|---|---|---|
AR vs A(Rerank 的净贡献) | +8.3pp | +3.9pp |
AB vs A(多路召回的净贡献) | +10.6pp | +2.2pp |
ARB vs AR | +2.2pp | −1.1pp |
我之前准备拿去讲的招牌数字「多路召回 +11.1%」,在清洗过的语料上只剩 +2.2pp。
这不是"改造退化了",恰恰相反——它说明那部分增益本来就不是检索能力:HTML 标签稀释了 embedding 质量,多路召回(BM25 字面路)恰好能绕过噪声捞回一部分。脏语料下,多路召回在补偿清洗的缺失;清洗之后,dense 变强了,BM25 的边际价值自然回落。
这两个机制是部分替代关系,不是叠加关系。而"C"这种部分替代,在只有一次测量的报告里是看不出来的——它会伪装成"C 有 +11% 的独立价值"。
第二个发现:专名子集上,朴素检索依然碾压生产链路
按查询类型分层后(专名类 n=6):
| 配置 | 旧 | 新 |
|---|---|---|
| A 生产链路(纯向量) | 21.7% | 28.3% |
| AR + Rerank | 26.7% | 30.0% |
| AB 多路召回 | 30.0% | 31.7% |
| B 朴素检索 | 39.8% | 43.9% |
清洗提升了所有配置,但排序没变。 生产链路在低频专名上是结构性弱点——时间窗把部分正确文档排除在外、文章级归并把低频专名信号摊薄、纯语义排序又把它们挤到后面。这是"检索质量直觉"里最硬的一条:工程环节越多,不等于效果越好。
第三个发现:检索层的提升没有传导到生成层
这一点最容易被忽略。同一批语料,生成层指标(官方 RAGAS,55 题集):
| 指标 | 旧语料 | 新语料 | 配对 bootstrap 95% CI | 显著 |
|---|---|---|---|---|
| Faithfulness | 0.905 | 0.887 | [0.83, 0.94] | — |
| Answer Relevancy | 0.792 | 0.777 | [0.69, 0.87] | — |
注意这两个数不能直接和旧值比:新题集从 20 题扩到 55 题、覆盖更均衡,难度上升本身就会让指标下降。在共享的 20 题子集上两次独立运行,Faithfulness 是 0.905 → 0.905(Δ −0.000),Answer Relevancy 是 0.793 → 0.793(Δ +0.001)——这既验证了"下降来自题目变难",也验证了整条测量链是稳的。
真正的结论是:检索层 +9.4pp 的改善,在生成层没有观测到同幅度的传导。 这不是坏消息,而是提醒——"我的检索变好了"和"我的系统答得更好了"是两个命题,各自需要各自的证据。
四、评测自身的三个陷阱
上面每个数字我都标了 n 和口径。原因是我在跑这批实验时,连着踩了三个坑,每一个都能让结论完全反过来。
陷阱一:时间窗用 wall-clock,同一份报告隔周跑不出同一结果
评测框架里有个 WINDOW_DAYS = 7,用来模拟"生产链路只检索近 7 天的内容"。它当时是这么算的:
cutoff = time.time() - WINDOW_DAYS * 86400 # ← 随 wall-clock 漂移
语料的最新文章是 9 月 22 日。我在 9 月 28 日重跑时,7 天窗内只剩 10 篇文章——于是所有带时间窗的配置(A / AR / AB / ARB)分数集体跌到 8.8%。
看起来像严重回归,实际是语料过期。
这个坑的危险之处在于:它伪装成一个"技术结论"。如果我不查证就去调参数,会浪费大量时间优化一个不存在的问题;如果我不查证就写进报告,那这份报告一周后自己都复现不出来。
修法是把窗口锚在固定日期上:
AS_OF = os.environ.get("EVAL_AS_OF", "").strip()
def _cutoff() -> float:
"""时间窗下界。EVAL_AS_OF 支持 'YYYY-MM-DD'(按当日 00:00 计)或 ISO 时间串。"""
if AS_OF:
try:
base = datetime.strptime(AS_OF, "%Y-%m-%d")
except ValueError:
base = datetime.fromisoformat(AS_OF)
return base.timestamp() - WINDOW_DAYS * 86400
return time.time() - WINDOW_DAYS * 86400
不设该变量时行为完全不变(纯增量,不破坏历史可比性)。同时把基准日和语料目录写进报告头,让报告能自证数字是怎么来的。
验证锚定是否有效:旧语料在固定基准日下跑出 A0 = 50.6% / B = 53.3%,而一周前的报告是 50.0% / 53.6% → 偏差 ≤ 0.6pp。
陷阱二:judge 随机失败,会静默抬高平均值
做对照实验时,LLM judge 偶尔会失败,失败的查询被整条剔除。
问题在于:失败是随机的。 同一天三次跑,分别出现"查询 X 失败"、"查询 Y 失败"、"0 失败"。而如果被剔除的恰好是低分查询,剩下的平均值就会被整体抬高。
我就遇到了:旧语料 18/18 成功、新语料 17/18,被剔除的偏偏是一个低分配置查询。那次对照的差值有一部分是假的。
所以现在的纪律是:任何要写进结论的对比,两边必须跑满同一查询集——不够就重跑,直到对齐。报告头里也记录成功数与失败清单。
陷阱三:n=18 的时候,差值连正负都保不住
上表里 ARB vs AR 这一行:
- 旧语料:+2.2%
- 新语料:−1.1%
符号翻转了。
n=18 的评测集,任何一个配置间的差值都在噪声量级。这意味着 "Rerank 带来 +X%" 这类结论,单次测量根本支撑不了。
正确做法是配对检验看置信区间(比如配对 bootstrap 报 95% CI),而不是比两个均值。n 太小的时候,唯一诚实的表述是"观测到正向趋势,但未达显著"。
五、上线:三个只有真跑才会遇到的问题
本地全绿之后,我把这套改造部署到了线上(一台一直在跑的云主机)。这一步踩的坑最多,也最不像"技术问题"。
问题一:代码已经分叉了 10 天
我以为线上跑的就是本地代码,于是只推了 4 个"这次改动相关"的文件。
服务直接起不来了:
web/api.py line 82, in <module>
from rag.query_rewrite import rewrite_query
ModuleNotFoundError: No module named 'rag.query_rewrite'
真因:线上代码是 10 天前的版本,本地这期间新增了 54 个文件、改动了 15 个。我推的新 api.py 依赖了线上根本不存在的模块。
教训:代码会分叉,而且分叉是静默的。动手前必须先量差集——我后来写了个全量 md5 三方对照工具,输出是这样的:
| 类别 | 数量 |
|---|---|
| 只在本地有(线上缺失) | 54 |
| 两边都有但内容不同 | 15 |
| 只在线上有(绝不能删) | 2 |
| 内容一致 | 202 |
最后那一行特别重要:有 2 个文件只在线上存在。如果当时用了"整目录覆盖"式的部署,这两个文件就被静默删掉了。
问题二:把可避免的失败变成了宕机
更该反省的是流程本身。
systemd 的机制是:没重启之前,服务一直跑旧代码。 所以那次 ModuleNotFoundError 本不该影响线上——它只是一个"新代码没准备好"的信号。
是我把"上传"和"重启"做成了连续动作,于是把一个可避免的失败变成了宕机。
现在的部署流程加了一道导入门禁:
本地预检(编译 + 前端语法)
→ 线上预检(服务状态、目标文件指纹)
→ 备份所有将被覆盖的文件
→ 上传
→ 停服
→ 导入门禁:python -c "from web.api import app" ← 就是上次挂掉那一步
├─ 通过 → 启动
└─ 失败 → 自动回滚 + 启动旧代码,中止(服务不宕)
→ 启动后验证:健康检查 + 真实问答端到端 + 埋点落库
关键点是门禁放在停服之后、启动之前,且失败路径会自动恢复。这样最坏情况是"这次没更新成功",而不是"服务挂了"。
问题三:重建语料之前,得先证明"还原"是可靠的
线上存量语料是旧分块器的产物,而且分块改造只对新抓的文章生效——存量不会自动更新。所以要让线上也统一,必须重建索引。
但这里有个硬约束:原文抓不回来了(RSS 是滚动窗口,本地也没有原文缓存)。重建的原文只能从存量 chunk 去重叠还原:
def deoverlap(chunks):
"""把相邻 chunk 的重叠段去掉,还原原文(最长「后缀 = 前缀」匹配)。"""
text = chunks[0]
for nxt in chunks[1:]:
cut = 0
for length in range(min(len(text), len(nxt), 1200), 0, -1):
if text.endswith(nxt[:length]):
cut = length
break
text += nxt[cut:]
return text
而这是一个删除 1252 个块的不可逆操作。所以在动手前,我量化了四条前提:
| 检查 | 线上实测 | 判读 |
|---|---|---|
| chunk 长度分布 | max 正好 1000、超过 1100 的 0 个 | 全库分块参数一致 → 还原前提成立 |
| 相邻块匹配不上 | 54.7%(1101/2014) | 与本地旧语料 50.3% 同构 → 正常,见下 |
| "疑似有重叠但边界错位"的对数 | 0 | 决定性:绝不会重复正文 |
| 还原后 / 原文字符比 | 0.8911 | 与本地已验证过的语料逐位相同 |
第三条是关键的。54.7% 的相邻块"连 1 个字符都对不上"看着很吓人,但原因是 langchain 的 overlap 是按 split 粒度施加的,很多边界本来就没有重叠——拼起来就是对的。
真正危险的是另一种情况:有重叠,但边界对不齐。那会导致拼接时把同一段正文重复进去。我加了一个专门的判据(尾部与头部的最长公共子串 ≥ 60 但又不是前后缀关系),线上实测为 0。
第四条则是一个意外的交叉验证:还原比 0.8911 与本地那份已经跑通、且结果可信的语料完全相同。同码同参,行为一致。
一个必须单独复查的字段
重建会把 metadata 从首块复制给新块,其中包含时间戳字段。这个字段如果被写成字符串,向量库的时间过滤条件会静默匹配不到——生产链路的"近 7 天"检索会返回空,而日志里不会有任何报错。
所以重建后我单独查了这一项:全部 1726 条都是数值类型,生产检索链路正常返回结果。
重建结果
| 重建前 | 重建后 | |
|---|---|---|
| chunk 总数 | 2976 | 1726 |
| 文章数 | 962 | 962 |
| 含 HTML 标签 | 782(26.3%) | 0 |
| 孤儿块 / 块序号有洞 | — | 0 |
| 多块文章标题前缀缺失 | — | 0 / 441 |
| 执行结果 | — | 962 篇 0 失败 |
embedding 成本约 1 元量级。写入顺序是先写新块、再删孤儿——中途崩溃只会"新旧并存",绝不会整篇消失,可重跑续做。
六、沉淀
把这次改造里可迁移的部分抽出来,大概是这几条:
关于借鉴
- 跨项目借鉴时,先找对方的隐含假设("输入是 Markdown"),而不是它的功能列表。假设不成立的失败方式通常是静默的。
- 主动写下"不采纳什么、为什么不采纳",和写"采纳了什么"一样重要——前者防止过度设计。
- 移植过来的策略要用命中率验证它真的在跑。heading 只命中 1 篇时,代码没有任何报错。
关于评测 4. 任何带时间窗的评测都必须锚定基准日,否则报告隔周就自证不了。 5. 对比实验两边必须跑满同一查询集;LLM judge 的失败是随机的,剔除的偏偏是低分样本时会静默抬高均值。 6. n 小了就报置信区间,不要比均值。差值连正负都保不住的时候,任何"提升 X%"都是编的。 7. 每次报数字都带上语料版本。同一套代码、同一个基准日,换了语料结论就会变——而语料是会随时间变的。
关于工程 8. 增益要问一句"它到底在补偿什么"。部分替代关系在单次测量里会伪装成独立价值——多路召回 +11.1% 缩水到 +2.2pp 就是这么来的。 9. 代码分叉是静默的,部署前先量差集,并且一定要识别出"只在线上有"的文件。 10. 把"验证"放在"生效"之前(导入门禁),最坏情况才是"没更新成功"而不是"服务挂了"。 11. 破坏性操作前,先把它依赖的前提量化出来。这次重建的全部信心来自那四条检查,而不是"应该没问题"。
结语
这次改造里,真正让我改变认知的不是分块算法,而是两件事:
第一,评测是会被推翻的。 我原本准备讲"多路召回 +11.1%",是清洗之后自己把它证伪了——那部分增益在补偿脏文本,不是互补召回。能自己先讲出这一点,比数字本身值钱得多。
第二,可复现性是评测的一等公民,不是附加项。 一个"隔周跑不出同样结果"的评测框架,比没有评测更危险——它会让你对错误结论产生信心。锚定基准日、记录语料版本、两边跑满同一查询集,这些做法看起来都很琐碎,但它们决定了那些数字能不能被追问。
一句话总结:
改分块容易,证明它变好很难;而最难的是,证明的过程中发现你原来的"好"是有水分的,并且愿意把它讲出来。