2024 年我做一个文档问答系统,用户上传一份产品手册,然后能用自然语言向它提问,系统去手册里找答案——也就是现在常说的 RAG。它的核心套路我很清楚:把文档切成一块一块,每块算一个向量存进向量库,用户提问时把问题也算成向量,检索出最相似的几块,连同问题一起喂给大模型作答。这一整条链路里,有一步叫"把文档切成块",怎么切?这件事我没多想,就有了方案:按字数切。第一版我做得很顺手——我写了个函数,把文档每 500 个字切成一块,一块接一块,整整齐齐。本地拿一份手册一测,提了几个问题,答得还像模像样。我心里很笃定:切块嘛,不就是把长文档分成小段,按字数均匀切最简单,这套 RAG 稳了。可等这个系统真正面对五花八门的真实文档和真实提问,一串问题冒了出来。第一种最先把我打懵:有些问题,手册里明明白白写着答案,系统却答不上来,或者答得驴唇不对马嘴。第二种最难缠:我去看检索召回的内容,发现一个关键句子被从正中间切断了,前半句在一块、后半句在另一块,两块单独看都不成意思。第三种最头疼:有个问题召回的块里,确实有一句是对的,可那一块足足几百字,那一句被一大堆无关内容埋着,模型被这堆噪声带偏了。第四种最莫名其妙:有一块召回出来是"它支持三种部署模式",可"它"指的是什么,在这一块里根本找不到——主语在上一块里。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为切块就是个机械的分割动作,把长文档按固定字数均匀切开,切得整齐就行。可切块根本不是一个无关紧要的预处理步骤,它直接决定了整个 RAG 系统检索质量的上限。道理很硬:检索时,被召回的最小单位就是"块";如果一个块本身语义就是残缺的、是噪声混着信息的、是缺了上下文的,那么无论后面的向量检索多准、大模型多强,它拿到的原料就是坏的,做不出好菜。按固定字数切,正是在用一把对文档语义结构一无所知的尺子,生硬地裁剪文档——它必然会把完整的句子、段落、表格拦腰切断。要把 RAG 做扎实,根上要明白:切块的目标不是"切得均匀",而是"让每一块都尽量是一个语义完整、大小适中、自带必要上下文的单元"。本文从头梳理:为什么按固定字数切会切碎语义,怎么按语义边界切,为什么块之间要留重叠,块的大小该怎么权衡,怎么给块保留住它的上下文,以及一些把它做扎实要避开的工程坑。
问题背景
先把 chunking 在 RAG 里的位置说清楚。一个 RAG 系统,离线阶段要做的事是:把文档切块、给每块算向量、存进向量库。在线阶段:把用户问题算成向量,去向量库里检索最相似的若干块,把这些块作为"资料"和问题一起塞进大模型的 prompt,让模型基于资料作答。整条链路里,chunking 是第一步,也是最容易被当成"小事"的一步。
错误认知是:切块只是个把长文本分小的机械操作,按固定长度切最省事,切多大、在哪切都无所谓。真相是:块是检索和召回的最小单位,块的质量,就是喂给模型的原料的质量,它给整个系统的效果设了一个上限。把这一点摊开,第一版的几类问题就都能解释了:
- 语义被拦腰切断:固定字数的切点落在句子、段落中间,一个完整的意思被劈成两半,两个半块单独都检索不出、也表达不清原意。
- 块太大引入噪声:一个大块里只有一两句和问题相关,其余几百字都是无关内容,既稀释了向量的语义、又在 prompt 里挤占 token、干扰模型。
- 块太小丢失上下文:块小到不足以自我解释,出现"它""该功能"这类指代,而指代的对象在别的块里,模型无从理解。
- 结构信息被拍平:文档的标题层级、表格、列表在切块时被当成纯文本拍平,召回的块不知道自己属于哪一章、哪个主题。
所以 chunking 要做对,核心不是把切分写得更快,而是让切分这个动作"读懂"文档的语义结构,切出的每一块都尽量自成一个完整、干净、带上下文的单元。下面六节,就从第一版的固定字数切讲起,一步步把它改对。
一、为什么"按固定字数切"会切碎语义
第一版的切法,业内叫固定大小切分:不看内容,只数字数,数满 500 就一刀切下。它唯一的优点是简单。但它有一个根本的盲点:它对文档的语义结构完全无知。文档不是一串均匀的字符流,它是有结构的——由章节、段落、句子层层组成,每一级都是一个有意义的单元。固定字数的那把尺子,完全无视这套结构,它的切点纯粹由"数到第 500 个字"决定,而第 500 个字落在哪里,是随机的:可能正好落在一句话的中间,落在一个词的中间,落在一个表格的中间。
# 反面教材:按固定字数切,完全无视文档的语义结构
def fixed_size_split(text: str, chunk_size: int = 500) -> list:
chunks = []
for i in range(0, len(text), chunk_size):
# 切点纯粹由字数决定,落在句子中间也照切不误
chunks.append(text[i:i + chunk_size])
return chunks
doc = ("产品支持三种部署模式。第一种是单机模式,适合开发测试,"
"所有组件运行在同一台机器上,资源占用最低但不具备高可用。"
"第二种是集群模式,适合生产环境……") # 一份真实文档会长得多
for idx, c in enumerate(fixed_size_split(doc, 40)):
print(f"[块{idx}] {c}")
# 你会看到某一块以"第二种是集"结尾,下一块以"群模式"开头
# 一个完整的概念被生生劈成两半,两块单独看都不知所云
这种切法的危害,会沿着 RAG 的链路一路放大。第一步,算向量时:一个残缺的半句话,算出来的向量本身就语义模糊——它既不完整代表前半句的意思,也不代表后半句。第二步,检索时:用户的问题向量,很难和这个模糊的向量对得上,该被召回的内容召不回来。第三步,就算侥幸召回了:模型拿到的是半句话,它要么理解错,要么干脆答不出。第一版"答不上明明写着的答案",根子就在这里——不是检索不行,是被检索的那个"块",从出生起就是残缺的。
这一节要建立的认知是:文档是有语义结构的,而固定字数切分用的是一把对这套结构完全无知的尺子。切分这个动作的本质,是"决定在哪里下刀"。下刀的位置好不好,标准只有一个:是否落在了语义的自然缝隙上。句子和句子之间、段落和段落之间,是天然的缝隙,在那里下刀,两边都是完整的。而一个句子的内部、一个词的内部,是语义最紧密的地方,在那里下刀就是破坏。固定字数切分的悲剧在于,它的下刀位置和语义缝隙毫无关系,纯属碰运气——而文档那么长,运气好的切点是少数,绝大多数刀都砍在了不该砍的地方。要修这个问题,就得换一把能"看见"语义缝隙的尺子。
二、按语义边界切:段落、句子优先
换尺子的思路,叫递归字符切分。它的核心想法是:文档的语义结构是分级的,切分时也要按级来,优先在大的语义缝隙下刀,大缝隙不够用时再退而求其次找小缝隙。具体说,先尝试按"段落"切(通常是连续两个换行);如果某一段还是太长、超过了目标大小,再在这一段内部按"句子"切(句号、问号、感叹号);如果某个句子还是太长,才最后退回到按字符硬切。这样,下刀的位置总是尽可能落在最大的那个语义缝隙上。
# 递归字符切分:优先按大的语义边界切,不够才退到小的
def recursive_split(text: str, chunk_size: int,
separators: list = None) -> list:
# 分隔符按"语义粒度从大到小"排列
if separators is None:
separators = ["\n\n", "\n", "。", "!", "?", " ", ""]
# 文本已经够短,直接作为一块
if len(text) <= chunk_size:
return [text] if text.strip() else []
sep = separators[0]
rest = separators[1:]
# 用当前这一级分隔符切开
parts = text.split(sep) if sep else list(text)
chunks, buffer = [], ""
for part in parts:
piece = part + sep if sep else part
# 累加还不超尺寸,就先攒着
if len(buffer) + len(piece) <= chunk_size:
buffer += piece
else:
if buffer:
chunks.append(buffer)
# 单个 part 自己就超了:用更细的分隔符递归切它
if len(piece) > chunk_size and rest:
chunks.extend(recursive_split(piece, chunk_size, rest))
buffer = ""
else:
buffer = piece
if buffer:
chunks.append(buffer)
return [c for c in chunks if c.strip()]
这套切法和固定字数切的差别,是质的差别。固定字数切是"先决定切点,内容被动接受";递归切分是"先尊重内容的结构,切点服从结构"。绝大多数块,会恰好是一个或几个完整的段落、完整的句子。只有在极少数情况下——某个句子本身就长得超过了块大小——它才不得不退回去做字符硬切,而这种情况在正常文档里很罕见。
这一节的认知是:好的切分,是让切点去适应文档,而不是让文档去迁就切点。固定字数切的思维是"我有一把 500 字的尺子,文档你来配合我";递归切分的思维是"文档你有怎样的结构,我顺着你的结构来下刀"。这个主从关系一颠倒,切出来的块的质量就天差地别。这里还藏着一个更普遍的道理:处理任何有结构的数据——文档、代码、日志、HTML——都应该先识别并尊重它的固有结构,而不是把它当成无结构的字节流来硬处理。一旦你开始"按结构办事",很多本来很别扭的问题会自然消失。递归切分,就是把这个道理用在了文档切块上。
三、给块加重叠:防止边界信息丢失
按语义边界切,已经能保证大多数块内部是完整的了。但还有一个边界问题没解决:有些信息的完整含义,是跨越段落边界的。比如一段讲"集群模式",下一段开头说"在这种模式下,需要至少三个节点"——"这种模式"指的就是上一段的集群模式。如果切点恰好落在两段之间,"需要三个节点"这条信息所在的块,就丢掉了"是哪种模式"这个关键前提。解决办法是让相邻的块之间有一段重叠:每一块的开头,带上前一块结尾的一小段内容。
# 给相邻块加重叠:每块开头带上前一块结尾的一段
def split_with_overlap(text: str, chunk_size: int,
overlap: int) -> list:
# 先按语义边界切成基础块
base = recursive_split(text, chunk_size)
if overlap <= 0 or len(base) <= 1:
return base
overlapped = [base[0]]
for i in range(1, len(base)):
prev = base[i - 1]
# 取前一块结尾的 overlap 个字,拼到当前块前面
tail = prev[-overlap:] if len(prev) > overlap else prev
overlapped.append(tail + base[i])
return overlapped
# overlap 一般取 chunk_size 的 10% 到 20%
chunks = split_with_overlap(long_doc, chunk_size=500, overlap=80)
# 这样"在这种模式下"那一块,开头就带着上一块结尾
# 提到的"集群模式",指代不再悬空
重叠的大小是有讲究的。太小,起不到衔接作用;太大,相邻块的内容重复太多,既浪费存储,又会让检索时召回一堆内容高度雷同的块。常见的取值是块大小的百分之十到百分之二十。它本质是花一点冗余,买一份"边界处不丢信息"的保险。
这一节的认知是:无论你的切分边界找得多准,"被切开"这个动作本身,就一定会切断某些跨边界的语义联系。第二节让切点落在语义缝隙上,解决的是"不要切碎一个完整单元";但即便切点完美,相邻两个单元之间那种"承上启下"的联系,还是会被切断——因为它们本来就是被一个边界分开的。重叠就是承认这一点之后给出的补偿:既然边界两侧的联系会断,那就让每一块多带一点"邻居的尾巴",把那条被切断的线,在块的内部重新接上一截。这是一种典型的工程权衡——用可控的冗余,换取信息完整性。理解了"切分必有损失、重叠是对损失的补偿",你就不会再纠结"到底该不该重叠",而会去想"重叠多少最划算"。
四、块的大小是个权衡:不是越大越好也不是越小越好
前面一直在用 chunk_size 这个参数,但还没正面回答:它到底该设多大。这是 chunking 里最关键、也最被反复纠结的一个决策。先说清楚两个方向的代价。块太大:一个块里塞了好几个主题,它的向量是这几个主题的"平均",语义模糊,检索精度差;而且召回后,一大块内容里只有一小部分有用,其余都是噪声,既挤占 prompt 的 token,又干扰模型。块太小:块倒是干净了,但常常小到语义不完整,出现悬空的指代,而且检索时要召回很多个小块才能凑齐回答一个问题所需的信息。
# 块大小要按 token 计量,并显式控制在一个合理区间
import tiktoken
_enc = tiktoken.get_encoding("cl100k_base")
def token_len(text: str) -> int:
# 用 token 数而不是字符数衡量块大小
return len(_enc.encode(text))
def split_by_token_size(text: str, target_tokens: int = 300,
min_tokens: int = 80) -> list:
raw = recursive_split(text, chunk_size=target_tokens * 3)
result, buffer = [], ""
for piece in raw:
merged = buffer + piece
if token_len(merged) < min_tokens:
# 太小的块先攒着,和后面的合并,避免产生碎块
buffer = merged
else:
result.append(merged)
buffer = ""
if buffer:
# 收尾的小残块并入最后一块,而不是单独留着
if result:
result[-1] += buffer
else:
result.append(buffer)
return result
那到底设多大?没有放之四海皆准的数字,但有原则。它要和你的内容类型匹配:问答型、条目型的内容(比如 FAQ),天然就短,块可以小;叙述性强、逻辑连贯的长文,块要大一些才能保住完整的论述。它还要和你的提问类型匹配:如果用户多问"某个具体事实",小而精的块检索更准;如果多问"总结、对比"这类需要综合的问题,块要大一些。下面这张表把这些权衡列清楚:
chunk 大小的权衡 方向 好处 代价 适合 -------------------------------------------------------------- 块偏大 语义完整 上下文足 向量模糊 噪声多 费token 叙述长文 综合性问题 块偏小 向量精准 噪声少 易缺上下文 指代悬空 FAQ 条目 事实性问题 适中 兼顾两者 需要调试找到平衡点 多数通用场景 常见经验起点:每块 200 到 500 token,重叠为块大小的 10%-20%; 务必用自己的真实文档和真实问题做检索评估后再定,不要照搬。
这一节的认知是:chunk_size 不是一个有"标准答案"的参数,而是一个必须在"检索精度"和"语义完整"这两个互相拉扯的目标之间求平衡的权衡值。把它调大,你买到了语义完整,付出的是检索精度和 token;把它调小,你买到了检索精度,付出的是上下文完整性。这两个方向不可能同时拉满。所以正确的态度,不是去网上找一个"最佳 chunk size"抄过来——因为它根本不存在——而是理解这个权衡的两端各是什么,然后用你自己的真实文档、真实问题去做检索评估,看哪个值在你的场景下效果最好。能把参数选择从"抄一个数"变成"理解权衡后做评估",是 RAG 调优里很重要的一步成熟。
把按结构解析、递归切分、控制大小、加重叠这几步串起来,一份文档变成一批高质量块的完整流程,就是下面这张图:
[mermaid]
flowchart TD
A[原始文档] --> B[按结构解析 识别标题段落表格]
B --> C[递归按语义边界切分]
C --> D{块大小是否在合理区间}
D -->|过大| C
D -->|过小| E[与相邻块合并]
D -->|合适| F[给相邻块添加重叠]
E --> F
F --> G[附加标题路径等元数据]
G --> H[计算向量 写入向量库]
五、给块保留上下文:标题路径与元数据
还有一类上下文,是重叠也补不回来的——文档的层级结构。一份手册有章、有节、有小标题,某一段文字的完整含义,往往依赖它所处的标题路径。比如一段话写"默认开启,可在配置中关闭",这段话出现在"日志模块"章节下,意思就完全确定;可一旦它被切成块、脱离了原文,这一块就只剩孤零零一句"默认开启,可在配置中关闭",检索时根本对不上"日志模块怎么配置"这样的提问。解决办法是:切块时,把这一块在文档里的标题路径,作为元数据一起带上,甚至直接拼进块的文本里。
# 切分时记录每块的标题路径,作为上下文一并保留
import re
def split_with_heading_path(markdown: str, chunk_size: int) -> list:
lines = markdown.split("\n")
heading_stack = [] # 当前所处的标题层级栈
blocks, buffer = [], []
def flush():
text = "\n".join(buffer).strip()
if not text:
return
path = " > ".join(h[1] for h in heading_stack)
for piece in recursive_split(text, chunk_size):
blocks.append({
"text": piece,
"heading_path": path, # 如 部署 > 集群模式
# 把标题路径拼进正文,让向量也带上这层上下文
"embed_text": f"[{path}]\n{piece}" if path else piece,
})
for line in lines:
m = re.match(r"^(#+)\s+(.*)", line)
if m:
flush()
buffer = []
level = len(m.group(1))
# 维护标题栈:弹出同级或更深的,压入当前标题
while heading_stack and heading_stack[-1][0] >= level:
heading_stack.pop()
heading_stack.append((level, m.group(2).strip()))
else:
buffer.append(line)
flush()
return blocks
这里有个值得注意的细节:标题路径不只是存进元数据备查,更要拼进真正参与向量计算的那段文本里(代码里的 embed_text)。这样,这一块的向量本身就带上了"我属于部署章节下的集群模式"这个语义,用户问"集群模式怎么部署"时,检索就能对得上。元数据除了标题路径,还可以包括来源文件名、页码、章节号、更新日期等,它们在检索过滤、给用户标注答案出处时都有用。
这一节的认知是:一个块脱离原文档独立存在之后,它会丢失所有"由位置带来的隐性上下文",而切块时正是把这些隐性上下文显式地固定下来的唯一时机。在原文里,一段话属于哪一章,是它的物理位置自动赋予的,不需要写出来;可一旦它被切成一个独立的块、扔进向量库,它和原文的位置关系就彻底断了——除非你在切的时候,主动把这层关系记录下来。这就是为什么 chunking 不能只产出"一段文本",而要产出"文本 + 元数据"。切块这个动作,真正在做的事,是把一份连续的、靠位置隐含了大量上下文的文档,转换成一批离散的、必须自带上下文才能独立成立的单元。想清楚这一点,你就会在切块时格外认真地对待元数据,因为切块的那一刻,是给每个块"安上身份"的唯一机会。
六、把 chunking 做扎实,要避开的工程坑
前面五节搭出了一套像样的切分:按语义切、带重叠、控大小、附元数据。但要真正经得起生产里各种文档的考验,还有几个坑得专门讲。第一个最常见:表格和代码块绝对不能被切碎。一个表格被从中间切开,两半都不再是合法的表格,语义彻底毁掉;一段代码被切断同理。正确做法是,在切分之前先把这类"不可分割的整体"识别出来、保护起来,让切分逻辑跳过它们的内部。
# 切分前先保护表格、代码块这类不可分割的整体
import re
def protect_atomic_blocks(text: str):
atomic, placeholders = [], {}
def stash(match):
key = f"__ATOMIC_{len(atomic)}__"
atomic.append(match.group(0))
placeholders[key] = match.group(0)
return key # 用占位符替换,让切分逻辑碰不到它
# 把 Markdown 代码块整体抠出来换成占位符
text = re.sub(r"```[\s\S]*?```", stash, text)
# 把连续的表格行整体抠出来
text = re.sub(r"(?:^\|.*\|$\n?)+", stash, text, flags=re.M)
return text, placeholders
def restore_atomic(chunk: str, placeholders: dict) -> str:
# 切完之后,把占位符还原回原始的表格、代码块
for key, original in placeholders.items():
chunk = chunk.replace(key, original)
return chunk
# 流程:protect -> 正常切分 -> restore
# 表格和代码块要么完整在某一块里,要么单独成一块,绝不被切断
第二个坑,是检索之后的上下文扩展。块切得再好,单个块承载的信息也有限。一个实用技巧是:检索时按块的向量找到最相关的块,但真正喂给模型时,把这个块和它在原文里紧邻的前后块一起带上。这样,既享受了小块带来的检索精度,又给了模型足够的上下文。这要求你在切块时,给每个块记录它在原文里的序号。
# 检索命中小块,喂给模型时扩展到相邻块,兼顾精度与上下文
def expand_with_neighbors(hit_chunk_ids: list, all_chunks: list,
window: int = 1) -> list:
selected = set()
for cid in hit_chunk_ids:
# 把命中块前后各 window 个相邻块也纳入
for j in range(cid - window, cid + window + 1):
if 0 <= j < len(all_chunks):
selected.add(j)
# 按原文顺序返回,保证上下文连贯
return [all_chunks[j] for j in sorted(selected)]
# 检索用小块(精准),作答用扩展后的上下文(完整)
# 这就是常说的 small-to-big:小块检索,大块喂给模型
还有几个坑值得点一下。其一,块大小一定要按 token 数衡量,而不是字符数——因为下游的 embedding 模型和大模型,处理的都是 token,而且 embedding 模型本身有最大输入长度,块超了会被悄悄截断。其二,切分的质量必须被评估,不能切完就不管:准备一组真实问题和它们的标准答案,跑检索,看该召回的块有没有被召回,用这个去反向调你的 chunk_size 和重叠。其三,不同类型的文档值得用不同的切分策略,代码文档、法律条文、对话记录,各有各的天然结构,不必强求一套切法走天下。这几个坑串起来是同一个意思:chunking 不是一段"写一次就定死"的工具代码,它是 RAG 系统里需要持续观察、评估、按文档类型调整的一个环节。它的好坏没法只靠读代码判断,必须靠"拿真实问题去检索、看召回准不准"来检验。把切分当成一个要被评估、被迭代的对象,而不是一个一次性的预处理脚本,RAG 的效果才有持续变好的可能。
关键概念速查
| 概念 | 说明 |
|---|---|
| chunking | 把文档切成块的过程,块是 RAG 检索与召回的最小单位 |
| 固定大小切分 | 按固定字数机械切分,无视语义结构,易把句子段落拦腰切断 |
| 语义边界 | 段落、句子之间的天然缝隙,下刀于此两侧才都完整 |
| 递归字符切分 | 按分隔符从大到小递归切分,优先在大的语义边界下刀 |
| chunk overlap | 相邻块之间的重叠内容,补偿被边界切断的跨块语义联系 |
| chunk size | 块的大小,在检索精度与语义完整之间权衡,应按 token 计量 |
| 标题路径 | 块在文档层级中的位置,作为元数据保留并拼入向量文本 |
| 元数据 | 块自带的来源、章节、序号等信息,用于过滤、扩展、标注出处 |
| 原子块保护 | 表格、代码块等不可分割整体,切分前识别保护,避免被切碎 |
| small-to-big | 用小块检索保精度,作答时扩展到相邻块保上下文 |
避坑清单
- 不要把切块当成无关紧要的预处理:块的质量给整个 RAG 系统的效果设了上限。
- 不要按固定字数切:那把尺子对语义结构无知,必然把句子、段落拦腰切断。
- 不要让切点无视文档结构:用递归切分,优先在段落、句子等语义边界下刀。
- 不要让相邻块完全不重叠:切分必然切断跨块联系,用重叠补偿,常取块的 10%-20%。
- 不要追求一个"标准 chunk size":它是检索精度与语义完整的权衡,需评估后定。
- 不要按字符数衡量块大小:下游按 token 处理,embedding 模型超长会被截断。
- 不要丢掉标题路径:块脱离原文会丢失位置上下文,切块时是固定它的唯一时机。
- 不要只产出纯文本:块要带元数据,来源、章节、序号在过滤和扩展时都有用。
- 不要把表格、代码块切碎:切分前识别这类原子整体并保护,绝不从中间切开。
- 不要切完就不管:用真实问题做检索评估,据此反向调切分参数和策略。
总结
回头看第一版那个"按字数均匀切"的 RAG,它的错误很典型。它不在某一行代码,而在一个对 chunking 的根本误解:以为切块只是个机械的、把长文档分小的预处理动作,切得整齐就行。真相是,块是检索和召回的最小单位,块的质量就是喂给模型的原料的质量,它给整个系统的效果设了一个上限。固定字数切分,用一把对语义结构一无所知的尺子,切出了一堆残缺、混杂、缺上下文的块,后面的检索再准、模型再强,也救不回这堆坏原料。
而把 chunking 做对,工程量并不小。它不是调一个 chunk_size 参数那么简单,而是要按语义边界递归地切、给相邻块加重叠补偿、在检索精度和语义完整之间权衡块的大小、把标题路径和元数据一并保留下来,还要保护表格代码这类原子块、做检索后的上下文扩展、按 token 计量、并持续用真实问题去评估。一套能产出高质量块的切分,是这些环节一个不少地拼起来的。
这件事其实很像把一本厚书拆成一沓资料卡片,做成一个卡片检索盒。拆得好的人,会顺着书的章节、段落来拆,每张卡片正好是一个完整的知识点;还会在卡片角上标好"这是第几章第几节"(标题路径),会让相邻卡片的内容稍微搭一点边(重叠),会把书里那张完整的表格单独抄成一张卡而不是撕成两半(原子块保护)。拆得差的人,拿尺子量着每五厘米裁一刀,结果一句话裁成两张卡,一张图裁得七零八落。日后要查资料,前一种盒子一翻就能找到完整答案,后一种盒子翻出来的全是断章残句。RAG 里的 chunking,就是在做"拆书做卡片"这件事,拆得好不好,决定了这个盒子好不好用。
这类问题还有一个共同的麻烦:它在本地、在小数据上很难暴露。你自己拿一两份格式规整的文档、提几个简单问题去测,固定字数切看起来也能答对——因为你的文档恰好结构简单,你的问题恰好没踩到那些被切碎的块。真正会把切分的裂缝撑开的,是上线后那些格式千奇百怪的真实文档,和用户那些刁钻、需要精确定位的真实提问。所以如果你正在做一个 RAG 系统,别等用户反馈"它答不上手册里明明写着的东西",才回头怀疑切分。在写下切块的第一行代码时,就把它当成决定系统效果上限的关键一环来认真对待——按语义切、带上下文、可评估——这是这篇文章最想留给你的一句话。
—— 别看了 · 2026