Files
zhuangang/topics/ai/rag/01-pipeline-chunking.md

115 lines
5.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: ai.rag.pipeline-chunking
title: RAG 流水线和文档切分:加载、清洗、切分策略、元数据
type: lesson
domain: ai
stack: [rag]
tags: [RAG, 文档加载, 切分, chunk, 元数据, 嵌入]
level: basic
prerequisites: [python.python.syntax-for-js]
est_hours: 3
related: [ai.rag.hybrid-rerank, ai.vector-db.ann-metrics, ai.rag.interview]
week: [5]
order: 1
---
# RAG 流水线和文档切分:加载、清洗、切分策略、元数据
## 解释说明
**为什么需要 RAG**:大模型不知道公司内部资料,也不知道训练截止之后的信息;直接问会“编”(幻觉)。RAG 把相关资料放进提示词,让模型“开卷答题”,并且能给出引用来源。和微调相比:知识更新只需要更新索引,成本低、可追溯。
**两条流水线**
```mermaid
flowchart LR
subgraph 离线:建索引
A[加载文档 PDF/Word/网页] --> B[清洗] --> C[切分 chunk] --> D[嵌入向量] --> E[(向量库 + 元数据)]
end
subgraph 在线:问答
Q[用户问题] --> R[检索 top-k] --> F[权限过滤 / 重排] --> P[拼提示词] --> L[大模型生成] --> O[答案 + 引用]
end
E --> R
```
**加载和清洗**:PDF 的页眉页脚、目录、页码、表格是最常见的噪音;扫描件需要 OCR。清洗质量直接决定检索质量。
**切分(chunking)**——RAG 效果最关键的环节之一:
| 策略 | 做法 | 适合 |
| --- | --- | --- |
| 固定长度 + 重叠 | 每 N 个字符 / token 一块,相邻块重叠一部分 | 没有结构的长文本 |
| 按结构切分 | 按标题、段落、列表切,节太长再细分 | 制度、手册、Markdown、网页(**首选**) |
| 按句子 / 语义切分 | 句子为单位,按语义相似度合并 | 长篇叙述 |
| 父子块 | 用小块检索(精准),返回所在的大块给模型(上下文完整) | 问答需要上下文的场景 |
块的大小:太小(一句话)上下文不完整,“满十年 10 天”不知道说的是年假还是病假;太大则包含多个主题,检索不精准、浪费 token。中文制度类文档常见几百字一块,需要用评测来调。
**元数据**:每个块带上文档 ID、标题、小节路径、页码、更新时间、权限标签。用途:引用来源、按权限过滤、按时间过滤、增量更新时删除旧块。**把标题路径拼进块的正文**(“员工手册 / 年假:……”)能明显改善孤立块的检索。
**嵌入(embedding)**:把文本变成向量,语义相近的文本向量距离近。选择嵌入模型要看:中文效果、向量维度、最大输入长度、价格 / 是否能私有部署。**建索引和查询必须用同一个模型**,换模型要全量重建索引。具体模型和参数以各服务商官方文档为准。
## 案例
公司制度问答第一版:把 PDF 直接按 500 字切分,结果“病假”一节被切成两半,问“病假工资怎么发”检索到的块只有前半段;页眉“内部资料 第 3 页”混进每个块。改进:先转 Markdown 去掉页眉页脚,按标题切分,每块带“文档 / 小节”前缀,节太长再按句子切并保留重叠。
## 代码示例
按标题切分 + 句子级细分 + 重叠 + 标题前缀(摘自 [examples/mini-rag/mini_rag.py](examples/mini-rag/mini_rag.py)):
```python
import re
from dataclasses import dataclass
@dataclass
class Chunk:
id: str
doc_id: str
text: str
acl: set[str]
def split(doc_id: str, title: str, text: str, acl: set[str], max_len: int = 80, overlap: int = 15) -> list[Chunk]:
out: list[Chunk] = []
for sec in re.split(r"\n(?=# )", text.strip()): # 按一级标题分节
heading, _, body = sec.partition("\n")
heading = heading.lstrip("# ").strip()
sentences = [s for s in re.split(r"(?<=[。!?])", body.strip()) if s]
buf, pieces = "", []
for s in sentences: # 节内按句子累积,超长就切
if buf and len(buf) + len(s) > max_len:
pieces.append(buf)
buf = buf[-overlap:] # 保留重叠,避免句子间信息断开
buf += s
if buf:
pieces.append(buf)
for p in pieces:
out.append(Chunk(f"{doc_id}#{len(out)}", doc_id, f"【{title} / {heading}】{p}", acl))
return out
doc = """# 年假
入职满一年的正式员工享有带薪年假 5 天,满十年 10 天,满二十年 15 天。年假需提前 3 个工作日在 OA 中申请。
# 病假
病假需提供医院证明,每年累计 10 天以内按全额工资发放。"""
for c in split("hr-leave", "员工手册", doc, {"all"}, max_len=40, overlap=8):
print(c.id, c.text)
```
输出中每一块都带有“【员工手册 / 年假】”这样的前缀,即使块本身只有“满十年 10 天”,也能被“年假”相关的问题检索到。
## 易错点
- **不看切分结果就建索引**:一定要抽样打印切分后的块,检查是否切断了表格、步骤、条款。
- **按字符数重叠会截断词语**:上面的输出里第二块以“十年 15 天”开头(原文是“满二十年 15 天”)。实际项目更常用“重叠最后一两句”而不是固定字符数。
- **只用固定长度切分**:结构化文档的标题信息丢失。
- **块没有元数据**:无法引用来源、无法做权限过滤、更新文档时删不掉旧块。
- **建索引和查询用了不同的嵌入模型**(或同一模型不同版本):向量空间不一致,检索结果混乱。
- **文档更新后只追加不删除**:新旧版本同时被检索到,答案自相矛盾。用文档 ID 先删后加。
- **把整篇文档塞进提示词**:成本高、模型容易忽略中间内容,而且受上下文长度限制。
## 面试常问
见 [RAG 面试题](interview.md):RAG 和微调的区别、切分策略怎么选、chunk 大小怎么定、元数据有什么用。