Agent RAG

0 阅读13分钟

模型本身只知道训练时见过的知识。

如果你问它最近发生的事情,或者企业内部文档里的内容,它通常并不知道。更麻烦的是,它有时不会直接说“不知道”,而是编一个看起来很像真的答案。

这就是常说的幻觉

RAG 要解决的核心问题就是:回答前先检索资料,再让模型基于资料生成答案。

RAG 全称是 Retrieval-Augmented Generation,中文通常叫“检索增强生成”。

可以拆成三步理解:

Retrieval:先从外部知识库检索相关资料
Augmented:把资料和用户问题一起放进 prompt
Generation:模型基于资料生成回答

rag_01.png

先记住一句话:RAG 不是让模型记住所有资料,而是在模型回答前,把相关资料找出来塞给它。

RAG 解决什么问题

普通模型调用大概是这样:

用户问题 -> 模型 -> 答案

RAG 调用会多一个检索环节:

用户问题 -> 检索知识库 -> 拼接上下文 -> 模型 -> 答案

所以 RAG 适合这些场景:

  • 企业内部制度问答。
  • 产品手册问答。
  • 客服知识库。
  • 文档、论文、电子书问答。
  • 历史聊天记录检索。

它不适合解决所有问题。

如果问题本身不依赖外部资料,直接调用模型就够了。如果资料质量很差,RAG 也只会把差资料更快地送给模型。

向量和相似度

RAG 最难的地方不是“把资料塞进 prompt”,而是“怎么找到相关资料”。

关键词搜索只看字面匹配:

问题:员工吃饭报销超过 200 怎么办?
文档:餐饮类报销单次金额超过 200 元需要直属主管审批。

这两个句子字面不完全一样,但语义是相关的。

所以 RAG 通常使用语义搜索。语义搜索的底层依赖向量。

向量可以理解成一组数字:

"餐饮报销" -> [0.12, -0.38, 0.76, ...]
"吃饭费用" -> [0.11, -0.35, 0.72, ...]

如果两个文本意思接近,它们的向量方向通常也会接近。

余弦相似度就是用向量夹角判断相似程度:

夹角越小:越相似
夹角越大:越不相似

rag_02.png

二维图只是为了方便理解。真实 embedding 通常是几百维、上千维,无法画出来,但数学上仍然可以计算相似度。

Embedding 模型

Embedding 模型负责把文本转成向量。

文本 -> Embedding Model -> 向量

rag_03.png

RAG 里有两个地方会用到 embedding:

1. 建库时:把文档切块,并转成向量存起来
2. 提问时:把用户问题转成向量,去向量库里找相似文档

注意一个关键点:文档向量和问题向量要使用同一个 embedding 模型。

如果建库时用 A 模型,查询时用 B 模型,向量空间可能不一致,相似度就没有意义。

内存向量库 InMemoryVectorStore

先用内存向量库看完整流程。

InMemoryVectorStore 适合学习和本地 demo。它把向量放在内存里,进程结束数据就没了。

优点:不用启动数据库,代码简单
缺点:不能持久化,不适合生产

先来安装依赖:

uv add langchain-core langchain-openai

先准备模型、embedding 和几份文档:

import os

from langchain_core.documents import Document
from langchain_openai import ChatOpenAI, OpenAIEmbeddings


model = ChatOpenAI(
    # RAG 场景通常希望答案稳定、少发挥;创造性需求再调高 temperature。
    temperature=0,
    model=os.environ["AI_MODEL"],
    api_key=os.environ["AI_KEY"],
    base_url=os.environ["AI_BASE_URL"],
)

embeddings = OpenAIEmbeddings(
    # 建库和查询必须使用同一个 embedding 模型,否则向量空间不一致。
    model=os.environ["AI_EMBEDDING_MODEL"],
    api_key=os.environ["AI_KEY"],
    base_url=os.environ["AI_BASE_URL"],
)

