20 文档切片
文档切片
文档切片的核心是把清洗后的长文本切成大小合适、语义完整、带来源信息的 chunk,为后面的 Embedding 和向量检索做准备。
[TOC]
1. 学完本节要达到什么程度
前面已经得到:
cleaned_text
本节要把它切成:
chunk 0
chunk 1
chunk 2
...
每个 chunk 都要有:
documentId
chunkIndex
content
titlePath
pageStart
pageEnd
charStart
charEnd
metadata
切片不是随便按字数切。
切得好,后面检索更准。
切得差,RAG 很容易答非所问。
切片流程图:
graph TD;
A["cleaned_text"] --> B["识别标题 / 段落 / 页码"];
B --> C["按规则生成初始 chunks"]; C --> D{"chunk 太大吗?"};
D -- "是" --> D1["继续按分隔符递归拆分"];
D1 --> E; D -- "否" --> E{"chunk 太小或语义断裂吗?"};
E -- "是" --> E1["和相邻内容合并或增加 overlap"]; E1 --> F; E -- "否" --> F["补充 metadata<br/>titlePath / page / char range"]; F --> G["写入 rag_chunk"]; G --> H["后续生成 Embedding"];
2. 为什么要切片
如果把整篇文档都送进向量库,会有问题:
文本太长
向量表示不精准
检索结果太粗
上下文浪费
模型难以定位答案
如果切得太碎,也会有问题:
语义断裂
标题丢失
上下文不足
回答缺少依据
切片的目标是平衡:
足够小,便于检索
足够完整,保留语义
带 metadata,能回到来源
3. chunk size
chunk size 表示每个 chunk 的大小。
常见单位:
字符数
token 数
段落数
标题层级
学习阶段可以先按字符。
中文文档可以先试:
chunk_size = 800
chunk_overlap = 120
英文文档可以先试:
chunk_size = 1000
chunk_overlap = 150
这不是固定答案。
后面要根据检索效果调整。
4. overlap
overlap,中文可以理解为“重叠”。
它表示相邻 chunk 之间保留一部分重复内容。
比如:
chunk 0:A B C D
chunk 1:D E F G
这里 D 就是重叠。
为什么需要 overlap?
因为答案可能跨越切片边界。
没有 overlap,边界附近的信息容易断。
但 overlap 也不能太大。
太大会导致:
重复内容多
向量库变大
检索结果重复
成本增加
学习阶段可以先用:
overlap = chunk_size 的 10% 到 20%```
## 5. 按段落切片
最简单的切片是按段落累加。
伪代码:
```python
def split_by_paragraph(text: str, max_chars: int = 800):
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] chunks = [] current = [] current_length = 0
for paragraph in paragraphs: paragraph_length = len(paragraph)
if current and current_length + paragraph_length > max_chars: chunks.append("\n\n".join(current)) current = [paragraph] current_length = paragraph_length else: current.append(paragraph) current_length += paragraph_length
if current: chunks.append("\n\n".join(current))
return chunks```
优点:
```text
简单
容易理解
保留段落边界
缺点:
没有 overlap标题信息可能丢
超长段落处理不好
6. 使用 RecursiveCharacterTextSplitter
LangChain 常用 RecursiveCharacterTextSplitter。
安装:
python -m pip install langchain-text-splitters```
代码:
```python
from langchain_text_splitters import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=800, chunk_overlap=120, separators=[ "\n\n", "\n", "。",
"!",
"?",
";",
",",
" ", "", ],)
chunks = text_splitter.split_text(cleaned_text)
它会按分隔符递归尝试。
优先保留大结构:
段落
换行
句子
词
字符
这比直接按固定长度切更自然。
7. 中文分隔符
中文没有英文那样明显的空格分词。
所以分隔符建议加入:
。
!
?
;
,
、
更完整一点:
separators = [
"\n\n", "\n", "。",
"!",
"?",
";",
",",
"、",
".", "!", "?", ";", ",", " ", "",]
这样不容易把中文句子切得太难看。
8. 按标题切片
Markdown 文档适合按标题切片。
比如:
# RAG 原理
## 为什么需要 RAG## RAG 流程
标题能提供上下文。
chunk 里最好保留标题路径:
RAG 原理 > 为什么需要 RAG```
简单解析标题路径:
```python
def collect_title_path(lines: list[str], current_path: list[str], line: str):
if line.startswith("# "): return [line.removeprefix("# ").strip()]
if line.startswith("## "): return current_path[:1] + [line.removeprefix("## ").strip()]
if line.startswith("### "): return current_path[:2] + [line.removeprefix("### ").strip()]
return current_path```
切片时把 `titlePath` 写入 metadata。
检索结果返回时,用户就知道答案来自哪个章节。
## 9. Chunk 数据结构
Python 可以先定义:
```python
from pydantic import BaseModel
class TextChunk(BaseModel):
document_id: str chunk_index: int content: str title_path: str | None = None page_start: int | None = None page_end: int | None = None char_start: int | None = None char_end: int | None = None metadata: dict = {}```
数据库对应前面设计的 `rag_chunk`。
重点是:
```text
content 用于 embeddingmetadata 用于过滤和溯源
chunk_index 用于排序
10. 保留页码
前面 PDF 解析时,如果保留了:
[PAGE 1]
切片时可以尝试识别当前页码。
示例:
import re
def detect_page_marker(line: str) -> int | None:
match = re.fullmatch(r"\[PAGE\s+(\d+)\]", line.strip()) if not match: return None
return int(match.group(1))```
切片时记录:
```text
page_start
page_end
这样回答来源可以显示:
产品手册.pdf,第 3 页
第一版页码不完美没关系。
关键是有这个意识。
11. 生成 chunk
基础函数:
from langchain_text_splitters import RecursiveCharacterTextSplitter
def chunk_text(document_id: str, cleaned_text: str) -> list[TextChunk]:
splitter = RecursiveCharacterTextSplitter( chunk_size=800, chunk_overlap=120, separators=[ "\n\n", "\n", "。",
"!",
"?",
";",
",",
"、",
" ", "", ], )
parts = splitter.split_text(cleaned_text) chunks = [] cursor = 0
for index, content in enumerate(parts): start = cleaned_text.find(content, cursor) if start < 0: start = cursor end = start + len(content) cursor = end
chunks.append(TextChunk( document_id=document_id, chunk_index=index, content=content, char_start=start, char_end=end, metadata={ "chunker": "recursive-character-v1", "chunk_size": 800, "chunk_overlap": 120, }, ))
return chunks```
这里的 `find` 不是完美方案。
但学习阶段可以先用。
后面要更精确,可以在切片前自己维护字符偏移。
## 12. 写入数据库
Java 或 Python 都可以写 `rag_chunk`。
如果 Java 控制入库,Python 返回 chunks:
```json
{
"documentId": "xxx", "chunks": [ { "chunkIndex": 0, "content": "...", "charStart": 0, "charEnd": 800, "metadata": {} } ]}
Java 写入:
DELETE old chunks by document_id
INSERT new chunks
UPDATE rag_document.status = CHUNKED
为什么先删旧 chunk?
因为清洗策略或切片策略改了以后,可能要重新切片。
同一个文档只保留当前版本的 chunk,第一版最简单。
后面可以加版本号。
13. 切片版本
metadata 里建议记录:
{
"chunker": "recursive-character-v1", "chunkSize": 800, "chunkOverlap": 120}
原因:
方便排查
方便重新切片
方便比较不同策略效果
后面如果换成按标题切片,可以变成:
markdown-header-v1
14. 切片质量检查
切片后至少检查:
chunk 数量是否大于 0是否有空 chunk最大 chunk 是否明显超长
平均 chunk 长度是否合理
前几个 chunk 是否语义完整
metadata 是否带 documentIdchunkIndex 是否连续
简单统计:
def chunk_stats(chunks: list[TextChunk]) -> dict:
lengths = [len(chunk.content) for chunk in chunks]
return { "chunk_count": len(chunks), "min_length": min(lengths) if lengths else 0, "max_length": max(lengths) if lengths else 0, "avg_length": sum(lengths) // len(lengths) if lengths else 0, }```
把统计结果写入日志。
RAG 调试时很有用。
## 15. 切片策略的工程化管理
切片结果是检索系统真正搜索的最小单元。切片策略一旦变化,向量、索引和评测结果都会变化,因此不能只保存最终 chunk 文本。
### 15.1 策略版本
建议记录 `chunkerName`、`chunkerVersion`、`chunkSize`、`overlap`、分隔符集合和标题拼接方式。新策略上线时可以保留旧版本,先进行灰度评测,再决定是否全量重建。
同一文档的不同切片版本不要混在同一检索集合中,否则返回结果难以解释。可以通过版本字段过滤,或为重建过程使用独立批次号。
### 15.2 父子切片和上下文扩展
较小 chunk 有利于精确召回,但给模型的上下文可能不完整。工程上可以同时保存父段落与子切片:使用子切片检索,命中后按需补充父段落或相邻片段。
上下文扩展要设置边界,避免跨章节拼接无关内容。标题路径、页码和字符偏移可以帮助判断相邻片段是否真正属于同一语义单元。
### 15.3 质量指标
除了平均字符数,还应观察过短 chunk 比例、超长 chunk 比例、重复 chunk 比例、标题缺失比例和关键问题的 Recall@K。
切片验收要使用真实问题。仅查看长度分布无法判断答案是否被切断,也无法发现表格、步骤列表和错误码被拆散的问题。
## 16. 常见问题
### 16.1 chunk size 多少最好
没有固定答案。
先用:
```text
800 字符
120 overlap
再根据检索效果调整。
如果答案经常缺上下文,可以适当增大。
如果检索结果很泛,可以适当减小。
16.2 overlap 越大越好吗
不是。
overlap 太大会让很多 chunk 很像。
检索结果容易重复。
一般先用 10% 到 20%。
16.3 Markdown 要按标题还是按字符
优先按标题保留结构。
但标题下面内容太长时,仍然要继续按字符或段落切。
常见策略:
先按标题分段
每段太长再递归切片
16.4 chunk 里要不要带标题
建议带。
比如 content 前面加:
标题路径:RAG 原理 > 检索流程
正文...
这样 embedding 时能带上章节语义。
但也要避免标题重复过多。
17. 练习清单
完成几件事情:
安装 langchain-text-splitters实现 recursive character 切片
设置中文 separators生成 TextChunk 对象
记录 chunkIndex记录 charStart 和 charEndmetadata 记录 chunker 版本
写入 rag_chunk更新 rag_document.status = CHUNKED输出 chunk_stats检查空 chunk 和超长 chunk```
建议目录:
```text
ai-agent-study
├── python
│ └── rag-service
│ ├── chunkers.py
│ ├── schemas.py
│ └── main.py
├── java
│ └── rag-api
│ └── PythonChunkClient.java
└── docs
└── document-chunking.md
18. 小结
本节的结论:
文档切片要在大小、语义完整性和来源信息之间做平衡。
最小链路:
cleaned_text
-> RecursiveCharacterTextSplitter -> TextChunk[] -> rag_chunk -> status=CHUNKED
切片完成后,RAG 入库流程已经走完了文档处理的前半段。
下一步就可以开始生成 Embedding,并把 chunk 变成可检索的向量数据。