检索增强生成(Retrieval-Augmented Generation, RAG)通过把外部知识库接入 LLM 推理,缓解幻觉、补充时效性知识、实现私有数据问答。然而从 Demo 到生产,RAG 的检索质量、排序质量与评估闭环决定了系统的最终可用性。本文给出完整的 RAG 工程视图。
RAG 架构全景与演进路线
RAG 的核心理念是「检索-增强-生成」三段式流水线:先从知识库检索候选片段,再把片段注入 Prompt,最后让 LLM 基于证据生成答案。按工程成熟度,RAG 通常被划分为三代:
Naive RAG(朴素 RAG):对文档做固定切块、批量 Embedding、Top-K 向量检索、拼接 Prompt。实现简单,但对查询改写、相关性排序、上下文冗余都没有显式处理,检索噪声会直接污染生成质量。
Advanced RAG(进阶 RAG):在检索前增加查询改写(Query Rewriting)、查询路由(Query Routing)、混合检索与 Reranker 重排,在生成前增加上下文压缩与引用标注,检索质量与可解释性显著提升。
Modular / Agentic RAG(模块化与智能体 RAG):将检索拆解为可编排的模块,由 LLM 作为控制器决定「何时检索、检索什么、是否多跳检索」,支持反思(Self-RAG)、迭代修正(CRAG)与工具调用。
┌─────────────────────────────────────────────────────────────┐
│ Agentic RAG(控制器编排检索、迭代、反思) │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ 查询路由 │→│ 查询改写 │→│ 混合检索 │→│ Reranker │ │
│ │ Router │ │ Rewriter │ │ S+Dense │ │ CrossEnc │ │
│ └─────────┘ └──────────┘ └──────────┘ └───────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 知识库索引:切分 → Embedding → 向量库 + BM25 + 元数据 │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌─────────┐ ┌───────────────┐ ┌──────────────────────┐ │
│ │ 上下文组装 │→│ Prompt 注入 │→│ LLM 生成 + 引用标注 │ │
│ └─────────┘ └───────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
| 阶段 | 朴素 RAG | 进阶 RAG | Agentic RAG |
|---|---|---|---|
| 查询处理 | 原样检索 | 路由 + 改写 + HyDE | 控制器按需检索 |
| 检索方式 | 仅向量 Top-K | 混合检索 + Rerank | 自适应多跳检索 |
| 排序 | 向量相似度 | Cross-Encoder 重排 | 排序 + 反思校验 |
| 生成 | 拼接 Top-K | 压缩去噪 + 引用 | 迭代修正 + 停止条件 |
| 评估 | 无 | RAGAS 离线指标 | 在线 + 反馈闭环 |
文档切分与索引策略
检索单元的质量决定召回上限。切分(Chunking)的目标是让每个片段自洽、语义完整、便于命中,常见策略如下:
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 固定窗口 | 按 256/512 token 硬切 | 简单、稳定 | 切断语义,噪声高 |
| 递归字符 | 按分隔符层级递归切 | 结构友好 | 粒度需调参 |
| 语义切分 | 按句向量相似度断点 | 语义完整 | 计算成本高 |
| 父子分块 | 父块粗切、子块细切 | 命中子块、注入父块 | 存储翻倍 |
父子分块(Parent-Child Chunking) 是生产环境最常用的模式:子块用于检索匹配,命中后把父块(上下文更完整的段落)注入 Prompt,兼顾检索精度与上下文完整性。LangChain 中可通过 ParentDocumentRetriever 实现。
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 子块:细粒度,用于向量检索
child_splitter = RecursiveCharacterTextSplitter(
chunk_size=256, chunk_overlap=32,
separators=["\n\n", "\n", "。", "!", "?", " "]
)
# 父块:粗粒度,用于注入上下文
parent_splitter = RecursiveCharacterTextSplitter(
chunk_size=1024, chunk_overlap=128
)
切分参数的核心权衡是块大小 vs 语义完整性。块越小,检索粒度越细但上下文越碎片化;块越大,语义越完整但 Top-K 可能被无关内容占用。经验起点是 256-512 token、10-20% 重叠率,再按业务语料评估调整。
索引阶段还需同步构建 稀疏索引(BM25/Elasticsearch) 与元数据过滤字段(时间、来源、文档类型、租户 ID),为混合检索与标量过滤做准备。
检索向量化与 Embedding 选型
Embedding 模型决定向量检索的上限。选型主要看三个维度:语义对齐度(是否匹配领域语料)、向量维度(影响存储与检索成本)、MTEB 基准表现。
| 模型 | 维度 | 特点 | 适用场景 |
|---|---|---|---|
| text-embedding-3-large | 3072(可截断) | OpenAI 官方,支持 MRL | 通用问答 |
| text-embedding-3-small | 1536 | 成本低、速度快 | 大规模索引 |
| bge-large-zh | 1024 | 中文表现强 | 中文知识库 |
| bge-m3 | 1024 | 多语言 + 多粒度 + 多检索方式 | 多语言混合 |
| e5-mistral-7b | 4096 | 超强语义但推理贵 | 离线高质量索引 |
from sentence_transformers import SentenceTransformer
# 用 bge-m3 构建向量,维度 1024,支持 dense + sparse
model = SentenceTransformer("BAAI/bge-m3", device="cuda")
corpus = ["RAG 通过外部知识库增强 LLM 生成质量", "LoRA 是一种参数高效微调方法"]
embeddings = model.encode(corpus, normalize_embeddings=True)
print(embeddings.shape) # (2, 1024)
MRL(Matryoshka 表征学习)维度截断与**量化存储(int8/uint8)**是控制成本的两大利器:OpenAI 支持 MRL 将 3072 维截断至 512/1024/1536 维,向量库则普遍支持对 float32 向量做 int8 量化,存储可压缩 4 倍、召回损失通常小于 1%。
实践原则:Embedding 与检索链路的版本必须与文档索引「同源同版」。更换 Embedding 模型意味着必须重建索引,否则向量分布不匹配会导致召回率骤降。
查询路由与改写
用户的原始问题往往不适合直接检索:问句可能是口语化的、包含指代(「那个模型」)、过短或歧义。查询改写(Query Transformation)负责把原始查询改造成更适合检索的形式。
查询路由(Query Routing):由 LLM 判断查询类型(闲聊 / 知识问答 / 需要检索 / 需要工具调用),并分派到对应流水线。典型的分类结果可以是枚举指令。
HyDE(Hypothetical Document Embeddings):先让 LLM 根据查询生成一段「假设答案」,再用该答案的向量去检索,缓解查询与文档之间的词汇鸿沟。
Multi-Query:把一个问题改写成多个视角的子问题,分别检索后合并去重。
from langchain_core.output_parsers import JsonOutputParser
ROUTER_SYSTEM = """
你是查询路由器。请根据用户问题返回 JSON:
{"type": "general_chat" | "rag_query" | "tool_call", "reason": "判断依据"}
若问题需要查阅私有知识库才能回答,则返回 rag_query。
"""
def route_query(user_query: str) -> str:
response = llm.invoke([
{"role": "system", "content": ROUTER_SYSTEM},
{"role": "user", "content": user_query}
])
return JsonOutputParser().parse(response.content)["type"]
查询改写示例:用户问「它对比 LoRA 有什么优势」,改写器应先补充指代(LoRA 指代的对象),再扩展为完整问题「对比 LoRA,QLoRA 在显存占用与训练精度上有什么优势」,从而显著提升检索命中率。
| 改写技术 | 原理 | 适用场景 | 代价 |
|---|---|---|---|
| 指代消解 | 把「它」「那个」替换为实体 | 多轮对话 | 低 |
| 查询扩展 | 添加同义词与领域词 | 稀疏领域 | 低 |
| Multi-Query | 生成多个子问题 | 复杂问题 | 中(检索放大) |
| HyDE | 生成假设答案再检索 | 词汇鸿沟大 | 高(额外生成) |
| RAG-Fusion | 多路结果 RRF 融合 | 全面召回 | 高 |
混合检索:稀疏 + 密集 + BM25
单一检索器各有盲区:密集向量擅长语义相似但可能丢失精确关键词(如型号、商品号),**稀疏检索(BM25)擅长精确词匹配但对同义改写不敏感。混合检索(Hybrid Search)把两者结合,再用倒数排名融合(Reciprocal Rank Fusion, RRF)**合并结果。
def rrf_fuse(result_lists: list[list[str]], k: int = 60) -> list[str]:
"""RRF:为每个文档在每路结果的排名打分并累加。"""
scores: dict[str, float] = {}
for ranked in result_lists:
for rank, doc_id in enumerate(ranked, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
RRF 不需要分数归一化,直接对排名做倒数加权,鲁棒性远好于「加权平均相似度」。融合时的权重调节同样关键:关键词密集场景(电商、代码、合同编号)可给 BM25 更高权重;开放语义问答场景(FAQ、客服)可给密集向量更高权重。
# 以 Qdrant 为例:query_points 同时走 dense 与 sparse 两路
curl -X POST http://localhost:6333/collections/kb/points/search \
-H "Content-Type: application/json" -d '{
"prefer_vector": "dense",
"query": 0.9,
"query_sparse": [{"index": 12, "value": 0.7}],
"limit": 20
}'
混合检索的收益在「专有名词 / 产品型号 / 代码标识符」等精确匹配场景最明显。建议在离线评测集上对比「纯向量」「纯 BM25」「混合 + RRF」三档的命中率,再决定融合权重,而不是拍脑袋定比例。
Reranker 重排序
向量检索是**双塔(Bi-Encoder)**结构:查询与文档分别编码后算余弦相似度,速度快但只捕捉粗粒度语义。Reranker 采用 Cross-Encoder,把「查询 + 文档」拼接后整体过一遍 Transformer,直接输出相关性分数,精度更高但推理代价大,因此只用于对 Top-50/100 的重排。
| 对比 | Bi-Encoder | Cross-Encoder |
|---|---|---|
| 编码方式 | 查询与文档各自独立编码 | 查询与文档拼接后联合编码 |
| 相关性精度 | 中 | 高 |
| 延迟 | 毫秒级(可预计算) | 每对需一次前向 |
| 典型角色 | 召回(Recall) | 精排(Precision) |
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
def rerank(query: str, candidates: list[str], top_k: int = 5) -> list[str]:
pairs = [[query, doc] for doc in candidates]
scores = reranker.compute_score(pairs, normalize=True)
ordered = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, _ in ordered[:top_k]]
二阶段检索(Two-Stage Retrieval)是生产标准做法:第一阶段用双塔做宽召回(Top-50/100,海选),第二阶段用 Reranker 做精排(Top-5/8,精选)。精排阶段还可以叠加硬规则——命中精确关键词的文档强制置顶,未命中但语义相似的文档作为补充。
def two_stage_retrieve(query: str):
candidates = vector_db.search(query, top_k=50) # 召回
candidates += bm25_search(query, top_k=50) # 稀疏补充
candidates = dedupe_and_filter_by_metadata(candidates) # 去重 + 过滤
return rerank(query, candidates, top_k=5) # 精排
生成阶段:上下文组装与提示注入
检索到证据后,生成阶段决定 LLM 如何使用证据。核心工程点包括上下文组装(哪些片段进 Prompt、顺序如何)、去噪(与问题无关的片段剔除)、引用标注(答案附证据来源,提升可信度与可追溯性)。
上下文组装的关键原则:
- 按相关性降序排列片段,最相关的内容靠近问题
- 控制上下文总量,通常 3-8 段、1-2K token,避免「上下文淹没」
- 注入引用元数据,要求 LLM 在答案中标注 [1][2] 对应的证据编号
- 设置「未知即未知」约束:证据不足以回答时明确说「知识库中未找到」
from langchain_core.prompts import ChatPromptTemplate
RAG_PROMPT = ChatPromptTemplate.from_messages([
("system", """你是一个知识库问答助手。仅依据提供的证据回答,禁止臆测。
规则:
1. 若证据不足以回答,回答「知识库中未找到相关信息」。
2. 回答必须引用证据编号,格式为 [1][2]。
3. 保持回答简洁准确,使用中文。"""),
("human", "证据:\n[1]{doc_1}\n[2]{doc_2}\n[3]{doc_3}\n\n问题:{question}"),
])
def build_chain():
return RAG_PROMPT | llm | CitationParser()
**上下文压缩(Contextual Compression)**解决「Top-K 里有噪声」的问题:对每个片段做相关性打分,低于阈值的片段直接丢弃;对长片段抽取与问题最相关的句子,只注入精华部分。这能显著降低幻觉率,代价是每次生成多一次校验调用。
RAG 评估:RAGAS 与检索命中率
RAG 系统的质量由检索质量与生成质量两个维度组成,前者用检索命中率、MRR 衡量,后者用 RAGAS 框架的无参考指标衡量。
| 指标 | 维度 | 公式/含义 | 目标 |
|---|---|---|---|
| Recall@K | 检索 | 相关文档在 Top-K 中的比例 | 越高越好 |
| MRR | 检索 | 首个相关文档排名的倒数 | 越高越好 |
| Faithfulness | 生成 | 答案中声称可被证据支持的占比 | 越高越好 |
| Answer Relevancy | 生成 | 答案与问题的相关性 | 越高越好 |
| Context Precision | 检索+生成 | 证据片段中相关信息占比 | 越高越好 |
| Context Recall | 检索+生成 | 证据是否覆盖答案所需信息 | 越高越好 |
**检索命中率(Hit Rate)**是生产中最先看的指标:对一批标准问答对,检查「正确答案是否出现在检索结果 Top-5 中」。召回不足时先别调 Prompt,而应优先排查切分粒度、Embedding 模型与混合检索权重。
from ragas import evaluate
from ragas.metrics import (
faithfulness, answer_relevancy, context_precision, context_recall
)
from datasets import Dataset
eval_set = Dataset.from_dict({
"question": ["LoRA 相比全量微调节省多少显存?"],
"answer": ["LoRA 仅训练低秩增量矩阵,显存占用可降低约 2-3 倍。"],
"contexts": [["LoRA 通过低秩分解冻结原权重,只训练增量部分。"]] ,
"ground_truth": ["LoRA 只训练低秩增量矩阵,显存显著下降。"],
})
result = evaluate(
eval_set,
metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
llm=evaluator_llm,
embeddings=embedding_model,
)
print(result)
**Golden Set(黄金评测集)**是评估的基石:由人工构造 200-500 条覆盖常见问题形态的问答对,标注标准答案与相关文档 ID。任何检索链路改动(换 Embedding、改切分、调权重)都先在 Golden Set 上回归,避免「改了 A 好了、坏了 B」的隐性退化。
Agentic RAG 与生产调优
Agentic RAG 把检索决策交给 LLM 控制器,适用于查询需要多步推理、需要跨多个知识源汇总、或需要判断「要不要检索」的场景。典型模式包括:
- Self-RAG:LLM 自我评估「是否需要检索」「检索结果是否足够」,不足则迭代检索
- CRAG(Corrective RAG):先检索,再对检索结果做质量评估,噪声高则触发查询改写重新检索
- 多跳检索(Multi-Hop):先检索得到中间实体,再基于中间实体进行第二轮检索
def agentic_retrieve(query: str, max_iterations: int = 3) -> list[str]:
evidence: list[str] = []
current_query = query
for _ in range(max_iterations):
docs = two_stage_retrieve(current_query)
verdict = llm.judge("""基于以下证据,判断是否足以回答:
证据: {docs}
请回答: SUFFICIENT / INSUFFICIENT / NEED_EXTRA
如果 NEED_EXTRA,请给出新的检索问题。""", docs)
if verdict.status == "SUFFICIENT":
evidence = docs; break
current_query = verdict.new_query # 迭代改写
return evidence
生产调优的杠杆集中在延迟预算与缓存上:
| 调优项 | 手段 | 收益 |
|---|---|---|
| 检索延迟 | 缩小召回 K(50→20)、向量 int8 量化 | P95 检索 < 20ms |
| 重排延迟 | 只对 Top-20 做 Rerank,限制片段长度 | 节省 60%+ 重排耗时 |
| LLM 延迟 | 上下文压缩 + 更小的生成模型 | 首 token 延迟下降 |
| 成本 | 语义缓存(Semantic Cache)+ 结果缓存 | 命中可省 40-70% Token |
| 稳定性 | 混合检索降级:向量库不可用回退 BM25 | 检索高可用 |
关键认知:RAG 优化顺序应当是「评估先行、检索优先」。先建 Golden Set 摸清检索命中率与生成 Faithfulness 基线,再逐环节优化;多数生产问题(幻觉、答非所问)根因在检索而非 Prompt。
总结
| 环节 | 核心决策 | 常用方案 |
|---|---|---|
| 索引 | 切分粒度 | 父子分块 256/1024 token |
| 向量化 | Embedding 选型 | bge-m3 / text-embedding-3 |
| 查询 | 路由与改写 | Multi-Query + HyDE |
| 检索 | 召回融合 | BM25 + 向量 + RRF |
| 排序 | 精排 | Cross-Encoder Reranker |
| 生成 | 上下文注入 | 压缩去噪 + 引用标注 |
| 评估 | 质量闭环 | RAGAS + Golden Set |
| 编排 | 复杂查询 | Agentic RAG 多跳检索 |
RAG 工程的关键不是堆砌组件,而是建立「索引 → 检索 → 排序 → 生成 → 评估」的完整闭环,用可量化的指标驱动每一环的迭代优化。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。