Agent RAG

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

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

这就是常说的幻觉

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

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

可以拆成三步理解:

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

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

RAG 解决什么问题

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

txt 复制代码
用户问题 -> 模型 -> 答案

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

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

所以 RAG 适合这些场景:

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

它不适合解决所有问题。

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

向量和相似度

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

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

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

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

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

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

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

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

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

txt 复制代码
夹角越小:越相似
夹角越大:越不相似

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

Embedding 模型

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

txt 复制代码
文本 -> Embedding Model -> 向量

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

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

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

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

内存向量库 InMemoryVectorStore

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

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

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

先来安装依赖:

bash 复制代码
uv add langchain-core langchain-openai

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

python 复制代码
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",
        },
    ),
]

然后创建向量库并检索:

python 复制代码
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:

txt 复制代码
文档 -> 向量库
问题 -> 检索相关文档
相关文档 + 问题 -> prompt
prompt -> 模型回答

Loader 和 Splitter

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

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

txt 复制代码
原始文件 -> Loader -> Document[]
Document[] -> Splitter -> 小块 Document[]
小块 Document[] -> Embedding -> 向量
向量 -> Vector Store

例如用网页 loader 读取页面:

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

bash 复制代码
uv add langchain-community beautifulsoup4
python 复制代码
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

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

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

这里会用到文本切分器:

bash 复制代码
uv add langchain-text-splitters
python 复制代码
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 控制相邻块之间重复多少内容。

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

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

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

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

txt 复制代码
chunk_overlap = chunk_size 的 10% 到 30%

Splitter 类型

LangChain 里常见的 splitter 有这些:

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

多数业务先用 RecursiveCharacterTextSplitter 就够了。

Token 分割

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

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

bash 复制代码
uv add tiktoken
python 复制代码
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

python 复制代码
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 计算长度:

python 复制代码
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:

python 复制代码
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 做得好不好,通常不只取决于模型。

更关键的是这些环节:

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

常见问题:

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

小结

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

  1. RAG 是检索增强生成,不是让模型永久记住资料。
  2. Embedding 模型负责把文本转成向量。
  3. 向量库负责按语义相似度找资料。
  4. Loader 把原始资料变成 Document。
  5. Splitter 把大文档切成适合检索的小块。
  6. InMemoryVectorStore 适合学习和本地 demo,生产环境需要换成持久化向量库。
  7. RAG 的质量主要取决于资料、切块、检索和 prompt,而不是只取决于模型。
相关推荐
copyer_xyf1 小时前
【RAG】向量数据库:milvus
后端·python·agent
铁皮饭盒1 小时前
Bun 哪比 Node.js 快?
javascript·后端
Artech1 小时前
[MAF预定义的AIContextProvider-03]ChatHistoryMemoryProvider——赋予Agent从经验中学习的能力
ai·c#·agent·memory·maf
copyer_xyf2 小时前
Agent 记忆管理
后端·python·agent
葫芦和十三8 小时前
图解 MongoDB 02|BSON:你以为存的是 JSON,其实是带类型的二进制
后端·mongodb·agent
葫芦和十三8 小时前
图解 MongoDB 01|文档数据库
后端·mongodb·agent
runnerdancer10 小时前
LLM是怎么处理messages数组的,提示词缓存又是什么
前端·agent
陈随易10 小时前
VSCode的Copilot扩展支持接入DeepSeek,Kimi了!
前端·后端·程序员
我不是外星人12 小时前
有了 Harness Engineering ,真的还需要研发工程师吗?
前端·后端·ai编程