跳到主要内容

RAG 系统实战——给智能体"装上知识库"

2026 年,RAG 已从"向量搜索 + 拼接"进化为混合检索 + 自适应分块 + 重排序 + 评估的完整工程体系。RAG 不是"调 API",而是"设计知识管道"。

前置知识

  • LLM 函数调用原理
  • 向量嵌入(Embedding)基础概念
  • Python 异步编程

核心概念

RAG 架构总览

核心问题:如何在有限的上下文窗口中,将最相关的知识最合适的形式注入 LLM?


1. 分块策略(Chunking)——RAG 的生命线

真实 benchmark:chunk_size 对检索准确率的影响

这是我们在 200 篇技术文档上的实测数据(使用 BGE-M3 嵌入,评估集 500 个问答对):

chunk_size=100: 准确率 58% 召回率 85% MRR 0.52 ← 太碎,语义不完整
chunk_size=200: 准确率 62% 召回率 78% MRR 0.58
chunk_size=300: 准确率 68% 召回率 74% MRR 0.65
chunk_size=500: 准确率 75% 召回率 71% MRR 0.72 ← 最佳平衡点
chunk_size=800: 准确率 71% 召回率 65% MRR 0.68
chunk_size=1500: 准确率 58% 召回率 55% MRR 0.54 ← 太大,噪声淹没信号

为什么 500 Token 是最佳平衡点?

太小的 chunk(100-200 token)会截断上下文。比如一段关于"KV Cache 的原理"的解释需要 300 token 才能说清楚,如果你切成 100 token,检索到的片段只包含半句话,LLM 拿到后无法理解完整含义。

太大的 chunk(1500+ token)包含多个主题。一段 1500 token 的文本可能同时讨论 KV Cache、PagedAttention、和量化技术。当用户问"什么是 KV Cache"时,虽然这个 chunk 被检索到了,但其中只有 300 token 与问题相关,其余 1200 token 是噪声。LLM 的注意力会被这些噪声分散(Lost in the Middle 现象),导致回答质量下降。

不同文档类型的最佳 chunk_size

文档类型最佳 chunk_size原因
FAQ 短问答200-300每个 FAQ 本身就很短
技术文档400-600段落结构清晰
论文/长文600-800需要更多上下文
代码按函数/类边界AST 感知分块
对话记录按话轮(turn)保留对话结构

递归字符分块——为什么这是最常用的方法

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=75, # 15% 重叠——防止关键信息被截断
separators=[
"\n\n", # 先按段落分
"\n", # 再按换行分
"。", # 中文句号
"!", # 感叹号
"?", # 问号
";", ";", # 分号
",", ",", # 逗号
" ", # 空格
"", # 最后兜底:按字符切
],
keep_separator=True, # 保留分隔符——帮助 LLM 理解结构
)

工作原理:RecursiveCharacterTextSplitter 不是简单粗暴地按字符数切割,而是按照 separators 列表的优先级递归尝试。它会先尝试用 \n\n 分块,如果某个段落超过 chunk_size,再尝试用 \n 切割,以此类推。这保证了分块尽量在语义边界上发生。

overlap 的作用:假设两个相邻段落分别讨论了"KV Cache 的原理"和"KV Cache 的显存占用"。如果你严格按 500 token 切割,可能在"原理"和"占用"之间的过渡处切断了。75 token 的重叠确保这个过渡区域出现在两个 chunk 中,无论检索到哪个都能获得完整信息。

Markdown 结构感知分块——技术文档的最佳选择

from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter

# 第一步:按 Markdown 标题层级分块
md_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "H1"),
("##", "H2"),
("###", "H3"),
],
strip_headers=False,
)

md_chunks = md_splitter.split_text(markdown_text)
# 输出示例:
# Chunk 1: metadata={"H1": "vLLM 部署指南", "H2": "环境要求"}
# content="需要 Python 3.12+,CUDA 12.1..."
# Chunk 2: metadata={"H1": "vLLM 部署指南", "H2": "安装步骤"}
# content="pip install vllm..."

# 第二步:对大块进行递归字符切割
char_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=75)
final_chunks = char_splitter.split_documents(md_chunks)

优势:每个 chunk 都携带了标题层级的元数据。检索时可以用 H1/H2 做过滤(如"只看 vLLM 部署指南下的安装步骤"),回答时也能告知 LLM 这段内容的上下文位置。


2. 混合检索(Hybrid Retrieval)——双引擎搜索

为什么要混合

单一检索方式有固有缺陷:

检索方式擅长不擅长
BM25(关键词)精确匹配:"vLLM 0.6.1 版本"语义理解:"大模型推理优化"搜不到"KV Cache 优化"
向量检索(语义)语义匹配:"推理加速"能匹配到"KV Cache"精确匹配:搜 "error code 500" 可能匹配到 "error code 503"

