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

5.9 KiB
Raw Blame History

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
rag
RAG
文档加载
切分
chunk
元数据
嵌入
basic
python.python.syntax-for-js
3
ai.rag.hybrid-rerank
ai.vector-db.ann-metrics
ai.rag.interview
5
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 大小怎么定、元数据有什么用。