chapter 10 / rag · 预计学习时间 120 分钟
第 6 章说过:模型的事实知识主要存在 FFN 权重里。这种参数化知识有三个结构性缺陷:
RAG(检索增强生成)的解法是把闭卷考试改成开卷:回答前先从外部知识库检索相关材料塞进上下文,让模型「引用」而非「回忆」。管线一句话:文档 → 分块 → 嵌入 → 入库;查询 → 嵌入 → 近邻检索 →(重排)→ 拼 prompt → 生成。每一环都有技术细节,逐个拆。
核心抽象在第 5 章已经铺好:语义相似 = 向量空间中距离近。embedding 模型把任意文本映射为稠密向量(768-3072 维),用对比学习训练:拉近相关对(问题↔答案、标题↔正文),推开无关对(同 batch 其他样本当负例),损失是 InfoNCE(第 2 章交叉熵的孪生兄弟——「在 batch 里找出正例」的分类):
$$L = -\log \frac{e^{\text{sim}(q, d^+)/\tau}}{\sum_{d \in \text{batch}} e^{\text{sim}(q, d)/\tau}}$$相似度用余弦(normalize 后等价于点积)。两个工程要点:① 双塔结构——查询和文档各自独立编码,文档向量可离线预计算,查询时只算一次 query 嵌入 + 近邻查找(这是 RAG 能快的原因);② 它的代价是查询和文档没有交互,精度天花板低于把两者拼起来过一遍模型的 cross-encoder——这个权衡在 §6 的重排序里回收。
18 条玩具文档摆在 2D「语义地图」上(嵌入空间的卡通版),4 个预设查询。必做实验:① 查「钱能退吗」——关键词模式零结果(语料里没有「钱」「退」整词),语义模式精准命中退款簇:这就是 embedding 解决的「词汇鸿沟」;② 查「VIP 有什么特权」——「VIP」从未在语料出现,语义模式照样落进会员簇中央;③ 查「怎么导出像素作品」后切换两种模式——都能命中:关键词检索没死,所以生产系统是混合检索。
青色 Q = 查询 · 绿色编号点 = Top-k 命中 · 灰点 = 未命中文档 · 注意中央的菜谱/天气干扰簇
暴力扫描十亿个 1024 维向量找最近邻:每查询 ~4TB 内存读取,不可行。生产用近似最近邻(ANN),主流是 HNSW(分层可导航小世界图):
文档怎么切,决定了检索单元的语义完整性——分块做坏了,后面全部白搭:
实验室实验③的工程化:语义检索懂同义改写但对精确字符串(型号、报错码、人名)反而不如 BM25 关键词检索。生产标准是两路召回 + 融合 + 重排:
RAG 是管线,评估必须分段归因(第 8 章评测先行方法论的 RAG 版):
| 维度 | 问题 | 度量 |
|---|---|---|
| 检索质量 | 该找的找到了吗 | Recall@k / MRR(需要标注的 query→相关块对) |
| 忠实度 | 答案是否只基于检索内容 | LLM-as-judge 逐句核对引用(RAGAS 思路) |
| 答案相关性 | 是否回答了问题本身 | LLM-as-judge / 人工抽检 |
失败模式清单(按管线位置排查,附最常见解药):
「上下文都 1M token 了,把全部文档塞进去不就行了?」三个理由让 RAG 死不了:成本(第 9 章刚算过:每次请求付全库 token 的 prefill 钱,KV cache 吃显存;检索只付 top-5 块);精度(lost in the middle 在百万 token 下更严重——大海捞针评测好看,多针推理仍掉链子);权限与时效(检索层天然支持逐用户 ACL 过滤和秒级增量更新,塞上下文做不到)。实践是光谱:文档 <50K token 直接塞(缓存还便宜,第 9 章),大库用 RAG,两者混用。
演进方向是 Agentic RAG:检索不再是「一次查询一次生成」的固定管线,而是把搜索变成 Agent 的工具——模型自己决定查不查、查什么、查几轮,对结果不满意就改写查询再查,跨文档多跳推理。这正是下一章的主题:当 LLM 拿到工具和循环,一切管线都变成了它的决策。
import numpy as np
from sentence_transformers import SentenceTransformer, CrossEncoder
# ---- 离线:建库(双塔的文档侧,预计算一次) ----
docs = open('knowledge.md').read().split('\n## ') # 结构感知分块:按二级标题
embedder = SentenceTransformer('BAAI/bge-m3') # 中英双语 embedding
doc_vecs = embedder.encode(docs, normalize_embeddings=True) # (n, 1024),归一化后点积=余弦
# ---- 在线:检索 + 重排 + 生成 ----
def retrieve(query, k_recall=20, k_final=5):
q = embedder.encode([query], normalize_embeddings=True)
sims = doc_vecs @ q.T # 一行矩阵乘=全库余弦相似度
cand = np.argsort(-sims[:, 0])[:k_recall] # 召回 top-20(玩具规模暴力扫;上量换 HNSW)
reranker = CrossEncoder('BAAI/bge-reranker-v2-m3') # 交叉编码器精排
scores = reranker.predict([(query, docs[i]) for i in cand])
return [docs[cand[i]] for i in np.argsort(-scores)[:k_final]]
def answer(query):
chunks = retrieve(query)
context = '\n\n'.join(f'[资料{i+1}] {c}' for i, c in enumerate(chunks))
prompt = (f"仅依据下列资料回答,资料中没有的信息明确说「资料中未提及」,"
f"并标注引用的资料编号。\n\n{context}\n\n问题:{query}")
return llm(prompt) # 任意 LLM API;注意:context 置前可吃 prompt cache(第 9 章)
print(answer("退款多久到账?")) # → 依据[资料2],3-5 个工作日原路退回。