芥末
发布于 2026-04-13 / 0 阅读
0
0

RAG 文档切割如何避免语义被截断

RAG(Retrieval-Augmented Generation,检索增强生成)系统通常不会把整篇文档直接塞给 LLM(Large Language Model,大语言模型),而是先把文档切成多个 chunk,再对 chunk 做 Embedding(向量化),查询时召回最相关的片段交给模型生成答案。

这个流程看起来简单,但文档切割有一个很容易被低估的问题:chunk 边界可能刚好切断完整语义

假设服务条款里有这样一段:

前三条款适用于个人用户。

第四条:企业用户享有优先客服通道,响应时间不超过 2 小时,
并可申请专属技术顾问服务。此条款自 2024 年 1 月 1 日起生效。

如果 chunk 边界刚好落在“响应时间不超过 2 小时”之后,系统里可能出现两个片段:

第四条:企业用户享有优先客服通道,响应时间不超过 2 小时,
并可申请专属技术顾问服务。此条款自 2024 年 1 月 1 日起生效。

这不是简单的信息丢失。文字还在库里,但语义被拆散了。用户问“企业用户有哪些客服权益”时,第一段只包含部分权益,第二段缺少主语和上下文,两段单独向量化后的相关性都可能不够强,最后一个都召回不到。

RAG 里的语义截断,本质上是三个问题叠加:

问题 表现 后果
边界切断句子 一个完整句子被拆成两段 单个 chunk 语义残缺
chunk 失去上下文 “此条款”“该服务”等指代不清 Embedding 无法编码真实主题
检索粒度不匹配 小 chunk 好检索,大 chunk 好阅读 召回精度和上下文完整性互相拉扯

处理这个问题不能只靠把 chunk 调大。chunk 越大,内容越杂,向量表示越容易混入多个主题,检索精度会下降。更稳的做法是把方案分成两类:切割阶段尽量不切断语义,检索阶段把上下文补回来

flowchart LR
    A[原始文档] --> B[切割策略]
    B --> C[chunk 入库]
    C --> D[向量检索]
    D --> E[上下文补全]
    E --> F[LLM 生成答案]

    B --> B1[重叠切割]
    B --> B2[语义边界切割]
    B --> B3[命题化切割]

    E --> E1[句子窗口检索]
    E --> E2[父子切割]
    E --> E3[Contextual Retrieval]

重叠切割:给边界留缓冲区

重叠切割是最常见的基础方案。它让相邻 chunk 之间保留一段重复内容,避免边界附近的文字只出现在单侧 chunk 中。

flowchart LR
    A["chunk 1: A B C D"] --> B["chunk 2: C D E F"]
    B --> C["chunk 3: E F G H"]

这里的 C DE F 就是 overlap。假设一句话横跨两个 chunk,重叠区可以让这句话有机会完整出现在某个片段里。

常见配置是把 overlap 设为 chunk size 的 10% 到 20%。例如:

chunk_size = 800
chunk_overlap = 150

这个比例不是越大越好。overlap 太小,边界保护作用有限;overlap 太大,重复内容会明显增加,带来三类成本:

overlap 设置 结果 问题
过小,例如 5% 重叠区域短 仍然可能切断完整句子或段落
适中,例如 10%~20% 覆盖大多数边界语义 成本和效果比较均衡
过大,例如 40% 重复内容很多 存储、索引、召回结果都会变臃肿

重叠切割适合作为默认兜底,但它只能缓解边界附近的文字丢上下文,不能保证每个 chunk 都是完整语义单元。比如一个长段落本身包含多个事实,overlap 仍然可能让一个事实被拆散,或者让检索结果里堆满重复片段。

语义边界切割:不要从句子中间下刀

比 overlap 更合理的切法,是尽量在自然边界切割,比如标题、段落、列表项、句子结束位置。

句子通常是表达完整意思的最小单位。把句子从中间截断,前半段缺谓语、后半段缺主语,单独做 Embedding 时语义会变形。段落则往往承载一个更完整的小主题,优先在段落边界切,可以减少上下文断裂。

