
◆ 博主名称: QuZhengRong
AI俘虏,样式苦手
目录

文档切片是 RAG 的前置处理步骤,它直接决定了后续检索可获取的内容,切得不好,召回的片段要么臃肿,要么缺少关键信息,无法为 大模型 提供强有力的数据支持。
常见的切片方法有窗口、递归、语义这三种,下面分别介绍下这些方法的优缺点,以及如何在实际场景中选择合适的切片方法。
一、为什么做文档切片
RAG 的流程是先把文档转化为向量然后存储到 向量数据库 中,用户提问时把问题也向量化,找出最相似的片段再返回给模型。
文档切片要做的是把文档切成 chunk 块,之后再分块存入向量数据库。那你可能好奇,为什么不直接把整篇文档做成一个向量?原因有以下三点:
模型上下文有限。长的文档有几十万字,全部放进对话可能会超出模型的上下文限制。
长文档转化为一个向量后,语义会被平均掉。比如文档里有选址、成本、外卖、加盟好几个话题,压成一个向量之后,每个话题的特征都不突出,检索评分变低。
长文档里混杂大量无关内容,会稀释核心语义,向量表征无法聚焦局部关键信息,导致召回精度大幅下降
基于以上理由,长文档要做切片。切片的目标也有三个:
- 块内的语义尽量完整
- 块的大小适配 embedding 模型
- 块与块之间不要重复太多
用一份 餐饮经营避坑手册.md 作为切片素材,点击链接可下载文件:
二、准备工作
用 uv 安装切片依赖。
uv add langchain-text-splitters langchain-openai python-dotenv
嵌入模型用的是 百炼 的 text-embedding-v4,在 .env 里配 API_KEY 和 BASE_URL
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings
import os
load_dotenv()
embedder = OpenAIEmbeddings(
model="text-embedding-v4",
api_key=os.getenv("API_KEY"),
base_url=os.getenv("BASE_URL"),
# 百炼只收原始字符串,必须关掉,否则 langchain 会先按 token 截断再发,接口报 400
check_embedding_ctx_length=False,
# 百炼单次最多 10 条,langchain 默认按 1000 条一批发,也会报 400
chunk_size=10,
)
三、窗口切分
窗口切分是按固定长度来切,每块之间留一段重叠。重叠的部分是被切断的那句话,上一块的结尾在下一块的开头再出现一次,检索时至少有一块是完整的,可以作为标记拼接文档。
# 块大小按字符数算,中文 1 个字大约 0.6~1 个 token
CHUNK_SIZE = 300
# 相邻两块重叠的字符数,一般取 CHUNK_SIZE 的 10%~20%
OVERLAP = 60
# 末尾碎片小于这个长度就并入上一块
MIN_CHUNK_SIZE = 80
def window_split(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = OVERLAP) -> list[str]:
step = chunk_size - overlap # 每次向前滑动的步长
chunks = [text[i:i + chunk_size] for i in range(0, len(text), step)]
# 最后一块太短时并入上一块:overlap 部分已经在上块里了,只补新增内容
if len(chunks) > 1 and len(chunks[-1]) < MIN_CHUNK_SIZE:
tail = chunks.pop()
chunks[-1] = chunks[-1] + tail[overlap:]
return chunks
运行结果:
原文总长度:3690 字
块数:16
平均长度:287 字
最长 / 最短:300 / 90 字
冗余率(重叠部分占比):19.6%
第 2 块结尾:...扣点普遍在营业额的百分之十到百分之二十之间,另外还有物业费、推广费、保证
第 3 块开头:证金。商场店适合标准化的连锁品牌,独立小店进场容易被各种规则耗死。社区店租金...
第 2 块结尾那句保证金被切成了两半,这就是窗口切分的典型问题:切分的地点可能不是句子、段落的边界,分割了语义。
再看冗余率,16 块里有 15 处重叠,重复内容占了 19.6%,也就是要多存五分之一的文本。overlap 调大能缓解断句,但冗余也跟着涨,一般取 10% 到 20%。
优缺点:
✅ 实现最简单,不挑文档类型,速度快,无需额外调模型
❌ 无法识别句子边界,一句话常被截断;重叠区域会产生冗余,调大 overlap 虽然能改善断句问题,但存储成本也会随之增加
窗口切片适合没有明显结构的长文本,比如日志、访谈记录、纯文字的会议纪要。
四、递归切分
递归切分的思路是给一堆分隔符排优先级,先用最粗的分隔符切,切完后如果文本还是太大了,再换细一级的分隔符继续切,直到每块都小于阈值。和窗口切分的区别是它优先在段落和句子的边界上断开。
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 默认分隔符是针对英文设计的(\n\n、\n、空格),中文文档要自己排优先级
CN_SEPARATORS = [
"\n## ",
"\n### ",
"\n\n",
"\n",
"。",
"!",
"?",
";",
",",
" ",
"",
]
splitter = RecursiveCharacterTextSplitter(
separators=CN_SEPARATORS,
chunk_size=300,
chunk_overlap=50,
length_function=len, # 中文按字符数统计,不用 tiktoken 估 token
is_separator_regex=False,
)
chunks = splitter.split_text(text)
分隔符这一项是递归切割场景里最值得关注的地方。 langchain 的默认分隔符是 \n\n、\n、空格这些,对英文好用,但在中文场景下就不太适用了。这次要切的是手册,我把 \n## 、\n### 放在最前面,更符合中文文档的结构。
【默认分隔符】
块数:16
平均长度:238 字
最长 / 最短:296 / 116 字
以句号/问号结尾的块:7/16
【中文分隔符】
块数:18
平均长度:203 字
最长 / 最短:296 / 79 字
以句号/问号结尾的块:18/18
以句号结尾的块从 7 个变成 18 个,也就是基本每块都停在完整句子上了。
优缺点:
✅ 通用性强,切出来的块基本停在句子边界上,速度快,不需要调 embedding,改改分隔符就能适配不同类型的文档
❌ 只认文本分隔符,不认文档结构,表格和代码块这类需要整体保留的内容会被切坏;块的语义完整靠运气,跨句子的语义切换它感知不到
递归切分是 RAG 里最常用的切片方法,markdown、技术文档都能用。
五、语义切分
语义切分不按长度切,按语义切。做法是先把文档拆成句子,算相邻两句的向量相似度,相似度掉下去说明话题换了,就在这里断开。
def semantic_split(sentences: list[tuple[str, str]], vectors: list[list[float]]) -> list[dict]:
chunks: list[dict] = []
current_title, current_text = sentences[0]
current_body: list[str] = [current_text]
break_points: list[tuple[int, float]] = []
for i in range(1, len(sentences)):
title, sentence = sentences[i]
similarity = cosine_similarity(vectors[i - 1], vectors[i])
too_long = len("".join(current_body)) + len(sentence) > MAX_CHUNK_SIZE
# 相似度掉到阈值以下,说明换话题了;块太长也要强制切
if similarity < SIMILARITY_THRESHOLD or too_long:
chunks.append({
"title": current_title,
"text": "".join(current_body),
"sentence_count": len(current_body),
})
break_points.append((i, similarity))
current_title, current_text = title, sentence
current_body = [sentence]
else:
current_body.append(sentence)
chunks.append({
"title": current_title,
"text": "".join(current_body),
"sentence_count": len(current_body),
})
# 打印最明显的几个断点,方便判断阈值合不合适
break_points.sort(key=lambda x: x[1])
print("\n相似度最低的 5 个断点(切得最狠的位置):")
for index, similarity in break_points[:5]:
print(f" 第 {index} 句 相似度 {similarity:.3f} -> {sentences[index][1][:30]}")
return merge_small_chunks(chunks)
句子拆分那一步我把 markdown 的标题行单独拎出来,作为这一句所属的章节记下来,不参与相似度计算。这样切完之后每块都知道自己属于哪一章,检索时能顺便带上章节信息。
def load_sentences(text: str) -> list[tuple[str, str]]:
sentences: list[tuple[str, str]] = []
current_title = ""
for line in text.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("#"):
current_title = line.lstrip("#").strip()
continue
# 中文句末标点切句,保留标点本身
for piece in re.split(r"(?<=[。!?;])", line):
piece = piece.strip()
if piece:
sentences.append((current_title, piece))
return sentences
阈值是这个策略唯一要调的参数,但它没有通用值,换一份文档、换一个 embedding 模型,相似度分布都会变,所以先跑一遍看均值再定数字更可靠些。
优缺点:
✅ 切出来的块语义最完整,基本都在话题切换处断开,检索和生成体验最好,无需手动设定块大小
❌ 需要额外多一轮 embedding 调用;阈值没有通用值,更换文档或模型就需要重新调参;效果依赖分句质量,遇到标点不规范、无标点的文本,表现不如递归切分
语义切分适合知识库、FAQ、客服记录、产品说明书这类对语义完整要求高的内容。
六、总结
三种策略在同一份文档上的比较结果:
| 策略 | 块数 | 平均长度 | 需要调模型 | 适合什么文档 |
|---|---|---|---|---|
| 窗口切分 | 16 | 287 字 | 不需要 | 日志、纪要、结构不明显的长文本 |
| 递归切分 | 18 | 203 字 | 不需要 | 通用文档、博客、技术手册 |
| 语义切分 | 19 | 173 字 | 需要 | 知识库、FAQ、话题切换频繁的内容 |
七、LuckReport 项目推荐

导航:LuckReport专栏
1、项目简介
Luck-Report 是一款基于开源项目 UReport2 重构的 Java 高性能报表引擎,通过迭代单元格可以实现任意复杂的中国式报表。相较于 UReport2,Luck-Report 在技术架构上进行了全新升级,后端基于 SpringBoot 框架开发、前端采用 Vue 框架构建,技术选型贴合当下主流项目开发标准,可精准适配各类实际开发需求。
Luck-Report 提供了全新的基于网页的报表设计器,可以在 Chrome、Firefox、Edge 等各种主流浏览器运行(IE 浏览器除外)。使用 Luck-Report,打开浏览器即可完成各种复杂报表的设计制作。
Luck-Report 基于 Apache-2.0 开源协议 开源