首发于「林间昭语」 | 作者:程序员林间 | 阅读时间:约 8 分钟
关联阅读:前一篇《向量数据库怎么选》
一、最大的坑:你的文档"碎"得不对
很多人觉得 RAG 难,是难在"效果调优"。
但实际上,60% 的问题出在第一步:文本分割。
你可能遇到过这种情况:
-
问 AI "这份合同的关键条款是什么?"它答了一堆无关内容
-
明明文档里有答案,但 AI 硬是说"没找到"
-
检索出来了,但召回的内容东一块西一块,看不懂在说什么
这些问题,90% 是因为你的文档没有被正确地"切开"。
二、为什么文本分割这么重要?
2.1 原理:AI 是怎么"读"文档的?
RAG 的流程是:
文档 → 分块 → 向量化 → 存入向量库
↓
用户问题 → 向量化 → 检索最相似的块 → 送给 LLM
关键点:AI 不是读整篇文档,而是一次只读一个块。
如果这个块太小 → 上下文不够,AI 看不懂
如果这个块太大 → 语义太杂,AI 注意力分散
如果切的位置不对 → 把一句完整的话切成两半,意思全变了
2.2 一个真实的失败案例
某客户的产品手册,每页是一个产品型号,格式是:
【产品名称】
XXX
【产品型号】
XXX
【功能特点】
- 功能1: xxx
- 功能2: xxx
最初用固定 500 字分块,结果:
- 第一个块包含了"产品名称"和"产品型号"
- 第二个块从"功能特点"中间断开
- 第三个块只有后半部分功能
检索"这个产品有哪些功能"时,召回的内容全是碎的,用户体验极差。
后来改成按"【】"标题分割,每个块都是一个完整的产品卡片,问题立刻解决。
三、四种文本分割策略
3.1 固定长度分割(最简单,但效果最差)
from langchain.text_splitter import CharacterTextSplitter
splitter = CharacterTextSplitter(
chunk_size=500,
chunk_overlap=50
)
chunks = splitter.split_text(text)
问题:完全不考虑语义,该断的地方不断,不该断的地方乱断。
适用场景:只有纯文本,没有任何结构
3.2 递归字符分割(推荐默认选项)
Langchain 推荐的做法,按优先级逐层尝试分割:
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=256, # 目标块大小(字符)
chunk_overlap=64, # 重叠区域
separators=[
"\n\n", # 第一优先:按段落分割
"\n", # 第二优先:按换行分割
"。", # 第三优先:按句号分割
",", # 第四优先:按逗号分割
"" # 最后:按字符分割
]
)
chunks = splitter.split_text(text)
原理:
-
先按
\n\n(段落)分割 -
如果段落太长,按
\n(换行)分割 -
如果还太长,按句号分割
-
以此类推,直到块足够小
实测效果:比固定长度分割,召回率提升 15-20%。
3.3 按标题/结构分割(最适合有结构的文档)
对于有明确结构的文档(手册、报告、合同),按结构分割效果最好:
from langchain.text_splitter import MarkdownTextSplitter
# 按 Markdown 标题分割
splitter = MarkdownTextSplitter(
chunk_size=500,
overlap=50
)
chunks = splitter.split_text(markdown_text)
或者用更灵活的方案:
import re
from langchain.text_splitter import TextSplitter
class StructuredSplitter(TextSplitter):
def __init__(self, pattern, **kwargs):
self.pattern = re.compile(pattern)
super().__init__(**kwargs)
def split_text(self, text):
# 按章节标题分割
sections = self.pattern.split(text)
return [s.strip() for s in sections if s.strip()]
# 按 "第X章" 或 "【XXX】" 分割
splitter = StructuredSplitter(
pattern=r'(第\d+章|【[^】]+】)',
chunk_size=500
)
chunks = splitter.split_text(contract_text)
3.4 语义分割(进阶方案,效果最好但实现复杂)
用 Embedding 模型判断"语义断点",在意义完整的地方才切断:
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
# 按语义断点分割
splitter = SemanticChunker(
embeddings=OpenAIEmbeddings(),
breakpoint_threshold_amount=0.95 # 相似度阈值
)
chunks = splitter.create_documents([long_text])
原理:
-
把文本切成句子
-
计算相邻句子的 Embedding 相似度
-
相似度突然下降的地方 = 语义断点
-
在断点处切断
效果:比递归分割,召回率再提升 5-10%。
缺点:速度慢,成本高(需要调用更多 Embedding API)。
四、分块参数的实战经验
4.1 块大小:256 是中文最优值
| 块大小 | 召回率 | 精确率 | 适用场景 |
|--------|--------|--------|----------|
| 128 | 中 | 高 | 语义极密集的内容 |
| 256 | 高 | 高 | 大多数中文文档 |
| 512 | 高 | 中 | 结构清晰的长文档 |
| 1024 | 中 | 低 | 不推荐 |
为什么是 256?
-
中文 256 字符 ≈ 300-400 token
-
刚好够 LLM 理解一小段完整内容
-
又不至于信息太杂
4.2 重叠:20-25% 是黄金比例
chunk_overlap = int(chunk_size * 0.25) # 256 * 0.25 = 64
作用:防止块边界切断语义,让检索更连贯。
五、Embedding 模型的选择与优化
5.1 为什么 Embedding 决定召回上限?
如果说文本分割是"切菜",Embedding 就是"把菜变成向量"。
如果菜切得再整齐,但向量转化得不对,AI 依然找不到。
5.2 中文场景模型选型
| 模型 | 中文效果 | 速度 | 推荐度 |
|------|----------|------|--------|
| BGE-large-zh | ⭐⭐⭐⭐⭐ | 中 | 首选 |
| BGE-base-zh | ⭐⭐⭐⭐ | 快 | 备选 |
| M3E | ⭐⭐⭐⭐ | 快 | 特定场景 |
| text2vec | ⭐⭐⭐ | 快 | 轻量场景 |
5.3 Embedding 实战代码
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
# 初始化(支持本地部署,数据不出网)
embedding = HuggingFaceBgeEmbeddings(
model_name="BAAI/bge-large-zh",
model_kwargs={
"device": "cpu" # 或 "cuda" 如果有 GPU
},
encode_kwargs={
"normalize_embeddings": True # 归一化,检索更准
}
)
# 单句向量化
query_vec = embedding.embed_query("劳动合同最长期限是多久?")
print(f"向量维度: {len(query_vec)}")
# 批量向量化
doc_vecs = embedding.embed_documents([
"第一章 总则",
"第二章 劳动者的权利和义务",
"第三章 劳动合同的订立"
])
六、完整实战:从文档到向量
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
from langchain_community.vectorstores import Qdrant
# 1. 加载文档
loader = PyPDFLoader("产品手册.pdf")
pages = loader.load_and_split()
# 2. 文本分割
splitter = RecursiveCharacterTextSplitter(
chunk_size=256,
chunk_overlap=64,
separators=["\n\n", "\n", "。", ","]
)
chunks = splitter.split_documents(pages)
# 3. 向量化
embedding = HuggingFaceBgeEmbeddings(model_name="BAAI/bge-large-zh")
# 4. 存入向量数据库
vectorstore = Qdrant.from_documents(
documents=chunks,
embedding=embedding,
client=client,
collection_name="product_manual"
)
print(f"✅ 处理完成:{len(pages)} 页 → {len(chunks)} 个块")
七、常见问题与解决方案
| 问题 | 原因 | 解决方案 |
|------|------|----------|
| 召回率低 | 块太小或太大 | 调 chunk_size 到 256 |
| 检索不连贯 | 块之间没重叠 | 加 chunk_overlap |
| 相关内容搜不到 | Embedding 模型不对 | 换成 BGE-large-zh |
| 结构化文档效果差 | 没按结构分割 | 用 MarkdownSplitter |
| 多文档效果不稳定 | 每个文档格式不同 | 先统一格式再分割 |
总结
三个核心要点:
-
文本分割是 RAG 的第一步,也是最容易出问题的步骤
-
256 字块 + 64 字重叠,是中文文档的最优默认参数
-
有结构的文档(手册、合同)按结构分割,效果比固定长度好 30%+
下一步
文本分割看起来是"体力活",但其实是最能拉开差距的地方。
很多人搭建知识库效果不好,不是算法不行,是文档没切对。
搞定分割,下一步就是调优。下一篇《RAG 效果调优》会讲 5 个核心参数怎么调。
如果你也在处理文档分割的问题,欢迎扫码聊一聊。
我可以帮你评估现有文档结构,给出具体的分割策略建议。
备注"分割",送你一份《常见文档分割策略对比表》👇
📌 关联阅读
关注「林间昭语」公众号,回复以下关键词领取资料:
- 回复"知识库" → 领取《RAG 交付自检清单》
- 回复"分割" → 领取《常见文档分割策略对比表》
- 回复"选型" → 领取《向量数据库选型评估表》
- 回复"调优" → 领取《RAG 调优参数手册》
关注「林间昭语」,用技术创造可能。
点击上方蓝色公众号名称 → 设为星标 🌟,第一时间收到干货。