flowchart TD
    A[原始文档] --> B[按标题和段落拆分]
    B --> C{当前段落是否超过 chunk size}
    C -- 否 --> D[整段放入 chunk]
    C -- 是 --> E[按句子继续拆分]
    E --> F[按句子累积到大小上限]
    F --> G[生成 chunk]

一个简单的实现思路是:先按段落切,再按句子填充 chunk。只有当单个段落过长时,才继续用句子边界拆分。

def build_chunks(sentences, max_tokens, tokenizer):
    chunks = []
    current = []
    current_tokens = 0

    for sentence in sentences:
        sentence_tokens = len(tokenizer.encode(sentence))

        if current and current_tokens + sentence_tokens > max_tokens:
            chunks.append("".join(current))
            current = []
            current_tokens = 0

        current.append(sentence)
        current_tokens += sentence_tokens

    if current:
        chunks.append("".join(current))

    return chunks

实际工程里可以用 NLP(Natural Language Processing,自然语言处理)工具识别句子边界,例如 spaCynltk,中文场景也可以结合标点规则、标题层级、Markdown 结构、HTML 标签来做。结构化文档尤其适合这种方式,比如产品说明、API 文档、合同条款、FAQ(Frequently Asked Questions,常见问题)。

语义边界切割的局限也很明确:它能避免从句子中间切断,但不能解决“句子本身依赖前文”的问题。比如“该服务自 2024 年起生效”虽然是完整句子,但离开前文后仍然不知道“该服务”是什么。

句子窗口检索:检索要细,返回要宽

句子窗口检索把“用于检索的粒度”和“交给模型阅读的粒度”拆开处理。

存储时,每个句子单独向量化,检索时用单句做匹配;一旦命中某个句子,返回给 LLM 的不是这个句子本身,而是它前后若干句组成的窗口。

sequenceDiagram
    participant User as 用户问题
    participant Retriever as 向量检索器
    participant Store as 句子索引
    participant LLM as 大语言模型

    User->>Retriever: 查询“企业用户有哪些客服权益”
    Retriever->>Store: 检索最相关句子
    Store-->>Retriever: 命中第 12 句
    Retriever->>Store: 取第 10~14 句
    Store-->>LLM: 返回句子窗口
    LLM-->>User: 基于完整上下文回答

这种方式的关键点是:检索用小颗粒度保证定位准确,生成用上下文窗口保证信息完整

例如文档被切成句子后:

S10: 第四条适用于企业用户。
S11: 企业用户享有优先客服通道。
S12: 企业用户的客服响应时间不超过 2 小时。
S13: 企业用户可以申请专属技术顾问服务。
S14: 此条款自 2024 年 1 月 1 日起生效。

如果检索命中 S12,系统可以返回 S10-S14,让模型同时看到适用对象、权益内容、生效时间,而不是只看到孤立的一句话。

句子窗口检索适合对召回精度要求高的知识库,尤其是条款、说明书、技术文档这类句子密集的资料。代价是索引记录数会变多,因为每个句子都要单独建向量;同时还要维护句子顺序,才能在命中后取前后窗口。

父子切割:小块负责召回,大块负责回答

父子切割和句子窗口检索的目标相似,都是分离检索粒度和阅读粒度。区别在于,父子切割会提前维护两层 chunk:

  • 子 chunk:较小,例如 150~300 token,用于向量检索。
  • 父 chunk:较大,例如 800~1200 token,用于返回给 LLM。
  • 两者通过 parent_id 关联。
flowchart TD
    A[父 chunk P1: 完整段落或小节] --> B[子 chunk C1]
    A --> C[子 chunk C2]
    A --> D[子 chunk C3]

    E[用户查询] --> F[检索子 chunk]
    F --> C
    C --> G[根据 parent_id 找到 P1]
    G --> H[把父 chunk 返回给 LLM]

入库时可以保存两份数据:

{
  "child_id": "doc_001_c_02",
  "parent_id": "doc_001_p_01",
  "child_text": "企业用户的客服响应时间不超过 2 小时。",
  "parent_text": "第四条:企业用户享有优先客服通道,响应时间不超过 2 小时,并可申请专属技术顾问服务。此条款自 2024 年 1 月 1 日起生效。"
}

