RAG 分块策略 本节摘要:分块配置对检索质量的影响,堪比嵌入模型的选择(Vectara,NAACL 2025)。分块搞错了,再多重排也救不回。你把一份 50 页合同塞进 RAG,用户问「解约条款是什么?」,检索器却返回封面页——为什么?因为模型在 512 token 的块上训过,解约条款埋在第 20 页、跨页断裂、局部无关键词与查询挂钩。修法不是「买个更好的嵌入模型」,而是分块:多大?重叠?在哪切?要不要带上下文?2026 年 2 月的基准给出反直觉结果——Vectara 研究里递归式 512 token 分块以 69% 对 54% 击败语义分块;SPLADE + Mistral-8B 上重叠带来零可测收益;2,500 token 上下文处存在「上下文悬崖」,响应质量骤降。
本节摘要:分块配置对检索质量的影响,堪比嵌入模型的选择(Vectara,NAACL 2025)。分块搞错了,再多重排也救不回。你把一份 50 页合同塞进 RAG,用户问「解约条款是什么?」,检索器却返回封面页——为什么?因为模型在 512 token 的块上训过,解约条款埋在第 20 页、跨页断裂、局部无关键词与查询挂钩。修法不是「买个更好的嵌入模型」,而是分块:多大?重叠?在哪切?要不要带上下文?2026 年 2 月的基准给出反直觉结果——Vectara 研究里递归式 512 token 分块以 69% 对 54% 击败语义分块;SPLADE + Mistral-8B 上重叠带来零可测收益;2,500 token 上下文处存在「上下文悬崖」,响应质量骤降。「显而易见」的答案(语义分块、20% 重叠、1000 token)常常是错的。本节为六种策略(定长、递归、语义、句子、父文档、晚期分块)外加上下文检索建立直觉,告诉你何时取用何者——并把一条压过所有默认的规则交给你:把块大小匹配到查询类型。
对应原课程:Phase 5 · Lesson 23 ·
chunking-strategies-rag(原英文phases/05-nlp-foundations-to-advanced/23-chunking-strategies-rag/docs/en.md)。前置依赖:第 14 节(信息检索)、第 22 节(嵌入模型)。
阅读完本节,你应当能够:
你把一份 50 页合同塞进 RAG 系统。用户问:「解约条款是什么?」检索器返回封面页。为什么?因为模型在 512 token 的块上训过,解约条款埋在第 20 页、跨页断裂、局部无关键词与查询挂钩。
修法不是「买个更好的嵌入模型」,而是分块。多大?重叠?在哪切?带不带周围上下文?
2026 年 2 月的基准给出反直觉结果:
「显而易见」的答案(语义分块、20% 重叠、1000 token)常常是错的。
RecursiveCharacterTextSplitter。先试 \n\n,再 \n,再 .,再空格。优雅回退。2026 默认。把块大小匹配到查询类型:
| 查询类型 | 块大小 |
|---|---|
| 事实型(「CEO 叫什么?」) | 256~512 token |
| 分析型 / 多跳 | 512~1024 token |
| 整节理解 | 1024~2048 token |
💡 NVIDIA 2026 基准:块应大到装得下答案加局部上下文,小到检索器的 top-K 聚焦在答案而非上下文噪声上。
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)
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 在你领域上调。过高→碎片;过低→一个巨型块。
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
关键洞见:父块去重。多个子块可能映射到同一父块,全返回会浪费上下文。
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)]
索引带上下文化的块。查询时,检索从额外的周围信号里受益。
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)
总要基准。「最佳」策略未必匹配任何博客帖。
min_tokens。2026 的栈:
| 情形 | 策略 |
|---|---|
| 首次构建,语料未知 | 递归,512 token,无重叠 |
| 事实型 QA | 递归,256~512 token |
| 分析型 / 多跳 | 递归,512~1024 token + 父文档 |
| 交叉引用密集(合同、论文) | 晚期分块或上下文检索 |
| 对话型 / 对话语料 | 轮次级块 + 说话人元数据 |
| 短话术(推文、评论) | 一文档 = 一块 |
💡 2026 模式:从递归 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.
\n\n 再 \n 再 . 再空格,LangChain RecursiveCharacterTextSplitter。min_tokens。下一节,我们钻进一个经典难题——进入「共指消解」,看如何把「他」「她」「它」这些代词指回正确的实体,以及为何这是机器阅读理解与信息抽取的隐性地基。