RAG 不是搜索加拼接:分块、重排与上下文压缩的实践
过去半年我们把一套检索增强系统从原型推到线上,中间返工过三次。回头看,最贵的教训是:召回率的天花板由分块策略决定,而不是由向量模型决定。换更强的 embedding 模型带来的提升,往往不如把分块重做一遍。
一、朴素方案会在哪里失败
最常见的起手式是:按 512 字固定切分 → 全部灌进向量库 → 查询时取 top-5 → 拼进 prompt。这个方案在演示时表现良好,上线后会在几类问题上系统性翻车:
- 答案跨块:一个定义在块尾,它的例外条款在下一块开头。取回其中一块,模型给出的答案是错的而不是"不知道"。
- 表格被腰斩:表头留在上一块,数据行进了下一块。模型看到一堆无标签的数字,开始编造列名。
- 查询与文档词汇不匹配:用户问「怎么退货」,文档写的是「逆向物流申请流程」。稠密向量在这种同义改写上不如想象中稳。
- 实体名和编号:查「SKU-88213 的保修期」,向量检索对精确 ID 极不敏感,BM25 反而一击即中。
二、分块:语义边界优先于固定长度
我们最终的分块器是一个三层回退结构:
SEPARATORS = [
"\n## ", # 1. Markdown 二级标题
"\n### ", # 2. 三级标题
"\n\n", # 3. 段落
"。", ";", # 4. 句子
]
def split(text, target=700, hard_max=1100):
"""按分隔符优先级递归切分,只在超过 hard_max 时下沉一级。"""
for sep in SEPARATORS:
parts = text.split(sep)
if max(len(p) for p in parts) <= hard_max:
return merge_small(parts, sep, target)
return hard_split(text, hard_max)
三条配套规则同样重要:
- 标题路径前置。每个块的开头强制拼上它所属的完整标题链,例如「售后政策 > 退换货 > 生鲜类目」。这一条把跨块指代问题解决了大半,实现成本却几乎为零。
- 表格不切。检测到 Markdown 表格或 HTML table 就整体保留,超长时按行切但每片都复制表头。
- 重叠 15%。相邻块共享约 100 字。重叠比例再高收益递减,但存储和检索噪声线性上升。
三、检索:混合召回是必需品,不是优化项
把 BM25 和向量检索的结果用倒数排名融合(RRF)合并:
RRF 的好处是不需要对两路分数做归一化——它们的量纲根本不可比。只用排名,鲁棒得多。
重排序器(cross-encoder)放在混合召回之后,把 top-50 精排到 top-8。它比双塔模型准得多,因为查询和文档在同一次前向里做完整交互;代价是不能预计算,延迟随候选数线性增长。50 个候选在 A10 上大约 40 ms,可以接受。
四、上下文压缩:给模型的不是越多越好
拿到 8 个块之后直接全塞进去是有代价的。长上下文里存在明确的位置效应:放在中间的信息被利用的概率显著低于开头和结尾。我们做过一个对照实验,把正确答案分别放在 8 个位置上:
| 正确块的位置 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| 回答正确率 | 0.94 | 0.89 | 0.81 | 0.76 | 0.74 | 0.79 | 0.86 | 0.92 |
中间凹陷了 20 个点。据此我们做了两件事:
- 按重排分数交替排布:最相关的放最前,次相关的放最后,最不相关的塞中间。这是纯粹的排布技巧,零成本,正确率提升约 3 个点。
- 句级抽取压缩:用一个小模型判断每个句子与查询的相关性,剔除明显无关的句子。上下文缩短 40%,延迟降低 22%,正确率不降反升 1.5 个点。
五、评测:不要只看端到端准确率
端到端指标掉了,你无法知道该修检索还是该修生成。我们把评测拆成三个独立的量:
- 召回命中率:正确答案所在的块是否进了候选集。这是检索的上界,生成再强也救不回来。
- 忠实度:回答里的每个事实性陈述能否在给定材料中找到支撑。用一个独立的判别模型逐句核对。
- 可答性判断准确率:材料确实不足时,模型是否正确拒答。这一项最容易被忽略,也最影响用户信任。
建立这三个指标之后,每次回归都能立刻定位到是哪一环退化了。上线前的最后一次返工就是靠它发现的:某次向量库重建后维度顺序错位,召回命中率从 0.86 掉到 0.61,而端到端准确率只掉了 9 个点——因为模型用参数化知识蒙对了一部分,掩盖了真正的故障。
六、还没解决的问题
多跳问题依然是弱项。「A 公司 2024 年的营收,比它 2022 年收购的那家公司同期营收高多少」这类查询,需要先检索出被收购方是谁,再用这个名字发起第二次检索。目前的单轮召回结构处理不了,正在试的方案是让模型显式产出子查询后迭代检索,但延迟会翻倍,还在权衡。