7.3 切片策略的取舍
一句话结论:切片有一个根本矛盾——块小则检索准,块大则上下文全。父子分块是唯一能同时满足两者的结构;而"按标题切"几乎总是优于"按字符数切"。
读这篇之前
需要先读 7.2(四个阶段)。这一篇把影响最大的一步单独展开。
一、根本矛盾:准 vs 全
| 块大小 | 检索精度 | 上下文完整性 | Token 成本 |
|---|---|---|---|
| 很小(128 字) | 高(匹配精确) | 低(缺上下文) | 低 |
| 中等(512-1024) | 中 | 中 | 中 |
| 很大(4000+) | 低(语义被稀释) | 高 | 高 |
为什么块小检索准? 因为一个向量代表整块的意思。块越大,包含的主题越多,向量就越"平均",反而不像任何一个具体问题。
为什么块大上下文全? 因为答案可能需要前后几句才能说清。
这个矛盾无法靠调参解决——只能靠结构化解法。
二、解法:父子分块(Parent-Child Chunking)
思路:检索时用小块,生成时用大块。[原文 723]
① 文档先切成大块(Parent,约 512 字符)
② 每个大块再细分为小块(Child,约 128 字符)
③ 小块做 Embedding 并存储,但保留 parent_id
④ 检索时匹配小块 → 找到对应的大块 → 用大块作为上下文
| 优势 | 说明 |
|---|---|
| 检索精度高 | 小块语义集中,匹配准 |
| 上下文完整 | 送给模型的是完整的大块 |
这个设计的本质:把"检索粒度"和"阅读粒度"解耦。这是 RAG 工程里最值得学的一个结构。
一个具体例子
文档里有一段:
配置数据库连接需要设置三个参数:host、port、user。host 应填写内网地址,port 通常是 3306……(后续 400 字)
用户问:"数据库 port 默认是多少?"
- 小块检索:命中"port 通常是 3306"那一小段 → 很精准
- 返回大块:把整个"配置数据库连接"段落给模型 → 它能顺带理解 host/user 的语境
如果只有小块:模型只能看到"port 通常是 3306",不知道在说什么数据库。
如果只有大块:检索时这段可能因为"平均向量"不够像问题而漏掉。
三、按结构切 vs 按字符切
优先级顺序
一个实用的优先级:
| 优先级 | 切法 | 适用 |
|---|---|---|
| 1(最优) | 按文档结构(Markdown 标题、HTML 标签) | 有清晰层级 |
| 2 | 按语义边界(段落 → 句号 → 逗号) | 无结构但通顺 |
| 3(兜底) | 按固定字符数 | 都不可用时 |
为什么按标题切最优? 因为标题是作者给的语义边界——作者在写"## 记忆系统"时,已经声明了这一节讲什么。
用字符数切,等于无视作者的所有结构化努力。
中文文档的特殊问题
| 问题 | 说明 | 处理 |
|---|---|---|
| 没有空格分词 | 英文按空格切很自然,中文不行 | 按标点切,不要按空格 |
| 标点全角 | 。!?, 与半角不同 |
分隔符列表要包含全角 |
| 段落长 | 中文段落常很长 | 需要二级切分 |
| 代码块 | 一整个代码块不能切一半 | 代码块作为不可分割单元 |
最后一条最容易出事:如果你把一段代码从中间切开,两边都成了无法运行的碎片,检索到了也没用。
四、重叠(Overlap):为什么必须留
chunk_overlap 的作用是让相邻块之间有重叠。原因:
块1:[....... 句子A 句子B ]
块2:[ 句子B 句子C ....... ]
↑ 重叠部分
不留重叠的风险:如果答案横跨两句,且正好被切在两个块之间,那么两个块单独看都不完整——检索到任何一个都答不对。
留多少? 常见 10%~15%。太小不起作用,太大浪费 Token。
五、另一个思路:加"上下文头"
除了父子分块,还有一个低成本高收益的做法:给每块加上来自原文的上下文前缀。
原始块:「默认值是 3306,需要时可修改。」
加上下文头:
「来自文档《数据库配置指南》第 3 节「连接参数」:
默认值是 3306,需要时可修改。」
收益:块本身变得"自解释",向量化后语义更完整;模型读到也知道这是关于数据库连接的。
代价:每块多几十字,索引变大。
这个技巧在实践中效果往往超出预期,尤其对标题很多、每节很短的文档。
六、切片效果怎么验证
切完不能直接上线。三个验证方法:
| 方法 | 怎么做 | 能发现 |
|---|---|---|
| 人工抽查 20 块 | 随机看切出来的块 | 是否切在句子中间、代码是否被切碎 |
| 答案定位测试 | 拿 10 个已知答案,看它在哪块 | 块是否包含完整答案 |
| 检索命中率 | 用 20 个问题测召回 | 切片粒度是否合适 |
第二个最有价值:如果答案被切成了两半,怎么调检索参数都无效。
七、常见误区
误区一:chunk_size 512 就是标准答案。
只是经验值起点。要看文档类型:API 文档适合小块,散文适合大块。
误区二:按固定字符数切最简单。
切在句子中间会让两块都失效。有结构就按结构切。
误区三:overlap 越大越安全。
太大浪费 Token 和索引空间。10-15% 足够。
误区四:代码块可以随便切。
切碎的代码无意义。代码块应作为不可分割单元。
误区五:切片是一次性工作。
文档更新、问答效果差时,都要重新评估切片。
误区六:切片不好可以靠提示词补。
不能。信息不在块里,模型只能编(2.3)。
八、动手:父子分块的完整实现
7.3 讲了父子分块。这里是可运行的版本。
def build_parent_child(text, parent_size=1600, child_size=400):
parents, children = [], []
# ① 先按标题或长度切成大块
for ptext, ptitle in split_parents(text, parent_size):
pid = f"p-{len(parents)}"
parents.append({"id": pid, "title": ptitle, "text": ptext})
# ② 大块再切小块
for ctext in split_children(ptext, child_size):
children.append({
"text": ctext,
"parent_id": pid, # ← 关键:指向大块
"title": ptitle,
})
return parents, children
检索时的两步
def retrieve(question, k=5):
# ① 用小块检索(精确)
child_hits = store.search_children(embed(question), top_k=k * 3)
# ② 取对应的父块(完整)
parent_ids = {h["parent_id"] for h in child_hits}
parents = [store.get_parent(pid) for pid in parent_ids]
# ③ 送给模型的是父块,不是小块
return parents[:k]
第 ③ 步是全部价值所在:检索精度来自小块,上下文完整性来自大块。
三个参数的经验值
| 参数 | 建议 | 说明 |
|---|---|---|
| parent_size | 1200-2000 字符 | 约 300-500 汉字,能说清一件事 |
| child_size | 300-500 字符 | 约 80-150 汉字,语义集中 |
| k(返回父块数) | 3-5 | 多了会稀释注意力 |
一个反例:父子关系没建对
错:小块检索后直接返回小块
后果:模型看到"默认值是 3306",不知道在说什么
→ 可能答成"端口默认 3306"(其实那是数据库配置节的内容)
父子分块的核心不是"切两次",而是"返回父块"。
九、动手:中文文档切片的三个特殊处理
7.3 讲了切片原则。中文有三个坑要单独处理。
坑一:没有空格,不能按词切
# 错:按空格切(中文几乎没有空格)
chunks = [p for p in text.split(" ") if p]
# 对:按标点切
SENT_END = "。!?;\n"
英文按空格切是自然的,中文必须按标点。
坑二:全角标点要单独列
SEPARATORS = ["\n\n", "\n", "。", "!", "?", ";", ",", "、", " "]
注意"。"是全角,"."是半角 —— 只写半角会匹配不到中文句号。
坑三:代码块不能被切断
def split_keeping_code(text, max_len):
blocks = re.split(r'(```.*?```)', text, flags=re.S)
out = []
for b in blocks:
if b.startswith("```"):
out.append(b) # 整块保留
else:
out.extend(split_text(b, max_len)) # 普通文本才切
return out
代码块被切断,两边都变成无法运行的碎片 —— 检索到了也没用。
一个中文特有现象
| 现象 | 影响 |
|---|---|
| 长句多(一逗到底) | 按句号切会得到很长的块 |
| 括号成对 | 不能在括号中间切 |
| 中英混排 | 英文部分可以按空格切 |
所以中文切片通常需要"两级":先按句号,再看块是否还超长;超长则按逗号/分号再切。
十、动手:知识库该不该"一份多切"
一个容易被忽略的设计:同一份文档可以有不同的切片方式,服务不同查询。
| 查询类型 | 适合的切法 |
|---|---|
| "X 是什么" | 按定义段切(小) |
| "X 和 Y 的区别" | 按对比段切(中) |
| "X 怎么配置" | 按操作步骤切(保持步骤完整) |
| "总结一下这个文档" | 按章节切(大) |
一个可行的做法
def build_multi_granularity(doc):
yield from split_by_heading(doc, max_len=2000) # 章节级
yield from split_by_para(doc, max_len=600) # 段落级
yield from split_by_definition(doc) # 定义句级
三套索引,检索时混用 —— 用段落级做主力,定义句级补精确问题。
代价
| 成本 | 说明 |
|---|---|
| 索引体积 | ×2-3 |
| embedding 成本 | ×2-3 |
| 召回后去重 | 需要(同一内容可能被多处召回) |
判据:
只是"问答检索" → 一种切法够
既要精确问题又要"总结全文" → 多粒度
多数场景一种切法就够 —— 多粒度是"效果好但更贵"的选项。
十一、动手:切片效果的三个量化指标
7.3 讲了怎么切。但切完怎么知道好不好?三个指标。
指标一:块长度分布
lens = [len(c["text"]) for c in chunks]
print(f"平均 {mean(lens):.0f} | 中位 {median(lens):.0f} | P95 {percentile(lens,95):.0f}")
健康值:平均接近目标值,P95 不超过 2 倍。
异常:P95 远大于平均 → 有超长块没被切开(常见于长表格/代码)。
指标二:答案完整性
# 取 20 个已知问题,看答案是否完整落在某一块里
complete = sum(1 for q in questions if any(q["answer"] in c["text"] for c in chunks))
print(f"完整率: {complete/len(questions)*100:.0f}%")
健康值:> 85%。低于 70% 说明切得太碎。
指标三:块内主题一致性
# 每块内部,句子两两相似度的平均值
def coherence(chunk):
sents = split_sentences(chunk["text"])
pairs = [(a,b) for i,a in enumerate(sents) for b in sents[i+1:]]
return mean(cosine(embed(a), embed(b)) for a,b in pairs)
健康值:> 0.5。低于 0.3 说明块里混了多个主题。
三个指标的组合判断
| 现象 | 结论 |
|---|---|
| 完整率低 + 一致性高 | 切太碎,该加大 |
| 完整率高 + 一致性低 | 切太大,块里混了多个主题 |
| P95 异常大 | 有未切开的超长内容 |
这三个指标用 20 分钟就能测一遍,比"感觉不太准"有用得多。
十二、动手:切片参数的一个小步快跑实验
7.3 讲了原则。真正定参数要靠实验。
实验设计
for size in [300, 500, 800, 1200]:
for overlap in [0, 50, 100]:
chunks = build(docs, size, overlap)
score = evaluate(chunks) # 用 7.2 的测试集
print(f"size={size:5} overlap={overlap:4} → {score:.2f}")
一个典型结果
| size | overlap | 召回率 |
|---|---|---|
| 300 | 0 | 0.71 |
| 300 | 50 | 0.78 |
| 500 | 50 | 0.85 |
| 500 | 100 | 0.86 |
| 800 | 50 | 0.83 |
| 1200 | 50 | 0.76 |
三个结论:
| 结论 | 说明 |
|---|---|
| 太小(300)召回差 | 语义不完整 |
| 500-800 是甜区 | 对本类文档 |
| overlap 50 已足够 | 100 的边际收益很小 |
关键提醒
这个甜区只对你的文档成立。
- API 文档(短小离散)→ 甜区偏小
- 散文/教程(长段落)→ 甜区偏大
- 表格多的文档 → 需要更小的块
所以"经验值 512"只是起点,必须自己跑一遍。
一个补充:切片与"问答对"的配合
有些知识天然是"问答对"(FAQ、文档的 Q&A 段),这类内容不必按长度切。
| 内容类型 | 切法 |
|---|---|
| 散文 | 按段落/字符数 |
| FAQ | 一问一答为一个块 |
| 表格 | 整表一块(7.5 的教训) |
| 代码示例 | 示例 + 说明为一个块 |
| 步骤清单 | 整个清单一块 |
一个混合切片的实现
def smart_split(doc):
out = []
for section in split_by_heading(doc):
kind = detect_kind(section) # faq / table / code / prose
if kind in ("faq", "table", "code"):
out.append(section) # 整块不切
else:
out.extend(split_prose(section, max_len=600))
return out
关键在 detect_kind:判断这一节是"结构化内容"还是"散文"。
收益
| 内容 | 改进前 | 改进后 |
|---|---|---|
| FAQ | 答案被切成两半 | 一问一答完整 |
| 表格 | 行错位 | 整表可读 |
| 代码 | 语法破碎 | 可运行 |
这类"按内容类型切"的收益,往往比"调 chunk_size"大得多。
十三、下一篇
切片决定"能找到什么",检索方式决定"找得准不准"。
下一篇讲混合检索与重排——为什么只用向量检索会漏掉重要的东西,以及重排为什么是性价比最高的一步。
一个补充:切片的"元数据"要存什么
切出来的每一块,除了文本和向量,还要存四样东西:
| 字段 | 用途 |
|---|---|
| doc_id | 增量更新时按文档删(7.2) |
| title | 拼进嵌入文本,也用于展示引用 |
| position | 排序权重(文档开头常是概述) |
| parent_id | 父子分块(7.3) |
缺 doc_id 的后果最严重:文档更新时不知道该删哪些块,只能全量重建。
一句总结
切片的最高原则是"不要破坏原文的语义单元" —— 一个问答、一张表、一段代码,都是不可分割的。
在它们的基础上再谈"多大合适",顺序就对了。
先守语义单元,再调参——这个顺序反过来,调多少参数都没用。
这是切片这件事上,唯一必须先想清楚的问题。
切片的最终判断
不是"切多大",而是"哪些内容不能被切"。 先标注不可分割单元,剩下的再按长度切。
先标不可切,再谈切多大。顺序对了,参数才有意义。