# 定义一些文档,模拟知识库
documents = [
    Document(
        page_content="""
星河制造的日常费用报销分为办公采购、差旅费用和招待费用三类。
员工提交报销申请时,必须填写费用类型、用途、发生日期和金额。
餐饮类报销单次金额超过 200 元需要直属主管审批;超过 1000 元需要部门负责人审批。
所有报销必须在消费发生后的 30 天内提交,超期申请默认退回。
电子发票和纸质发票都可以使用,但票据信息必须完整可核验。
        """.strip(),
        metadata={
            # metadata 不参与语义生成,但会在检索后帮助展示来源、做过滤和排查召回问题。
            "id": "doc-expense-policy",
            "title": "报销制度",
            "topic": "expense",
        },
    ),
    Document(
        page_content="""
星河制造当前主推两款工业传感器。
HX-100 面向中小型工厂,特点是部署成本低、功耗低、维护简单,适合基础温湿度监控。
HX-300 面向高要求产线,支持更高频率的数据采样,并带有异常波动预警能力。
两款产品都支持通过标准 API 接入企业内部系统,但 HX-300 在并发数据上传能力上更强。
        """.strip(),
        metadata={
            "id": "doc-product-intro",
            "title": "产品资料",
            "topic": "product",
        },
    ),
    Document(
        page_content="""
星河制造为所有正式销售的硬件产品提供 1 年标准保修服务。
HX-300 的企业版客户在签署增值服务协议后,可以获得 2 年延长保修和 7x12 小时技术支持。
若设备因人为损坏、非授权拆机或外部电力事故导致故障,不在免费保修范围内。
客户提交售后工单时,需要提供设备序列号、购买时间和故障现象说明。
        """.strip(),
        metadata={
            "id": "doc-after-sale",
            "title": "售后承诺",
            "topic": "service",
        },
    ),
]

然后创建向量库并检索:

from langchain_core.vectorstores import InMemoryVectorStore


# 创建库(传入文档和嵌入模型)
vector_store = InMemoryVectorStore.from_documents(
    documents,
    embedding=embeddings,
)

question = "餐饮报销超过 200 元需要谁审批?"

# 普通 RAG 只需要文档时,用 similarity_search(question, k=2) 就够了。
# 这里为了教学和调试召回质量,使用 with_score 版本额外拿到相似度分数。
# with_score 会额外返回相似度分数,方便观察检索排序和排查召回质量。
# 真正交给模型的 context 通常不需要包含 score。
results = vector_store.similarity_search_with_score(question, k=2)

# 检索结果不能原样塞给模型,需要先整理成适合 prompt 阅读的上下文文本。
# item 的结构是 (Document, score):Document 给模型用,score 给开发者判断召回质量。
def format_retrieved_document(item, index: int) -> str:
    doc, score = item

    # score 留给日志和调试;context 只放模型回答需要的来源和正文。
    print(f"资料 {index + 1} 相似度:{score:.4f}")

    return f"""资料 {index + 1}
标题:{doc.metadata["title"]}
内容:{doc.page_content}"""


context = "\n\n".join(
    format_retrieved_document(item, index)
    for index, item in enumerate(results)
)

prompt = f"""
你是一个 RAG 学习案例助手。
请严格根据提供的资料回答问题,不要补充资料中没有的信息。
如果资料中找不到答案,请直接回答"根据当前知识库,无法回答这个问题"。

问题:
{question}

资料:
{context}
"""

result = model.invoke(prompt)
print(result.content)

这段代码里,Document(...) 只是把原始文本整理成 LangChain 认识的文档对象。真正的正文放在 page_content,而标题、分类、来源这类辅助信息放在 metadata

InMemoryVectorStore.from_documents(documents, embedding=embeddings) 会做两件事:先用 embedding 模型把每个文档转成向量,再把这些向量放进内存向量库。也就是说,这一步同时完成了“向量化”和“建库”。

检索时有两个常用 API。普通 RAG 只需要文档,直接用 similarity_search(question, k=2) 就够了。如果你想观察召回质量,再用 similarity_search_with_score(question, k=2),它会额外返回相似度分数。分数主要给开发者看,不是必须放进 prompt。

最后,model.invoke(prompt) 才是生成阶段:把用户问题和检索到的资料一起交给模型,让模型基于资料回答。

这就是最小 RAG:

文档 -> 向量库
问题 -> 检索相关文档
相关文档 + 问题 -> prompt
prompt -> 模型回答

Loader 和 Splitter

刚才的 demo 里,文档是手动写成 Document 的。

