Contents

RAG增强检索:知识库驱动的智能体

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系统分两个阶段:

离线阶段(索引构建)——“给课本做索引”

  1. 文档加载:从PDF、Markdown、网页等来源读取原始文本
  2. 分块(Chunking):把长文档切成语义连贯的小片段
  3. Embedding:用嵌入模型把每个文本块转成向量
  4. 存储:把向量和元数据写入向量数据库

在线阶段(检索生成)——“翻书找答案再作答”: 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-m3e5-large(可本地部署,免费但需GPU)。对中文场景,bge-m3text-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用到的clientLLM_MODELjson需要从rag_agent模块导入,实际项目中建议把通用配置抽到一个config.py里统一管理,各模块引用即可。

8.7.3 常见问题与优化方向

问题 表现 优化方向
检索不到 召回率低 增大top_k、改用多查询、调整分块大小
检索到但不相关 准确率低 加重排序、缩小top_k、优化embedding模型
回答有幻觉 准确性低 强化提示词约束、要求标注来源、降低temperature
回答不完整 完整性低 增加上下文量、改用Map-Reduce处理多文档
回答太啰嗦 相关性低 提示词约束简洁、加摘要步骤

一个实用的优化闭环:构建评测集 → 跑baseline → 改一个变量 → 再跑 → 看指标变化。切忌同时改多个地方,否则无法归因。

💡 小贴士:评测集怎么搭? 找20-50个真实用户问题,人工标注每个问题的"正确文档"和"标准答案"。这份小数据集就是你的"体检表"。每次改了分块策略、embedding模型或检索参数后,都跑一遍评测集看指标升降。没有评测集的优化等于蒙眼调参——改了也不知道是变好还是变差。

8.8 小结

本章我们从零构建了完整的RAG增强Agent,核心要点回顾:

  1. RAG = 检索 + 生成,让Agent无需训练即可拥有专属知识,知识可实时更新、可溯源。就像开卷考试——不必背书,但要会翻书
  2. 文档处理是地基,分块质量直接决定检索上限,递归分块兼顾语义和长度
  3. 向量库是核心存储,ChromaDB轻量易用,配合text-embedding-3-small性价比高
  4. 高级检索提升精度,多查询扩召回、重排序提精度、混合检索补字面匹配
  5. Agent自主检索,把检索做成工具,让Agent按需调用,而非每次都查
  6. 评估驱动优化,用召回率/准确率/MRR量化检索,用LLM-as-Judge量化生成

到这里,我们的Agent已经具备了:自主推理(第6章)、工具调用(第7章)、知识检索(本章)。一个强大的单体Agent已经成型。但现实中的复杂任务,往往一个Agent搞不定——需要多个Agent分工协作。下一章我们进入多智能体协作的世界。

8.9 预告第9章:多智能体协作

当任务复杂到单个Agent的上下文装不下、单一角色难以兼顾时,我们需要多个Agent像团队一样协作:有的负责规划、有的负责执行、有的负责审查。第9章将探讨:

  • 多智能体架构模式(层级式/对等式/流水线式)
  • Agent间通信协议与消息传递
  • 群体涌现行为与共识机制
  • 实战:搭建一个"研发团队"多Agent系统

从单兵作战到团队协作,Agent的能力边界将再次拓展。