2024 年我做一个企业知识库问答系统,用 RAG 把公司的几百份文档喂给大模型。第一版我做得很省事:把每份文档,按固定 500 个字符,机械地切成一块块 chunk,逐块向量化、存进向量库;用户提问时,检索出最相关的几块,塞给模型作答。本地我拿几篇排版规整的文档测了测——真不错:问什么答什么,有来有回。我心里很踏实:"RAG 的分块嘛,就是把文档按固定长度,切成一段段,不就行了。"可等这个系统真正上线、面对公司里那些格式五花八门的真实文档,一串问题冒了出来。第一种最先把我打懵:用户问一个问题,检索回来的 chunk,正好从一句话的中间断开——前半句在上一块、后半句在下一块,模型拿到的是半句没头没尾的话,根本看不懂。第二种:一个完整的操作步骤,"第一步……第二步……第三步……",被固定长度拦腰切成了两块,检索只命中其中一块,模型就只答了一半的步骤。第三种:一份文档里的表格,被从中间切断,表头留在上一块、数据行落在下一块,模型读到那半张没有表头的数据,输出的全是乱的。第四种最隐蔽:每个 chunk 里只有光秃秃的正文,不知道它来自哪份文档、属于哪一章节——模型答出来的东西,既没法告诉用户"出处在哪",我也没法判断"这块内容是不是早就过时了"。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"RAG 的分块,就是把文档按固定长度,机械地切成一段段"。这句话把"文档"当成了一长串没有结构的字符流。可它不是。文档是有结构的:它有标题的层级、有段落的边界、有列表、有表格、有代码块——这些都是不可切断的"语义单元"。固定长度一刀切,这把刀根本不看这些结构,它会从句子中间、从步骤中间、从表格中间,任意地剁下去。真正做好 RAG 的分块,核心不是"按长度切得整齐",而是理解文档的结构、沿着它的天然边界下刀、把每一块的大小控制在合适区间并让边界互相重叠、还要给每一块带上能溯源的元数据。这篇文章就把 RAG 的文档分块梳理一遍:为什么"固定长度一刀切"是错的、怎么按文档结构递归地切分、chunk 大小和重叠该怎么定、每个 chunk 该带上哪些元数据、表格和代码这类特殊内容怎么切,以及检索粒度、父子分块、chunk 更新这些把分块真正做对要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:把文档按固定 500 字符机械切块之后,上线冒出一串问题:检索回来的 chunk 从句子中间断开,模型看到半句话;一个完整的操作步骤被拦腰切成两块,模型只答了一半;表格被从中间切断,表头和数据分了家,模型读出来全是乱的;chunk 里只有正文、没有来源信息,答案无法溯源、也无法判断是否过时。
我当时的错误认知:"RAG 的分块,就是把文档按固定长度,机械地切成一段段。"
真相:分块(chunking)是 RAG 流程里极容易被低估的一环。很多人觉得它只是个"把长文本切短"的预处理小步骤,随便切切就行。可实际上,分块的质量,直接决定了检索的质量,而检索的质量,又决定了整个 RAG 的回答质量。原因在于:大模型最终看到的,不是完整的文档,而是被切出来、又被检索回来的那几个 chunk。如果 chunk 本身就是残缺的——半句话、半张表、半个步骤——那么无论你的向量检索多准、模型多强,它能拿到的素材就是残缺的,答案自然也好不了。更关键的是,文档从来不是均匀的字符流:它有标题层级、有段落、有列表、有表格、有代码块。这些是语义上的"完整单元",一旦被从中间切断,语义就碎了。所以分块的本质,不是"切得整齐",而是"切得不破坏语义"。
要把 RAG 的文档分块做对,需要几块认知:
- 为什么"固定长度一刀切"是错的——它会切断句子、步骤、表格;
- 按结构切分——沿着标题、段落这些天然边界下刀;
- 大小与重叠——chunk 不太大不太小,边界要互相缝合;
- 元数据——每个 chunk 要带上来源、标题路径,才能溯源;
- 特殊内容、父子分块、chunk 更新这些工程坑怎么处理。
一、为什么"固定长度一刀切"是错的
先把这件最根本的事钉死:一份文档,在你眼里是有结构、有层次的——你一眼能看出哪里是标题、哪里是一个完整的步骤、哪里是一张表格。但在"按固定长度切"的程序眼里,文档只是一根长长的字符串,它数到第 500 个字符,就"咔"地剁一刀,完全不管这第 500 个字符此刻正落在哪里。它可能落在一个词的中间,可能落在一句话的中间,可能落在"第二步"和"第三步"的中间,也可能落在一张表格的表头和数据之间。这把刀是"瞎"的——它对文档的语义结构一无所知。
下面这段代码,就是我那个"上线就切碎语义"的第一版:
# 反面教材:把文档按固定字符数,机械地一刀一刀切
def naive_chunk(text, size=500):
chunks = []
for i in range(0, len(text), size):
chunks.append(text[i:i + size])
return chunks
# 破绽一:第 500 个字符,可能正落在一句话、一个词的中间。
# 破绽二:一个完整的步骤列表、一张表格,会被任意切断成两块。
# 破绽三:切出来的 chunk 只有正文,不知道它来自哪篇文档、哪一章。
这段代码在本地拿排版规整的文档测试时表现尚可,因为那几篇文档段落短小、没有表格、没有跨段的长步骤,固定长度的刀恰好没切到要害。它的问题不在代码本身,而在一个被忽略的前提:它默认"文档是一根可以任意位置切断的均匀字符流"。可真实的文档充满了不可切断的语义单元。于是那串问题就有了解释:读到半句话,是因为刀切在了句子中间;步骤只答一半,是因为一个列表被切进了两个 chunk;表格读成乱码,是因为表头和数据被切散了。问题的根子清楚了:做好分块的工程量,全在"承认文档有结构、有不可切断的语义单元"之后——你不让刀去看文档的结构,就只能切出一地语义的碎片。先从让刀"长出眼睛"说起。
二、按结构切分:沿文档的天然边界下刀
让分块的刀"长出眼睛",核心思路是递归切分(recursive splitting):不要一上来就按字符数硬切,而是优先沿着文档"最大的、语义最完整的边界"切;如果切出来的块还是太大,再退一步,沿着"次一级的边界"切;层层递进,直到每一块都足够小。文档的边界,是有语义强弱之分的:段落之间的空行(最强)、单个换行、句号叹号问号、空格、最后才是单个字符(最弱、最不得已):
def recursive_split(text, max_size=500):
"""递归分块:优先沿"强"边界切,切不够小,再退用"弱"一级边界。"""
# 分隔符按"语义强度"从强到弱排列
separators = ["\n\n", "\n", "。", "!", "?", " ", ""]
def _split(s, seps):
if len(s) <= max_size:
return [s] if s.strip() else []
sep = seps[0]
parts = s.split(sep) if sep else list(s)
chunks, buf = [], ""
for p in parts:
piece = p + sep
if len(buf) + len(piece) <= max_size:
buf += piece # 还装得下,继续攒
else:
if buf:
chunks.append(buf)
if len(piece) > max_size:
# 单段仍超长 —— 退一级,用更弱的分隔符继续切
chunks.extend(_split(piece, seps[1:]))
buf = ""
else:
buf = piece
if buf:
chunks.append(buf)
return chunks
return _split(text, separators)
下面这张图,把一份文档被结构化切分的完整流程串起来:
这里的认知要点是:递归切分的智慧,在于它永远"优先保全大的语义单元"。它把一整段、一整句尽量留在同一个 chunk 里,只有当一个单元实在太大、装不下时,才不得已退一级、动用更弱的刀。这样切出来的 chunk,边界总是落在语义的"接缝"上,而不是"中间"。但光"切在接缝上"还不够——切出来的块,大小是否合适、相邻块的边界要不要缝合,还得继续打磨。
三、chunk 大小与重叠:不太大,不太小,边界要缝合
chunk 切多大,是个需要权衡的取舍:切太大,一个 chunk 里塞了太多内容,真正相关的那句话被一堆无关文字稀释,向量检索时相关性就模糊了,而且喂给模型时浪费上下文、引入噪声;切太小,一个 chunk 只有一两句话,缺少必要的上下文,模型看不懂它在说什么。经验上,一个 chunk 落在两三百个 token 上下,是个比较稳的区间。另一个关键设计是重叠:让相邻的两个 chunk,共享一小段重叠的内容,这样即便某句话不幸被切在了边界上,它在前后两个 chunk 里也都能留下完整的一份:
def split_with_overlap(text, size=500, overlap=80):
"""滑动窗口切分:相邻 chunk 重叠 overlap 个字符,缝合被切断的边界。"""
chunks = []
start = 0
while start < len(text):
end = start + size
chunks.append(text[start:end])
if end >= len(text):
break
# 关键:下一块的起点往回退 overlap,和上一块的尾部重叠
start = end - overlap
return chunks
# 重叠让被切断的句子,在相邻两块里都能各留下完整的一份
还有一个容易被忽略的细节:切分应该按 token 数算,而不是按字符数算。因为大模型的上下文、向量模型的输入,计量单位都是 token;而一个中文字、一个英文单词,占的 token 数并不一样。按字符切,你算不准一个 chunk 到底占多少 token:
import tiktoken
_enc = tiktoken.get_encoding("cl100k_base")
def chunk_by_tokens(text, max_tokens=300, overlap_tokens=40):
"""按 token 数而非字符数切分 —— 因为模型和向量库都按 token 计量。"""
tokens = _enc.encode(text)
chunks = []
start = 0
while start < len(tokens):
window = tokens[start:start + max_tokens]
chunks.append(_enc.decode(window))
if start + max_tokens >= len(tokens):
break
start += max_tokens - overlap_tokens # 往回退,留出重叠
return chunks
这里的认知要点是:chunk 的大小不是越大越好,也不是越小越好,而是要在"上下文足够"和"噪声足够少"之间找平衡。重叠则是给切分这件有损的事买的一份保险——它用一点点存储冗余,换来"边界上的句子不会丢"。而无论切多大,都该用 token 计量,因为那才是模型和向量库真正"看见"的单位。切好了、大小也合适了,但每个 chunk 此刻还是一段"无名无姓"的文本——它得有身份。
四、给每个 chunk 带上元数据:让它能溯源
开头第四个问题——"chunk 不知道自己来自哪"——根子是我只存了 chunk 的正文,丢掉了它的身份信息。一个合格的 chunk,不该只是一段文本,它还应该背着一身元数据:它来自哪份文档、文档的标题是什么、它在文档里属于哪一章哪一节、文档什么时候更新的。这些元数据有两个大用处:一是溯源(回答用户时能附上"出处:某文档 第几章"),二是过滤(检索时可以限定"只在某个部门的文档里找"、"排除掉一年前的旧文档"):
from dataclasses import dataclass, field
@dataclass
class Chunk:
"""一个 chunk 不只是文本,还要背上一身能溯源、能过滤的元数据。"""
text: str
doc_id: str # 来自哪份文档
doc_title: str # 文档标题
heading_path: list = field(default_factory=list) # 章节标题路径
chunk_index: int = 0 # 在文档里的第几块
updated_at: str = "" # 文档的更新时间
def build_heading_path(blocks):
"""遍历文档的标题块,为每段正文算出它所属的"标题路径"。"""
path = [] # 形如 ["第2章 安装部署", "2.1 环境准备"]
for block in blocks:
if block["type"] == "heading":
level = block["level"]
path = path[:level - 1] # 回退到上一级标题
path.append(block["text"])
else:
block["heading_path"] = list(path)
return blocks
这里的 heading_path 尤其有用。它记录了一个 chunk "沿着标题一路走下来的位置",比如 ["第2章 安装部署", "2.1 环境准备"]。这个路径,本身就是一段高质量的上下文——把它拼在 chunk 正文前面一起向量化,能让一段原本"不知所云"的正文,瞬间有了归属。这里的认知要点是:一个没有元数据的 chunk,是一段"失忆"的文本——它能被检索到,却说不清自己是谁、从哪来。给它带上来源和标题路径,它才从一段孤立的文字,变成一条可溯源、可过滤、可信任的知识。常规正文的分块讲完了,但文档里还有表格、代码这些"硬骨头"。
五、特殊内容:表格与代码块怎么切
开头第三个问题——"表格被切成乱码"——暴露了一个事实:文档里有些内容,是绝对不能用通用规则去切的。最典型的就是表格和代码块。表格的要害在于:表头和数据行是一体的,一旦把表头切走、只留数据,这些数据就失去了含义。正确的做法是:在切分之前,先把表格整体识别出来、抠出来,作为一个不可分割的 chunk:
import re
def protect_tables(text):
"""把 Markdown 表格整体抠出来,绝不让它在中间被切断。"""
table_pattern = re.compile(r"(^\|.+\|\s*$\n?)+", re.MULTILINE)
segments = []
last = 0
for m in table_pattern.finditer(text):
if m.start() > last:
segments.append({"type": "text", "content": text[last:m.start()]})
# 整张表作为一个不可分割的 chunk
segments.append({"type": "table", "content": m.group()})
last = m.end()
if last < len(text):
segments.append({"type": "text", "content": text[last:]})
return segments
代码块也是一样:一段代码从中间切断,就成了无法运行、无法理解的残片。Markdown 里的代码块有明确的围栏标记,可以据此把整段代码保护起来:
def protect_code_blocks(text):
"""以三反引号围栏为界,把代码块整体保留,不在代码中间下刀。"""
segments = []
in_code = False
buf = []
for line in text.split("\n"):
if line.strip().startswith("```"):
buf.append(line)
if in_code:
# 围栏闭合,整段代码作为一个独立 chunk
segments.append({"type": "code", "content": "\n".join(buf)})
buf = []
in_code = not in_code
else:
buf.append(line)
if buf:
segments.append({"type": "text", "content": "\n".join(buf)})
return segments
这里的认知要点是:分块不是"一套规则切遍所有内容"。文档里存在一些"原子单元"——表格、代码块、有时还有一个完整的有序列表——它们的内部结构是一体的,切断即损毁。正确的顺序是:先把这些原子单元整体识别、隔离出来,再对剩下的普通正文施加通用的切分规则。分块的主体讲完了,最后是几个真正上规模后才会撞见的工程坑。
六、工程坑:父子分块、检索粒度与 chunk 更新
五块设计之外,还有几个工程坑,不处理就会让 RAG 的检索要么不准、要么过时。坑 1:检索要的"小",和喂给模型要的"大",是矛盾的——用父子分块化解。chunk 切小,向量检索更精准(一个小块主题单一,相似度算得准);可 chunk 切小,喂给模型时上下文又不够。这两个需求是打架的。父子分块(small-to-big)是经典解法:用切得很小的"子块"去做向量检索,一旦命中,却把它所属的、更大的"父块"喂给模型——检索的精准和上下文的完整,两头都占:
def build_parent_child(doc_text, doc_id):
"""父子分块:用小块拿去检索,命中后却把它所在的大块喂给模型。"""
parents = recursive_split(doc_text, max_size=1500) # 大块:上下文足
index = []
for p_idx, parent in enumerate(parents):
parent_id = f"{doc_id}-p{p_idx}"
# 每个大块,再切成更小的检索块
for child in split_with_overlap(parent, size=300, overlap=50):
index.append({
"child_text": child, # 拿它做向量检索:小而精准
"parent_id": parent_id,
"parent_text": parent, # 命中后真正喂给模型:大而完整
})
return index
坑 2:chunk 入库时,元数据要能用于过滤。第四节给 chunk 备好的元数据,只有真正写进向量库、并被检索时用上,才有意义。入库时要把元数据一并存好:
def index_chunks(collection, chunks):
"""把带元数据的 chunk 写进向量库 —— 元数据要能用于检索时过滤。"""
collection.add(
documents=[c.text for c in chunks],
metadatas=[{
"doc_id": c.doc_id,
"doc_title": c.doc_title,
"heading_path": " > ".join(c.heading_path),
"updated_at": c.updated_at,
} for c in chunks],
ids=[f"{c.doc_id}-{c.chunk_index}" for c in chunks],
)
坑 3:文档会更新,chunk 不能只增不改。知识库里的文档会被修订。如果文档改了,你只往向量库里追加新 chunk、却不删旧的,那么同一份文档的新旧两版 chunk 会同时存在,检索时新旧混杂、甚至检出已经作废的旧内容。正确做法是:文档更新时,按内容 hash 判断它到底变没变;变了,就先把这份文档的所有旧 chunk 删干净,再重新切分入库:
import hashlib
def sync_document(collection, doc_id, new_text, splitter):
"""文档更新时:内容没变就跳过,变了就先删旧 chunk 再重建。"""
new_hash = hashlib.md5(new_text.encode("utf-8")).hexdigest()
old = collection.get(where={"doc_id": doc_id}, limit=1)
if old["metadatas"]:
old_hash = old["metadatas"][0].get("content_hash")
if old_hash == new_hash:
return "unchanged" # 内容没变,不必重建
# 内容变了:先把这份文档的所有旧 chunk 删干净,再重新切分入库
collection.delete(where={"doc_id": doc_id})
chunks = splitter(new_text)
# ... 给每个 chunk 带上 content_hash,再调 index_chunks 入库
return "reindexed"
坑 4:不同文档类型,该用不同的分块策略。一篇散文式的说明文档,和一份 API 接口手册、一份 FAQ 问答集,结构完全不同。FAQ 最自然的 chunk 单元是"一问一答",API 手册是"一个接口"。别指望一套分块规则打天下,要按文档类型,挑配套的切法。坑 5:切完要抽样检查 chunk 质量。分块策略写完,别直接信它。随机抽几十个 chunk 出来,人眼看一看:有没有半句话?有没有被切断的表格?有没有空洞无意义的碎片?chunk 的质量,是 RAG 效果的地基,这个地基值得你花时间亲眼验过。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 固定长度切分 | 按字符数机械下刀,会切断句子、步骤、表格 |
| 递归分块 | 按分隔符语义强度从强到弱,逐级切到合适大小 |
| chunk 重叠 | 相邻块共享一段重叠内容,缝合被切断的边界 |
| 按 token 切分 | 用 token 而非字符计量,贴合模型与向量库 |
| 标题路径 | 记录 chunk 所属章节层级,可拼进正文增强上下文 |
| chunk 元数据 | 来源文档、更新时间等,支撑溯源与检索过滤 |
| 表格保护 | 整张表作为不可分割单元,表头与数据不分家 |
| 代码块保护 | 以围栏为界整体保留,不在代码中间下刀 |
| 父子分块 | 小块用于精准检索,命中后喂大块保证上下文 |
| chunk 同步 | 文档更新时按内容 hash 判断,变了才删旧重建 |
避坑清单
- 固定长度一刀切会切断句子、步骤和表格,把语义剁成碎片。
- 优先按文档结构递归切分,沿标题、段落这些天然边界下刀。
- 相邻 chunk 要重叠一段,缝合被切在边界上的句子。
- chunk 不是越大越好也不是越小越好,大噪声多小缺上下文。
- 按 token 数而非字符数切分,贴合模型和向量库的计量。
- 每个 chunk 必须带元数据,否则答案无法溯源、无法过滤。
- 表格要整体保留,表头和数据行被切散就读不懂了。
- 代码块要以围栏为界整体保护,别在代码中间下刀。
- 父子分块:小块用于精准检索,大块用于喂给模型。
- 文档更新要按内容 hash 判断,先删旧 chunk 再重建。
总结
回头看那串"半句话、半个步骤、半张表格、无名无姓的 chunk"的问题,以及我后来在分块上接连踩的坑,最该记住的不是某一个切分参数,而是我动手前那个想当然的判断——"RAG 的分块,就是把文档按固定长度,机械地切成一段段"。这句话错在它把"文档"看成了一根可以任意位置剪断的均匀绳子。我以为切分只是个无足轻重的预处理,切得整齐就行。可我忽略了:文档是有骨架的——标题是它的关节,段落是它的肌理,表格和代码是它的器官;一把不看结构的刀,切的不是绳子,是在剁一个有血有肉的东西。固定长度一刀切,切出来的不是知识的片段,而是知识的碎尸——每一块都似是而非,拼不回完整的意思。
所以做 RAG 的文档分块,真正的工程量不在"写一个按长度切的循环"那几行代码上。那几行,谁都会写。真正的工程量,在于你要承认"文档是有结构的、有不可切断的语义单元",并让你的刀学会看着结构下手:它有标题层级,你就递归地、沿着从强到弱的边界切;它的句子会被切在边界上,你就让相邻块重叠一段去缝合;它的每一块需要身份,你就给它带上来源和标题路径;它有表格和代码这些原子单元,你就先把它们整体隔离、再切别的;检索要小、喂给模型要大,你就用父子分块两头都占;文档还会更新,你就按 hash 判断、先删旧再重建。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"一刀切"为什么错,再讲怎么按结构递归切、大小和重叠怎么定、元数据怎么带、表格代码怎么特殊处理,最后是父子分块、检索粒度、chunk 更新这几个把分块做扎实的工程细节。
你会发现,给 RAG 切文档,和现实里"把一本厚书,拆成一张张便于查阅的卡片"完全相通。一个粗心的人会怎么做?他拿把尺子,量着每 10 厘米就剪一刀(这就是固定长度一刀切)。结果呢?一句话被剪成两半、一张插图被拦腰剪断、连目录都被剪得七零八落(这就是切碎的句子和表格),而且每张卡片上都没写它是从哪本书、哪一页来的,过后谁也说不清这张卡片在讲什么(这就是丢了元数据)。而一个细心的人怎么做?他会先翻一遍书,看清它的章节结构(这就是理解文档结构),然后沿着每一节、每一段的天然接缝去拆(这就是按结构递归切分);遇到跨页的表格和插图,他会把它们完整地单独取出来(这就是表格代码的特殊保护);每张卡片的角上,他都工工整整写好"出自第几章第几节"(这就是标题路径元数据)。两个人都把书拆成了卡片,可前者拆出的是一堆废纸,后者拆出的是一套真正能用来查阅的知识卡片。
最后想说,RAG 的分块做没做对,差距永远不会在"本地拿几篇规整文档一测就准"时暴露——本地你挑的那几篇文档段落短、没表格、没长列表,固定长度的刀恰好没切到要害,你会觉得"按长度切一切"已经是全部。它只在真实的、文档格式五花八门、有大量表格代码和跨段步骤、还会不断被修订的知识库里才显形。那时候它会用最伤回答质量的方式给你结账:做不好,你的问答系统会把半句话、半张表喂给模型,模型只能连蒙带猜地答出残缺甚至错误的内容,用户还无从知道答案的出处、更无从分辨它是不是过时的;而做对了,你的检索会稳稳地召回一个个语义完整的 chunk:每一块该长则长、该短则短,表格和代码完好无损,每条答案都能清清楚楚地标明"出自某文档某章节"。所以别等"用户抱怨答案残缺又对不上"找上门,在你写下那行按长度切的循环时就该想清楚:我面对的不是一根均匀的字符流,而是一份有骨架、有器官、会更新的结构化文档——它的句子、它的步骤、它的表格,这一个个语义单元,我的刀是不是都替它们让开了?这些问题有了答案,你切出来的才不只是一堆"长度整齐"的文本块,而是一套真正完整、可溯源、撑得起高质量问答的知识地基。
—— 别看了 · 2026