大多数RAG效果差,问题不在模型,在文档被切成了什么样。
一、为什么先查切分
RAG 的结果质量由三件事决定:切分、检索、生成。
很多人一上来就换模型、调 prompt、改 embedding——但如果你把文档切成了一堆语义碎片,后面做的全是无用功。切分是RAG的第一道闸门,闸门错了,后面怎么优化都是在错误的基础上打补丁。
而且切分不是一个"切多大"的问题。生产环境里,一套文档库通常包含制度规范、FAQ、操作手册、长报告等多种类型,不同类型应该用不同的切分策略,甚至组合使用。
二、切分策略谱系
先看清全景。常用的切分策略有六种,各有定位:

固定长度切分
按 token 或字符数硬切,比如每 100 个字符一块。
优点:实现最简单,什么文档都能切。
问题:完全不管语义边界,一句话可能在中间被切断。比如"本制度适用于集团总部及下属所有"后面直接接"分公司的员工"——检索时只命中前半句,信息是残缺的。
定位:无结构纯文本的兜底方案,不推荐作为主策略。
重叠窗口切分
固定长度切分加上 overlap,让相邻块共享一部分内容。
它解决的是"上下文断裂"问题——被切断的句子,在前一块或后一块还能找到完整的版本。
代价:同一份内容被重复存储,向量数量变多,存储和检索成本上升。
定位:长文本、段落边界模糊时的常用补强手段。
递归字符切分
按分隔符的层级递归切:先按段落切,段落太长再按句子切,句子太长再按短语切。
这是处理自然文本时最通用的方案。它能尽量让每个块落在语义完整的边界上。
定位:自然文本的通用默认选项。
结构化切分
按 Markdown 标题、章节、表格等文档结构切分,同时把标题、章节号保存为元数据。
检索命中片段后,元数据能告诉 LLM"这段内容属于哪个章节",回答时能带上章节上下文。
定位:制度规范、技术手册、API 文档这类有明确结构的文档。
语义切分
用 embedding 计算句与句之间的相似度,在语义边界处断开。
它解决的是固定切分"切断语义"的核心问题——但需要先对句子做 embedding,计算成本高,速度慢。
定位:内容边界不明显、但需要高质量切分的场景。
父子块(small-to-big)
把文档同时切成小块和父块:小块进向量库参与检索,命中小块后,把对应的完整父块(整节、整章)作为上下文喂给 LLM。
小块召回准确,父块上下文完整——两个问题一起解决。
代价:同一内容存两份,存储翻倍,逻辑更复杂。
定位:混合文档库、问答场景的首选方案。
三、生产里怎么组合
实际项目中几乎不会只选一种策略。切分器通常长这样:

判断文档类型,分派到对应策略:
制度规范 → 结构化切分。这类文档有明确的章节结构,按章节切分并保留标题做元数据。用户问"报销标准是什么",检索到第三章某个片段时,元数据能带上"费用管理制度·第三章",回答不会丢失上下文。
FAQ → 按问答对切分。每个问题连同答案切成一个块。用户问的问题和 FAQ 里的问题高度相似,检索直接命中整条问答对,答案完整。如果按段落切,答案会被切成碎片。
操作手册 → 递归段落 + 重叠窗口。操作流程需要连续上下文,用递归切分保持段落完整,再加重叠窗口防止流程节点被切断。
混合文档库 → 父子块。文档类型多、查询粒度不一的时候,小块保证召回精度,父块保证生成时有完整上下文。
组合的关键不是"选一个最好的策略",而是按文档类型设计分派规则,再统一入库。
四、切分器代码骨架
下面是按类型路由的切分器骨架,纯 Python 实现,不引入额外依赖:
import redef split_fixed(text, chunk_size=100):"""固定长度切分,兜底用"""return [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]def split_recursive(text, max_len=100):"""递归字符切分:段落 → 句子 → 短语"""# 先按段落切paragraphs = re.split(r"\n\s*\n", text)chunks = []for p in paragraphs:if len(p) <= max_len:chunks.append(p)else:# 段落太长,按句子切sentences = re.split(r"(?<=[。!?])", p)buf = ""for s in sentences:if len(buf) + len(s) > max_len and buf:chunks.append(buf)buf = selse:buf += sif buf:chunks.append(buf)return chunksdef split_by_heading(text):"""结构化切分:按标题切,返回 (标题, 内容) 对"""blocks = re.split(r"(?m)^#{1,3}\s+(.+)$", text)result = []for i in range(1, len(blocks), 2):heading = blocks[i].strip()body = blocks[i+1].strip() if i+1 < len(blocks) else ""if body:result.append((heading, body))return resultdef split_by_doc_type(doc_type, text, title=""):"""按文档类型分派切分策略"""if doc_type == "规范":return [(h, b, f"{title}·{h}") for h, b in split_by_heading(text)]elif doc_type == "FAQ":return [(title, text, title)] # 一问一答一条elif doc_type == "手册":return [(i, c, f"{title}·段落{i}") for i, c in enumerate(split_recursive(text))]else:return [(i, c, f"{title}·块{i}") for i, c in enumerate(split_fixed(text))]
这个骨架的核心是 split_by_doc_type:文档类型进,chunk 加元数据出。你实际落地时只需要扩展每种类型的切分逻辑和元数据规则。
五、怎么验证切分好坏
切分好坏不是靠感觉,用检索命中率来验证。
自检方法:
从真实使用场景里挑 10 个问题(不是编的,是你业务上真会有人问的问题) 对每个问题跑一次向量检索,看 top-1 命中的 chunk 是不是真的包含答案 记录命中率:10 个问题命中 7 个,就是 70% 调整切分策略,重新入库,同一个测试集再跑一遍,对比命中率变化
对比规则:一次只改一个变量。要么只改切分方式,要么只改 chunk 大小,要么只改 overlap——同时改三个,出了问题你分不清是哪个引起的。
两个常见误判:
检索命中率低,就以为是 embedding 模型不好——先看切分。很多时候是 chunk 把答案切成两半了,embedding 再好也救不回来。 chunk 越大越准——不一定。块越大,块内噪音越多,检索精度反而下降;块越小,召回越准但上下文越缺。所以才有父子块这种组合方案。
六、会遇到的问题
chunk 太小,检索不到完整答案。 调大 chunk 或改用父子块,小块召回、父块喂给 LLM。 chunk 太大,检索结果噪音多。 调小 chunk,或用结构化切分让每个块聚焦一个主题。 换了切分后结果没变。 检查是不是忘了重新入库——切分策略改了,向量库里的旧数据不会自动更新,需要重建。重建方法:删除 ./rag_demo目录后重跑入库代码,或者把入库的add换成upsert直接覆盖。不同文档混在一个库里,检索互相干扰。 按文档类型分 collection,或者把类型写进元数据,检索时按类型过滤。
📢 你的RAG切分现在用的什么策略?有没有遇到"切分切断了答案"的案例?评论区说说。
夜雨聆风