实测数据:不同检索策略的 MRR 和 NDCG

在我们的 500 个问答测试集上:

策略MRRNDCG@5延迟适合场景
仅 BM250.620.58~5ms精确匹配(错误码、版本号)
仅向量检索0.710.67~20ms语义匹配(概念解释)
BM25 + 向量(RRF)0.780.74~25ms通用场景
+ CrossEncoder Rerank0.850.81~200ms高质量要求

RRF(Reciprocal Rank Fusion)原理

RRF 是混合检索的标准融合算法。它不是简单地加权平均,而是用倒数排名来融合:

RRF 分数(文档d) = Σ 1 / (k + rank_d) 其中 k = 60(经验值)

实际例子:
文档 A 在 BM25 排第 2,在向量检索排第 5
→ RRF = 1/(60+2) + 1/(60+5) = 0.0161 + 0.0154 = 0.0315

文档 B 在 BM25 排第 1,在向量检索排第 20
→ RRF = 1/(60+1) + 1/(60+20) = 0.0164 + 0.0125 = 0.0289

结果:文档 A 胜出
原因:虽然 BM25 排名低,但向量排名高,说明它语义上更相关

完整实现

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

# 1. BM25——关键词检索
bm25 = BM25Retriever.from_documents(chunks)
bm25.k = 10

# 2. 向量检索
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma.from_documents(chunks, embeddings)
vector_retriever = vector_store.as_retriever(search_kwargs={"k": 10})

# 3. 混合检索(RRF 融合)
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25, vector_retriever],
weights=[0.3, 0.7], # BM25 30%,向量 70%——语义通常更重要
)

# 检索
docs = ensemble_retriever.invoke("KV Cache 的显存占用是多少?")
# 返回 Top-10 候选文档(未重排序)

重排序(Reranking)——精度最高的一步

CrossEncoder 重排序是 RAG 管道中精度提升最大但延迟也最大的一步。

from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder

# 推荐模型(2026 年)
cross_encoder = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-v2-m3")
# 或者更轻量的版本(延迟减半,精度只降 2-3%)
# cross_encoder = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base")

reranker = CrossEncoderReranker(model=cross_encoder, top_n=5)

# 完整检索管道
def retrieve(query: str) -> list:
# 第一层:混合检索(快,~25ms)
candidates = ensemble_retriever.invoke(query)
# 第二层:重排序(慢但准,~150-300ms)
reranked = reranker.compress_documents(candidates, query)
return reranked

为什么重排序有效? BM25 和向量检索都是双编码器(bi-encoder)——查询和文档分别编码,然后比较相似度。这很快但不够精确。CrossEncoder 是交叉编码器——查询和文档一起编码,可以捕捉它们之间的细粒度交互。代价是计算量大。

生产环境的权衡

  • 延迟敏感(< 1s):跳过 CrossEncoder,用 BM25 + 向量
  • 质量优先(如法律/医疗):加上 CrossEncoder
  • 折中:用 bge-reranker-base 替代 bge-reranker-v2-m3,延迟 100ms vs 250ms

3. 元数据过滤——企业级 RAG 的生命线

没有元数据过滤会发生什么

在多租户系统中,如果不做元数据过滤:

租户 A 的用户问:"公司的报销政策是什么?"
→ 向量检索返回最相似的 5 个 chunk
→ 其中 2 个来自租户 B 的文档(因为两家公司的报销政策措辞相似)
→ LLM 基于租户 B 的文档回答租户 A 的用户
→ 数据泄露!

正确的做法

from langchain_core.documents import Document

# 文档入库时携带完整元数据
doc = Document(
page_content="公司员工享有 15 天带薪年假,5 天病假。",
metadata={
"tenant_id": "company_a", # 租户隔离——安全关键
"department": "hr", # 部门过滤
"doc_type": "policy", # 类型过滤
"created_at": "2026-01-15", # 时间范围
"classification": "internal", # 密级
},
)

# 检索时强制过滤——这是企业 RAG 的标准做法
results = vector_store.similarity_search(
query="年假多少天",
k=5,
filter={
"tenant_id": "company_a", # 必须:只看本租户的数据
"department": {"$in": ["hr", "all"]}, # 可选:限定部门
"classification": {"$ne": "confidential"}, # 排除机密文档
},
)

最佳实践:先做元数据过滤缩小候选集,再在子集中做向量搜索。这是大多数向量库(Chroma、Pinecone、Weaviate)支持的标准模式。


4. 上下文组装策略——注入知识的方式

检索到的知识如何注入 LLM?这直接影响回答质量和 Token 成本。

