3937 字
约 13 分钟
18
7.3 切片策略的取舍
2026-10-25
无标签

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 的后果最严重:文档更新时不知道该删哪些块,只能全量重建。

一句总结

切片的最高原则是"不要破坏原文的语义单元" —— 一个问答、一张表、一段代码,都是不可分割的。

在它们的基础上再谈"多大合适",顺序就对了。

先守语义单元,再调参——这个顺序反过来,调多少参数都没用。

这是切片这件事上,唯一必须先想清楚的问题。

切片的最终判断

不是"切多大",而是"哪些内容不能被切"。 先标注不可分割单元,剩下的再按长度切。

先标不可切,再谈切多大。顺序对了,参数才有意义。

7.3 切片策略的取舍
https://clxhxhhr.top/posts/4899/
作者
clxstart
发布于
2026-10-25
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。