115 lines
5.9 KiB
Markdown
115 lines
5.9 KiB
Markdown
---
|
||
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 大小怎么定、元数据有什么用。
|