乐于分享
好东西不私藏

RAG答非所问?先查文档切分

RAG答非所问?先查文档切分

大多数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(0len(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 = s                else:                    buf += s            if buf:                chunks.append(buf)    return chunksdef split_by_heading(text):    """结构化切分:按标题切,返回 (标题, 内容) 对"""    blocks = re.split(r"(?m)^#{1,3}\s+(.+)$", text)    result = []    for i in range(1len(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 加元数据出。你实际落地时只需要扩展每种类型的切分逻辑和元数据规则。


五、怎么验证切分好坏

切分好坏不是靠感觉,用检索命中率来验证。

自检方法:

  1. 从真实使用场景里挑 10 个问题(不是编的,是你业务上真会有人问的问题)
  2. 对每个问题跑一次向量检索,看 top-1 命中的 chunk 是不是真的包含答案
  3. 记录命中率:10 个问题命中 7 个,就是 70%
  4. 调整切分策略,重新入库,同一个测试集再跑一遍,对比命中率变化

对比规则:一次只改一个变量。要么只改切分方式,要么只改 chunk 大小,要么只改 overlap——同时改三个,出了问题你分不清是哪个引起的。

两个常见误判:

  • 检索命中率低,就以为是 embedding 模型不好——先看切分。很多时候是 chunk 把答案切成两半了,embedding 再好也救不回来。
  • chunk 越大越准——不一定。块越大,块内噪音越多,检索精度反而下降;块越小,召回越准但上下文越缺。所以才有父子块这种组合方案。

六、会遇到的问题

  1. chunk 太小,检索不到完整答案。 调大 chunk 或改用父子块,小块召回、父块喂给 LLM。
  2. chunk 太大,检索结果噪音多。 调小 chunk,或用结构化切分让每个块聚焦一个主题。
  3. 换了切分后结果没变。 检查是不是忘了重新入库——切分策略改了,向量库里的旧数据不会自动更新,需要重建。重建方法:删除 ./rag_demo 目录后重跑入库代码,或者把入库的 add 换成 upsert 直接覆盖。
  4. 不同文档混在一个库里,检索互相干扰。 按文档类型分 collection,或者把类型写进元数据,检索时按类型过滤。

📢 你的RAG切分现在用的什么策略?有没有遇到"切分切断了答案"的案例?评论区说说。

#数据架构 #AI动态 #RAG实战