RAG 的准确率丢在切分边界上:小块召回与大块重排的两难
切分把长文档裁成可索引的单元,重排给初筛出的候选重新打分排序,两者常被当成可以各自调参的开关,实际是一条链上的上下游:切分决定证据能不能进入候选集,重排只决定它排第几。最常见的误区是以为块切得越小召回越准、重叠留得越多越安全,或者指望重排救回根本没被召回的内容。本文从边界破坏指代、父块与子块分离、双塔与交叉编码器的交互差异讲起,说明为什么小块索引、大块生成是生产默认,以及候选深度、截断长度与延迟预算该如何一起定。
是什么
切分(chunking)是在建索引之前,把一份长文档裁成若干可以独立检索的文本单元;重排(reranking)是在一阶段召回给出候选之后,用更贵的模型对每一个「查询—候选」对重新打分并排序。把这条链想成法院调卷:切分决定哪些卷宗能被立案庭找到,重排决定找到的卷宗按相关程度怎么摆到法官桌上。
它重要,是因为上下文窗口再长也买不回两样东西:被切走的证据,和被噪声稀释的注意力。企业知识库、合同条款问答、代码库检索这些场景里,答案常常只有一两句话,却埋在几十页文档中间,切分与重排几乎直接决定了系统上限。
两者不是并列,而是串联:切分是召回的天花板,重排是天花板下的排序;前者做错,后者再准也只能在错候选里挑一个相对不那么错的。
核心原理
切分切掉的不只是字符,还有指代关系。 固定窗口按 token 数硬切,最省事,也最容易把一句话劈成两半;递归切分按「段落、换行、句号、空格」的优先级逐级回退,尽量落在自然边界上,512 token 左右是常见的工程起点;语义切分则先算相邻句子的向量相似度,在相似度骤降处断开,块的主题更纯,但块长忽大忽小,索引规模不好预估。
真正麻烦的是边界处丢失的上下文。一个块里写着「该接口不需要鉴权,其余接口一律校验签名」,若指明「该接口」是哪一条的那句被切到上一块,这个块就只剩没头没尾的规则,嵌入向量也表达不出它约束的是谁。条件句被拆开、表头与数据行分家、函数签名与实现分离,同样会让块「检索得到、回答不了」——只盯召回率不够,字面匹配不等于能支撑答案。
业内有三种补法。第一种是给每个块加一段文档级前缀,说明它出自哪份文件、哪个章节、在讲什么,Anthropic 公布的 Contextual Retrieval 就是用模型为每块生成五十到一百 token 的说明再拼接索引,其报告称仅上下文嵌入就把前二十块的检索失败率降低约三成半,叠加 BM25 后接近五成,再加重排接近七成。第二种是延迟切分(late chunking):先用长上下文嵌入模型把整篇文档编码,再按边界对 token 向量做池化,让每个块的向量在池化前已经「看」过全文,指代关系因而保留。第三种是父块—子块结构:用小块建索引保证匹配精度,命中后返回它所属的大块给模型,把「检索单元」和「生成单元」拆开。
重叠不是免费保险。 常见做法是相邻块留一成到两成的重叠,防止边界句子两边都不完整。但重叠的代价是索引膨胀、同一段证据占多个候选位,还会把邻居的主题噪声带进块里。Jina 在延迟切分的论文里做过一组 BEIR 对比,固定切分加十六 token 重叠与不加相比,nDCG@10 并没有明显优势。重叠该不该留、留多少,要靠评测说话,而不是背一个百分比。
重排的本质是补上「联合看」的能力。 一阶段用的双塔模型(bi-encoder)把查询和文档分别编码成两个向量,再算余弦相似度。文档向量可以离线算好,所以能在毫秒级扫过百万级文档;代价是查询与文档的交互被压进一个向量,模型无从比较「不包含」「除外」这类细粒度逻辑。用户问「哪些接口不需要鉴权」,一段讲「所有接口都必须鉴权」的文字,在向量空间里和问题的距离可能同样近。
交叉编码器(cross-encoder)换了个做法:把查询和文档拼成一条序列送进 Transformer,让两边的 token 在每一层互相注意,最后输出一个相关性分数。它能读出否定、条件和数值冲突,因此在公开评测里通常能带来可观的排序提升。但它没法预计算——文档的表示依赖具体的查询,每个「查询—文档」对都要单独跑一次前向。这就是两阶段检索的由来:一阶段用便宜的双塔或 BM25 广撒网保召回,二阶段只对几十到上百个候选做精排。按每对十毫秒估算,一百个候选就是大约一秒,延迟预算直接决定了候选深度。
ColBERT 用 token 级向量做延迟交互,比交叉编码器快,存储却要涨一到两个数量级;用大模型直接重排效果可能更好,但成本与波动更大,通常只用于十到二十个候选。
候选深度与顺序,比重排模型本身更常被忽略。 一阶段取五十到一百个候选,重排后留五到十个进上下文,是常见的配比。若一阶段的召回率只有八成,那么即使重排完美,最终也只有八成的问题能拿到正确证据。另外,重排分数只保证相对更相关,跨查询直接卡一条分数线很容易误杀。进入提示词的块还有位置效应:开头和结尾更容易被用上,堆在中间的长上下文容易被跳过,所以重排的产出应当用来裁剪,而不是把候选一股脑塞进去。
def split(doc, size=400):
chunks, parent = [], "root"
for para in doc.split("\n\n"):
if para.startswith("#"):
parent = para.lstrip("# ").strip()
for i in range(0, len(para), size):
chunks.append((para[i:i + size], parent))
return chunks
def expand(hits, top_n=5):
seen, out = set(), []
for text, parent in hits:
if parent not in seen:
seen.add(parent)
out.append(parent)
return out[:top_n]
这段代码只做一件事:子块负责被检索,父块负责被展开,并且同一个父块只返回一次。少了最后这步去重,五个命中可能全来自同一章节,上下文被重复内容占满,真正互补的证据反而挤不进来。
常见考点与误区
- 误区一:「块切得越小,召回越准。」 小块确实更容易和短查询在向量空间里对齐,但它也更容易丢掉前提和指代,导致「检索命中、生成答错」。公开对比里出现过语义切分召回更高、端到端正确率却低于递归切分的情况。正确说法是:检索单元可以小,生成单元要完整,两者不必是同一个东西。
- 误区二:「重叠留得越多越安全。」 重叠只是把边界风险摊到两个块上,并不会恢复被切断的语义;它还会让索引变大、候选多样性下降。留不留、留多少,应该用固定查询集去测,而不是照搬教程里的默认值。
- 考点三:「双塔和交叉编码器的本质差别是什么?」 不是参数量,而是有没有联合交互。追问「为什么不能拿交叉编码器直接建索引」,答案是文档表示依赖查询,无法离线预计算,只能对候选逐对打分。
- 误区四:「重排能救回没召回的证据。」 不能。重排是在给定候选集内重新排序,天花板由一阶段的 recall@k 决定。所以评测要分开看:recall@50 衡量一阶段,NDCG@10 衡量重排,答案忠实度衡量生成,三者混在一起就定位不到问题。
- 考点五:「重排分数能当统一阈值用吗?」 不能。不同查询的分数分布不同,交叉编码器还有最大长度截断,长块的后半段可能根本没被读到。拿分数做过滤,必须按查询归一化或改用「取前 N 个」的策略。
- 误区六:「上下文窗口够长,RAG 和重排都可以省了。」 长窗口解决的是「放得下」,不解决「找得准」和「注意力被稀释」。全量塞入既贵又容易触发中间内容被忽略,检索与重排仍然是性价比更高的路径。
实践建议
- 先建评测再调参。固定一批真实问题,分别记录 recall@50、NDCG@10 和答案正确率;任何切分改动都要端到端回归,否则很容易被单点召回指标的上涨骗过去。
- 默认用「递归切分 + 父子块」:子块三百到六百 token 建索引,父块按章节或条款聚合后喂给模型;分隔符按文档类型定,Markdown 认标题,代码认函数与类,PDF 先做版面还原再切。
- 一阶段候选取五十到一百,重排后留五到十;用延迟预算反推候选深度,能批量打分就批量,延迟敏感的场景换小型交叉编码器或延迟交互模型。
- 索引里保留 doc_id、章节路径、页码等元数据,重排前先按父块去重;确认重排模型的最大输入长度,长块要么截断策略明确,要么先切后拼。
- 监控空召回率、重排前后 top-1 变化率、引用块的位置分布,它们比离线总分更早暴露切分与候选深度的问题。
一句话总结:切分决定证据能不能进候选,重排只决定它排第几;前者是上限,后者是兑现。