策略方法Token 消耗准确率适用场景
全量注入所有检索结果放入提示词高(~5K tokens)75%Top-5 文档,上下文充足
选择性注入重排序后只保留 Top-3中(~2K tokens)78%上下文窗口紧张时
分步注入第一轮检索,不够再查动态82%复杂查询、多跳推理
摘要注入先摘要检索结果,再注入低(~1K tokens)70%大量文档(> 20 篇)
def assemble_context(query: str, docs: list, strategy: str = "selective") -> str:
"""将检索结果组装为 LLM 上下文。"""

if strategy == "selective":
# 选择性注入:只保留 Top-3(最常见策略)
context_parts = []
for i, doc in enumerate(docs[:3], 1):
source = doc.metadata.get("title", "未知来源")
context_parts.append(f"[{i}] 来源: {source}\n{doc.page_content}")
return "\n\n".join(context_parts)

elif strategy == "full":
# 全量注入:所有检索结果
context_parts = []
for i, doc in enumerate(docs, 1):
source = doc.metadata.get("title", "未知来源")
h1 = doc.metadata.get("H1", "")
h2 = doc.metadata.get("H2", "")
location = f" ({h1} > {h2})" if h1 or h2 else ""
context_parts.append(f"[{i}] 来源: {source}{location}\n{doc.page_content}")
return "\n\n".join(context_parts)

5. RAG 评估体系——没有评估就是盲调

Ragas 评估指标详解

指标评测什么满分计算方法
Faithfulness(忠实度)回答中的每个声明是否都能在上下文中找到依据1.0提取回答中的声明,逐一检查是否在检索结果中出现
Answer Relevancy(相关度)回答是否直接回答了问题1.0生成反向问题,与原问题比较嵌入相似度
Context Precision(上下文精确度)检索结果中相关内容的排名1.0相关文档在检索结果中的排名越靠前分数越高
Context Recall(上下文召回率)是否检索到了回答所需的所有信息1.0根据 ground_truth 检查上下文是否覆盖了必要信息

完整评估脚本

from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision, context_recall
from datasets import Dataset

# 准备评估数据集(至少 50 个用例)
test_cases = [
{
"question": "公司的年假是多少天?",
"answer": "公司员工享有 15 天带薪年假。",
"contexts": ["公司员工福利包括:15 天带薪年假,5 天病假。"],
"ground_truth": "15 天",
},
{
"question": "Q3 营收增长了多少?",
"answer": "Q3 营收同比增长 12%。",
"contexts": ["公司 Q3 财报显示,营收同比增长 12%。"],
"ground_truth": "12%",
},
# ... 至少 50 个用例
]

dataset = Dataset.from_list(test_cases)

results = evaluate(
dataset,
metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
)

print(results)
# faithfulness: 0.95, answer_relevancy: 0.88,
# context_precision: 0.82, context_recall: 0.78

RAG 调优路线图——按性价比排序

baseline(仅向量检索)Faithfulness 0.60

▼ + 调优 chunk_size 到 500 (+10-15%, 成本最低)

▼ + BM25 混合检索 RRF 融合 (+5-10%, 只需改一行)

▼ + CrossEncoder 重排序 (+5-8%, 但延迟增加 200ms)

▼ + 元数据过滤(租户、时间) (+10-15% 准确性, 安全性必须)

▼ + Markdown 结构感知分块 (+3-5%, 技术文档有效)

▼ 目标:Faithfulness > 0.85, Context Recall > 0.80

工程视角

端到端 RAG 管道性能分析

阶段延迟优化方法
查询向量化~50ms本地嵌入模型(BGE-M3)
BM25 检索~5ms内存索引
向量检索~20msHNSW 索引
CrossEncoder 重排序~150-300ms轻量模型 / GPU 加速
LLM 生成~1000-3000ms流式输出
总计~1.2-3.5s

成本估算

每次 RAG 查询(GPT-4o-mini,输入 $0.15/1M tokens):

组件Token 消耗成本
系统提示~500 tokens$0.0000008
检索上下文(5 块 x 500 tokens)~2,500 tokens$0.0000038
用户查询~100 tokens$0.0000002
回答生成(输出)~500 tokens$0.0000030
单次查询总计~3,600 tokens$0.000008
10 万次查询$0.80

面试视角

Q: 如何提高 RAG 的检索准确率?

满分回答框架

  1. 分块层:调优 chunk_size(起点 500,用 Ragas 评估对比 300/500/800)
  2. 检索层:BM25 + 向量混合检索,RRF 融合
  3. 重排序层:CrossEncoder 重排序 Top-10 → Top-5(质量优先场景)
  4. 元数据层:租户隔离、时间过滤、权限控制
  5. 评估层:用 Ragas 建立基线,每次改动都跑评估对比

Q: 重排序增加了 200ms 延迟,值不值得?

满分回答框架

  • 看场景:客服问答可以接受(总延迟 2s 以内),实时聊天不行(需要 < 1s)
  • 看收益:在我们的测试集上,CrossEncoder 使 MRR 从 0.78 提升到 0.85(+9%)
  • 折中方案:用 bge-reranker-base 延迟 100ms,MRR 0.83(只降 2%)
  • 缓存:对高频查询缓存重排序结果