真实项目里,资料通常来自网页、PDF、Word、Markdown、数据库、对象存储。它们需要先变成统一的 Document

原始文件 -> Loader -> Document[]
Document[] -> Splitter -> 小块 Document[]
小块 Document[] -> Embedding -> 向量
向量 -> Vector Store

rag_04.png

例如用网页 loader 读取页面:

这里会用到网页 loader 和 HTML 解析器:

uv add langchain-community beautifulsoup4
from langchain_community.document_loaders import WebBaseLoader


# WebBaseLoader 会拿到网页正文和部分 metadata;真实项目通常还要额外做去噪和正文抽取。
loader = WebBaseLoader("https://example.com/article")
loaded_documents = loader.load()

print(loaded_documents[0].page_content)
print(loaded_documents[0].metadata)

这里的 WebBaseLoader 负责读取网页,并把网页内容转换成 LangChain 标准的 Document 列表。

loader.load() 才是真正执行加载的动作。返回结果里的每一项都是一个 Document,正文在 page_content,来源 URL 等信息在 metadata

真实项目里,网页加载后通常还要做正文抽取和去噪,否则导航栏、评论区、推荐列表可能会进入知识库。

如果文档很长,不能整篇塞进向量库,通常需要切块:

这里会用到文本切分器:

uv add langchain-text-splitters
from langchain_text_splitters import RecursiveCharacterTextSplitter


splitter = RecursiveCharacterTextSplitter(
    # 这里的单位默认是字符数,不是 token。中文场景要结合模型上下文再调。
    chunk_size=400,
    # overlap 是用存储成本换语义连续性,适合答案可能跨段落的资料。
    chunk_overlap=50,
    # 分隔符从强语义边界到弱边界排列,最后的空字符串表示实在不行再硬切。
    separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
)

split_documents = splitter.split_documents(loaded_documents)
print(len(split_documents))

RecursiveCharacterTextSplitter 是通用首选的文本切分器。它不会一上来就硬切字符,而是按照 separators 从前到后尝试切分:先按段落,再按换行,再按句号、逗号、空格,最后实在不行才硬切。

chunk_size 是每个 chunk 的目标大小。默认情况下它按字符数计算,不是 token 数。chunk_overlap 是相邻 chunk 之间保留的重复内容,用来减少“答案刚好被切断”的问题。

split_documents(...) 接收一批 Document,返回切好的小 Document 列表。原文的 metadata 会继续跟着每个 chunk,后面做来源追踪时很重要。

chunk_size 控制每块大小。

chunk_overlap 控制相邻块之间重复多少内容。

rag_05.png

overlap 的作用是保留上下文连续性。

rag_06.png

如果完全没有 overlap,某个答案可能刚好被切在两个块中间,检索时只拿到半句话。

但是 overlap 也不是越大越好。它会增加存储量、embedding 成本和检索噪声。

通常可以先从这个范围开始:

chunk_overlap = chunk_size 的 10% 到 30%

Splitter 类型

LangChain 里常见的 splitter 有这些:

from langchain_text_splitters import (
    CharacterTextSplitter,
    Language,
    MarkdownTextSplitter,
    RecursiveCharacterTextSplitter,
    TokenTextSplitter,
)

rag_07.png

分割器核心依据推荐场景特点
RecursiveCharacterTextSplitter多种分隔符递归尝试通用文本、网页、PDF首选,尽量保留语义
CharacterTextSplitter单一分隔符格式非常稳定的文本简单,但容易切断语义
TokenTextSplitterToken 数量严格控制上下文成本精准,但可能切断句子
MarkdownTextSplitterMarkdown 结构技术文档、博客更尊重标题结构

多数业务先用 RecursiveCharacterTextSplitter 就够了。

Token 分割

模型按 token 计费,也按 token 限制上下文长度。

在 Python 里,可以用 tiktoken 估算 OpenAI 系列模型的 token 数。

uv add tiktoken
import tiktoken


enc = tiktoken.encoding_for_model("gpt-4")


def count_tokens(text: str) -> int:
    return len(enc.encode(text))


print(count_tokens("apple"))
print(count_tokens("苹果"))

# 如果模型没有明确对应关系,也可以直接使用 cl100k_base。
cl100k = tiktoken.get_encoding("cl100k_base")
print(len(cl100k.encode("RAG 是检索增强生成")))

