RAG 的分块策略
分块配置对检索质量的影响,和嵌入模型的选择一样大(Vectara NAACL 2025)。分块搞错了,再多重排也救不了你。
类型: Build 语言: Python 前置要求: Phase 5 · 14(信息检索)、Phase 5 · 22(嵌入模型) 预计时间: ~60 分钟
问题背景
你把一份 50 页的合同放进 RAG 系统。用户问:"What is the termination clause?"。检索器返回封面页。为什么?因为模型是在 512-token 块上训练的,而终止条款埋在 20 页之后、跨页断开,本地又没有把它和查询挂上钩的关键词。
修法不是"买个更好的嵌入模型"。修法是分块。多大?要重叠吗?在哪里切?带不带周围上下文?
2026 年 2 月的基准给出意外的结果:
- Vectara 的 2026 研究:递归 512-token 分块打赢了语义分块,准确率 69% → 54%。
- SPLADE + Mistral-8B 在 Natural Questions 上:重叠提供了零个可测量的好处。
- 上下文悬崖:响应质量在约 2500 token 的上下文附近急剧下降。
"显而易见"的答案(语义分块、20% 重叠、1000 token)往往是错的。这节课为六种策略建立直觉,告诉你何时该抓哪个。
核心概念
固定分块。 每 N 个字符或 token 切一刀。最简单的基线。会在句子中间切断。压缩好,连贯差。
递归。 LangChain 的 RecursiveCharacterTextSplitter。先试着按 \n\n 切,再 \n,再 .,再空格。回退得干净。2026 年的默认。
语义。 给每个句子做 embedding。算相邻句子之间的余弦相似度。在相似度跌破阈值处切。保住主题连贯性。更慢;有时产出 40-token 的小碎片,伤害检索。
句子。 按句子边界切。每块一句,或一个 N 句的窗口。在约 5k token 以内追平语义分块,成本只是零头。
父文档。 既存小的子块用于检索,又存更大的父块用于上下文。按子块检索;返回父块。优雅退化:糟糕的子块仍返回合理的父块。
后期分块(2024)。 先在 token 级给整篇文档做 embedding,再把 token embedding 池化成块 embedding。保住跨块上下文。配长上下文嵌入器(BGE-M3、Jina v3)用。算力更高。
上下文检索(Anthropic,2024)。 给每个块前置一段 LLM 生成的、说明它在文档中位置的摘要("This chunk is section 3.2 of the termination clauses...")。在 Anthropic 自己的基准里有 35-50% 的检索提升。索引起来贵。
打败一切默认的那条规则
把块大小匹配到查询类型:
| 查询类型 | 块大小 |
|---|---|
| 事实型("what is the CEO's name?") | 256-512 token |
| 分析型 / 多跳 | 512-1024 token |
| 整章理解 | 1024-2048 token |
NVIDIA 的 2026 基准。块要大到足以容纳答案加本地上下文,又小到让检索器的 top-K 聚焦在答案上而非上下文噪声上。
n5-chunk-cuts动手构建
第 1 步:固定和递归分块
def chunk_fixed(text, size=512, overlap=0):
step = size - overlap
return [text[i:i + size] for i in range(0, len(text), step)]
def chunk_recursive(text, size=512, seps=("\n\n", "\n", ". ", " ")):
if len(text) <= size:
return [text]
for sep in seps:
if sep not in text:
continue
parts = text.split(sep)
chunks = []
buf = ""
for p in parts:
if len(p) > size:
if buf:
chunks.append(buf)
buf = ""
chunks.extend(chunk_recursive(p, size=size, seps=seps[1:] or (" ",)))
continue
candidate = buf + sep + p if buf else p
if len(candidate) <= size:
buf = candidate
else:
if buf:
chunks.append(buf)
buf = p
if buf:
chunks.append(buf)
return [c for c in chunks if c.strip()]
return chunk_fixed(text, size)第 2 步:语义分块
def chunk_semantic(text, encoder, threshold=0.6, min_chars=200, max_chars=2048):
sentences = split_sentences(text)
if not sentences:
return []
embs = encoder.encode(sentences, normalize_embeddings=True)
chunks = [[sentences[0]]]
for i in range(1, len(sentences)):
sim = float(embs[i] @ embs[i - 1])
current_len = sum(len(s) for s in chunks[-1])
if sim < threshold and current_len >= min_chars:
chunks.append([sentences[i]])
else:
chunks[-1].append(sentences[i])
result = []
for group in chunks:
text_group = " ".join(group)
if len(text_group) > max_chars:
result.extend(chunk_recursive(text_group, size=max_chars))
else:
result.append(text_group)
return result在你的领域上调 threshold。太高 → 碎片。太低 → 一个巨块。
第 3 步:父文档
def chunk_parent_child(text, parent_size=2048, child_size=256):
parents = chunk_recursive(text, size=parent_size)
mapping = []
for p_idx, parent in enumerate(parents):
children = chunk_recursive(parent, size=child_size)
for child in children:
mapping.append({"child": child, "parent_idx": p_idx, "parent": parent})
return mapping
def retrieve_parent(child_query, mapping, encoder, top_k=3):
child_embs = encoder.encode([m["child"] for m in mapping], normalize_embeddings=True)
q_emb = encoder.encode([child_query], normalize_embeddings=True)[0]
scores = child_embs @ q_emb
top = np.argsort(-scores)[:top_k]
seen, parents = set(), []
for i in top:
if mapping[i]["parent_idx"] not in seen:
parents.append(mapping[i]["parent"])
seen.add(mapping[i]["parent_idx"])
return parents关键洞见:给父块去重。多个子块可能映射到同一个父块;全返回会浪费上下文。
第 4 步:上下文检索(Anthropic 模式)
def contextualize_chunks(document, chunks, llm):
context_prompts = [
f"""<document>{document}</document>
Here is the chunk to situate: <chunk>{c}</chunk>
Write 50-100 words placing this chunk in the document's context."""
for c in chunks
]
contexts = llm.batch(context_prompts)
return [f"{ctx}\n\n{c}" for ctx, c in zip(contexts, chunks)]索引这些加了上下文的块。查询时,检索受益于额外的周围信号。
第 5 步:评估
def recall_at_k(queries, corpus_chunks, encoder, k=5):
chunk_embs = encoder.encode(corpus_chunks, normalize_embeddings=True)
hits = 0
for q_text, gold_idxs in queries:
q_emb = encoder.encode([q_text], normalize_embeddings=True)[0]
top = np.argsort(-(chunk_embs @ q_emb))[:k]
if any(i in gold_idxs for i in top):
hits += 1
return hits / len(queries)永远做基准测试。对你的语料而言"最好"的策略,可能和任何博客文章都对不上。
坑
- 只在事实型查询上评估分块。 多跳查询会揭示截然不同的赢家。用按查询类型分层的评估集。
- 没有最小尺寸的语义分块。 产出 40-token 的碎片,伤害检索。永远强制
min_tokens。 - 把重叠当 cargo cult。 2026 年的研究发现重叠常常提供零好处,却让索引成本翻倍。要测量,别假设。
- 没有最小/最大约束。 5 token 或 5000 token 的块都会破坏检索。夹住。
- 跨文档分块。 绝不让一个块跨两篇文档。永远按文档分块,再合并。
实际使用
2026 年的栈:
| 场景 | 策略 |
|---|---|
| 首次构建、语料未知 | 递归,512 token,无重叠 |
| 事实型 QA | 递归,256-512 token |
| 分析型 / 多跳 | 递归,512-1024 token + 父文档 |
| 大量交叉引用(合同、论文) | 后期分块或上下文检索 |
| 对话 / 对白语料 | 轮次级块 + 说话人元数据 |
| 短话语(推文、评论) | 一篇文档 = 一个块 |
从递归 512 起步。在一个 50 查询的评估集上测 recall@5。再从那里调。
拿去用
存为 outputs/skill-chunker.md:
---
name: chunker
description: Pick a chunking strategy, size, and overlap for a given corpus and query distribution.
version: 1.0.0
phase: 5
lesson: 23
tags: [nlp, rag, chunking]
---
Given a corpus (document types, avg length, domain) and query distribution (factoid / analytical / multi-hop), output:
1. Strategy. Recursive / sentence / semantic / parent-document / late / contextual. Reason.
2. Chunk size. Token count. Reason tied to query type.
3. Overlap. Default 0; justify if >0.
4. Min/max enforcement. `min_tokens`, `max_tokens` guards.
5. Evaluation plan. Recall@5 on 50-query stratified eval set (factoid, analytical, multi-hop).
Refuse any chunking strategy without min/max chunk size enforcement. Refuse overlap above 20% without an ablation showing it helps. Flag semantic chunking recommendations without a min-token floor.练习
- 简单。 用 fixed(512, 0)、recursive(512, 0)、recursive(512, 100) 给一份 20 页文档分块。对比块数和边界质量。
- 中等。 在 5 篇文档上建一个 30 查询的评估集。为递归、语义、父文档测 recall@5。哪个胜出?和那些博客文章对得上吗?
- 困难。 实现上下文检索。测量相对基线递归的 MRR 提升。报告索引成本(LLM 调用)对准确率增益。
关键术语
| 术语 | 人们怎么说 | 它实际是什么 |
|---|---|---|
| 块(Chunk) | 文档的一片 | 被 embedding、索引、检索的子文档单元。 |
| 重叠(Overlap) | 安全边距 | 相邻块共享的 N 个 token;2026 年基准里常常无用。 |
| 语义分块 | 聪明分块 | 在相邻句 embedding 相似度跌落处切。 |
| 父文档 | 两级检索 | 检索小的子块,返回更大的父块。 |
| 后期分块 | embedding 之后再分块 | 在 token 级给整篇文档做 embedding,池化成块向量。 |
| 上下文检索 | Anthropic 的戏法 | 索引前给每个块前置一段 LLM 生成的摘要。 |
| 上下文悬崖 | 2500-token 那堵墙 | RAG 里在约 2.5k 上下文 token 附近观测到的质量下降(2026 年 1 月)。 |
延伸阅读
- Yepes et al. / LangChain — Recursive Character Splitting docs —— 生产里的默认。
- Vectara (2024, NAACL 2025). Chunking configurations analysis —— 分块和嵌入选择一样要紧。
- Jina AI — Late Chunking in Long-Context Embedding Models (2024) —— 后期分块论文。
- Anthropic — Contextual Retrieval —— 用 LLM 生成的上下文前缀带来 35-50% 检索提升。
- NVIDIA 2026 chunk-size benchmark — Premai summary —— 按查询类型选块大小。