04
RAG 全链路
模块 04 · RAG 全链路
本章目标:掌握"检索增强生成(RAG)"的整条链路——客户的海量文档怎么变成"模型能查、能答、少胡说"的知识。这几乎是 FDE 落地最多就拿得出手的核心技能。
4.1 先讲明白:为什么需要 RAG
大模型有两个天生的毛病:
- 它不知道客户私有知识(你家合同格式、审批规则、产品手册),训练时没见过。
- 它会乱编(幻觉),尤其问到它不知道的东西时。
RAG 的做法特别朴素——像查资料一样:
用户提问 → 先从客户文档里把最相关的几段"查"出来 → 把资料 + 问题一起给模型 → 模型"看着资料回答",就不容易胡说、也能答客户的私有知识。
一句话人话:RAG = 给模型配一个"随查随用的公司知识库",引经据典地回答。
4.2 RAG 全链路图(背下来)
客户文档(PDF/Word/MD)
│ ① 文档解析 & 清洗
▼
干净的纯文本
│ ② 文本分块 (chunking)
▼
若干小块 (chunk)
│ ③ 向量化 (embedding) + 建索引
▼
向量库 (Chroma/Milvus/PGVector)
▲ │
用户提问 ─┴─ ④ 查询向量化 → ⑤ 相似度检索(top-K)
│
▼ ⑥ 重排 (rerank)
精选片段
│ ⑦ 组装 Prompt: 片段+问题
▼
大模型 → 引经据典地回答
每一环都是优化点,也是客户项目里"为什么效果差"的排查入口。
4.3 ① 文档解析 & 清洗(最容易被低估的一环)
4.3.1 现实有多残酷
客户给你的不是干净的 .txt,而是:
- 扫描版 PDF(纯图片,没有文字层)→ 需要 OCR。
- 有页眉页脚、乱表格、双栏 → 解析后一堆脏文本。
- Word 里嵌图片/公式、Excel 报错的表。
"解析不清 → 分块就烂 → 检索就准不了 → 模型就胡说"。 这条因果链要刻进脑子。
4.3.2 常用手段
| 文档 | 工具/做法 |
|---|---|
| PDF 文字版 | pdfplumber / PyMuPDF(保留版面位置最好) |
| 扫描 PDF/图片 | OCR:PaddleOCR / Tesseract / 多模态模型 |
| Word | python-docx 抽正文,丢掉页眉脚注 |
| Markdown/HTML | 转纯文本或保留标题结构 |
| 表格 | 转成"行文"描述(如"某行某列"),或结构化存储 |
4.3.3 清洗
- 去除页眉页脚、重复行、连续空行。
- 规范化空白、编码(乱码/)。
- 大表格、长段落可能要"折叠"或拆分处理。
4.4 ② 文本分块(chunking):仔细!决定检索准不准
核心矛盾:块太小 → 信息不完整、上下文缺失;块太大 → 检索不精准、token 费。
业界常用的"结构感知分块":
def smart_chunk(text, max_len=500, overlap=50):
"""优先按标题/段落切,其次按句切,保留 overlap 防止上下句被切断。"""
chunks = []
# 这里用简易伪代码示意;真实场景用标题结构 + 递归拆分
paragraphs = [p for p in text.split("\n\n") if p.strip()]
buf = ""
for p in paragraphs:
if len(buf) + len(p) <= max_len:
buf += p + "\n\n"
else:
if buf: chunks.append(buf.strip())
# 超长段落按句再拆
buf = p if len(p) <= max_len else split_by_sentence(p, max_len)
if buf: chunks.append(buf.strip())
return chunks
def split_by_sentence(p, max_len):
# 按句号切,拼装到长度内
out, cur = [], ""
for sent in p.replace("。", "。\n").splitlines():
ss = sent.strip()
if not ss: continue
cur += ss + "。"
if len(cur) >= max_len:
out.append(cur); cur = ""
if cur: out.append(cur)
return out
关键点(面试加分):
- 结构优先:先按标题/章节切,别把章节切成两半。
- overlap(重叠):相邻块重叠一两句,防止问题正好卡在切开处。
- 块大小 + 检索数(top-K)联动:决定每次"塞给模型多少 token"。
4.5 ③ 向量化 & 建索引
- embedding 模型:把"一段文字"变成"一组数字(向量)",语义相近 → 数字相近。
- 用 embedding 模型给每个 chunk 生成向量,存入向量库,同时建好索引。
- 每次增删改都要同步向量库,否则客户文档更新了,检索还是老的(这是高频坑)。
import chromadb
from chromadb.utils import embedding_functions
client = chromadb.PersistentClient(path="/data/chroma")
col = client.get_or_create_collection(
"contracts",
embedding_function=embedding_functions.DefaultEmbeddingFunction(),
metadata={"hnsw:space": "cosine"},
)
# 写索引
col.upsert(
ids=["c1", "c2"],
documents=["第3条 违约金为本金10%", "第7条 免责情形包括不可抗力"],
metadatas=[{"doc_id": "contract-a", "title": "采购合同"}],
)
人话:向量库就是"按意思查"的数据库,存的时候把每段变成向量 + 留点元数据(来源、页、标题)。
补一段:向量相似度到底怎么算(余弦 vs 欧式)
向量库检索的本质是"找一个和问题向量最接近的 chunk 向量",这个"接近"就是向量相似度。常用的两种:
- 余弦相似度:只看"方向"不看"长度",两个向量夹角越小越接近,取值 [-1, 1]。文本向量维度高、长度还会随词频变化,所以语义检索默认用余弦(上面代码里的
hnsw:space: "cosine"就是它)。 - 欧式距离:算两个点的直线距离,值越小越近。适合对"绝对数值"敏感的场景,文本语义一般不用它。
最小示意(纯 Python,不依赖库,看懂原理就行):
import math
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(x * x for x in b))
return dot / (na * nb) # 越接近 1 说明越像
def euclidean(a, b):
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b))) # 越接近 0 说明越近
q = [1, 2, 3] # 问题向量(示意)
c = [1, 2, 4] # 某个 chunk 向量(示意)
print(cosine(q, c)) # 接近 1,说明很像
print(euclidean(q, c)) # 距离小,说明很近
想再往深处补(向量空间、余弦与相似度背后的数学),去 算法与 AI 看 embedding 与检索原理、去 Python 机器学习与深度学习 看向量空间与相似度的完整推导。
4.6 ④⑤ 查询检索 & 召回
res = col.query(
query_texts=["违约要赔多少"], # 用户问题,自动向量化
n_results=5,
where={"doc_id": {"$eq": "contract-a"}}, # 可加过滤,如只查某个文档
)
for doc, meta, dist in zip(res["documents"][0], res["metadatas"][0], res["distances"][0]):
print(meta["title"], round(dist, 3), doc[:40])
会出的问题(面试高频):
- 召回不全:问题关键词和文档用词不一致(同义改写),相似度不够。→ 用更好的 embedding、混合检索。
- 召回太杂:捞出来一堆不相关的。→ 提高阈值、重排。
- 权限没隔离:A 员工查到 B 部门的机密。→ 检索层加权限过滤(metadata 字段)。
4.7 ⑥ 重排(Rerank):把"大概相关"变成"精准命中"
向量检索找的是"语义接近",但 top-K 里可能有"接近但不准"的。重排是对候选再精排一遍。
- 为什么需要:纯向量 top-K 往往前几名才对,后几名噪音多。
- 做法:用一个 rerank 模型,对"问题 × 每个候选块"打分,重排后取 Top-N 甚至前 3。
- 混合检索的概念:
- 向量检索(语义)+ BM25/关键词检索(精确匹配,如专属名称、编号)。
- 两者结果合并、去重、重排。
- 对"合同编号、法条原文、商品名"这种必须精确的,关键词检索很关键。
人话:向量找"像的",BM25 找"对的",rerank 把最终该用的排前面。
4.8 ⑦ 组装 Prompt & 控制幻觉
4.8.1 把检索结果喂给模型
def build_prompt(query, chunks):
ctx = "\n\n".join(f"[片段{i+1}] {c}" for i, c in enumerate(chunks))
return (
"你是合同审单助手。请严格依据下列资料回答,资料没有的就说'资料中未提及',不要编造。\n"
f"---- 资料 ----\n{ctx}\n---- 问题 ----\n{query}"
)
4.8.2 降低幻觉的四个抓手
- "资料没有就直说"(上面 Prompt 就做了)。
- 给全来源引用:让模型回答时带来源片段,便于核查(也满足客户审计)。
- 检索兜底:检索结果太少/太差 → 明确告诉用户"暂未查到",别硬答。
- 严格约束格式 + 低 temperature:审单、法务这类要确定性。
4.9 RAG 评估 & 幻觉定位(和模块 06 呼应)
客户最常质疑:"AI 回答得对不对?"你得能量化:
| 层面 | 常见指标 | 人话 |
|---|---|---|
| 检索召回 | Recall@K、命中率 | 该查到的资料有没有被捞出来 |
| 检索精度 | Precision@K、RR@K | 捞出来的够不够准、排序靠前吗 |
| 回答质量 | 准确率、忠实度(faithfulness)、幻觉率 | 答案对不对、有没有脱离资料乱说 |
幻觉定位清单(遇到"答错"按这个查):
- 检索到的资料本身有没有相关内容?没有 → 是召回问题。
- 资料有但模型没用上 / 解读错 → 是 Prompt / 模型问题。
- 资料被截断了(超上下文窗口、分块切坏)→ 看是否被 max_tokens / 窗口吞掉。
- 权限 / 脏数据导致的错误资料捞进来了。
4.10 模块练习
- 拿一份真实 PDF(含目录),用 pdfplumber 抽文本并清洗,跑通"解析 → 分块"。
- 用 Chroma 建一个 10 段小语料库,写 5 个问题做检索,标出每问的 top-3 和相关度感受。
- 在检索后接一个简单 rerank 逻辑,对比加与不加的 top-5 差异。
- 实现"资料没有就直说 + 带来源引用"的 RAG Prompt,构造 3 个超出资料范围的问法验证不瞎编。
- 模拟一次"检索召回漏"的定位:故意选一个同义词改写的问题,尝试用混合检索/换 embedding 改善。
4.11 本章面试题
- "RAG 是什么?为什么能减少幻觉?" → 答:检索增强生成——先从知识库检索与问题最相关的片段,再带着资料让模型回答。因为给了真实依据,所以能答私有知识、减少凭空编造。
- "RAG 效果差,你怎么排查是召回还是生成的问题?" → 答:先看检索结果:把 top-K 片段打印出来,若资料里根本没相关内容→召回/索引问题;若资料有你但答案错→Prompt/模型/长度截断问题。分而治之。
- "分块大小怎么定?" → 答:看内容结构和检索目标:按标题结构先分,再结合 query 粒度、上下文完整性、token 成本平衡;配合 overlap 和 top-K 调节。
- "为什么有的客户场景需要混合检索(向量+BM25)?" → 答:向量擅长语义模糊匹配,BM25/关键词擅长精确匹配(编号、专名、法条)。合同、法务、单据场景常要精确命中,只靠向量会漏。
- "客户文档要权限隔离,你怎么做?" → 答:索引时给每块打 metadata(文档归属/密级/部门);检索时注入用户权限条件(where 过滤),保证只召回该用户有权限的块;必要时加数据脱敏(模块 07)。
4.12 小结
- RAG = 给模型配随查随用的知识库,引经据典回答问题。
- 完整链路:解析清洗 → 分块 → 向量化建索引 → 查询召回 → 重排 → 组装 Prompt。
- 解析不清 → 一切白搭;分块 + 检索 + 权限是三个最常出问题的环节。
- 幻觉四抓手:资料没有就直说、带来源、检索兜底、低 temperature 严格格式。
- 评估与幻觉定位是面对客户质疑的底气。下一章:让模型"会动手干活"——Agent 编排。
延伸阅读 · 去本站教学页补齐基础
- Rust + AI 全栈——看 RAG 七步流水线、向量库 qdrant、chunk 切分策略与 rerank 的完整代码。
- 算法与 AI——embedding、向量与检索的底层原理,理解"语义相近"到底在算什么。
- Python 机器学习与深度学习——向量空间、余弦/欧式相似度的数学推导与实现。