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