查询时只检索 child_text,因为小片段主题更集中,向量更容易匹配问题;命中后返回 parent_text,因为大段内容能提供完整语境。

父子切割比句子窗口更适合复杂文档结构。父 chunk 可以按标题、小节、表格说明或业务模块来定,不一定只是前后几句话。它的代价是索引和存储结构更复杂,需要维护父子 ID、去重逻辑以及多个子 chunk 命中同一个父 chunk 时的合并策略。

命题化切割:把知识拆成自包含事实

前面几种方案仍然依赖文档原有顺序。命题化切割换了一种思路:不按位置切,而是把内容改写成一条条独立的事实陈述,也就是 proposition(命题)。

一个合格的命题应该满足三个条件:

条件 含义
自包含 单独拿出来能看懂,不依赖“该服务”“此条款”等指代
单事实 只表达一个核心事实,不把多个权益混在一起
可检索 包含用户可能查询的关键实体和关系

服务条款可以被拆成这样:

企业用户享有优先客服通道。
企业用户的客服响应时间不超过 2 小时。
企业用户可以申请专属技术顾问服务。
企业用户专属客服和技术顾问服务条款自 2024 年 1 月 1 日起生效。

拆完后,每条命题的语义密度都很高。用户问“企业用户响应时间多久”,第二条命题会非常容易被召回;用户问“企业用户能不能申请技术顾问”,第三条命题也能直接命中。

flowchart LR
    A[原始段落] --> B[LLM 抽取命题]
    B --> C[命题 1: 优先客服通道]
    B --> D[命题 2: 2 小时响应]
    B --> E[命题 3: 专属技术顾问]
    C --> F[(向量库)]
    D --> F
    E --> F

命题化切割的质量通常很高,但成本也高:它需要额外调用 LLM 来抽取或改写命题,还要防止模型漏抽、错抽、过度改写。适合高价值知识库,例如法律条款、医疗资料、金融规则、企业内部核心流程;普通内容库通常不需要一上来就用这种重方案。

Contextual Retrieval:向量化前给 chunk 补背景

很多 chunk 召回效果差,不是因为切得太短,而是因为它离开文档后失去了背景。

例如:

此条款自 2024 年 1 月 1 日起生效,适用于所有企业版订阅用户。

这段话独立看不出“此条款”指什么。Embedding 模型只能编码它看到的文字,无法凭空知道前文讲的是“企业用户专属客服和技术顾问服务”。向量里没有这些主题信息,检索时就容易被其他“生效日期”“订阅用户”相关内容干扰。

Contextual Retrieval 的做法是在向量化之前,为每个 chunk 生成一段短背景,再把背景和 chunk 拼起来做 Embedding 和关键词索引。

flowchart TD
    A[完整文档] --> C[LLM 生成 chunk 背景]
    B[当前 chunk] --> C
    C --> D[背景说明]
    D --> E[背景 + 原 chunk]
    B --> E
    E --> F[Embedding 向量索引]
    E --> G[BM25 关键词索引]
    F --> H[混合检索]
    G --> H

可以把孤立 chunk:

此条款自 2024 年 1 月 1 日起生效,适用于所有企业版订阅用户。

补成更适合检索的文本:

这段内容说明企业用户专属客服和技术顾问服务条款的生效日期和适用范围。

此条款自 2024 年 1 月 1 日起生效,适用于所有企业版订阅用户。

这样做不会改变原始 chunk 的事实内容,只是在索引阶段补足“企业用户”“专属客服”“技术顾问服务”“生效日期”等检索关键语义。用户查询这些概念时,向量检索更容易命中正确片段。

Contextual Retrieval 常和 BM25(经典关键词相关性检索算法)一起使用。向量检索擅长语义相似,BM25 擅长精确关键词,两者混合后可以同时覆盖“意思相近”和“关键词明确”的查询。

它的主要成本来自 LLM 调用:每个 chunk 都要生成背景说明。实际落地时可以用 Prompt Caching(提示词缓存)降低成本,因为同一篇文档的完整内容在每次请求中都是相同前缀,只有当前 chunk 不同。开启缓存后,完整文档只需要被模型重复利用,不必每次都重新计算全部上下文。

