分块策略对比:五种切法的取舍 本节摘要:分块(Chunking)决定了检索器能看到什么。边界切错,再强的嵌入模型、再好的重排器、再聪明的 LLM 都无法在下游修复这个损伤。本节从零实现五种分块策略——定长窗口、按句、递归分割、语义聚类、结构化 Markdown 标题——并在一个带黄金答案标注的固定语料上测量 recall@k。你会亲眼看到:散文语料上语义聚类胜出,技术文档上结构化切分胜出,混合语料上递归分割因为自适应而稳住阵脚。读完本节,你能根据文档类型、平均段落长度、是否带显式结构这三个属性,在不跑基准的情况下为新语料选对默认分块器,并识别每种策略会注入的失败模式(孤儿句、符号中断、纯标题块、语义漂移)。 对应原课程:Phase 19 · Lesson 64 · (原英文 )。
本节摘要:分块(Chunking)决定了检索器能看到什么。边界切错,再强的嵌入模型、再好的重排器、再聪明的 LLM 都无法在下游修复这个损伤。本节从零实现五种分块策略——定长窗口、按句、递归分割、语义聚类、结构化 Markdown 标题——并在一个带黄金答案标注的固定语料上测量 recall@k。你会亲眼看到:散文语料上语义聚类胜出,技术文档上结构化切分胜出,混合语料上递归分割因为自适应而稳住阵脚。读完本节,你能根据文档类型、平均段落长度、是否带显式结构这三个属性,在不跑基准的情况下为新语料选对默认分块器,并识别每种策略会注入的失败模式(孤儿句、符号中断、纯标题块、语义漂移)。
对应原课程:Phase 19 · Lesson 64 ·
chunking-strategies-advanced(原英文phases/19-capstone-projects/64-chunking-strategies-advanced/docs/en.md)。本节属第 20 章「毕业项目」的进阶 RAG 赛道(Track F)。
阅读完本节,你应当能够:
每条 RAG 流水线都从「把源文档切成块」开始。块要小到嵌入模型塞得下,又要大到每块承载一个自洽的想法。在哪里下刀,不是超参数,而是检索器能返回什么的上界。
一个查询「预算中止阈值长什么样」,只有当承载中止阈值的那个块可达时才能命中。如果定长切分器把阈值数值和它的上下文切开了,嵌入向量就会漂到另一个簇,BM25 分数下降,重排器看到的是噪声,LLM 生成的答案就是错的。2024 年的论文《LongRAG》测出:仅仅因为分块选择的不同,检索召回率就有 35 个百分点的绝对摆动。2025 年关于上下文块头的后续工作缩小了这个差距,但没能关闭它。
本节把五种策略并排构建,跑在同一个带黄金标注的固定语料上,让你自己读召回数字。
一条带黄金标注的查询携带答案区间在源文档中的精确字符偏移。分块后,你问:检索器返回的 top-k 块里,有没有任何一块与黄金区间重叠?有,该查询的 recall@k 为 1;没有,为 0。跨查询集求平均。对每种策略跑同一套评估,数值的离散度就告诉你哪种边界策略在你的语料上活得下来。
code/main.py 实现了以下组件(完整代码见原课程,这里给出关键骨架):
fixed_window(text, size, overlap) —— 基线。sentence_chunks(text, target) —— 简单的句子打包器。recursive_split(text, separators, target) —— 分层递归。semantic_chunks(text, similarity_threshold) —— 基于质心的聚类,跑在一个确定性 mock 嵌入之上。structural_markdown(text) —— 标题感知的切分器。mock_embed(text, dim) —— 基于哈希的嵌入,让整个回路离线运行。DenseIndex —— 与第 65 节混合检索同形的稠密索引。eval_recall(strategy, corpus, queries, k) —— 对比回路。main(),在固定语料上跑每种策略,打印 recall@k 表。递归分割是「最强的单策略基线」,其精髓在于按最强分隔符优先尝试,失败就回退:
def recursive_split(text, separators, target): """按分隔符优先级递归切分,直到每块 <= target 字符。""" if len(text) <= target: return [text] sep = separators[0] if sep == "": # 兜底:按字符硬切 return [text[i:i+target] for i in range(0, len(text), target)] pieces = text.split(sep) chunks, buf = [], "" for piece in pieces: candidate = (buf + sep + piece) if buf else piece if len(candidate) > target and buf: chunks.append(buf) buf = piece else: buf = candidate if buf: chunks.append(buf) # 若单块仍超长,用下一级分隔符再切 result = [] for chunk in chunks: if len(chunk) > target and len(separators) > 1: result.extend(recursive_split(chunk, separators[1:], target)) else: result.append(chunk) return result
设计要点:分隔符列表通常是
["\n\n", "\n", "。", " ", ""]——从段落、到行、到句号、到空格、到字符。这种自适应让递归分割在结构不一致的文档上仍然稳住。
def semantic_chunks(text, similarity_threshold=0.6): """嵌入每句,按质心相似度聚类相邻句子。""" sentences = split_sentences(text) embs = [mock_embed(s) for s in sentences] chunks, cur, centroid = [], [0], embs[0] for i in range(1, len(sentences)): sim = cosine(centroid, embs[i]) if sim < similarity_threshold: chunks.append(" ".join(sentences[c] for c in cur)) cur, centroid = [], embs[i] cur.append(i) centroid = running_mean(centroid, embs[i]) if cur: chunks.append(" ".join(sentences[c] for c in cur)) return chunks
def eval_recall(strategy, corpus, queries, k=5): index = DenseIndex() for doc in corpus: for chunk in strategy(doc["text"]): index.add(chunk, doc_id=doc["id"]) hits = 0 for q in queries: results = index.search(q["text"], k) # 任一 top-k 块的字符区间与黄金区间重叠即命中 if any(overlap(r.span, q["gold_span"]) for r in results): hits += 1 return hits / len(queries)
运行:
python3 code/main.py
输出是一张小表,每行一种策略,每列一个 k。在结构化语料上按句策略会输;在 Markdown 语料上结构化切分会赢;在混合语料上递归分割因自适应而稳住;在散文语料上语义聚类因为没有有用结构线索而胜出。
把五种策略横向对比,关键是它们各自牺牲什么:
| 策略 | 切分依据 | 强项 | 弱项 | 适用语料 |
|---|---|---|---|---|
| 定长窗口 | 字符数 | 快、确定 | 边界极差 | 仅作对照基线 |
| 按句 | 句号/正则 | 不切断单词 | 切断段落与章节 | 无其他结构的散文 |
| 递归分割 | 分隔符层级 | 自适应 | 实现略复杂 | 结构不一致的混合文档 |
| 语义聚类 | 嵌入质心漂移 | 边界反映语义 | 慢、依赖嵌入模型 | 段内换主题的散文 |
| 结构化 Markdown | H1/H2/H3 | 每主题最小块 | 仅限良构文档 | Markdown/RFC/API 文档 |
💡 业界主流库(LangChain 的
RecursiveCharacterTextSplitter、LlamaIndex 的SentenceSplitter、Anthropic 的 Contextual Retrieval)本质上都是这五种策略的组合与增强。Anthropic 2024 年的 Contextual Retrieval 给每块加一句「上下文摘要」前置,能进一步缩小策略间差距,但仍无法关闭它——边界选择仍是上界。
孤儿句(Orphan sentences)。 按句打包会产生漏掉主题句的块,嵌入随后指向错误的簇。
符号中断(Mid-symbol cuts)。 在代码或 YAML 内部用定长窗口,会把一个标识符切成两半,两半都嵌入成噪声。
纯标题块(Header-only chunks)。 结构化 Markdown 会吐出只含 ## 标题 的块。要过滤掉,或把下一块的首段附上来。
语义漂移(Semantic drift)。 当语料整体都在一个主题上时,语义聚类会欠切,一个 5000 字符的块把许多具体答案压进一个弥散的嵌入。要把语义聚类和一个硬性字符上限组合使用。
嵌入陈旧(Stale embeddings)。 语义聚类用了一个嵌入模型。换模型,块也跟着变。把分块模型与检索模型分别锁版本,或一起重建索引。
eval_recall 回路:本节的评估函数返回的形状,被第 68 节「RAG 评估」直接复用来在生产中给策略选择打分。DenseIndex:与第 65 节「混合检索」共用同一形状,保证块在不同课之间可比。| 属性 | 取值 | 默认策略 |
|---|---|---|
| 文档类型 | 无结构的散文 | 递归分割,target 800 |
| 文档类型 | Markdown/RFC/API 文档 | 结构化 Markdown |
| 文档类型 | 代码 | AST 感知(超出本节范围,见第 02 节) |
| 段落长度 | 长、单一主题 | 按句,target 500 |
| 段落长度 | 短、混合主题 | 语义聚类,阈值 0.6 |
拿不准时选递归分割——它是最强的单策略基线。
生产模式:
tiktoken 做 token 窗口而非字符计数,在同一固定语料上对比定长窗口。下一节,我们将进入「混合检索 BM25 与稠密」——本节切出的块在这里被词法与语义两路索引,再用倒数排名融合合并成一份胜过任何单路的 top-k。