5.9 KiB
id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week, order
| id | title | type | domain | stack | tags | level | prerequisites | est_hours | related | week | order | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ai.rag.pipeline-chunking | RAG 流水线和文档切分:加载、清洗、切分策略、元数据 | lesson | ai |
|
|
basic |
|
3 |
|
|
1 |
RAG 流水线和文档切分:加载、清洗、切分策略、元数据
解释说明
为什么需要 RAG:大模型不知道公司内部资料,也不知道训练截止之后的信息;直接问会“编”(幻觉)。RAG 把相关资料放进提示词,让模型“开卷答题”,并且能给出引用来源。和微调相比:知识更新只需要更新索引,成本低、可追溯。
两条流水线
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):
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 面试题:RAG 和微调的区别、切分策略怎么选、chunk 大小怎么定、元数据有什么用。