8.1 引言:RAG让Agent拥有专属知识
在前面的章节中,我们构建的智能体已经能调用工具、执行多步推理、甚至自主规划任务。但它们仍有一个明显的短板——只知道训练数据里有的东西。
设想这样一个场景:你让Agent回答公司内部的报销流程、产品API文档、客户合同条款。这些信息从未出现在模型训练语料中,无论你用的是GPT-5.4还是其他任何模型,它要么"诚实"地说"我不知道",要么更糟——编造一个看似合理却完全错误的答案,这就是常说的幻觉(Hallucination)。
💡 小贴士:什么是"幻觉"?
幻觉指大模型生成看似可信但实际错误的内容。原因是模型基于概率"续写"文本,遇到知识盲区时倾向于"猜"一个答案,而不是承认不会。RAG通过把真实文档塞进上下文,能显著压低幻觉率。
解决这个问题的传统方法是微调(Fine-tuning):用领域数据继续训练模型。但微调成本高、周期长、更新慢,每次知识更新都要重新训练。
RAG(Retrieval-Augmented Generation,检索增强生成)给出了另一条路:不把知识塞进模型参数,而是在生成时动态检索外部知识库,把相关文档作为上下文喂给模型。
打个比方:RAG就像开卷考试。闭卷考试靠死记硬背(像纯LLM),记不住就瞎编;开卷考试允许翻书(像RAG),你不必背下整本书,但得知道在哪本书、哪一页、怎么找。模型扮演"答题者",向量库扮演"课本",检索就是"翻书"的动作。
再举一个更贴近的例子:假设你新入职一家公司,同事问你"年假怎么请"。如果你脑子里没存这份信息,纯靠猜肯定出错;但桌上有一本《员工手册》,你翻开目录找到"年假"那一页,照着读就行。RAG做的就是这件事——给Agent配一本随时可翻的"手册",而且这本手册可以随时更新,不用重新"培训"Agent。
本章我们从零构建一个完整的RAG增强Agent,覆盖文档处理、向量存储、检索策略、Agent集成、系统评估全流程。学完本章,你将掌握让Agent拥有"专属记忆"的核心能力。
8.2 RAG架构概述
8.2.1 标准RAG流程
一个标准RAG系统分两个阶段:
离线阶段(索引构建)——“给课本做索引”:
- 文档加载:从PDF、Markdown、网页等来源读取原始文本
- 分块(Chunking):把长文档切成语义连贯的小片段
- Embedding:用嵌入模型把每个文本块转成向量
- 存储:把向量和元数据写入向量数据库
在线阶段(检索生成)——“翻书找答案再作答”:
5. 检索:把用户问题转成向量,在库里找最相似的文本块
6. 生成:把检索到的文本块拼进提示词,让LLM基于上下文回答
整个过程一句话概括:先查后答,答有所据。
💡 小贴士:什么是Embedding(嵌入)?
Embedding是把文本映射成一串数字(向量)的技术。可以理解为给每段文本发一张"语义身份证"——意思相近的文本,身份证号码也相近。这样"找相似文本"就变成了"向量空间里找邻居",用数学距离衡量语义相似度。
8.2.2 RAG vs 微调:何时用哪个
| 维度 |
RAG |
微调 |
| 知识更新 |
实时,改库即可 |
需重新训练 |
| 成本 |
低,只需向量数据库 |
高,需要GPU和标注数据 |
| 可溯源 |
是,可返回原文出处 |
否,知识融进参数 |
| 适合场景 |
事实性知识、文档问答 |
改变模型风格、格式、行为 |
| 幻觉控制 |
较好,有上下文约束 |
一般 |
经验法则:知识用RAG,能力用微调。让Agent"知道"公司文档,用RAG;让Agent用特定语气说话、按特定格式输出,考虑微调。两者也可叠加——先微调出领域风格,再用RAG注入实时知识。
举两个具体场景帮你判断:
- 场景A:客服Agent需要回答公司退货政策 → 用RAG,因为政策会变,且需要引用条款出处
- 场景B:Agent需要用文言文风格回答 → 用微调,因为这是"能力"而非"知识",不依赖外部文档
8.3 文档处理Pipeline
文档处理是RAG的"地基"。垃圾进,垃圾出——分块质量差,检索再聪明也救不回来。
8.3.1 文档加载
不同格式需要不同加载器。我们用pypdf处理PDF,用内置方法读Markdown和纯文本,用requests+正则抓网页。先装依赖:
1
|
pip install pypdf chromadb openai
|
下面这个加载器根据来源后缀自动选择读取方式,对外只暴露一个load_document入口,调用方不用关心格式细节:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
# document_loader.py —— 多格式文档加载器
from pathlib import Path
from pypdf import PdfReader
import re
import requests
def load_pdf(file_path: str) -> str:
"""加载PDF文件,提取全部文本"""
reader = PdfReader(file_path)
text = ""
for page in reader.pages:
# 逐页提取文本,用换行分隔
text += page.extract_text() + "\n"
return text
def load_markdown(file_path: str) -> str:
"""加载Markdown文件,保留原始文本"""
return Path(file_path).read_text(encoding="utf-8")
def load_webpage(url: str) -> str:
"""抓取网页,去除HTML标签提取纯文本"""
resp = requests.get(url, timeout=10)
resp.raise_for_status()
html = resp.text
# 先去掉script/style整段,避免JS代码混入正文
html = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", html, flags=re.DOTALL)
# 再去掉其他HTML标签
text = re.sub(r"<[^>]+>", " ", html)
# 压缩多余空白
return re.sub(r"\s+", " ", text).strip()
def load_document(source: str) -> str:
"""根据来源自动选择加载器"""
if source.startswith("http"):
return load_webpage(source)
elif source.endswith(".pdf"):
return load_pdf(source)
elif source.endswith((".md", ".markdown", ".txt")):
return load_markdown(source)
else:
raise ValueError(f"不支持的文件格式: {source}")
|
8.3.2 分块策略
文档加载后是几万字的长文本,必须切成分块。分块太大则检索不精准、上下文冗长;分块太小则语义断裂、信息碎片化。常见三种策略:
- 固定长度分块:按字符数切分,简单但可能截断句子
- 语义分块:按段落/句子边界切分,保留语义完整
- 递归分块:先按大分隔符切,超长再递归切小,兼顾语义和长度
💡 小贴士:什么是Chunk(分块)?
Chunk就是把长文档切成的一小段一小段文本。为什么要切?一是Embedding模型有输入长度限制,整篇文档塞不下;二是检索时整篇文档粒度太粗,定位不到具体段落。切得好,检索才能"精确制导"而不是"狂轰滥炸"。
下面实现一个实用的递归分块器,这也是LangChain默认采用的思路。它优先按段落切,段落太长再按换行切,再不行按句号切,层层降级,尽量在语义边界处断开:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
# text_splitter.py —— 递归字符分块器
from typing import List
class RecursiveTextSplitter:
"""递归字符文本分块器:按分隔符层级递归切分"""
def __init__(
self,
chunk_size: int = 500,
chunk_overlap: int = 50,
separators: List[str] = None,
):
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
# 分隔符按优先级从高到低:段落 > 换行 > 句号 > 空格
self.separators = separators or ["\n\n", "\n", "。", ".", " ", ""]
def split_text(self, text: str) -> List[str]:
"""将长文本切分为多个分块"""
# 找出文本中存在的最高优先级分隔符
separator = self.separators[-1]
for sep in self.separators:
if sep == "":
continue
if sep in text:
separator = sep
break
# 按选定的分隔符切分
if separator:
splits = text.split(separator)
else:
splits = list(text) # 实在没分隔符就按字符切
# 拼接成不超过chunk_size的分块,带overlap
chunks = []
current = ""
for piece in splits:
candidate = current + separator + piece if current else piece
if len(candidate) > self.chunk_size and current:
# 当前块已满,保存并开启新块
chunks.append(current.strip())
# 重叠部分:保留尾部chunk_overlap字符,避免关键信息被切断
overlap_text = current[-self.chunk_overlap:] if self.chunk_overlap > 0 else ""
current = overlap_text + separator + piece
else:
current = candidate
if current.strip():
chunks.append(current.strip())
return chunks
|
使用方式:
1
2
3
4
5
|
splitter = RecursiveTextSplitter(chunk_size=500, chunk_overlap=50)
text = load_document("company_handbook.pdf")
chunks = splitter.split_text(text)
print(f"文档切分为 {len(chunks)} 个分块")
print(f"第一个分块示例: {chunks[0][:100]}...")
|
分块大小的经验值:中文文档建议300-600字一块,英文建议500-1000字符。overlap取chunk_size的10%-20%,避免关键信息恰好被切断在两块之间。
💡 小贴士:overlap(重叠)为什么重要?
想象一句关键的话刚好被切在两块之间——前半句在块A末尾,后半句在块B开头。如果用户检索命中了块A但没命中块B,就只拿到半句话,回答必然残缺。overlap让相邻块共享一小段文本,相当于"桥接",确保跨块的信息至少在一块里是完整的。
分块没有"最优答案",需要根据文档类型和问题粒度反复试验。技术文档适合按章节切,FAQ适合按问答对切,长文资讯适合按段落切。这也是为什么评估环节(8.7节)如此重要——没有度量就没有优化方向。
8.4 向量存储与检索
8.4.1 Embedding生成
Embedding把文本映射成高维向量,语义相近的文本在向量空间中距离也近。我们用OpenAI的text-embedding-3-small,它性价比高、1536维、对中英文都支持良好。
💡 小贴士:Embedding模型怎么选?
主流选择有三类:OpenAI的text-embedding-3-small(1536维,便宜稳定)、text-embedding-3-large(3072维,更准但更贵)、开源的bge-m3和e5-large(可本地部署,免费但需GPU)。对中文场景,bge-m3和text-embedding-3-small表现都不错。初期建议先用便宜的small版跑通流程,后续按评估指标决定是否升级。
下面封装两个函数:单条生成和批量生成。批量接口能减少API调用次数,灌库时务必用批量版以节省时间和费用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
# embedding.py —— Embedding生成封装
from openai import OpenAI
# OpenAI SDK会自动读取环境变量里的密钥,无需手动传入
client = OpenAI()
EMBEDDING_MODEL = "text-embedding-3-small"
def get_embedding(text: str) -> list:
"""生成单条文本的Embedding向量"""
text = text.replace("\n", " ") # API建议去掉换行,避免影响向量质量
resp = client.embeddings.create(
model=EMBEDDING_MODEL,
input=text,
)
return resp.data[0].embedding
def get_embeddings_batch(texts: list) -> list:
"""批量生成Embedding,效率更高"""
cleaned = [t.replace("\n", " ") for t in texts]
resp = client.embeddings.create(
model=EMBEDDING_MODEL,
input=cleaned,
)
# 按index排序确保返回顺序与输入一致
sorted_data = sorted(resp.data, key=lambda x: x.index)
return [d.embedding for d in sorted_data]
|
💡 小贴士:什么是向量检索?
传统数据库靠"精确匹配"找数据(如WHERE id=123);向量数据库靠"相似度"找数据——给定一个查询向量,找出库中距离最近的几条。距离计算常用余弦相似度:两个向量方向越一致,相似度越高,跟向量长度无关。可以类比成"看两句话方向是否一致",而非"逐字对比"。
8.4.2 ChromaDB实战
ChromaDB是一个轻量级开源向量数据库,无需单独部署服务,数据落盘到本地目录,非常适合开发和中小型应用。
下面的VectorStore类封装了"写入"和"检索"两个核心操作。写入时先调Embedding把文本转向量,再连同原文和元数据一起存入;检索时把查询转向量,让ChromaDB找近邻:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
# vector_store.py —— 基于ChromaDB的向量存储与检索
import chromadb
class VectorStore:
"""向量数据库封装:存储与检索"""
def __init__(self, persist_path: str = "./chroma_db", collection_name: str = "knowledge"):
# 创建持久化客户端,数据写入本地目录
self.client = chromadb.PersistentClient(path=persist_path)
# 获取或创建集合(类似数据库中的表)
self.collection = self.client.get_or_create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"}, # 使用余弦相似度
)
def add_documents(self, chunks: list, metadatas: list = None, batch_size: int = 100):
"""将文本分块批量写入向量库"""
for i in range(0, len(chunks), batch_size):
batch = chunks[i:i + batch_size]
# 为每个分块生成唯一ID
ids = [f"doc_{i + j}" for j in range(len(batch))]
# 生成向量(这里手动调用,便于演示流程全貌)
embeddings = get_embeddings_batch(batch)
meta = metadatas[i:i + batch_size] if metadatas else [{}] * len(batch)
self.collection.add(
embeddings=embeddings,
documents=batch,
metadatas=meta,
ids=ids,
)
print(f"已写入 {len(chunks)} 个分块到向量库")
def search(self, query: str, top_k: int = 5) -> list:
"""语义检索:返回与query最相似的top_k个分块"""
query_embedding = get_embedding(query)
results = self.collection.query(
query_embeddings=[query_embedding],
n_results=top_k,
include=["documents", "metadatas", "distances"],
)
# 整理结果为字典列表,方便上层使用
docs = results["documents"][0]
metas = results["metadatas"][0]
dists = results["distances"][0]
return [
{"content": d, "metadata": m, "distance": dist}
for d, m, dist in zip(docs, metas, dists)
]
|
完整使用示例——构建知识库并做一次检索测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
# 构建知识库
store = VectorStore(persist_path="./my_kb")
# 加载并分块文档
raw = load_document("产品手册.pdf")
chunks = RecursiveTextSplitter(chunk_size=500, chunk_overlap=50).split_text(raw)
# 为每个分块打上来源元数据,方便后续溯源
metas = [{"source": "产品手册.pdf", "chunk_index": i} for i in range(len(chunks))]
# 写入向量库
store.add_documents(chunks, metadatas=metas)
# 检索测试
results = store.search("如何申请退款?", top_k=3)
for r in results:
print(f"[相似度: {1 - r['distance']:.4f}] {r['content'][:80]}...")
|
相似度这里用1 - distance转换,因为ChromaDB返回的是余弦距离(越小越相似),转成相似度更直观。
8.5 高级检索策略
基础语义检索能满足大部分需求,但复杂场景下往往不够精准。本节介绍三种进阶策略。
8.5.1 多查询检索(Multi-Query)
用户提问往往措辞单一,可能与文档表述方式不一致,导致漏检。比如用户问"咋退款",文档写的是"退货流程",措辞不同可能检索不到。多查询检索的思路是:让LLM把用户问题改写成多个不同角度的子问题,分别检索后合并去重,从而提高召回率。
这就像你去图书馆找书,只问管理员一个问题可能找不到,但换个说法再问一次、再换一次,总有一次能命中。多查询检索把这个"换说法"的过程自动化了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
# multi_query.py —— 多查询检索
from openai import OpenAI
client = OpenAI() # 自动读取环境变量OPENAI_API_KEY
LLM_MODEL = "gpt-5.4"
def generate_multi_queries(question: str, n: int = 3) -> list:
"""让LLM把原问题改写成n个不同表述的子问题"""
prompt = f"""你是一个检索查询改写器。请把下面的问题改写成{n}个语义相同但表述不同的版本,
用于从向量库中检索相关文档。每行一个,不要编号。
原问题:{question}
改写结果:"""
resp = client.chat.completions.create(
model=LLM_MODEL,
messages=[{"role": "user", "content": prompt}],
temperature=0.7,
)
queries = resp.choices[0].message.content.strip().split("\n")
# 原问题也加入检索队列,保底
return [question] + [q.strip() for q in queries if q.strip()][:n]
def multi_query_search(store, question: str, top_k: int = 3) -> list:
"""多查询检索:合并各子问题的结果并去重"""
queries = generate_multi_queries(question, n=3)
print(f"生成 {len(queries)} 个查询: {queries}")
all_results = []
seen_contents = set() # 用于去重
for q in queries:
for r in store.search(q, top_k=top_k):
# 用内容前50字符作为去重键
key = r["content"][:50]
if key not in seen_contents:
seen_contents.add(key)
all_results.append(r)
# 按距离升序(距离越小越相似)
all_results.sort(key=lambda x: x["distance"])
return all_results[:top_k * 2] # 返回合并后的top结果
|
8.5.2 重排序(Reranking)
向量检索速度快但精度有限,top_k较大时噪声多。重排序的思路是:先粗检索召回较多候选(如20条),再用更精细的模型对候选逐一打分排序,取最相关的几条。
打个比方:向量检索像用渔网捞鱼——一网下去捞上来很多,但大小混杂;重排序像挑鱼——把网里的鱼倒出来,一条条挑出最肥美的。两步配合,既不会漏掉好鱼,也不会被小鱼虾浪费精力。
💡 小贴士:什么是Cross-Encoder(交叉编码器)?
普通Embedding是"双塔"结构——query和doc各自编码成向量再算相似度,快但粗。Cross-Encoder把query和doc拼在一起送入模型,直接输出相关性分数,更准但更慢。所以常用"双塔粗召回 + 交叉精排"的两段式策略,兼顾速度和精度。
重排序模型常用Cross-Encoder,这里用cross-encoder库演示:
1
|
pip install sentence-transformers
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
# reranker.py —— 交叉编码器重排序
from sentence_transformers import CrossEncoder
# 加载预训练交叉编码器,支持中英文
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def rerank(query: str, candidates: list, top_k: int = 3) -> list:
"""对候选分块用交叉编码器重新打分排序"""
# 构造(query, doc)对
pairs = [(query, c["content"]) for c in candidates]
scores = reranker.predict(pairs)
# 把分数附加到候选上
for c, s in zip(candidates, scores):
c["rerank_score"] = float(s)
# 按重排序分数降序
candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
return candidates[:top_k]
|
8.5.3 混合检索(关键词+语义)
语义检索擅长理解意图,但对专有名词、编号、代码等"字面匹配"场景不如关键词检索。比如搜"ISO-9001"这种编号,向量检索可能返回一堆"质量管理体系"的近似文本,而关键词检索能精准命中。混合检索两者结合:分别用BM25关键词检索和向量语义检索,再融合排名。
💡 小贴士:什么是BM25?
BM25是经典的关键词检索算法,基于词频和文档长度做打分。它比纯"字面包含"更聪明——出现次数多的词权重高,但会被文档长度稀释。Elasticsearch默认排序就是BM25的变种。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
|
# hybrid_search.py —— 混合检索
import math
class BM25Index:
"""简易BM25关键词索引(演示用,生产环境可用rank_bm25库)"""
def __init__(self, documents: list, k1: float = 1.5, b: float = 0.75):
self.docs = documents
self.k1 = k1
self.b = b
# 分词(中文简单按字切,生产建议用jieba)
self.tokenized = [list(d) for d in documents]
self.doc_len = [len(t) for t in self.tokenized]
self.avg_len = sum(self.doc_len) / len(self.doc_len) if self.doc_len else 0
# 统计词频
self.df = {} # 文档频率:某词出现在多少篇文档
self.tf = [] # 各文档的词频
for tokens in self.tokenized:
freq = {}
for t in tokens:
freq[t] = freq.get(t, 0) + 1
self.tf.append(freq)
for t in freq:
self.df[t] = self.df.get(t, 0) + 1
self.N = len(documents)
def search(self, query: str, top_k: int = 5) -> list:
"""返回[(index, score)]列表"""
q_tokens = list(query)
scores = []
for i in range(self.N):
s = 0.0
for t in q_tokens:
if t not in self.df:
continue
# BM25公式:IDF * TF归一化
idf = math.log((self.N - self.df[t] + 0.5) / (self.df[t] + 0.5) + 1)
tf = self.tf[i].get(t, 0)
norm = tf * (self.k1 + 1) / (tf + self.k1 * (1 - self.b + self.b * self.doc_len[i] / self.avg_len))
s += idf * norm
scores.append((i, s))
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]
def hybrid_search(store, bm25_index, chunks, query: str, top_k: int = 5, alpha: float = 0.5) -> list:
"""混合检索:语义(alpha) + 关键词(1-alpha)"""
# 语义检索
semantic_results = store.search(query, top_k=top_k * 2)
# 关键词检索
bm25_results = bm25_index.search(query, top_k=top_k * 2)
# 归一化分数并融合
score_map = {} # key: chunk内容前50字符
# 语义分数归一化(距离转相似度)
max_sim = max([1 - r["distance"] for r in semantic_results]) or 1
for r in semantic_results:
key = r["content"][:50]
score_map[key] = score_map.get(key, 0) + alpha * (1 - r["distance"]) / max_sim
# BM25分数归一化
max_bm = max([s for _, s in bm25_results]) or 1
for idx, s in bm25_results:
key = chunks[idx][:50]
score_map[key] = score_map.get(key, 0) + (1 - alpha) * s / max_bm
# 取分数最高的top_k个,回填原文
ranked = sorted(score_map.items(), key=lambda x: x[1], reverse=True)[:top_k]
content_map = {c[:50]: c for c in chunks}
return [{"content": content_map.get(k, k), "score": s} for k, s in ranked]
|
混合检索的alpha参数控制语义和关键词的权重,一般0.5起步,根据实际效果调整。如果业务里专有名词多,把alpha调低(偏关键词);如果提问多为口语化意图,把alpha调高(偏语义)。
8.6 RAG-Agent集成
前面我们搭建了检索能力,但检索本身是被动的——需要用户主动调用。真正的RAG Agent应该自主判断何时检索、检索什么、如何用结果。思路是:把检索封装成一个工具,交给Agent按需调用。
举个例子:用户问"今天天气怎么样"——这是通用知识,Agent不需要检索公司知识库,直接回答即可;但如果问"公司报销流程是什么"——这是专属知识,Agent必须先检索再答。让Agent自己判断该不该查、查什么,才是真正的"智能",否则每次都查一遍库既慢又浪费。
💡 小贴士:为什么用工具模式而非"每次都检索"?
有些RAG实现是"无脑检索"——不管用户问什么都先查一遍库。这有两个问题:一是慢,每次回答多一个检索环节;二是噪声,通用问题(如"1+1等于几")检索到的文档反而干扰回答。工具模式让Agent像人一样判断"这个问题需不需要翻书",更自然也更高效。
8.6.1 把检索做成Agent工具
下面这份代码是本章的"集大成者":它定义了一个search_knowledge_base工具,并实现了一个Agent循环——LLM自主决定是否调用工具、调用几次,拿到结果后组织最终回答。注意Agent循环里工具调用的消息收发顺序,这是Function Calling的核心机制。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
|
# rag_agent.py —— 完整的RAG增强Agent
import json
from openai import OpenAI
client = OpenAI() # 自动读取环境变量OPENAI_API_KEY
LLM_MODEL = "gpt-5.4"
# 工具定义:让Agent可以调用知识库检索
tools = [
{
"type": "function",
"function": {
"name": "search_knowledge_base",
"description": "从知识库中检索与用户问题相关的文档片段。当用户询问公司政策、产品文档、内部流程等专属知识时调用此工具。",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "检索查询词,应聚焦于用户问题的核心关键词",
},
"top_k": {
"type": "integer",
"description": "返回的文档片段数量,默认5",
"default": 5,
},
},
"required": ["query"],
},
},
}
]
def execute_tool(tool_name: str, args: dict, store, reranker) -> str:
"""执行工具调用,返回结果字符串"""
if tool_name == "search_knowledge_base":
query = args["query"]
top_k = args.get("top_k", 5)
# 先粗检索,召回较多候选
candidates = store.search(query, top_k=top_k * 3)
# 再重排序精炼
top_results = rerank(query, candidates, top_k=top_k)
# 拼成文本供Agent阅读
lines = []
for i, r in enumerate(top_results, 1):
source = r.get("metadata", {}).get("source", "未知")
lines.append(f"[{i}] (来源: {source})\n{r['content']}")
return "\n\n".join(lines) if lines else "未检索到相关文档。"
return "未知工具"
def run_rag_agent(question: str, store, reranker, history: list = None) -> str:
"""运行RAG增强Agent:自主决定是否检索"""
system_prompt = """你是一个知识库问答助手。你可以调用search_knowledge_base工具检索知识库。
规则:
1. 当问题涉及公司专属知识(政策、产品、流程、文档内容)时,必须先检索再回答。
2. 回答必须基于检索到的文档内容,不要编造。
3. 如果检索结果与问题无关,诚实告知用户知识库中没有相关信息。
4. 回答时引用来源,格式如:(来源: xxx.pdf)"""
messages = [{"role": "system", "content": system_prompt}]
if history:
messages.extend(history)
messages.append({"role": "user", "content": question})
# Agent循环:最多3轮工具调用
for _ in range(3):
resp = client.chat.completions.create(
model=LLM_MODEL,
messages=messages,
tools=tools,
temperature=0.3,
)
msg = resp.choices[0].message
# 如果没有工具调用,说明Agent已准备好最终回答
if not msg.tool_calls:
return msg.content
# 处理工具调用
messages.append(msg) # 把assistant消息(含tool_calls)加入历史
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
print(f"🔧 Agent调用工具: {tc.function.name}({args})")
result = execute_tool(tc.function.name, args, store, reranker)
# 把工具结果喂回给Agent
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result,
})
return "Agent处理轮次超限,请简化问题后重试。"
|
💡 小贴士:为什么Agent循环要限轮次?
Agent可能陷入"检索→不满意→再检索→还不满意"的死循环。设上限(这里3轮)能兜底,避免烧token烧到天荒地老。生产环境还可加超时和成本上限双重保险。
8.6.2 运行RAG Agent
把前面所有模块串起来跑一次。首次运行会加载文档建库,之后直接复用已持久化的向量库:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
if __name__ == "__main__":
# 1. 初始化向量库并灌入文档
store = VectorStore(persist_path="./company_kb")
if len(store.collection.get()["ids"]) == 0:
# 首次运行:加载文档
raw = load_document("员工手册.pdf")
chunks = RecursiveTextSplitter(500, 50).split_text(raw)
metas = [{"source": "员工手册.pdf", "chunk_index": i} for i in range(len(chunks))]
store.add_documents(chunks, metadatas=metas)
# 2. 初始化重排序器
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
# 3. 提问
answer = run_rag_agent(
question="公司年假最多能休几天?需要提前多久申请?",
store=store,
reranker=reranker,
)
print("🤖 回答:", answer)
|
运行时你会看到Agent先调用search_knowledge_base检索,拿到文档片段后再组织回答,并标注来源。整个过程对用户透明——用户只管提问,Agent自主完成"查+答"。
8.7 RAG系统评估
RAG系统不是搭完就万事大吉,必须量化评估才能持续优化。评估分两个维度:检索质量和生成质量。
💡 小贴士:为什么要评估?
很多团队搭完RAG就直接上线,结果用户反馈"答非所问"却不知从何改起。评估的价值在于"定位病因":是检索没召回正确文档(检索问题),还是召回了但生成时没用上(生成问题)?两个问题对应的优化方向完全不同。有指标才能对症下药。
8.7.1 检索质量评估
准备一批标注数据:每个问题对应一个"正确文档"集合,然后看检索结果命中多少。
- 召回率(Recall):相关文档是否被检索到,
命中数 / 应命中数
- 准确率(Precision):检索结果中有多少是相关的,
命中数 / 检索总数
- MRR(Mean Reciprocal Rank):第一个相关结果的平均排名倒数
💡 小贴士:什么是MRR?
MRR衡量"第一个正确答案排第几"。如果第一个相关结果排在第1位,得分1;排第3位,得分1/3。所有问题取平均就是MRR。MRR越高,说明用户越快能看到正确答案。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
# evaluation.py —— 检索质量评估
def evaluate_retrieval(store, test_cases: list, top_k: int = 5):
"""
test_cases: [{"question": "...", "relevant_ids": ["产品手册.pdf#3", "产品手册.pdf#7"]}, ...]
"""
total_recall = 0
total_precision = 0
total_mrr = 0
for case in test_cases:
results = store.search(case["question"], top_k=top_k)
# 拼出检索到的文档标识:来源#chunk序号
retrieved_ids = [r["metadata"].get("source", "") + f"#{r['metadata'].get('chunk_index', '')}" for r in results]
relevant = set(case["relevant_ids"])
retrieved = set(retrieved_ids)
# 召回率
hits = relevant & retrieved
recall = len(hits) / len(relevant) if relevant else 0
total_recall += recall
# 准确率
precision = len(hits) / len(retrieved) if retrieved else 0
total_precision += precision
# MRR:第一个相关结果的排名倒数
mrr = 0
for i, rid in enumerate(retrieved_ids):
if rid in relevant:
mrr = 1 / (i + 1)
break
total_mrr += mrr
n = len(test_cases)
return {
"recall": total_recall / n,
"precision": total_precision / n,
"mrr": total_mrr / n,
}
|
8.7.2 生成质量评估
生成质量更难量化,常用LLM-as-Judge:让另一个LLM按维度打分。核心维度:
- 相关性(Relevance):回答是否切题
- 准确性(Faithfulness):回答是否忠于检索文档,没有幻觉
- 完整性(Completeness):是否覆盖了问题所有要点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
def evaluate_generation(question: str, answer: str, context: str) -> dict:
"""用LLM对生成回答打分(1-5分)"""
judge_prompt = f"""请对以下RAG系统的回答打分(1-5分),评估三个维度:
1. 相关性:回答是否切题
2. 准确性:回答是否忠于参考文档,无编造
3. 完整性:是否完整回答了问题
问题:{question}
参考文档:{context}
回答:{answer}
请输出JSON格式:{{"relevance": x, "faithfulness": x, "completeness": x, "reason": "..."}}"""
resp = client.chat.completions.create(
model=LLM_MODEL,
messages=[{"role": "user", "content": judge_prompt}],
response_format={"type": "json_object"},
temperature=0,
)
return json.loads(resp.choices[0].message.content)
|
注意:evaluate_generation用到的client、LLM_MODEL、json需要从rag_agent模块导入,实际项目中建议把通用配置抽到一个config.py里统一管理,各模块引用即可。
8.7.3 常见问题与优化方向
| 问题 |
表现 |
优化方向 |
| 检索不到 |
召回率低 |
增大top_k、改用多查询、调整分块大小 |
| 检索到但不相关 |
准确率低 |
加重排序、缩小top_k、优化embedding模型 |
| 回答有幻觉 |
准确性低 |
强化提示词约束、要求标注来源、降低temperature |
| 回答不完整 |
完整性低 |
增加上下文量、改用Map-Reduce处理多文档 |
| 回答太啰嗦 |
相关性低 |
提示词约束简洁、加摘要步骤 |
一个实用的优化闭环:构建评测集 → 跑baseline → 改一个变量 → 再跑 → 看指标变化。切忌同时改多个地方,否则无法归因。
💡 小贴士:评测集怎么搭?
找20-50个真实用户问题,人工标注每个问题的"正确文档"和"标准答案"。这份小数据集就是你的"体检表"。每次改了分块策略、embedding模型或检索参数后,都跑一遍评测集看指标升降。没有评测集的优化等于蒙眼调参——改了也不知道是变好还是变差。
8.8 小结
本章我们从零构建了完整的RAG增强Agent,核心要点回顾:
- RAG = 检索 + 生成,让Agent无需训练即可拥有专属知识,知识可实时更新、可溯源。就像开卷考试——不必背书,但要会翻书
- 文档处理是地基,分块质量直接决定检索上限,递归分块兼顾语义和长度
- 向量库是核心存储,ChromaDB轻量易用,配合
text-embedding-3-small性价比高
- 高级检索提升精度,多查询扩召回、重排序提精度、混合检索补字面匹配
- Agent自主检索,把检索做成工具,让Agent按需调用,而非每次都查
- 评估驱动优化,用召回率/准确率/MRR量化检索,用LLM-as-Judge量化生成
到这里,我们的Agent已经具备了:自主推理(第6章)、工具调用(第7章)、知识检索(本章)。一个强大的单体Agent已经成型。但现实中的复杂任务,往往一个Agent搞不定——需要多个Agent分工协作。下一章我们进入多智能体协作的世界。
8.9 预告第9章:多智能体协作
当任务复杂到单个Agent的上下文装不下、单一角色难以兼顾时,我们需要多个Agent像团队一样协作:有的负责规划、有的负责执行、有的负责审查。第9章将探讨:
- 多智能体架构模式(层级式/对等式/流水线式)
- Agent间通信协议与消息传递
- 群体涌现行为与共识机制
- 实战:搭建一个"研发团队"多Agent系统
从单兵作战到团队协作,Agent的能力边界将再次拓展。