这是《LLM 应用开发 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 切块把长文档转换成可检索的知识单元。块太大,向量语义被稀释;块太小,上下文不足。切块策略要与文档结构、检索方式和生成目标匹配。
8.1 为什么要切块
长文档
-> 解析 blocks
-> chunk
-> embedding
-> index
-> retrieve top K
-> rerank
-> context
切块目标:
- 每个块语义相对完整;
- 关键信息不被切断;
- 大小适合向量模型输入;
- 保留标题、页码和来源;
- 避免重复内容过多;
- 支持权限和版本过滤;
- 支持增量更新。
8.2 常见策略
| 策略 | 适用 | 优点 | 缺点 |
|---|---|---|---|
| 固定长度 | 日志、纯文本 | 简单 | 易切断语义 |
| 递归字符切块 | 通用文档 | 平衡效果好 | 仍依赖分隔符 |
| 按标题切块 | 手册、政策 | 结构清晰 | 需要解析质量 |
| 按段落合并 | 文章 | 语义自然 | 长度不均 |
| 表格整块 | 结构化数据 | 行列完整 | 可能超长 |
| 代码块整体 | 技术文档 | 保持语法 | 缺上下文 |
| 语义切块 | 高价值语料 | 更贴近语义 | 计算成本高 |
8.3 固定长度切块
def fixed_chunk(text: str, size: int = 600, overlap: int = 80):
chunks = []
step = max(size - overlap, 1)
for start in range(0, len(text), step):
chunk = text[start:start + size]
if chunk:
chunks.append(chunk)
if start + size >= len(text):
break
return chunks
固定长度适合基线,不适合作为最终复杂文档方案。
8.4 递归切块
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
separators=["\n## ", "\n### ", "\n\n", "\n", "。", ";", " ", ""],
chunk_size=600,
chunk_overlap=80,
)
chunks = splitter.split_text(
"## 退款政策\n标准订单可在签收后 7 天内申请退款。\n生鲜商品不支持无理由退款。"
)
分隔符优先从自然结构到字符级,尽量避免句子被硬切。
8.5 按标题切块
def heading_chunk(blocks, max_chars=900):
chunks = []
current = {"title": "root", "parts": []}
for block in blocks:
if block["type"] == "heading":
if current["parts"]:
chunks.append(current)
current = {"title": block["text"], "parts": []}
else:
current["parts"].append(block["text"])
size = sum(len(p) for p in current["parts"])
if size >= max_chars:
chunks.append(current)
current = {"title": current["title"], "parts": []}
if current["parts"]:
chunks.append(current)
return chunks
每个 chunk 建议写入完整标题路径:
员工手册 / 考勤 / 远程办公
8.6 Overlap
重叠用于降低边界信息丢失:
chunk 1: A B C D
chunk 2: C D E F
chunk 3: E F G H
设置建议:
- 通用文本 10% 到 20%;
- 条款文本可按条重复标题;
- 代码块不宜机械重叠;
- 表格不要随机重叠;
- 重叠会提高存储成本;
- 用召回实验验证收益。
8.7 大小选择
| 块大小 | 效果 |
|---|---|
| 太小 | 语义不完整,检索噪声多 |
| 太大 | 向量语义稀释,上下文成本高 |
| 合适 | 单一主题,能独立回答局部问题 |
常见起点:
中文:300-800 字符
英文:300-1000 tokens
表格:按表或按逻辑行组
FAQ:一问一答一个块
不同文档类型可以配置不同策略,不要全库统一硬切。
8.8 元数据与父块
{
"chunk_id": "policy-001#c12",
"doc_id": "policy-001",
"parent_id": "policy-001#sec3",
"title_path": ["退款政策", "特殊商品"],
"page": 8,
"effective_time": "2026-01-01",
"acl": ["cs_agent"],
"content": "生鲜商品不支持无理由退款。"
}
父子检索:
向量命中小块
-> 找父块或相邻块
-> 送入 rerank
-> 构造上下文
小块负责召回,父块负责上下文,这是长文档常用方案。
8.9 增量更新
新文档版本
-> checksum 比较
-> 未变化:跳过
-> 变化:解析新版
-> diff blocks
-> 新增/替换 chunk
-> 旧版标记 inactive
-> 保留审计
注意事项:
- 文档删除要同步索引;
- 避免只覆盖部分旧 chunk;
- 权限变化要即时生效;
- 保留版本便于回滚;
- 监控索引与源文档一致性。
8.10 评估
构造问答对验证切块:
| 指标 | 说明 |
|---|---|
| Recall@K | 相关 chunk 是否进入候选 |
| MRR | 首个相关结果排名 |
| Boundary Error | 关键句被切断 |
| Context Sufficiency | 候选能否支持回答 |
| Cost per Query | 检索 token 成本 |
比较实验:
策略 A:600 字符,80 overlap
策略 B:标题 + 900 字符
策略 C:小块召回 + 父块生成
本章小结
切块要保留语义完整性和结构元数据。常见做法是按标题与自然段落递归切块,为 FAQ、表格和代码设计专用规则,并通过小块召回、父块补上下文的方式兼顾精度与完整性。切块参数应通过召回实验确定。
思考题
- chunk_overlap 解决什么问题,带来什么代价?
- 为什么表格和普通文本不适合同一策略?
- 父子块检索如何工作?
- 文档更新时如何避免新旧版本同时被检索?
- 如何评估一个切块策略是否更好?