encoding_for_model("gpt-4") 会根据模型名选择对应的 token 编码器。这样算出来的 token 数更接近实际模型计费和上下文占用。

如果你使用的模型没有明确映射,可以直接用 get_encoding("cl100k_base") 做估算。很多 OpenAI 兼容模型也会用接近的编码方式。

enc.encode(text) 会把文本变成 token id 数组。数组长度就是 token 数,所以常见写法是 len(enc.encode(text))

也可以直接用 TokenTextSplitter

from langchain_core.documents import Document
from langchain_text_splitters import TokenTextSplitter


document = Document(
    page_content="""
FastAPI 是一个用于构建 API 的现代 Python Web 框架。
它基于类型注解,适合构建高性能服务,也常用于 AI 应用后端。
    """.strip()
)

splitter = TokenTextSplitter(
    # TokenTextSplitter 适合强控成本;缺点是可能把句子从中间切开。
    chunk_size=50,
    # token overlap 通常比字符 overlap 更接近真实上下文成本。
    chunk_overlap=10,
    # encoding_name 要和目标模型尽量匹配;不确定时先用 cl100k_base 做估算。
    encoding_name="cl100k_base",
)

chunks = splitter.split_documents([document])

for chunk in chunks:
    print(chunk.page_content)

TokenTextSplitter 是按 token 数切块的 splitter。它适合你必须严格控制上下文成本的场景,比如每个 chunk 最多只能占 500 tokens。

encoding_name 决定 token 怎么计算,应该尽量贴近你实际调用的模型。split_documents(...) 会按这个 token 预算输出多个 Document chunk。注意它更重视 token 数,不一定总能保住完整语义边界。

也可以让 RecursiveCharacterTextSplitter 按 token 计算长度:

import tiktoken
from langchain_text_splitters import RecursiveCharacterTextSplitter


enc = tiktoken.get_encoding("cl100k_base")

splitter = RecursiveCharacterTextSplitter(
    # 使用 token 作为 length_function 后,chunk_size 就从“字符数”变成“token 数”。
    chunk_size=100,
    chunk_overlap=20,
    separators=["\n\n", "\n", "。", ",", " ", ""],
    # 这种写法兼顾语义边界和 token 预算,通常比纯 TokenTextSplitter 更适合中文资料。
    length_function=lambda text: len(enc.encode(text)),
)

这种方式通常更稳:尽量按语义切,又能控制 token 上限。

切代码

如果资料是代码,可以使用语言感知的 splitter:

from langchain_core.documents import Document
from langchain_text_splitters import Language, RecursiveCharacterTextSplitter


code = """
class ShoppingCart:
    def __init__(self):
        self.items = []

    def add_item(self, product, quantity=1):
        self.items.append({"product": product, "quantity": quantity})
"""

document = Document(
    page_content=code,
    metadata={"source": "shopping_cart.py"},
)

splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON,
    # 代码切块要优先保护函数、类、作用域边界,避免检索到无法独立理解的半段逻辑。
    chunk_size=300,
    chunk_overlap=60,
)

chunks = splitter.split_documents([document])
print(chunks)

代码 RAG 最怕随便按字符切,因为函数、类、注释可能被切散。

RAG 的关键点

RAG 做得好不好,通常不只取决于模型。

更关键的是这些环节:

资料是否干净
切块是否合理
Embedding 模型是否合适
检索结果是否相关
Prompt 是否要求模型忠于资料

常见问题:

问题可能原因
答非所问检索结果不相关
找不到答案chunk 太大、太小,或 embedding 不合适
回答编造prompt 没要求基于资料回答
成本太高chunk 太碎、overlap 太大、topK 太高

小结

这一篇要记住几个核心点:

  1. RAG 是检索增强生成,不是让模型永久记住资料。
  2. Embedding 模型负责把文本转成向量。
  3. 向量库负责按语义相似度找资料。
  4. Loader 把原始资料变成 Document。
  5. Splitter 把大文档切成适合检索的小块。
  6. InMemoryVectorStore 适合学习和本地 demo,生产环境需要换成持久化向量库。
  7. RAG 的质量主要取决于资料、切块、检索和 prompt,而不是只取决于模型。