六种方案怎么选

不同方案解决的问题层次不同,工程上通常组合使用,而不是只选一个。

方案 核心思路 适合场景 主要代价
重叠切割 相邻 chunk 保留重复内容 几乎所有 RAG 系统的基础配置 存储和索引略增
语义边界切割 在句子、段落、标题边界切 结构清晰的文档、Markdown、HTML、合同条款 需要边界识别逻辑
句子窗口检索 单句检索,窗口返回 追求定位准确和上下文完整 向量记录数多
父子切割 小 chunk 检索,大 chunk 返回 通用知识库、长文档问答 需要维护父子关系
命题化切割 把内容拆成自包含事实 高质量、高价值、强事实型知识库 LLM 抽取成本高
Contextual Retrieval 向量化前给 chunk 补背景 chunk 孤立感强、指代多、上下文依赖重的文档 LLM 生成背景成本

一个比较稳的默认组合是:

flowchart LR
    A[文档解析] --> B[按标题/段落/句子切割]
    B --> C[加入适度 overlap]
    C --> D[生成子 chunk 向量]
    D --> E[父子关系或句子窗口]
    E --> F{质量要求很高?}
    F -- 否 --> G[直接混合检索]
    F -- 是 --> H[Contextual Retrieval 或命题化切割]
    H --> G

普通知识库可以从“语义边界切割 + 适度 overlap + 父子切割”开始,成本可控,效果也比较稳定。条款类、规则类、事实密集型文档可以增加句子窗口检索或命题化切割,让细粒度事实更容易被召回。上下文依赖特别强的文档,例如大量使用“该方案”“此服务”“上述规则”的资料,更适合引入 Contextual Retrieval。

工程落地时容易踩的坑

chunk size 不是越大越安全

大 chunk 确实能装下更多上下文,但也会混入更多主题。Embedding 会把多个主题压进同一个向量,用户查询某个细节时,相关信号可能被其他内容稀释。更好的方式是小粒度检索,再通过父子 chunk 或窗口补上下文。

overlap 不能替代语义边界

overlap 只是边界缓冲,不是语义理解。没有句子、段落、标题边界识别时,系统仍然可能切出残缺句子。尤其是中文文档,标点、列表、编号、表格说明都应该参与切割规则。

返回上下文要去重

父子切割和句子窗口检索都可能召回重叠内容。多个子 chunk 命中同一个父 chunk 时,只需要返回一次父 chunk;多个窗口有交集时,也应该合并连续区间,否则 LLM 会读到大量重复文本。

索引文本和展示文本可以分开

Contextual Retrieval 里,向量化文本可以是“背景 + 原 chunk”,但最终展示或传给模型时,可以保留原 chunk,也可以传增强后的 chunk,取决于业务需要。关键是要明确:增强背景主要服务检索,不应该引入未经校验的新事实。

质量评估要看召回而不是只看生成

语义截断首先影响的是检索召回。评估时可以准备一组标准问题和目标 chunk,观察 Top-K 结果是否命中目标,而不是只看最终回答是否“像是对的”。常用指标包括 Top-K 命中率、MRR(Mean Reciprocal Rank,平均倒数排名)和检索失败率。

小结

避免 RAG 文档切割中的语义截断,核心不是把 chunk 盲目调大,而是让“检索粒度”和“上下文粒度”各司其职。

切割阶段要尽量尊重语义边界,用句子、段落、标题来减少残缺 chunk,并用适度 overlap 做兜底。检索阶段要把上下文补回来,句子窗口检索和父子切割都能做到“小块命中、大块阅读”。质量要求更高时,可以用命题化切割把知识改造成独立事实,或者用 Contextual Retrieval 在向量化前为 chunk 补足文档背景。

一个成熟的 RAG 系统通常不是靠单一技巧解决语义截断,而是在切割、索引、召回、上下文组装这几个环节一起处理。这样既能保持检索精度,又能让 LLM 拿到足够完整的语义上下文。


评论