先用一句话说清楚
做 RAG 时,我们会把公司的文档、产品手册或知识库交给 AI。文档往往很长,不能每次提问都整篇塞给模型,所以要先把它切成许多小段。这件事就叫分块(chunking)。
用户提问后,系统从这些小段里找出最相关的几段,再让模型基于它们回答。
可以把它想成图书馆:
- 原始文档是一整本书;
- 分块是把书按合适的单位做成可检索的卡片;
- 检索是根据问题找卡片;
- 模型回答是阅读找出的卡片后给出结论。
卡片切得不好,哪怕模型很强,也可能找不到答案,或者只找到了半句话。
名称速查与示例代码
下表给出每种方案常见的英文名称。下面每一节只保留理解原理所需的核心实现;涉及嵌入模型或摘要模型的地方,以函数参数表示,接入时替换成你自己的模型服务即可。
| 中文名称 | 常见英文名 | 示例函数 |
|---|---|---|
| 固定长度切分 | Fixed-Size Chunking / Character Chunking | fixed_size_chunk |
| 递归结构切分 | Recursive Character Chunking | recursive_chunk |
| 句子窗口切分 | Sentence-Level Chunking / Sliding Window Chunking | sentence_window_chunk |
| 语义切分 | Semantic Chunking | semantic_chunk |
| 父子分块 | Parent-Child Chunking / Small-to-Big Retrieval | parent_child_chunk |
| 后置分块 | Late Chunking | late_chunk_vectors |
| 分层分块 | Hierarchical Chunking / RAPTOR-style Chunking | hierarchical_chunk |
注意:示例为便于理解使用字符数近似控制大小;正式上线时应按实际嵌入模型的 token 长度限制配置,并在自己的语料上评测。
为什么模型上下文已经很长,还要分块?
直觉上,既然有些模型能读很长的内容,把整本手册直接放进去似乎更省事。但生产环境通常仍要分块,原因很实际:
- 更快、更省钱。 每次都把几十页甚至上百页内容送给模型,响应会慢,费用也更高。
- 更容易找到重点。 用户问"退款条件是什么",模型只需要看到退款相关的一小节,而不是所有产品说明。
- 减少干扰。 长文本里有太多无关信息,正确答案反而可能被淹没。
- 方便检查。 如果答案错了,我们可以回看"到底检索到了哪一段",从而判断是知识库、检索还是模型出了问题。
所以,分块不是把文本随便切短;它是在"切得足够小,方便找到"和"保留足够上下文,方便理解"之间找平衡。
一个简单例子
假设有一份售后政策:
商品签收后 7 天内可申请退货。定制商品不支持无理由退货。退货商品必须保持完好,并附上订单号。
如果切得太小,可能变成:
- 片段 A:签收后 7 天内可申请退货。
- 片段 B:定制商品不支持无理由退货。
- 片段 C:退货商品必须保持完好,并附上订单号。
用户问"定制商品能退吗?"时,片段 B 足够好;但用户问"普通商品退货有什么要求?"时,只返回 A 就不完整,还需要 C。
这就是分块设计要解决的核心:既要检索准,也要让返回内容足够完整。
七种常见分块方法
1. 固定长度切分(Fixed-Size Chunking):最简单,但容易切坏
最简单的分块方式:每隔 N 个字符切分一次。适合用于快速原型验证;但在正式生产环境中,几乎从来不选。
做法是每隔固定数量的字符或 token 切一刀,例如每 500 个字符一段。
优点是实现极其简单、速度快,适合先把 RAG 流程跑起来。缺点也很明显:它不知道句子、段落和标题,可能从一句话中间切开,也可能把表格或代码拆坏。
python
from langchain.text_splitter import CharacterTextSplitter
splitter = CharacterTextSplitter(
chunk_size=200,
chunk_overlap=0,
separator=" ",
)
chunks = splitter.split_text(text)
什么是"快速原型"?
"快速原型"不是正式成品,而是一个能尽快运行起来的简化版本,目的是先验证方向对不对。
例如,你想做一个"查询公司制度"的 RAG 助手,可以先把所有制度文档每 500 个字符直接切一段,建好向量索引,然后问它"年假有几天?"。这时最需要确认的是:文档能不能导入、检索链路通不通、模型能不能利用检索到的内容回答。
如果这个简化版本已经能解决基本问题,才值得继续优化切分质量;如果连它都跑不通,就没必要一开始花很多时间做复杂的语义切分或分层检索。
权衡与特点:
- 不理解文本语义,可能会从一句话的中间把内容切开。
- 速度快,且每次输入相同内容都会得到相同的切分结果。
- 只适合作为快速原型阶段的基线方案。
2. 按文档结构递归切分(Recursive Character Chunking):最值得先尝试的默认方案
这是大多数 RAG 技术栈中的默认分块方式。分块器会按顺序尝试一组分隔符:先按段落分隔,再按换行分隔,然后按句子边界分隔,最后才按空格分隔,直到每个片段都满足设定的大小上限。
这种做法更像人读文档的方式:先尽量在标题或段落之间切;如果某段还是太长,再按换行、句号、空格继续细分。
它不会强行保证每段一模一样长,但通常能保留比较自然的阅读单位。对于 Markdown、产品文档、接口说明和代码说明,这是很实用的起点。
python
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=300,
chunk_overlap=50,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(text)
这里的 chunk_overlap 是"重叠内容"。比如前一段最后一句是"退款需要提供订单号",下一段开头也保留它,后文即使离开前一段也不会失去关键条件。
权衡与特点:
- 能遵循文档原有的自然结构,例如标题和段落。
- 成本低、结果稳定,不需要额外调用嵌入模型,因此没有嵌入成本。
- 可能丢失跨段落的上下文:如果一个意思延续到下一段,切分时仍可能被分开。
- 适合作为代码、API 文档和结构清晰文档的默认分块方案。
3. 按句子切分(Sentence-Level Chunking)与滑动窗口(Sliding Window Chunking)
使用语言解析工具先按句子边界切分文本,然后将每 N 个句子组合成一个片段,并让相邻片段保留一部分重复内容。
这种方式先识别完整句子,再每几句组成一个片段。例如每 3 句为一段,并让下一段重复前一段的最后 1 句。
好处是不会从句子中间截断,前后文也更连贯。它适合帮助中心文章、会议纪要、博客和普通说明文本。
代价是需要能正确分句的工具;同时,重复句子会让索引和上下文略微变大。对于聊天记录,通常应该按"谁说了一轮话"来切,而不是只看句号。
python
import spacy
nlp = spacy.load("en_core_web_sm")
doc = nlp(text)
sentences = [sent.text.strip() for sent in doc.sents]
window = 3
stride = 2
chunks = []
for i in range(0, len(sentences), stride):
window_sents = sentences[i : i + window]
if window_sents:
chunks.append(" ".join(window_sents))
权衡与特点:
- 能保留完整的句子,因此较好地维持了句子级别的语义单元。
- 需要额外引入 spaCy 或 NLTK 这类自然语言处理工具。
- 使用带步长的滑动窗口,可以保留跨句子的上下文;代价是相邻片段会有重复内容,增加 token 数量与存储、检索成本。
4. 语义切分(Semantic Chunking)基于嵌入向量确定边界:主题变了再切
先为每个句子生成嵌入向量,再计算相邻句子之间的余弦相似度。当相似度低于设定阈值时,就在这里切分。这样得到的每个片段内部通常围绕同一个主题,语义更连贯。
固定长度和按段落切分都只看文本表面。语义切分会进一步判断相邻句子"是不是还在讲同一件事":如果相似,就放在同一块;如果主题突然变了,就从这里切开。
例如一段文章先讲"登录",接着开始讲"计费",即使中间没有空行,语义切分也可能识别出这是两个主题。
它的优点是片段往往更聚焦;缺点是要先给很多句子生成向量,索引构建更慢、更贵,而且"相似到什么程度算同一主题"需要在自己的数据上调试。
python
import numpy as np
import spacy
from langchain_openai import OpenAIEmbeddings
def semantic_chunk(raw_text, threshold=0.75):
nlp = spacy.load("en_core_web_sm")
sentences = [sent.text.strip() for sent in nlp(raw_text).sents if sent.text.strip()]
emb = OpenAIEmbeddings()
vectors = np.array(emb.embed_documents(sentences))
chunks = [[sentences[0]]]
for i in range(1, len(sentences)):
prev = vectors[i - 1]
curr = vectors[i]
sim = float(prev @ curr / (np.linalg.norm(prev) * np.linalg.norm(curr)))
if sim >= threshold:
chunks[-1].append(sentences[i])
else:
chunks.append([sentences[i]])
return [" ".join(c) for c in chunks]
chunks = semantic_chunk(text)
LangChain 和 LlamaIndex 都提供了语义分块的封装工具(SemanticChunker、SemanticSplitterNodeParser),其底层使用的都是类似的逻辑。 权衡与特点:
- 相比递归切分,更能保留句子之间的语义连贯性。
- 在建立索引时,需要为每个句子生成嵌入向量,因此会增加嵌入成本。
- 相似度阈值是一个需要调优的超参数,应根据具体语料进行调整。
5. 父子分块(Parent-Child Chunking):用小纸条找答案,用完整小节来回答
保存两种不同粒度的文本片段:
- 较小的"子片段"(通常是一两句话)用于生成嵌入向量并建立索引;
- 较大的"父片段"(通常是一个完整段落或一个小节)同时保存。 检索时,系统根据子片段的嵌入向量进行搜索;但把命中的子片段所对应的父片段返回给大语言模型。 这样一步就能兼顾两点:小片段便于精准召回,大片段则能为模型提供更完整的上下文。
这是很容易理解、也非常实用的一种思路。
- 把一个小节拆成较小的"子片段",用于搜索;
- 同时保留原来的较大"父片段",例如完整段落或完整小节;
- 搜索时根据子片段找到位置;
- 真正交给模型时,返回对应的父片段。
继续用图书馆类比:小纸条告诉你答案在哪一页,真正给读者看的却是这一整页,而不是只给一个句子。
这样做可以同时得到两种好处:小片段便于精准命中,大片段能提供完整背景。缺点是要多保存一层映射关系,也会多占一些存储空间。
python
from langchain.retrievers import ParentDocumentRetriever
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import Chroma
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
vectorstore = Chroma(
collection_name="rag_demo",
embedding_function=OpenAIEmbeddings(),
)
docstore = InMemoryStore()
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=docstore,
parent_splitter=parent_splitter,
child_splitter=child_splitter,
)
# Index your source documents once so the retriever has something to return.
retriever.add_documents([Document(page_content=text)])
# At query time, the retriever embeds children but returns parent chunks.
results = retriever.invoke("How does Acme reranking work?")
在 LlamaIndex 中,与之对应的组合是 HierarchicalNodeParser + AutoMergingRetriever。 权衡与特点:
- 对多数生产级 RAG 系统而言,它通常能在检索精确率和召回率之间取得较好的平衡。
- 由于要同时保存大小两种粒度的片段,会占用更多存储空间。
- 如果没有明确理由选择其他方案,可以把它作为默认的起点。
适合: 企业知识库、政策制度、产品说明等既要精确搜索、又不能丢条件的场景。
实践建议: 如果检索常能找对主题,但模型的回答"少了前提或例外",优先试试父子分块。
6. 后置分块(Late Chunking):先通读全文,再为每段做索引
这是 2024 年提出的一种改变了传统处理顺序的方法。 传统做法是:先把文档切成多个片段,再分别为每个片段生成嵌入向量。 这种方法则是:先使用支持长上下文的嵌入模型对整篇文档进行编码,然后从生成的 token 嵌入向量中,按各个片段的范围进行汇聚,得到每个片段的向量。 因此,每个片段的向量不仅表示片段本身,还带有整篇文档的上下文信息。 下面的模式只是用于说明思路的伪代码。不同嵌入模型对 token 与原文字符位置的对应方式并不相同,实现起来并不简单;在生产环境中,应使用所选模型官方提供的参考实现。
普通做法是"先切成小段,再分别理解每段"。后置分块的想法恰好相反:先让支持长文本的嵌入模型读完整篇文档,再从中取出每个片段的向量表示。
因此,一个小片段在被检索时,可以带着整篇文档的背景信息。这对跨章节引用特别多的材料很有帮助,例如合同、研究论文、法规和多章节报告。
不过它的工程门槛更高:需要长上下文嵌入模型,还要处理 token 与原文位置的对应关系。实际项目中,最好优先使用嵌入模型官方提供的实现或示例。
python
# Illustrative only. Use the reference implementation linked below.
import numpy as np
def late_chunk_pseudocode(token_embeddings, char_to_token, chunk_spans):
# token_embeddings: shape (num_tokens, dim) from a long-context embedder.
# char_to_token: list of (char_start, char_end) per token from the model's tokenizer.
# chunk_spans: list of (char_start, char_end) per intended chunk.
chunk_vectors = []
for start, end in chunk_spans:
token_indices = [
i for i, (ts, te) in enumerate(char_to_token)
if ts >= start and te <= end
]
if token_indices:
chunk_vectors.append(np.mean(token_embeddings[token_indices], axis=0))
return np.array(chunk_vectors)
这段代码实现的是"按片段池化 token 向量"的关键步骤;获取全文 token 向量和位置偏移的接口因嵌入模型而异。
Jina AI 的 late-chunking 代码仓库提供了经过测试的实现。它处理了 Jina Embeddings v3 以及部分其他长上下文嵌入模型中,模型特定的 token 对齐问题。 权衡与特点:
- 对于语义会跨越片段边界的文档,例如法律文件、科学论文和多章节报告,效果提升明显。
- 需要使用长上下文嵌入模型,例如 Jina v3、Voyage 3 或 Cohere Embed v4;普通的 512 token 嵌入模型无法实现这种方法。
- 建立索引时,每篇文档的处理成本会略高一些;但它是对整篇文档进行一次处理,而不是对每个片段分别处理一次。
适合: 文档内部前后依赖很强、跨章节引用很频繁的语料。
不适合一开始就上: 如果基本分块方案还没有评测过,很难判断它是否真的带来收益。
7. 分层分块(Hierarchical Chunking / RAPTOR-style Chunking):先找目录,再找具体段落
构建一棵层级树:
- 最底层的叶子节点是句子或短文本片段。
- 将相邻、主题相近的叶子节点聚类,再让大语言模型为每一组生成摘要。
- 这些摘要会成为上一层节点。
- 不断重复这个过程,直到形成最顶层的总摘要。 用户提问时,系统会同时检索最底层的原始片段和中间层的摘要,并根据问题需要返回合适粒度的信息:问具体细节时偏向原始片段;问整体概况时偏向高层摘要。
分层方案会建立一棵"内容树":
- 最底层是原始句子或短段落;
- 往上一层,把相近内容归在一起并生成摘要;
- 继续向上,可以得到章节摘要甚至整篇文档摘要。
面对"这份报告的主要风险是什么"这类概括性问题,系统可以先找高层摘要;面对"第三章第 2 条的期限是多少"这类细节问题,再回到叶子节点寻找原文。
它强在处理很长、层次很多的材料,但构建成本高:需要聚类和生成摘要,文档更新后还可能要重建部分树。摘要本身也必须保留来源,避免无法追溯。
python
# Pseudocode sketch. Full implementations live in LlamaIndex's TreeIndex
# and the RAPTOR reference repo.
def build_tree(chunks, n_levels=3):
level = chunks
levels = [level]
for _ in range(n_levels):
clusters = cluster_by_embedding(level)
summaries = [summarize_with_llm(c) for c in clusters]
level = summaries
levels.append(level)
return levels
权衡与特点:
- 很适合处理需要多跳推理的问题:既需要具体细节,又需要整体摘要层面的上下文。
- 离线处理成本较高:每一层都需要进行聚类,并调用大语言模型生成摘要。
- 最适合内容相对稳定、篇幅较长的语料库,例如书籍、科学论文和监管申报文件。
适合: 书籍、论文、监管文件等更新不频繁、且问题既有概括也有细节的长文语料。
怎样选?照着这个顺序做就够了
- 先使用递归字符分块:每个片段控制在 300~500 个 token,相邻片段保留 10%~20% 的重叠内容。这种方案成本低、稳定可靠,作为基线方案通常很难被轻易超越。
- 如果检索召回率较低,即系统经常找不到包含正确答案的片段,就改用语义分块。它尤其适合长篇、以自然语言叙述为主的文本。
- 如果你既需要较高的召回率,又需要给大语言模型提供较完整的上下文,就采用父子分块(Parent-Child Chunking)。它通常能在生产环境中取得较好的精确率与召回率平衡。
- 如果文档的含义经常跨越多个片段,例如法律文件、科学论文或多章节报告,就采用后置分块(Late Chunking)。
- 如果需要针对长文档进行多跳推理,也就是需要串联多个位置的信息才能回答问题,可以在上述任一方案之上增加 RAPTOR 风格的分层检索结构。
不要为了追求后置分块(Late Chunking)或 RAPTOR,就跳过前面第 1 到第 3 步。 这些成本更高的方案通常只能带来有限的提高;只有先建立一个可正常运行的基线方案,才能通过对比看出它们是否真的带来了改进
分块大小和重叠量,到底设多少?
没有一个数字适合所有系统,但可以从以下起点开始:
- 普通知识库文章:每块约 300--600 token;
- 技术说明、代码密集内容:可以适当增大到 500--1000 token;
- 相邻块的重叠:先试 10%--20%;
- 对话记录:优先按完整轮次切分,不要生硬按长度截断。
重叠太少,前后条件容易断开;重叠太多,检索结果会变得重复,也会增加成本。最可靠的办法仍然是在自己的问题集上比较。
别只存文本:每个片段都应有"身份证"
建议为每个片段保存这些信息:
- 来自哪篇文档、哪个版本;
- 所属标题和章节路径;
- 页码、行号或原文起止位置;
- 最后更新时间;
- 谁可以访问;
- 若采用父子分块:它对应的父片段 ID。
这些信息的作用很大:模型回答后可以展示来源;文档更新时能精确重建;不同权限的用户也不会检索到不该看的内容。
对于表格、代码块和 Markdown 小节,尽量保持完整结构。尤其是函数、SQL、配置文件和表格,一旦从中间切开,往往既难检索也难理解。
怎么知道分块方案是不是真的更好?
不要只看"有没有搜到相似文字"。一个方案至少应从以下几个角度比较:
| 要看什么 | 通俗解释 |
|---|---|
| 找没找到 | 正确证据是否出现在前几个检索结果中? |
| 排得靠不靠前 | 正确内容是否总在很后面,导致模型可能看不到? |
| 答得对不对 | 模型最终回答是否真的解决了问题? |
| 有没有根据 | 回答中的关键说法能否在返回片段里找到依据? |
| 快不快、贵不贵 | 端到端响应时间和每次提问成本是否可接受? |
最好的做法是准备一小批真实问题,例如 50 到 100 个,并为每题标好参考答案或相关来源。之后固定模型、提示词和检索数量,只替换分块策略。这样比较出来的差异才可信。
如果结果不好,别只盯着分块。还要检查:文档是否干净、嵌入模型是否合适、元数据过滤有没有误伤、重排器是否需要加入,以及模型是否被要求"只依据给定内容回答"。
总结
分块可以理解为给知识库"编目录"。切得太粗,查到的内容很杂;切得太碎,重要上下文又会丢失。
对大多数项目而言,从按文档结构递归切分开始最合适;当发现"检索不准"或"回答不完整"时,再针对问题升级为语义切分或父子分块。先用真实问题验证,再引入后置分块或分层检索,能让系统更易维护,也更容易说明每一项复杂度带来了什么收益。