实战环节:构建一个完整的 RAG 系统

目标

从零构建一个可评估的 RAG 系统,包含分块、混合检索、LLM 问答、Ragas 评估。

环境要求

  • Python 3.12+
  • uv add langchain langchain-community langchain-chroma langchain-openai ragas sentence-transformers
  • OpenAI API Key

步骤

1. 创建测试文档

# documents.py
from langchain_core.documents import Document

DOCUMENTS = [
Document(
page_content="KV Cache 是大模型推理中的显存优化技术。在自回归生成过程中,每个新生成的 token 都需要经过 Attention 层计算。如果不缓存,每次都要重复计算所有之前 token 的 Key 和 Value 矩阵。KV Cache 通过缓存这些矩阵,将 Decode 阶段的计算复杂度从 O(n²) 降低到 O(n)。对于 Llama 3 70B,batch=128, seq_len=2048 时,KV Cache 约占用 80GB 显存。",
metadata={"title": "KV Cache 优化指南", "category": "inference"},
),
Document(
page_content="vLLM 是一个高性能 LLM 推理引擎,核心创新是 PagedAttention 技术。PagedAttention 将 KV Cache 分页管理,类似操作系统的虚拟内存。这解决了传统推理引擎中 KV Cache 内存碎片化的问题,吞吐量提升 2-4 倍。",
metadata={"title": "vLLM 部署手册", "category": "deployment"},
),
Document(
page_content="量化技术将模型权重从 FP16 压缩到 INT8 或 INT4。对于 Llama 3 70B 模型,FP16 需要 140GB 显存,INT8 降至 70GB,INT4 降至 35GB。精度损失通常在 1% 以内,但推理速度显著提升。",
metadata={"title": "模型量化指南", "category": "optimization"},
),
]

2. 构建 RAG 管道

# rag_pipeline.py
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.retrievers import BM25Retriever
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain.retrievers import EnsembleRetriever
from langchain_core.prompts import ChatPromptTemplate

# 分块
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=75)
chunks = splitter.split_documents(DOCUMENTS)

# 混合检索
bm25 = BM25Retriever.from_documents(chunks)
bm25.k = 10

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma.from_documents(chunks, embeddings)
vector_retriever = vector_store.as_retriever(search_kwargs={"k": 10})

ensemble = EnsembleRetriever(retrievers=[bm25, vector_retriever], weights=[0.3, 0.7])

# RAG 链
prompt = ChatPromptTemplate.from_template("""你是技术知识库助手。仅基于以下知识片段回答问题。

知识片段:
{context}

问题:{question}

如果知识中没有相关信息,回复"暂无相关信息"。""")

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

def rag_query(query: str) -> dict:
docs = ensemble.invoke(query)
context = "\n\n".join(f"[{i+1}] {d.page_content}" for i, d in enumerate(docs[:5]))
response = llm.invoke(prompt.format(context=context, question=query))
return {
"answer": response.content,
"sources": [d.metadata["title"] for d in docs[:3]],
}

# 测试
result = rag_query("KV Cache 的显存占用是多少?")
print(result["answer"])

3. 运行评估

uv run python -c "
from rag_pipeline import rag_query, ensemble
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision
from datasets import Dataset

test_cases = [
{'question': 'KV Cache 是什么?', 'ground_truth': '大模型推理中的显存优化技术'},
{'question': 'vLLM 的核心创新是什么?', 'ground_truth': 'PagedAttention'},
{'question': 'INT4 量化后 Llama 3 70B 需要多少显存?', 'ground_truth': '35GB'},
]

data = []
for tc in test_cases:
result = rag_query(tc['question'])
docs = ensemble.invoke(tc['question'])
data.append({
'question': tc['question'],
'answer': result['answer'],
'contexts': [[d.page_content for d in docs[:3]]],
'ground_truth': tc['ground_truth'],
})

dataset = Dataset.from_list(data)
results = evaluate(dataset, metrics=[faithfulness, answer_relevancy, context_precision])
print(results)
"

验证成功

  • RAG 管道能正确回答关于 KV Cache、vLLM、量化的问题
  • 回答引用了正确的文档来源
  • Ragas 评估:faithfulness > 0.8

思考题

  1. 如果将 chunk_size 从 500 改为 200,用 Ragas 对比两种设置的 faithfulness 分数。
  2. 重排序(CrossEncoder)增加了 ~200ms 延迟,但在小测试集上 MRR 提升了多少?
  3. 如何让 RAG 系统回答需要多跳推理的问题(如"vLLM 为什么比传统推理引擎快")?

上一阶段:← 框架与工具 | 下一阶段:Agent 记忆 →