固定分块大小会导致语义断裂,退款政策被截断后用户问「特价商品能退吗」可能答非所问;分块大小、重叠方式、语义分块策略直接影响RAG系统效果,是投入产出比最高的优化点。
问一个团队他们的 RAG 流水线是怎么工作的,他们会告诉你用了什么嵌入模型、什么向量数据库,也许还会提到重排序器。但如果问他们怎么分块(chunk)文档的,你通常只会得到"呃,500 个 token,重叠一些?默认设置就是这个。"
这个默认设置正在悄然决定系统给出的每个答案的质量。分块是 RAG 流水线中杠杆效应最高、但讨论最少的决策,我想用具体的例子而不是空口白话说服你相信这一点。
假设你的文档中有这样一条退款政策:
## Refund policy
Customers may return items within 30 days of delivery
for a full refund. Items must be unopened and in
original packaging. Opened electronics are subject to
a 15% restocking fee.
Sale items are final and cannot be returned unless
defective. Defective items can be returned within 90
days regardless of sale status.
现在用固定大小的分块器跑一遍,这种分块器每隔 N 个字符就切一刀。边界落点不同,你可能得到这样的一个块:
original packaging. Opened electronics are subject to
a 15% restocking fee.
Sale items are final and cannot be returned unless
用户问"我可以退换促销商品吗?"检索器找到了这个块(它确实包含了"Sale items are final and cannot be returned unless"),然后把它交给模型。模型读完回答说"促销商品不能退换,除非有缺陷"这句话中的关键例外"unless defective"已经被字符边界斩断了。90 天缺陷窗口期活在另一个块里,那个块得分较低,根本没进入 prompt。
你的技术栈没有任何问题。嵌入模型正常,向量数据库正常,LLM 完全按照上下文告诉它的方式回答。答案依然是错的——错在一个没有人自原型阶段之后再看过的分割函数里的一个微小偏差。
一个识别标题的分块器会把整个"Refund policy"部分作为一个块保留下来,这样模型就能同时看到规则和例外。技术栈相同,模型相同,答案正确。
你的本能可能是"好吧,那就把块切大一点,什么都切不断。"这只是用一个失败模式换来了另一个更隐蔽的。
建立这样的心智模型:一段文本的嵌入把它压缩成向量空间中的一个点。这个点代表了这段话的平均语义。所以:
小块给你精确的检索,但上下文支离破碎。一个讲退库费的两句话的块嵌入后形成一个尖锐、具体的点;一个关于退库费的问题正好落在它附近。但一旦检索出来,它可能没有足够的周围上下文让模型作答。"必须未开封"这句话在"它"上文中定义,而"它"在一段话之前时,就毫无用处了。
大块给你上下文,但相似度被稀释。一个 2,000 token 的块涵盖了退款、运费和保修,嵌入后变成三个主题之间的一个模糊中点。一个关于退库费的精准问题现在离那个 blob 不远但也不近,一个无关但更紧凑的块反而能排到它前面。你还烧了 prompt 配额:检索三个 2,000 token 的块,你给模型塞了 6,000 token 来回答一个一句话就能解决的问题。
没有放之四海而皆准的正确大小。只有一个你应该慎重做出的权衡:对大多数散文文档来说,200 到 500 token 之间是一个合理的起点,然后你得去测量(结尾会详细说)。
边界切割的标准缓解方案是重叠:每个块重复前一个块的最后 10% 到 20%,这样跨边界的一句话至少在一个块中是完整的。
重叠有效,你应该用一些。但注意它的代价:
存储和金钱。15% 的重叠意味着每次重新摄入都要永久嵌入和存储多 15% 的 token。
近乎重复的检索。两个重叠的块语义上几乎相同,所以它们会一起被检索出来。你的 top-3 实际上可能变成 top-2,有一个位置被一个副本浪费了。
它不解决真正的问题。重叠补丁了随意的切割,但没有让切割变得不那么随意。这是糊在一个不理解文档的分割器上的创可贴。
这就引出了真正的解决方案。
文档不是字符流。它们有结构:标题、章节、段落、列表项、表格行。反直觉的部分是,最好的分块算法几乎不能称之为算法;它是对作者已经给你的结构的尊重。
结构感知的分块意味着:先按标题分割。如果一个章节太长,按段落分割。如果一个段落还是太长,按句子分割。只有作为绝对最后的手段才按字符数切割。每个主流框架都有一个版本(递归字符分割,分隔符按从最有意义到最没意义的顺序排列),而 markdown-header 分割器对文档是原生支持的。
对于 HTML 或 markdown 文档,标题感知分块本身就消除了退款示例中整类半句话、半句话失败的错误。章节是作者用来组织意义的单元;与之一致的块免费继承了那种连贯性。
结构给了你另一个礼物。一旦按标题分割,你就知道每个块在文档层级中的位置,你可以把它作为一个小的头部前置再嵌入:
type Section = { pageTitle: string; path: string[]; body: string };
function withContextHeader(s: Section): string {
// "Returns & Refunds > Refund policy > Sale items"
const breadcrumb = [s.pageTitle, ...s.path].join(" > ");
return `${breadcrumb}\n\n${s.body}`;
}
// Embed the contextualized text, but keep the raw body too
const chunks = sections.map((s) => ({
text: withContextHeader(s), // what gets embedded and shown to the LLM
raw: s.body, // handy for display/citations
source: s.pageTitle,
}));
考虑这样的块,其正文只是"可以,30 天内,原包装。"单独嵌入,这段文本毫无意义;它可能是关于退鞋子的,也可能是关于租脚手架的。以"退货与退款 > 退款政策 > 促销商品"加正文嵌入,它现在在向量空间中靠近每一个退货相关的查询,当它进入 prompt 时模型知道"可以"指的是什么。
这个技巧每个块只花几十个 token,却经常能拯救短的、依赖上下文的章节(FAQ 是典型案例,因为一半的答案以"是"或"否"开头)。Anthropic 的"上下文检索"工作是同一个想法的花哨版本,用 LLM 来写一个针对块的上下文句子,但朴素的 breadcrumbs 以免费的方式给你带来了令人惊讶的大部分收益。
这里大多数团队就停下来了:他们目测三个答案,感觉不错,就上线了。然后他们在 Slack 里为分块大小争论一年,唯一证据就是直觉。
分块是可衡量的,而且衡量起来并不难。你需要一个黄金问题集:30 到 50 个真实问题,每个都标注了包含答案的文档(或章节)。然后你衡量检索命中率:对于每个问题,top-k 检索到的块中是否有任何一块来自标注的来源?
type GoldenQuestion = { question: string; expectedSource: string };
async function hitRate(golden: GoldenQuestion[], k = 5): Promise<number> {
let hits = 0;
for (const g of golden) {
const results = await retrieve(g.question, k); // your retrieval fn
if (results.some((r) => r.source === g.expectedSource)) hits++;
}
return hits / golden.length;
}
现在分块变更变成了实验而不是观点。用不同的策略重新分块,重新嵌入,运行黄金集,比较一个数字。固定 500 字符分块得分 62%,标题感知分块得分 78%,标题感知加上下文标题得分 84%(这些数字在你自己的语料上很典型,差值才是重点,不是绝对值)。
两个实践注意。首先,如果你有真实用户查询,从真实用户查询中获取黄金问题,因为真实用户的表达比你想象的更糟糕,而这正是检索必须承受的。第二,保持评估快速,在每次摄入变更时运行,就像你在每次提交时运行单元测试一样。一个需要笔记本和一下午的检索评估最多跑两次然后永远不会再跑。
如果你的 RAG 回答很平庸,本能反应是换一个更好的嵌入模型或更好的 LLM。先检查你的块。从索引中随机抽出十个读一读。如果一个块没有额外上下文就会让你困惑,那它对嵌入模型的困惑是它的两倍。
按优先级排列的做法:按结构分割而不是按字符数分割;散文保持几百 token 的块大小;只在结构缺失的地方加适度重叠;嵌入前 prepend breadcrumbs 头部;建立黄金集评估,这样未来每次变更都是一次测量,而不是争论。
模型获得了所有关注,因为它们是令人兴奋的部分。分块是沉闷的部分,决定了模型读到什么,没有模型能从被截成两半的段落中回答问题。
我在 Fetchply 工作,这是一个电商 AI 支持代理,在那里标题感知的块加 breadcrumbs 头部击败了我们测试过的每一个更聪明的替代方案。