RAG(Retrieval-Augmented Generation,检索增强生成)系统里,文档通常不能整篇直接塞给 LLM(Large Language Model,大语言模型)。原因很简单:文档太长,超过上下文窗口;即使没超过,把大量无关内容一起送进去,也会增加成本,并干扰模型回答。
所以工程上会先把文档切成一个个 chunk,再对每个 chunk 做 Embedding(向量表示),写入向量数据库。用户提问时,系统把问题向量化,检索最相关的 chunk,再交给 LLM 生成答案。
这个流程看起来很顺:
flowchart LR
A[原始文档] --> B[切割成 chunk]
B --> C[Embedding 向量化]
C --> D[(向量数据库)]
E[用户问题] --> F[问题向量化]
F --> D
D --> G[召回相关 chunk]
G --> H[LLM 生成回答]
真正麻烦的是:文档一旦切错,后面的 Embedding、检索、重排、生成都会被影响。
最常见的问题,就是语义被截断。
语义截断到底坏在哪里
假设文档里有一段服务条款:
前三条款适用于个人用户。
第四条:企业用户享有优先客服通道,响应时间不超过 2 小时,并可申请专属技术顾问服务。
此条款自 2024 年 1 月 1 日起生效。
如果系统按固定 token 数切割,边界刚好落在这句话中间,可能会得到两个 chunk:
chunk A:
前三条款适用于个人用户。
第四条:企业用户享有优先客服通道,响应时间不超过 2 小时,
chunk B:
并可申请专属技术顾问服务。
此条款自 2024 年 1 月 1 日起生效。
用户问:
企业用户有哪些客服权益?
理想答案应该覆盖三件事:
- 优先客服通道
- 2 小时内响应
- 可申请专属技术顾问
但切割后的两个 chunk 都有问题。
chunk A 只包含一半权益,chunk B 缺少主语和条款背景。向量模型看到 chunk B 时,只知道“可申请专属技术顾问服务”,却不知道这是“企业用户”的权益。结果可能是两个 chunk 的相似度都不够高,Top-K 检索时一个都召不回来。
语义截断的核心不是“文字消失了”,而是“完整事实被拆散后,每一部分都变得不够可检索”。
可以把它理解成下面这个链路问题:
flowchart TD
A[完整语义事实] --> B[被固定长度切成两半]
B --> C[每个 chunk 单独向量化]
C --> D[chunk 缺少上下文]
D --> E[向量语义不完整]
E --> F[检索相似度下降]
F --> G[答案所需信息没有被召回]
解决这个问题有两类思路:
| 方向 | 目标 | 典型方案 |
|---|---|---|
| 切割阶段避免破坏语义 | 尽量不要把完整句子、段落、事实拆开 | 重叠切割、语义边界切割、命题化切割 |
| 检索阶段补回上下文 | 允许细粒度检索,但返回给 LLM 时补充完整上下文 | 句子窗口检索、父子切割、Contextual Retrieval |
方案一:重叠切割,用 overlap 兜住边界信息
重叠切割是最基础的做法。它不让相邻 chunk 完全割裂,而是在 chunk 之间保留一段重复内容。
例如设置:
chunk_size = 800 tokens
chunk_overlap = 150 tokens
切割效果大致是:
flowchart LR
subgraph D[原始文档 token 序列]
A1[0 ~ 800] --- A2[650 ~ 1450] --- A3[1300 ~ 2100]
end
第二个 chunk 会从第一个 chunk 结束前的某个位置开始,这样即使一句话跨过边界,也有机会完整出现在某个 chunk 里。
一个简单的固定长度 overlap 切割可以这样写:
def chunk_with_overlap(tokens, chunk_size=800, overlap=150):
chunks = []
start = 0
while start < len(tokens):
end = start + chunk_size
chunks.append(tokens[start:end])
if end >= len(tokens):
break
start = end - overlap
return chunks
overlap 通常设置为 chunk size 的 10%~20%。比例太小,覆盖不了跨边界的完整语义;比例太大,重复内容会明显增加,向量库记录变多,检索结果里也可能出现大量相似 chunk。
| overlap 比例 | 效果 | 问题 |
|---|---|---|
| 0% | 存储最省 | 很容易切断句子或事实 |
| 5% 左右 | 成本低 | 对长句、复杂条款保护不足 |
| 10%~20% | 比较均衡 | 适合多数 RAG 知识库 |
| 40% 以上 | 边界保护更强 | 重复内容多,检索和生成都可能受干扰 |
overlap 是必要的基础配置,但不能把它当成完整方案。它只能提高“跨边界内容被保留”的概率,不能保证 chunk 本身语义独立,也不能解决“半句话单独向量化后语义变弱”的问题。
方案二:按语义边界切割,不在句子中间下刀
比固定长度更合理的做法,是优先识别自然语言里的边界,例如:
- 章节标题
- 段落
- 列表项
- 句子
- 表格行
- Markdown 标题层级
句子通常是表达完整意思的最小单位。把一句话从中间切开,前半段缺结果,后半段缺主语,都会影响 Embedding 表达。
语义边界切割的流程可以设计成这样:
flowchart TD
A[原始文档] --> B[按标题和段落粗分]
B --> C[段落过长则按句子细分]
C --> D[逐句累积到目标 chunk_size]
D --> E{加入下一句会超长吗}
E -- 否 --> D
E -- 是 --> F[封存当前 chunk]
F --> G[开启新 chunk]
Python 伪代码如下:
def semantic_chunk(sentences, max_tokens=800, overlap_sentences=1):
chunks = []
current = []
current_tokens = 0
for sentence in sentences:
sentence_tokens = count_tokens(sentence)
if current and current_tokens + sentence_tokens > max_tokens:
chunks.append("".join(current))
# 句子级 overlap,比 token 级 overlap 更不容易破坏语义
current = current[-overlap_sentences:]
current_tokens = sum(count_tokens(s) for s in current)
current.append(sentence)
current_tokens += sentence_tokens
if current:
chunks.append("".join(current))
return chunks
如果处理英文文档,可以用 spaCy 或 nltk 做句子切分;中文文档可以结合标点规则、标题规则和中文 NLP(Natural Language Processing,自然语言处理)工具。
import re
def split_chinese_sentences(text):
# 简化示例:按中文句末标点切分
parts = re.split(r'(?<=[。!?;])', text)
return [p.strip() for p in parts if p.strip()]
语义边界切割还有一个重要细节:不要只盯着句子。很多业务文档真正的语义单位是段落、条款、列表项或表格行。
例如:
第四条 企业用户权益
1. 企业用户享有优先客服通道。
2. 客服响应时间不超过 2 小时。
3. 企业用户可申请专属技术顾问服务。
这类内容最好把标题和列表项一起保留。否则单独切出“客服响应时间不超过 2 小时”,仍然会丢掉“企业用户权益”这个背景。
更稳妥的 chunk 结构是:
第四条 企业用户权益
1. 企业用户享有优先客服通道。
2. 客服响应时间不超过 2 小时。
3. 企业用户可申请专属技术顾问服务。
语义边界切割适合结构清晰的文档,比如产品手册、接口文档、合同条款、FAQ、Markdown 技术文档。对于 OCR 识别出来的扫描件、排版混乱的 PDF,它需要先做版面恢复和结构清洗,否则边界识别会不稳定。
方案三:句子窗口检索,用单句检索,用邻近句补上下文
语义边界切割仍然是在“切”上做优化。另一种思路是:检索时用更小的粒度提高命中率,返回给 LLM 时再补充周围上下文。
句子窗口检索(Sentence Window Retrieval)就是这个思路。
它把每个句子作为独立检索单元入库,但不把单句直接交给 LLM。当某个句子被命中时,系统会取出它前后 N 个句子,组成上下文窗口。
flowchart LR
A[句子 1] --> B[句子 2]
B --> C[句子 3 命中]
C --> D[句子 4]
D --> E[句子 5]
C -.检索命中.-> F[返回窗口: 句子 1 ~ 5]
存储时,每条记录至少包含:
{
"doc_id": "doc_001",
"sentence_id": 37,
"text": "企业用户可申请专属技术顾问服务。",
"embedding": [0.12, -0.03, 0.88]
}
检索命中后,通过 doc_id + sentence_id 找回邻近句子:
def build_sentence_window(sentences, hit_index, window_size=2):
start = max(0, hit_index - window_size)
end = min(len(sentences), hit_index + window_size + 1)
return "".join(sentences[start:end])
完整流程如下:
sequenceDiagram
participant U as 用户
participant R as RAG 服务
participant V as 向量库
participant S as 句子存储
participant L as LLM
U->>R: 提问
R->>V: 用问题向量检索单句
V-->>R: 返回命中的 sentence_id
R->>S: 获取前后 N 句
S-->>R: 返回上下文窗口
R->>L: 问题 + 上下文窗口
L-->>R: 生成答案
R-->>U: 返回结果
句子窗口检索的优势很明确:
- 检索粒度细,相关句子更容易被命中;
- 返回内容比单句完整,LLM 不容易缺上下文;
- 不需要提前构造复杂的父子 chunk 关系。
代价也明显:句子数通常远多于段落数,向量库记录会变多;检索命中多个相邻句子时,还需要做窗口合并和去重,否则上下文会重复。
适合使用句子窗口检索的场景包括:
- FAQ、帮助中心、产品说明;
- 用户问题往往对应文档中的某一句或某个短事实;
- 希望检索定位足够细,但生成时又需要完整段落。
方案四:父子切割,小块负责检索,大块负责生成
父子切割(Parent-Child Chunking)和句子窗口检索很像,核心都是“检索粒度”和“生成上下文”分离。
区别在于:句子窗口检索是在命中后动态找邻近句子;父子切割会提前维护小 chunk 和大 chunk 的映射关系。
flowchart TD
P1[父 chunk: 1000 tokens<br/>完整段落或章节片段]
P1 --> C1[子 chunk A: 200 tokens]
P1 --> C2[子 chunk B: 200 tokens]
P1 --> C3[子 chunk C: 200 tokens]
P1 --> C4[子 chunk D: 200 tokens]
C2 -.向量检索命中.-> R[返回父 chunk 给 LLM]
入库时可以分成两份数据:
parent_chunks = [
{
"parent_id": "p_001",
"text": "企业用户权益相关的完整段落……"
}
]
child_chunks = [
{
"child_id": "c_001",
"parent_id": "p_001",
"text": "企业用户享有优先客服通道。",
"embedding": [...]
},
{
"child_id": "c_002",
"parent_id": "p_001",
"text": "响应时间不超过 2 小时。",
"embedding": [...]
}
]
检索时只查子 chunk:
def retrieve_with_parent(query, vector_store, parent_store, top_k=5):
hits = vector_store.search(query, top_k=top_k)
parent_ids = []
for hit in hits:
parent_ids.append(hit["parent_id"])
# 去重,避免多个子 chunk 命中同一个父 chunk 后重复返回
unique_parent_ids = list(dict.fromkeys(parent_ids))
return [parent_store[parent_id] for parent_id in unique_parent_ids]
父子切割的价值在于:
- 子 chunk 短,语义更聚焦,检索准确;
- 父 chunk 长,保留上下文,生成更完整;
- 父 chunk 大小可以按业务控制,例如按段落、章节、小节构造。
它的工程复杂度比句子窗口更高。系统需要维护父子 ID,更新文档时也要同步更新父 chunk、子 chunk 和映射关系。如果文档经常变更,增量更新逻辑要设计清楚,否则容易出现子 chunk 指向不存在父 chunk 的问题。
父子切割适合通用知识库,尤其是文档层级比较稳定的场景,比如:
- 内部制度文档;
- API 文档;
- 产品白皮书;
- 技术方案文档;
- 合同、条款和政策说明。
方案五:命题化切割,把文档拆成独立事实
前几种方法大多保留了文档的原始表达。命题化切割(Proposition-based Chunking)更激进:它不再按照文本位置切,而是让 LLM 把文档改写成一条条独立、自包含的事实陈述。
例如原始句子是:
企业用户享有优先客服通道,响应时间不超过 2 小时,并可申请专属技术顾问服务。
命题化后可以得到:
企业用户享有优先客服通道。
企业用户的客服响应时间不超过 2 小时。
企业用户可以申请专属技术顾问服务。
这三句话有两个特点:
- 每条只表达一个事实;
- 每条都带有必要主语,单独拿出来也能理解。
命题化切割流程如下:
flowchart TD
A[原始段落] --> B[LLM 提取事实]
B --> C[生成自包含命题]
C --> D[命题质量校验]
D --> E[Embedding 入库]
E --> F[按命题检索]
可以使用类似这样的提示词生成命题:
请把下面的段落拆解成若干条独立命题。
要求:
1. 每条命题只表达一个事实。
2. 每条命题必须自包含,不能出现“该服务”“此条款”“上述内容”等缺少指代对象的表达。
3. 不要添加段落中没有的信息。
4. 保留关键限定条件,例如适用对象、时间、范围、数值。
段落:
{paragraph}
命题化切割特别适合事实密度高、问答要求精确的知识库。比如政策条款、保险责任、金融产品规则、医疗指南、企业制度等。
它的代价也高:
| 成本项 | 说明 |
|---|---|
| LLM 调用成本 | 每个段落都要经过模型拆解 |
| 质量校验成本 | 模型可能遗漏限定条件,也可能把一句话拆得过碎 |
| 可追溯性成本 | 需要保留命题和原始段落的映射,方便引用来源 |
| 更新成本 | 原始文档变更后,需要重新生成相关命题 |
工程上不要只保存命题,最好同时保存来源信息:
{
"proposition_id": "prop_001",
"text": "企业用户的客服响应时间不超过 2 小时。",
"source_doc_id": "doc_001",
"source_paragraph_id": "para_004",
"source_text": "企业用户享有优先客服通道,响应时间不超过 2 小时,并可申请专属技术顾问服务。"
}
这样既能用命题提高检索精度,又能在生成答案时引用原始上下文,减少模型误解。
方案六:Contextual Retrieval,在向量化前补充背景
Contextual Retrieval 是 Anthropic 提出的一种增强检索方法。它解决的是另一个常见问题:chunk 单独拿出来时,经常缺少文档背景。
例如某个 chunk 是:
此条款自 2024 年 1 月 1 日起生效,适用于所有企业版订阅用户。
人类看到这句话会问:“此条款”是哪条?如果前文说的是企业用户客服权益,那这句话就很重要;如果前文说的是退款规则,那含义完全不同。
Embedding 模型只能编码它看到的文本。chunk 里没有“企业用户客服权益”这些词,向量里自然也很难包含这层语义。
Contextual Retrieval 的做法是在 Embedding 之前,为每个 chunk 生成一段短背景,再把背景和 chunk 拼起来向量化。
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 --> I[(关键词索引)]
生成出来的背景可能是:
这段内容说明企业用户专属客服和技术顾问服务条款的生效日期与适用范围。
拼接后再入库:
这段内容说明企业用户专属客服和技术顾问服务条款的生效日期与适用范围。
此条款自 2024 年 1 月 1 日起生效,适用于所有企业版订阅用户。
这样做以后,chunk 的向量里会显式包含:
- 企业用户
- 专属客服
- 技术顾问服务
- 生效日期
- 适用范围
检索“企业用户客服权益什么时候生效”时,这个 chunk 更容易被召回。
Contextual Retrieval 可以和 BM25 一起使用。BM25 是一种基于词频和逆文档频率的稀疏检索算法,擅长匹配关键词;Embedding 检索擅长匹配语义。背景说明同时进入向量索引和 BM25 索引后,语义召回与关键词召回都会受益。
一个简化的处理流程如下:
def build_contextual_chunk(full_document, chunk, llm):
prompt = f"""
你会收到一篇完整文档和其中一个片段。
请用 1 到 2 句话说明该片段在完整文档中的上下文。
要求只补充必要背景,不要改写片段内容,不要添加文档中不存在的信息。
完整文档:
{full_document}
当前片段:
{chunk}
"""
context = llm.generate(prompt)
return context + "\n" + chunk
实际入库时,需要保存两个版本:
{
"chunk_id": "chunk_001",
"raw_text": "此条款自 2024 年 1 月 1 日起生效,适用于所有企业版订阅用户。",
"context": "这段内容说明企业用户专属客服和技术顾问服务条款的生效日期与适用范围。",
"indexed_text": "这段内容说明企业用户专属客服和技术顾问服务条款的生效日期与适用范围。\n此条款自 2024 年 1 月 1 日起生效,适用于所有企业版订阅用户。"
}
indexed_text 用于检索,raw_text 用于展示和引用。这样可以避免把模型生成的背景误当成原始内容。
Contextual Retrieval 的成本怎么控制
Contextual Retrieval 最大的成本来自 LLM 调用。假设一篇文档切成 100 个 chunk,如果每个 chunk 都把完整文档和当前 chunk 一起发给模型,成本会很高。
Prompt Caching(提示词缓存)可以明显降低这个成本。很多请求里,完整文档部分是相同的,变化的只有当前 chunk。开启缓存后,模型服务可以复用完整文档对应的 KV Cache(Key-Value Cache,键值缓存),后续请求只需要重点处理变化的 chunk 部分。
调用结构大致是:
可缓存前缀:
完整文档 full_document
非缓存部分:
当前 chunk
生成该 chunk 的上下文说明
用时序图表示:
sequenceDiagram
participant App as 索引程序
participant LLM as LLM 服务
App->>LLM: full_document + chunk_1
LLM-->>App: 生成 context_1,并缓存 full_document
App->>LLM: full_document + chunk_2
LLM-->>App: 复用 full_document 缓存,生成 context_2
App->>LLM: full_document + chunk_3
LLM-->>App: 复用 full_document 缓存,生成 context_3
Anthropic 公布的实验中,Contextual Retrieval 与 BM25 混合检索结合后,可以显著降低 Top-K 召回失败率。具体效果会受文档类型、chunk 大小、检索器、重排器和问题分布影响,但它对“指代多、上下文依赖强、chunk 单独看不明白”的文档尤其有价值。
几种方案怎么选
不同方案解决的问题层次不一样,不能简单说哪个最好。更合理的方式是按知识库特点组合使用。
| 方案 | 核心思路 | 适合场景 | 主要代价 |
|---|---|---|---|
| 重叠切割 | 相邻 chunk 保留重复内容 | 几乎所有 RAG 系统的基础配置 | 存储和检索结果有少量重复 |
| 语义边界切割 | 按标题、段落、句子、列表项切割 | 结构清晰的技术文档、产品文档、制度文档 | 需要解析文档结构和句子边界 |
| 句子窗口检索 | 单句向量检索,返回邻近句窗口 | 问题经常命中文档中某个短事实 | 向量记录数多,窗口合并要处理好 |
| 父子切割 | 子 chunk 检索,父 chunk 生成 | 通用知识库,追求检索精度和上下文完整性的平衡 | 需要维护父子映射,更新逻辑更复杂 |
| 命题化切割 | 用 LLM 拆成自包含事实 | 高价值、强事实型知识库 | LLM 成本高,需要质量校验 |
| Contextual Retrieval | 向量化前为 chunk 补背景 | 指代多、上下文依赖强、孤立 chunk 难理解的文档 | 需要额外 LLM 调用,可用 Prompt Caching 降低成本 |
常见组合可以这样选:
| 知识库类型 | 推荐组合 |
|---|---|
| 普通帮助中心、FAQ | 语义边界切割 + overlap |
| 产品文档、技术文档 | 标题/段落切割 + 父子切割 |
| 制度、合同、条款 | 语义边界切割 + 父子切割 + 必要时命题化 |
| 高价值企业知识库 | 语义边界切割 + Contextual Retrieval + BM25 混合检索 |
| 短事实密集型问答 | 命题化切割 + 来源段落回填 |
| 长 PDF、结构混乱文档 | 版面解析清洗 + 语义边界切割 + Contextual Retrieval |
一个更稳的工程落地流程
在真实 RAG 系统里,文档切割通常不靠单一策略。一个比较稳的索引流程可以设计成这样:
flowchart TD
A[上传文档] --> B[文本抽取与版面清洗]
B --> C[识别标题/段落/列表/表格]
C --> D[按语义边界生成基础 chunk]
D --> E[加入适度 overlap]
E --> F{是否需要高质量检索}
F -- 否 --> G[直接 Embedding + BM25 入库]
F -- 是 --> H{文档是否强依赖上下文}
H -- 是 --> I[Contextual Retrieval 补背景]
H -- 否 --> J{是否适合事实拆解}
J -- 是 --> K[命题化切割]
J -- 否 --> L[父子切割或句子窗口检索]
I --> M[Embedding + BM25 入库]
K --> M
L --> M
G --> N[检索与生成]
M --> N
几个参数可以作为起点:
chunk_size: 500-1000 tokens
chunk_overlap: 10%-20%
sentence_window_size: 2-3
child_chunk_size: 150-300 tokens
parent_chunk_size: 800-1500 tokens
top_k_vector: 20
top_k_after_rerank: 5
这些不是固定标准。文档越结构化,越应该依赖标题、段落和列表边界;文档越零散,越需要 Contextual Retrieval 或命题化来补足语义。
容易踩的坑
1. chunk 不是越大越好
增大 chunk size 的确能减少截断概率,但会带来新问题:一个 chunk 里混入太多主题,向量会变得模糊。
例如一个 2000 token 的 chunk 同时包含“登录接口”“计费规则”“企业客服权益”,用户问企业客服时,这个 chunk 可能相关;用户问计费规则时,它也可能相关。结果是检索排序不稳定,LLM 拿到的上下文也更杂。
chunk size 应该服务于检索精度,而不是单纯追求“装得下”。
2. overlap 太大会制造重复噪声
大 overlap 会让多个 chunk 高度相似。检索 Top-K 里可能出现 5 个内容几乎一样的 chunk,占掉本来应该给其他证据的名额。
如果 overlap 已经超过 30%,要检查是不是切割策略本身太粗糙。很多时候,改成句子边界切割比继续加 overlap 更有效。
3. 只做向量检索容易漏关键词
Embedding 检索擅长语义相似,但对数字、专有名词、错误码、接口名、版本号不一定稳定。RAG 系统最好结合 BM25 或其他关键词检索,再做融合排序。
flowchart LR
Q[用户问题] --> V[向量检索]
Q --> B[BM25 关键词检索]
V --> M[结果合并]
B --> M
M --> R[重排模型 rerank]
R --> L[LLM 生成]
4. 表格不能直接按普通段落切
表格里的语义往往依赖表头。只切出某一行,模型可能看不懂每个数字代表什么。
例如:
| 用户类型 | 客服响应时间 | 技术顾问 |
|---|---|---|
| 个人用户 | 24 小时内 | 不支持 |
| 企业用户 | 2 小时内 | 可申请 |
如果只切出:
企业用户 | 2 小时内 | 可申请
语义是不完整的。更好的做法是把表头带上:
表格说明:不同用户类型的客服响应时间和技术顾问权益。
用户类型:企业用户
客服响应时间:2 小时内
技术顾问:可申请
5. LLM 生成的上下文要和原始内容分开存
无论是命题化切割还是 Contextual Retrieval,都涉及 LLM 生成内容。工程上必须区分:
- 原始文本:来自文档,可作为引用依据;
- 生成背景:用于增强检索,不应直接当成原始证据;
- 改写命题:用于精确召回,需要保留来源映射。
否则答案引用时可能把增强信息当成文档原句,影响可信度。
一句话总结选型逻辑
RAG 文档切割要同时满足两个目标:检索时足够细,生成时上下文足够完整。基础做法是语义边界切割加适度 overlap;如果需要更高召回,可以用句子窗口或父子切割;如果 chunk 单独看缺少背景,使用 Contextual Retrieval;如果知识库要求事实级精准检索,再考虑命题化切割。