/4,949 字/阅读约 18 分钟

把 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 时符号会翻)与一次生产语料重建的前置验证一起记录。

眠月眠月
joker1point

换掉一行分块代码是半小时的事;证明它真的变好了,是另一个量级的事。

而且更麻烦的是:当你终于把「验证」做扎实,它往往会先推翻你原本准备拿去讲的结论。

这篇文章记录一次 RAG 分块层的重构——从发现问题、借鉴、实现,到 A/B 验证、跨环境上线、语料重建。但真正的重点是中间那几步:评测本身错在哪里,以及为什么"数字好看"和"数字可信"是两件事。


一、病灶:一句分块代码能坏到什么程度

起点很朴素。分块层当时只有一句话:

python
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 标签的 chunk38.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——按 #/## 切分。

我们照搬之后,第一次跑出来的策略命中是这样的:

text
short 247 / recursive 275 / title_only 49 / heading 1

heading 只命中 1 篇。 而库里明明有 20 篇结构清晰的 release notes。

查下去才明白:那些 release notes 是 HTML:

html
<h1>v0.29.0</h1>
<h2>Highlights</h2>
<ul><li><strong>...</strong></li></ul>

一个 # 都没有。 而清洗逻辑第一件事就是把标签剥掉——结构在切分之前就已经被抹平了。

修法不复杂,但顺序很讲究:在剥标签之前,先把 HTML 结构转成 Markdown。

python
# 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)

三步不能交换。实体解码必须放在剥标签之后——否则 &lt;script&gt; 这类转义内容会被当成真标签二次剥掉。

改完之后 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.4pp0.644 → 0.704
A0 去时间窗 + 文章级归并50.6%56.5%+5.9pp0.738 → 0.863
AR + Rerank48.3%53.3%+5.0pp0.741 → 0.750
AB 多路召回50.6%51.7%+1.1pp0.722 → 0.778
ARB 多路 + Rerank50.6%52.2%+1.6pp0.713 → 0.750
B 朴素检索53.3%56.4%+3.1pp0.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 + Rerank26.7%30.0%
AB 多路召回30.0%31.7%
B 朴素检索39.8%43.9%

清洗提升了所有配置,但排序没变。 生产链路在低频专名上是结构性弱点——时间窗把部分正确文档排除在外、文章级归并把低频专名信号摊薄、纯语义排序又把它们挤到后面。这是"检索质量直觉"里最硬的一条:工程环节越多,不等于效果越好。

第三个发现:检索层的提升没有传导到生成层

这一点最容易被忽略。同一批语料,生成层指标(官方 RAGAS,55 题集):

指标旧语料新语料配对 bootstrap 95% CI显著
Faithfulness0.9050.887[0.83, 0.94]—
Answer Relevancy0.7920.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 天的内容"。它当时是这么算的:

python
cutoff = time.time() - WINDOW_DAYS * 86400   # ← 随 wall-clock 漂移

语料的最新文章是 9 月 22 日。我在 9 月 28 日重跑时,7 天窗内只剩 10 篇文章——于是所有带时间窗的配置(A / AR / AB / ARB)分数集体跌到 8.8%。

看起来像严重回归,实际是语料过期。

这个坑的危险之处在于:它伪装成一个"技术结论"。如果我不查证就去调参数,会浪费大量时间优化一个不存在的问题;如果我不查证就写进报告,那这份报告一周后自己都复现不出来。

修法是把窗口锚在固定日期上:

python
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 个"这次改动相关"的文件。

服务直接起不来了:

text
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 本不该影响线上——它只是一个"新代码没准备好"的信号。

是我把"上传"和"重启"做成了连续动作,于是把一个可避免的失败变成了宕机。

现在的部署流程加了一道导入门禁:

text
本地预检(编译 + 前端语法)
  → 线上预检(服务状态、目标文件指纹)
  → 备份所有将被覆盖的文件
  → 上传
  → 停服
  → 导入门禁:python -c "from web.api import app"   ← 就是上次挂掉那一步
       ├─ 通过 → 启动
       └─ 失败 → 自动回滚 + 启动旧代码,中止(服务不宕)
  → 启动后验证:健康检查 + 真实问答端到端 + 埋点落库

关键点是门禁放在停服之后、启动之前,且失败路径会自动恢复。这样最坏情况是"这次没更新成功",而不是"服务挂了"。

问题三:重建语料之前,得先证明"还原"是可靠的

线上存量语料是旧分块器的产物,而且分块改造只对新抓的文章生效——存量不会自动更新。所以要让线上也统一,必须重建索引。

但这里有个硬约束:原文抓不回来了(RSS 是滚动窗口,本地也没有原文缓存)。重建的原文只能从存量 chunk 去重叠还原:

python
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 总数29761726
文章数962962
含 HTML 标签782(26.3%)0
孤儿块 / 块序号有洞—0
多块文章标题前缀缺失—0 / 441
执行结果—962 篇 0 失败

embedding 成本约 1 元量级。写入顺序是先写新块、再删孤儿——中途崩溃只会"新旧并存",绝不会整篇消失,可重跑续做。


六、沉淀

把这次改造里可迁移的部分抽出来,大概是这几条:

关于借鉴

  1. 跨项目借鉴时,先找对方的隐含假设("输入是 Markdown"),而不是它的功能列表。假设不成立的失败方式通常是静默的。
  2. 主动写下"不采纳什么、为什么不采纳",和写"采纳了什么"一样重要——前者防止过度设计。
  3. 移植过来的策略要用命中率验证它真的在跑。heading 只命中 1 篇时,代码没有任何报错。

关于评测 4. 任何带时间窗的评测都必须锚定基准日,否则报告隔周就自证不了。 5. 对比实验两边必须跑满同一查询集;LLM judge 的失败是随机的,剔除的偏偏是低分样本时会静默抬高均值。 6. n 小了就报置信区间,不要比均值。差值连正负都保不住的时候,任何"提升 X%"都是编的。 7. 每次报数字都带上语料版本。同一套代码、同一个基准日,换了语料结论就会变——而语料是会随时间变的。

关于工程 8. 增益要问一句"它到底在补偿什么"。部分替代关系在单次测量里会伪装成独立价值——多路召回 +11.1% 缩水到 +2.2pp 就是这么来的。 9. 代码分叉是静默的,部署前先量差集,并且一定要识别出"只在线上有"的文件。 10. 把"验证"放在"生效"之前(导入门禁),最坏情况才是"没更新成功"而不是"服务挂了"。 11. 破坏性操作前,先把它依赖的前提量化出来。这次重建的全部信心来自那四条检查,而不是"应该没问题"。


结语

这次改造里,真正让我改变认知的不是分块算法,而是两件事:

第一,评测是会被推翻的。 我原本准备讲"多路召回 +11.1%",是清洗之后自己把它证伪了——那部分增益在补偿脏文本,不是互补召回。能自己先讲出这一点,比数字本身值钱得多。

第二,可复现性是评测的一等公民,不是附加项。 一个"隔周跑不出同样结果"的评测框架,比没有评测更危险——它会让你对错误结论产生信心。锚定基准日、记录语料版本、两边跑满同一查询集,这些做法看起来都很琐碎,但它们决定了那些数字能不能被追问。

一句话总结:

改分块容易,证明它变好很难;而最难的是,证明的过程中发现你原来的"好"是有水分的,并且愿意把它讲出来。