RAG中的“Chunk”艺术:我试过10种切分策略后总结的结论

张开发
2026/4/18 19:37:25 15 分钟阅读

分享文章

RAG中的“Chunk”艺术:我试过10种切分策略后总结的结论
写在前面在RAG系统里chunk文本切分是最不起眼却最致命的一环。Embedding模型选错了可以换向量数据库慢了可以升配但chunk切得不好整个知识库就像把图书馆的书全撕成纸条再按关键词分类——检索到的永远是“纸条”而不是“知识”。过去三个月我在自己的企业知识库项目里系统性地测试了10种切分策略从最简单的固定长度到基于语义的智能切分最终得出了一些反直觉的结论。本文不搞玄学直接上数据、代码和可复现的最佳实践。一、为什么Chunk如此重要一个标准的RAG流程是文档 → 切分 → Embedding → 存储 → 检索 → 生成。Chunk处于最上游它的质量直接影响后续所有环节。切分太短每个chunk语义不完整检索到的片段缺少上下文LLM容易产生“断章取义”的回答。比如切出“体温超过38.5度”但丢失了前面的“儿童”和后面的“需立即就医”。切分太长chunk内包含大量无关信息检索时噪音多同时浪费LLM的上下文窗口和Token。更麻烦的是长chunk中真正相关的可能只占10%但向量相似度会被整体稀释导致召回失败。边界错误把一句话从中间切断、把一个表格拆成两半、把代码注释和代码分离——这些问题都会让检索到的内容无法直接使用。下图展示了chunk在RAG中的位置和影响二、10种切分策略详解我按照从简单到复杂的顺序测试了以下10种策略。测试语料为100份混合文档PDF/Word/Markdown包括技术手册、法律合同、公司制度、产品说明书。策略1固定长度切分按字符最简单粗暴每N个字符切一刀不考虑语义边界。def fixed_char_split(text, chunk_size500): return [text[i:ichunk_size] for i in range(0, len(text), chunk_size)]策略2固定长度切分按Token使用tokenizer如tiktoken或HuggingFace tokenizer按token数切分比按字符稍好但仍打断语义。from langchain.text_splitter import TokenTextSplitter splitter TokenTextSplitter(chunk_size200, chunk_overlap20)结果❌ 比按字符略好但仍然经常切断完整句子。适合对Token预算有严格限制的场景。策略3递归字符切分RecursiveCharacterTextSplitterLangChain的经典方案按优先级[\n\n, \n, 。, , , , , ]依次尝试切分尽可能保留段落和句子边界。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , ] )结果✅ 基线方案。在大多数中文文档上表现稳健切分质量与文档格式强相关格式越规范越好。策略4按句子切分SentenceSplitter使用NLP库如spaCy、nltk或zh_core_web_sm检测句子边界保证每个chunk由完整句子组成。可以再按句数合并到目标大小。from langchain.text_splitter import SpacyTextSplitter splitter SpacyTextSplitter(chunk_size500, chunk_overlap50)结果✅ 比递归切分更“干净”但中文分句有时会误判如“Mr. Wang”中的点号。适合以自然语言叙述为主的文档。策略5按段落切分ParagraphSplitter直接以空行或缩进作为段落边界。通常段落本身就是一个语义单元。def paragraph_split(text): return [p for p in text.split(\n\n) if p.strip()]结果✅ 非常符合人类阅读习惯。但段落长度差异大短段落如标题可能只有几个字长段落可能超过2000字。策略6Markdown结构切分MarkdownHeaderTextSplitter针对Markdown文档按标题层级H1、H2…切分保留父子关系作为元数据。from langchain.text_splitter import MarkdownHeaderTextSplitter headers [(H1, Title), (H2, Section)] splitter MarkdownHeaderTextSplitter(headers_to_split_onheaders)结果✅ 对于Markdown格式的技术文档、博客、Readme效果极佳。检索时能保留章节上下文甚至可以按标题过滤。策略7代码切分针对代码文件使用tree-sitter或langchain的RecursiveCharacterTextSplitter结合代码语言语法。保证不切断函数、类、导入语句。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter.from_language( languagepython, chunk_size500, chunk_overlap50 )结果✅ 对于代码库RAG如代码问答、文档生成这是必备方案。普通文档不需要。策略8滑动窗口切分Sliding Window让相邻chunk之间有一定重叠overlap保证边界信息不丢失。这个不是独立的切分方式而是叠加参数。splitter RecursiveCharacterTextSplitter(chunk_size500, chunk_overlap100)结果✅ 强烈推荐。重叠区域能让LLM在生成答案时看到更多上下文尤其当答案横跨两个chunk边界时。重叠大小一般为chunk_size的10%-20%。策略9语义切分Semantic Chunking先按句子切分然后用Embedding模型计算相邻句子的相似度当相似度低于阈值时切分。目标是让每个chunk内的句子在语义上高度相关。# 伪代码 sentences split_sentences(text) embeddings embed(sentences) chunks [] current_chunk [sentences[0]] for i in range(1, len(sentences)): sim cosine_similarity(embeddings[i-1], embeddings[i]) if sim threshold: chunks.append( .join(current_chunk)) current_chunk [sentences[i]] else: current_chunk.append(sentences[i])结果✅ 理论上最优但计算开销大需要预计算所有句子的Embedding。对中文语义切分效果提升有限且阈值难调。策略10文档结构感知切分Layout-aware Chunking针对PDF、Word等富文本文档使用布局分析如Unstructured库提取标题、段落、表格、图片标题等按结构块切分。from unstructured.partition.pdf import partition_pdf elements partition_pdf(file.pdf, strategyhi_res) chunks [str(e) for e in elements if e.category in [Title, NarrativeText, ListItem]]结果✅ 企业级文档的终极方案。能保留表格、列表、标题层级但依赖额外的布局解析库处理速度慢。三、实验对比我用100份文档得出的结论我对上述策略进行了量化评估指标包括检索命中率Hit Rate针对50个测试问题Top-5召回是否包含正确答案所在的chunk。答案完整性LLM基于召回chunk生成的答案是否包含全部必要信息人工评分1-5。平均chunk大小影响Token消耗。处理速度每秒处理的字符数。核心结论Markdown结构切分在技术文档、博客、Readme中表现最佳因为它天然符合人类组织知识的方式。递归字符切分 重叠窗口是最稳定、最通用的基线适合80%的普通文档。纯语义切分的收益不明显但开销巨大不推荐作为首选。固定长度切分是灾难永远不要在生产环境使用。四、最佳实践我总结的“Chunk黄金法则”基于上述实验我提炼出几条可复用的经验法则1先确定文档类型再选策略法则2chunk_size的黄金区间是300-800中文字符小于300信息太少检索时缺乏上下文大于800噪音增加且容易超过Embedding模型的最大长度很多模型只有512 token我的推荐500字符约200-250 token重叠50-100字符法则3始终使用重叠overlap重叠大小 chunk_size × 0.1 ~ 0.2。原因重要信息恰好在边界时两个chunk都会包含它提高召回概率。法则4保留元数据每个chunk至少要保存源文档名、页码、标题路径如果是Markdown、chunk序号。这样LLM可以回答“这段来自哪里”用户也能溯源。chunk.metadata { source: 2025年报.pdf, page: 12, section: 3.2 财务风险, chunk_id: 3 }法则5动态调整同一个文档中标题和正文可以分别处理。标题用小的chunk甚至单独存正文用常规大小。某些向量数据库如Milvus支持混合检索可以针对标题字段加权。五、代码示例生产级切分流水线下面是我最终在生产环境中使用的切分模块简化版from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain.document_loaders import UnstructuredPDFLoader, TextLoader from typing import List, Dict def smart_chunk_document(file_path: str, file_type: str) - List[Dict]: # 1. 加载文档 if file_type pdf: loader UnstructuredPDFLoader(file_path, modeelements) docs loader.load() # 合并文本元素 full_text \n.join([doc.page_content for doc in docs]) elif file_type md: with open(file_path, r) as f: full_text f.read() else: loader TextLoader(file_path) full_text loader.load()[0].page_content # 2. 选择切分器 if file_type md: # Markdown用结构切分 from langchain.text_splitter import MarkdownHeaderTextSplitter headers [(H1, h1), (H2, h2), (H3, h3)] splitter MarkdownHeaderTextSplitter(headers_to_split_onheaders) chunks splitter.split_text(full_text) else: # 默认递归字符 重叠 splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , ] ) chunks splitter.split_text(full_text) # 3. 包装为文档格式 result [] for i, chunk in enumerate(chunks): result.append({ content: chunk if isinstance(chunk, str) else chunk.page_content, metadata: { source: file_path, chunk_id: i, length: len(chunk), type: file_type } }) return result六、常见误区与避坑误区chunk_size越大越好事实超过Embedding模型最大长度会被截断且检索精度下降。误区所有文档用同一种切分策略事实混合文档类型需要不同策略。至少区分纯文本、Markdown、PDF、代码。误区不需要重叠反正LLM能理解事实如果答案横跨两个chunk没有重叠时两个chunk都检索不到完整信息。误区切分后直接存不检查质量事实写一个脚本随机抽样100个chunk人工检查是否有切断的单词、乱码、孤立标题。七、总结Chunk切分是RAG系统中“一分钱一分货”的环节——前期花多少精力后期就省多少补丁。我的结论很简单新手用递归字符切分 500/50的默认参数能覆盖大多数场景。进阶针对文档类型选择专用切分器Markdown用结构、代码用语法。专家结合布局分析和语义边界构建混合切分流水线。但请记住没有完美的chunk策略只有最适合你文档集合的策略。最重要的是实验、测量、迭代——用你自己的测试集跑一遍数据会告诉你答案。

更多文章