主题
RAG 全链路:从文档到答案
本文是 AI 应用系统学习系列的 L2 核心篇。前置:33. LLM API 开发详解。 学完可以配合面试题食用:02-RAG 全链路、16-GraphRAG、19-AI 搜索
RAG 解决什么问题
LLM 有三大硬伤:知识截止日期之后的内容不知道、私有数据进不去训练集、回答没有事实依据容易编造。RAG(Retrieval-Augmented Generation)的思路不是让模型"记住更多",而是每次回答前先查资料,再把资料塞进上下文让模型阅读后作答。
和微调相比:微调教模型学新的行为模式(比如格式、语气),适合让模型"学会做一件事";RAG 给模型"查资料的能力",适合让模型"知道一个事实"。适用边界的直觉判断——如果一个答案需要精确引用原文(政策条款、产品文档),走 RAG;如果只是让模型适应某种风格或知识体系,走微调。两者不互斥,生产环境通常调后用 RAG 兜底。
全链路总览:六步走
RAG 在运行时的流水线是固定的六个环节,少一步就断链:
加载 → 切分 → Embedding → 向量库 → 检索 → 重排 → 生成前两步是离线预处理(建库端),后三步是在线查询(推理端),Embedding 和向量库横跨两端。每个环节都有坑,一节一节拆。
切分策略:文档变片段
文档不能整篇塞进上下文——超过窗口截断、小于窗口检索粒度太粗。核心矛盾是:chunk 越大,召回率越高但精度越低;chunk 越小则相反。
三种主流切分方式:
- 固定窗口切分:按字符数硬切(比如每 512 字符一段),overlap 设 50-100 字符。实现最简单,但会切碎段落,语义完整度差。
- 递归字符切分:按段落→句子→单词的优先级逐级降级切分,保持语义单元完整。LangChain 的
RecursiveCharacterTextSplitter就是这个思路,separators=["\n\n", "\n", "。", ".", " ", ""]。适合大多数场景。 - 语义切分:用 embedding 检测句子间的语义跳变点,在内容主题切换处断块。实现复杂,但对问答类文档收益明显。
chunk 大小有经验值:通用问答 256-512 token,代码文档 512-1024,长文本摘要 1024-2048。overlap 一般取 chunk 大小的 10-20%,保证切在边界上的信息不丢失。
Embedding 与向量库
Embedding 模型把文本变成固定维度的浮点数向量。不同的 embedding 模型维度不同(768 维、1024 维、1536 维),维度越高理论上语义区分度越好,但存储和检索成本也高。
选 embedding 模型的关键指标不是"谁跑分高",而是和你待检索的文档语种与领域是否匹配。中文文档用 bge-large-zh 或 m3e 系列,英文用 text-embedding-3-small 或 e5。跨语种任务(英文 query 搜中文文档)需要双语 embedding 或翻译后检索。
向量库负责存储向量并提供相似度搜索。生产环境选型:
| 数据库 | 适用场景 | 注意点 |
|---|---|---|
| Chroma | 本地开发、Demo | 全内存,不适合生产数据量 |
| Pinecone | 纯托管,开箱即用 | 贵,数据迁移麻烦 |
| Milvus | 大规模生产(亿级) | 运维成本高,需要 k8s |
| pgvector | 已有 PostgreSQL 的团队 | 少一个中间件,但高并发性能不如专用库 |
选型原则:数据量少于 100 万条且不想多维护一个组件,pgvector 性价比最高。
检索增强:不止是搜到
简单向量检索最大的问题是:query 和 chunk 的语义分布不一致。用户问的是一句话,文档里写的是三大段,向量相似度匹配不准。
三个标准增强手段:
1. 混合检索(BM25 + 向量 RRF 融合)
BM25 做关键词匹配,向量做语义匹配,两者互补。用 RRF(Reciprocal Rank Fusion)合并排序:对每个文档在两种方式下的排名取倒数,求和后重新排序。公式简单暴力:
python
score = 1/(60 + rank_bm25) + 1/(60 + rank_vector)60 是平滑常数,避免极端情况。这个公式不用调参,效果稳定优于单检索。
2. Query 改写
用户问的 query 往往很短、不精确,直接搜会丢。两种改写方式:
- HyDE(假设文档嵌入):先让 LLM 生成一段"假设的理想答案",用这个答案的 embedding 去检索。效果在领域术语多的场景尤其好。
- 多查询扩展:把用户 query 用 LLM 扩写成 3-5 个不同角度的 query,每个都搜一遍,合并结果去重。代价是多次检索延迟。
3. Rerank
检索阶段拉回 top-50 或 top-100,然后用一个轻量级的 cross-encoder 逐对打分排序。cross-encoder 比 bi-encoder(普通 embedding 模型)精确得多,因为它在计算相似度时让 query 和 chunk 做了注意力交互。线上一般只 rerank top-20,因为 cross-encoder 的计算量和输入长度成正比。
常见劣化模式
RAG 项目最头疼的不是"做不出来",而是"做出了但有时好有时差,查不到根因"。三个典型劣化模式:
- 检索空:向量库里根本没有相关文档。原因:切分过细漏掉了关键信息、embedding 模型对领域术语不敏感、用户 query 太难(专业缩写)。排查:先做关键词盲搜,确认资料在不在库里。
- 检索错:文档在库里但没被召回。原因:chunk 太大/太小、query 改写失败、排序阶段正确 chunk 没进 top-K。排查:检查 rerank 的排序分布,看正确 chunk 排在第几位。
- 生成不忠实:检索对了但 LLM 没按资料回答。原因:prompt 里没有"只根据资料回答"的约束、上下文太长注意力丢失、模型本身幻觉。排查:看 LLM 的原始输出和检索结果的 n-gram 覆盖率。
动手实操:50 行迷你 RAG
下面这个例子用纯 Python(不依赖 LangChain)演示完整流程:文档切分→embedding→检索→rerank→生成。
python
import chromadb
from sentence_transformers import SentenceTransformer, CrossEncoder
from openai import OpenAI
import re
client = OpenAI() # 假设环境变量已配好
embed_model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
chroma = chromadb.Client()
# 1. 切分:按段落递归切,每段作为一个 chunk
doc = "这是一篇 AI Agent 技术文档……" # 你的源文档
paragraphs = [p.strip() for p in re.split(r'\n\n+', doc) if p.strip()]
chunks = []
for p in paragraphs:
# 超长段落再切,控制每段 256 token
if len(p) > 500:
chunks.extend([p[i:i+500] for i in range(0, len(p), 250)]) # overlap 250
else:
chunks.append(p)
# 2. 建库
collection = chroma.create_collection(name="demo")
embeddings = embed_model.encode(chunks).tolist()
collection.add(ids=[f"c{i}" for i in range(len(chunks))],
documents=chunks, embeddings=embeddings)
# 3. 检索 + 混合 RRF
query = "Agent 工具设计规范是什么?"
query_emb = embed_model.encode([query])[0].tolist()
vec_results = collection.query(query_embeddings=[query_emb], n_results=20)
# 4. Rerank 前 20
pairs = [(query, doc) for doc in vec_results["documents"][0]]
scores = reranker.predict(pairs)
ranked = sorted(zip(vec_results["documents"][0], scores),
key=lambda x: x[1], reverse=True)[:5]
# 5. 生成
context = "\n\n".join([doc for doc, _ in ranked])
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "只根据下文资料回答,不知道就说不知道"},
{"role": "user", "content": f"资料:\n{context}\n\n问题:{query}"}
]
)
print(response.choices[0].message.content)这段代码分三步走:用 SentenceTransformer 生成向量存入 Chroma,用 CrossEncoder 做 rerank 精排,最后把 top-5 塞进上下文让 LLM 回答。注意 rerank 环节做的是 query-doc 对打分,比单纯向量相似度排序准确得多。
常见误区与小结
- RAG 能解决所有幻觉:不是。RAG 只解决"没有资料"的幻觉,如果资料本身冲突或 LLM 不遵循指令,照样生成瞎话。
- chunk 越大越好:越大越容易把无关信息带进上下文,稀释注意力。精确答案需要的上下文往往不超过 512 token。
- 向量库适合所有检索:精确匹配(身份证号、订单号)向量检索不如倒排索引。用 BM25 兜底,不要只用向量。
- rerank 能解决一切检索问题:rerank 只优化排序,如果检索阶段根本没拉回正确 chunk,rerank 救不了。先保证召回率再优化精度。
- embedding 模型选最大的:参数量大不一定适合你的数据。在自己业务数据上跑 recall@k 对比测试,数字说话。
小结:RAG 的本质是"给 LLM 配一个实时资料库",建库端决定数据质量,推理端决定检索精度。生产环境最值得花时间优化的三个环节:切分策略(数据质量源头)、混合检索(召回率兜底)、rerank(精度兜底)。下一篇讲 Agent 系统——当 LLM 不止会查资料,还会自己做决策时,架构怎么设计。
参考
参考:Lewis et al., "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks" (2020) 参考:LangChain 官方文档 - Document Splitters 参考:Pinecone 学习中心 - RAG 架构指南