外观
RAG 实战
RAG(Retrieval-Augmented Generation)的本质是把"记忆"从模型参数里搬进外部索引:模型不再负责记住你的私有文档,而是学会"先检索、再回答"。
RAG 是生产级 LLM 应用里被使用最多的模式,没有之一:它同时解决知识截止、幻觉、私有数据、可溯源四个问题。但 RAG 也最容易"看起来能跑、上线就崩"——因为质量瓶颈不在模型,而在检索链路。本文给出端到端的可落地流程,每步都配参数建议与验证方法。概念与演进背景见案例研究:RAG。
一、端到端全景
一个 RAG 系统的完整链路:
离线(索引构建)
文档 → 解析 → 清洗 → chunking → embedding → 写入向量库(含元数据)
│
▼
在线(查询)
用户问题 → 查询改写 → 检索(向量 + BM25)→ 融合 → 重排序 → 组装提示 → 生成 → 校验输出本文按链路顺序展开。先记住一句总纲:每一环的错误都会向下游放大,而评估只发生在最后——所以你在优化"检索召回"时花的每一分钟,最终都会体现在答案质量上。
这条链路的迭代节奏:
| 阶段 | 关注指标 | 迭代节奏 |
|---|---|---|
| 起步 | 端到端能跑通 | 一版即可,别在细节上停留 |
| 检索优化 | Recall@k / Hit@k | 每次改动 chunk/检索/重排后重跑评测 |
| 生成优化 | 忠实度 / 答案相关 | 提示改动后回归 |
| 上线 | 线上反馈 + 抽检 | 持续,回流到 golden set |
二、文档解析与 chunking
1. 解析:把"文件"变成"干净的文本"
| 文档类型 | 解析工具 | 注意点 |
|---|---|---|
| PDF(文本型) | pypdf、pdfplumber | 表格、多栏排版容易乱 |
| PDF(扫描件) | OCR(Tesseract、PaddleOCR) | 先 OCR 再解析,成本高 |
| Word / PPT | python-docx、python-pptx | 注意页眉页脚、批注 |
| HTML / 网页 | BeautifulSoup、trafilatura | 去掉导航、广告、脚本 |
| Markdown / 代码 | 直接按结构切 | 保留代码块语义 |
解析是 RAG 失败的第一大来源
很多"检索不到"的排查最终发现是解析阶段把 PDF 表格切碎了。上线前务必抽样人工检查解析结果(每类文档抽 5~10 页),不要相信解析器默认输出。
2. chunking:切块的学问
chunk(文本块)是检索的最小单元。chunk 太小则上下文不完整,太大则向量被稀释、召回不精准。工程上常用两条经验路线:
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 固定大小 + 重叠 | 每 300~800 token 切一块,重叠 10~20% | 简单、可控 | 可能切断语义完整的句子/段落 |
| 结构感知(recursive) | 按段落→句子→句子的优先级切(如 LangChain RecursiveCharacterTextSplitter) | 尊重文本结构 | 依赖源文档结构质量 |
| 语义切分 | 用嵌入相似度找"语义断点"切块 | 块内主题一致 | 计算成本高 |
python
# LangChain 结构感知切分(最常用的默认方案)
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 每个块的目标字符数(中英文混排时建议按 token 估算)
chunk_overlap=80, # 相邻块重叠字符数,防止关键句被切在边界
separators=["\n\n", "\n", "。", "!", "?", ". ", "! ", "? ", " ", ""],
)
chunks = splitter.split_text(long_document)
print(f"切出 {len(chunks)} 个块,平均 {sum(len(c) for c in chunks)/len(chunks):.0f} 字符")3. chunk 大小与重叠:用实验说话
chunk 大小没有"标准答案",必须用你自己的文档和问题集做实验。做法是控制变量:
python
import json
# 固定一份 50~100 条真实问题的评测集(q, expected_chunk_id)
test_questions = json.load(open("rag_questions.json"))
def evaluate_chunk_size(chunk_size, chunk_overlap):
"""重建索引,测这批问题的 Top-5 召回率"""
chunks = split_document(doc, chunk_size, chunk_overlap)
index = build_vector_index(chunks) # 见第三节
hit = 0
for q in test_questions:
retrieved = search(index, q, k=5) # 见第四节
if q["expected_chunk_id"] in [r["id"] for r in retrieved]:
hit += 1
return hit / len(test_questions)
for size in [300, 500, 800, 1200]:
print(size, evaluate_chunk_size(size, size // 5))默认起点与调整方向
以 chunk_size≈500 token、overlap≈10~15% 为起点。若问题是"文档级概述"(需要全局理解),加大 chunk;若问题是"精确定位"(如合同条款),减小 chunk 并提高重叠。永远用召回率/答案质量说话,不要凭感觉。
三、Embedding 与向量库
1. 选 embedding 模型
embedding 把文本映射为稠密向量,语义相近的文本向量距离近。选型维度:
| 维度 | 建议 |
|---|---|
| 语言 | 中文场景优先中文优化模型(如 bge-large-zh、text-embedding-v3 等),通用英文用 text-embedding-3-large 或开源 E5/BGE 系列 |
| 维度与成本 | 1024~3072 维精度更高但存储/检索更贵;可先 768~1024 维起步 |
| 长文本支持 | 处理长文档块时,注意模型的 max sequence length(多数 512~8192 token) |
| 是否开源 | 数据不出内网时选开源模型(sentence-transformers 生态) |
python
# 开源 embedding(sentence-transformers 一行搞定)
pip install sentence-transformers
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-large-zh-v1.5") # 中文,1024 维
vec = model.encode("退款政策的第三条讲了什么") # 得到向量
print(vec.shape) # (1024,)2. 向量库选型
向量库承载"存储向量 + 近似最近邻检索(ANN)"。选型决策表:
| 向量库 | 形态 | 规模上限 | 场景 |
|---|---|---|---|
| FAISS | 库(嵌入进程) | 千万级 | 原型、离线批处理、单机 |
| pgvector | PostgreSQL 插件 | 百万~千万级 | 已有 PG 的业务系统、事务与向量共存 |
| Qdrant / Milvus | 独立服务 | 亿级 | 生产级、多副本、混合检索与过滤 |
| Chroma / LanceDB | 轻量嵌入式 | 百万级 | 本地原型、教学 |
python
# FAISS 最小索引(内存版,适合原型)
pip install faiss-cpu
import faiss
import numpy as np
dim = 1024
index = faiss.IndexFlatIP(dim) # 内积索引(与归一化向量配合=余弦相似度)
vecs = np.array([model.encode(c) for c in chunks])
faiss.normalize_L2(vecs) # L2 归一化,让内积等价于余弦
index.add(vecs)
q_vec = model.encode("退款政策第三条")
q_vec = q_vec / np.linalg.norm(q_vec)
D, I = index.search(q_vec[np.newaxis, :], k=5)
print("Top-5 chunk 编号:", I[0]) # 按相似度排序向量检索的局限
向量检索擅长"语义相似",不擅长"精确关键词匹配"(型号、编号、专有名词),这正是第四节混合检索要补的短板。
四、检索:混合检索、查询改写与 HyDE
1. 混合检索(Hybrid Search):BM25 + 向量
关键词检索(BM25) 对精确术语强,向量检索对语义近义强。生产 RAG 几乎都用混合检索再融合:
python
# 示意:把 BM25 与向量检索的 Top-k 结果按权重融合
# rank fusion:对每个结果,score = sum( 1/(60 + rank_i) for i in methods )
def reciprocal_rank_fusion(bm25_results, vector_results, k=60):
scores = {}
for rank, doc_id in enumerate(bm25_results + vector_results):
scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank + 1)
return sorted(scores.items(), key=lambda x: -x[1])融合后通常召回率高于任一单路。BM25 的实现:rank_bm25 库、Elasticsearch/OpenSearch、或 Qdrant/Milvus 内置的 sparse 检索。
2. 查询改写(Query Rewriting)
用户的原始提问往往不适合直接检索(太口语、太短、缺实体)。三步常见改写:
| 改写类型 | 示例 | 作用 |
|---|---|---|
| 补全 | "那家店退款呢" → "XX 店的退款政策是什么" | 补充缺失实体 |
| 多路改写 | 一句话变 2~3 个检索 query | 增加召回面 |
| 拆解 | "对比 A 和 B 的区别" → 两个子查询 | 分而治之 |
python
# 查询改写:一个最小实现(LLM 生成多路查询)
def rewrite_queries(question: str) -> list[str]:
prompt = f"""把下面的问题改写成 2~3 个更适合检索的查询。
要求:补全缺失实体、口语转书面、覆盖不同表达角度。只输出查询,每行一个。
问题:{question}"""
text = call_llm(prompt)
return [line.strip("- ").strip() for line in text.splitlines() if line.strip()]
# 检索时:原问题 + 改写查询一起召回,再融合去重
all_results = []
for q in [question] + rewrite_queries(question):
all_results += search(index, q, k=10) # search 用上文的向量/BM25 检索
# 然后走 reciprocal rank fusion(见混合检索)合并去重3. HyDE:让"假设答案"去检索
HyDE(Hypothetical Document Embeddings)的思想:先让 LLM 根据问题凭空写一段"假设答案",再用这段文字的向量去检索。因为"答案形态的文本"比"问题形态的文本"在向量空间里离真实文档更近,能显著提升召回(参考资料)。
python
def hyde_rewrite(question: str) -> str:
"""让 LLM 生成一段假设答案,作为检索的查询文本"""
prompt = f"""请根据下面的问题,写一段 100 字左右的假设性回答。
即使你不确定事实,也写出一段通顺、结构完整的"看起来像百科条目"的文字。
问题:{question}
假设回答:"""
return call_llm(prompt) # 返回这段文字,用它 encode 后去检索
q_vec = model.encode(hyde_rewrite(question)) # 检索用 HyDE 向量HyDE 的成本与风险
HyDE 每次查询多一次 LLM 调用(延迟 + 成本),且在"模型幻觉会误导检索"的任务上可能帮倒忙。先用不引入额外调用成本的改写试,再决定是否上 HyDE。
五、重排序(Reranking)
检索返回 Top-50,重排序模型(cross-encoder)再精排取 Top-5。cross-encoder 把"问题 + 文档块"成对打分,精度远高于向量相似度,是目前 RAG 性价比最高的精度杠杆之一:
python
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-base") # 中文重排序模型
pairs = [(question, chunk) for chunk in candidate_chunks]
scores = reranker.predict(pairs) # 成对打分
top5 = [c for _, c in sorted(zip(scores, candidate_chunks), reverse=True)[:5]]| 阶段 | 检索类型 | 输入 | 精度 | 速度 |
|---|---|---|---|---|
| 召回(Retrieve) | bi-encoder 向量 / BM25 | 单边编码 | 中 | 极快(毫秒级) |
| 精排(Rerank) | cross-encoder | 问题+文档对 | 高 | 慢(只对候选集做) |
六、生成:提示组装
检索到的内容要组装成"可回答"的提示。生成阶段有三个被反复验证的纪律:
text
你是内部知识库助手。只根据提供的资料回答。
规则:
1. 资料不足时,明确说"资料中没有相关信息",不要编造
2. 引用来源编号,如 [1][2]
3. 答案只使用资料内容,不超过资料范围
【资料】
[1](来源:销售手册 v3 第 5 页)……
[2](来源:退款政策 2025 版 第 2 条)……
【问题】
客户问:退款多久到账?
【回答】- "只根据资料回答" 是防幻觉的第一道闸(机制分析见幻觉:成因与缓解)。
- 给资料编号并强制引用:答案可溯源,也方便下游校验。
- "资料不足就明说":把"不知道"变成合法输出,而不是逼模型编。
上下文别硬塞
把所有检索结果(哪怕 20 块)全塞进提示,会稀释注意力且浪费 token。重排序后只喂 Top-3~5 块;与长上下文的权衡见上下文与长文本。
七、RAG 评测:三个维度缺一不可
RAG 的输出质量由检索质量和生成质量共同决定,评测必须分层:
| 维度 | 指标 | 含义 | 评测对象 |
|---|---|---|---|
| 检索召回 | Recall@k / Hit@k | 相关文档是否进了 Top-k | 检索链路 |
| 忠实度(Faithfulness) | 回答是否都能从资料中找到依据 | 防幻觉 | 生成链路 |
| 答案相关(Relevance) | 回答是否针对问题、不跑题 | 端到端 | 整体 |
python
# 用 RAGAS 快速量化(忠实度/相关性需要 LLM 打分)
pip install ragas
from ragas.metrics import faithfulness, answer_relevancy
from ragas import evaluate
# 构造 dataset(question / answer / contexts / ground_truth)
result = evaluate(dataset, metrics=[faithfulness, answer_relevancy])
print(result) # 输出各项 0~1 分数评测体系的完整方法论(golden set、回归、LLM-as-a-judge 的偏差)见评估实战。
八、建立 RAG golden set:评测的前提
RAG 评测的前提是带标准答案的测试集(问题 + 期望答案)。构造要点:
| 要点 | 说明 |
|---|---|
| 问题来源 | 线上真实日志、客服记录,而非自编 |
| 答案形态 | 二选一:(a) 期望命中的 chunk id;(b) 期望答案文本 |
| 难度分层 | 简单(直接可查)/ 中等(需多块拼合)/ 困难(需跨文档推理) |
| 数量 | 起步 50~100 条即可,持续追加 |
golden set 建好后,每次改动 chunk / 检索 / 重排 / 提示都重跑一遍——这就是 RAG 的回归测试。完整方法(分层评测、judge、成本控制)见评估实战。
九、常见失败模式表
| 失败现象 | 根因环节 | 排查与修复 |
|---|---|---|
| 检索到的东西"像但不相关" | embedding 领域漂移 | 换领域微调 embedding;加查询改写;调融合权重 |
| 精确编号/型号搜不到 | 纯向量检索弱于关键词 | 上 BM25 混合检索;必要时正则抽取后精确匹配 |
| 答案答非所问 | chunk 过大/切碎,信息割裂 | 调 chunk 大小;加重叠;用重排序精排 |
| 明显编造(资料里没有) | 生成阶段约束不足 | 强化"只根据资料回答";加引用编号;降低温度 |
| 老版本文档覆盖新版本 | 索引未版本化 | 元数据带版本号;检索过滤最新版本 |
| 长文档全局问题答不好 | 单块信息不足 | 分块摘要 + 二次检索;或换长上下文模型 |
| 新文档上线后不生效 | 索引没重建/缓存 | 建立文档变更驱动的增量索引流程 |
十、工具链选型
| 层 | 主流选项 | 选型要点 |
|---|---|---|
| 编排框架 | LangChain / LlamaIndex / 自研 | 原型快用框架,生产考虑自研或轻量封装(见框架与工具选型) |
| 向量库 | FAISS / pgvector / Qdrant / Milvus | 见第三节决策表 |
| Embedding | OpenAI embeddings / BGE / E5 | 中文场景优先中文模型 |
| 重排序 | bge-reranker / Cohere Rerank | 精度杠杆,强烈建议加 |
| 评测 | RAGAS / 自建 golden set | 见第七节 |
从最小可行到生产
第一版 RAG 用「PDF 解析 + RecursiveCharacterTextSplitter + 一个开源 embedding + FAISS + 混合检索 + bge-reranker + 3 条提示纪律」即可上线。先把这条链路跑通并建立评测集,再逐环替换更强的组件——每一步升级都用评测分数验证,而不是"感觉更好"。这条"别被框架绑架"的原则同样见框架与工具选型。
延伸阅读
- 案例研究:RAG 检索增强生成 —— RAG 演进(Naive → Advanced → Modular)与概念全景
- 幻觉:成因与缓解 —— RAG 防幻觉背后的机制:为什么"只根据资料回答"有效
- 评估实战 —— golden set、回归测试、LLM-as-a-judge 的完整实现
- 上下文与长文本 —— RAG vs 长上下文的权衡
- 提示工程实践 —— 生成阶段提示组装的系统性方法
- 框架与工具选型 —— 编排框架、向量库的完整决策表
- 常见陷阱与反模式 —— "上下文塞爆"等 RAG 相关陷阱
参考资料
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks(arXiv:2005.11401) —— RAG 原始论文,Facebook AI 提出
- Precise Zero-Shot Dense Retrieval without Relevance Labels(HyDE, arXiv:2212.10496) —— HyDE 的原始论文
- MTEB: Massive Text Embedding Benchmark(arXiv:2210.07316) —— 检索/embedding 模型榜单
- BGE/FlagEmbedding(GitHub) —— bge 系列 embedding 与重排序模型的官方仓库
- FAISS(GitHub) —— Meta 开源的向量检索库
- Qdrant 官方文档 —— 生产级向量数据库文档
- pgvector(GitHub) —— PostgreSQL 向量扩展
- LlamaIndex 官方文档 —— 数据框架,含 RAG 各环节组件
- LangChain 文本切分器文档 —— RecursiveCharacterTextSplitter 官方用法
- RAGAS(GitHub) —— RAG 评测开源库