Agent 开发学习笔记(七):RAG: 从建库到检索到生成

0 阅读47分钟

Agent 开发学习笔记(七):RAG: 从建库到检索到生成

前言

写到这里,前面六篇已经把 LangChain 的"零件"讲得差不多了:Model 篇讲了怎么和模型对话,Parser 篇讲了怎么把模型输出变成结构化数据,LCEL 篇讲了怎么用 | 把零件串起来。

但有一个问题一直悬着:模型不知道的东西,怎么让它知道?

你问它公司内部的产品手册,它不知道;你问它 2025 年的新政策,它不知道;你问它你自己的笔记,它更不知道。这不是模型笨,是它的训练数据里根本没有这些。

RAG(Retrieval-Augmented Generation,检索增强生成)就是解决这个问题的。 在模型生成回答前,先去外部资料库里检索相关内容,拼进 Prompt,再让模型基于这些内容回答。

这一篇要回答的核心问题就是:

模型不知道的东西,怎么让它知道?

拆开就是一条流水线:

问题落点
RAG 是什么?为什么需要?概念
文档从哪来?文档加载器
文档太长怎么办?文本分割器
文本怎么变成可检索的?Embedding
存到哪?向量数据库(FAISS / ChromaDB)
怎么查?检索
怎么和模型串起来?应用 / LCEL

和前后篇的关系

  • 前面 LCEL 篇讲了 |,这一篇组装 RAG 链路时会大量用到,但不再展开,标注"详见《LCEL》篇"
  • 前面 Parser 篇讲了输出解析,这一篇直接用 StrOutputParser,不重复
  • 后面 Agent 篇会讲:检索只是 Agent 的一种能力,Agent 还能调用工具、多步推理。这一篇末尾会指向 Agent 篇

这一篇的写法:按逻辑顺序排序——先讲清楚 RAG 是什么、为什么需要,再按"建库 → 检索 → 生成"的流水线一段段走。每一节都是一个小零件,串起来就是完整的 RAG。

1. RAG 是什么:模型不知道的东西,怎么让它知道

1.1 什么是 RAG

RAG 是 Retrieval-Augmented Generation 的缩写,中文叫检索增强生成

拆开看这三个词:

含义
Retrieval检索:从外部资料库里找出和问题相关的内容
Augmented增强:把这些内容拼进 Prompt,给模型"加料"
Generation生成:模型基于加料后的 Prompt 生成回答

核心逻辑一句话

在大模型生成回答前,先从外部知识库(如本地文档、数据库)中检索与问题相关的上下文,将检索到的信息与用户问题拼接后,再输入大模型生成回答。

打个比方:大模型像一个博学但记性有限的老师,他脑子里有大量通用知识,但没看过你们公司的内部文档。RAG 相当于给他配了一个可查询的资料库——每次回答前,先让他翻一下相关资料,再开口。

完整链路

用户问题
   ↓
【Retrieval】去资料库检索相关片段
   ↓
【Augmented】把片段拼进 Prompt
   ↓
【Generation】LLM 基于 Prompt 生成回答
   ↓
返回给用户

其中 Retrieval 是"前半段",RAG 是"整条链路"。没有 Retrieval,RAG 就退化成普通 LLM 问答;没有 Generation,Retrieval 就只是个搜索。 两者是"零件"和"整机"的关系。


1.2 为什么需要 RAG

大模型有三个天生的短板,RAG 正好补上:

1. 知识滞后

大模型的训练数据截止到某个时间点(比如智谱 GLM-4 截止到 2024 年),之后发生的事它不知道。你问它 2025 年的新政策,它要么说不知道,要么瞎编。

RAG 可以检索实时/最新文档,让回答跟上时间。

2. 私有数据无法调用

大模型没有学习过你的个人笔记、公司内部文档、行业专属资料。你问它"我们产品的退款政策是什么",它不可能知道。

RAG 可以把这些私有数据构建成向量库,让模型基于私有数据回答。这就是企业知识库问答、个人笔记检索的底层原理。

3. 容易产生幻觉

大模型在面对不熟悉的领域时,容易"一本正经地胡说八道"——这就是幻觉。它不是在骗你,是它真的"以为"自己知道。

RAG 通过检索真实存在的上下文,让回答有依据、可追溯,大幅降低幻觉概率。模型不再靠自己"回忆",而是照着检索到的资料回答。

两个概念总结

  • 垂直领域:做的窄、做的精,人群专一。比如猫咪喂养、前端工程、露营装备
  • 私域数据:抓在自己手里,反复利用。比如社群、企微、用户沉淀、复购运营

这两类数据,大模型训练时都见不到,正好是 RAG 的用武之地。


1.3 常见误解:RAG 不是"过时技术"

这一节专门破三个常见误解。因为很多人一听 RAG,第一反应是"这不就是早期技术吗,现在都流行微调/训练了"。

误解一:RAG 是 Agent 的一个方向

错。 RAG 不是 Agent 的一个方向,而是 Agent 开发中最核心、最基础的组件之一

  • RAG:专注于"检索外部信息 + 辅助生成回答",核心是"信息获取与复用",是一个被动的知识工具
  • Agent:具备"自主决策、多工具调用"能力的智能体,核心是"自主行为"

举个例子:一个智能问答 Agent,它的核心模块就是 RAG(负责检索相关知识),同时还会搭配"任务解析""工具选择"模块。当用户提问时,Agent 先判断"是否需要检索知识",如果需要,就调用 RAG 模块检索,再根据检索结果生成回答。

所以:RAG 是 Agent 的基础组件,不是 Agent 的方向。 学 RAG 是入门 Agent 的必经之路。

误解二:RAG 是"早期技术",现在都流行微调/训练

错。 RAG、微调、从头训练,是三个不同层次的手段,解决不同问题,不是新旧替代关系:

手段做什么成本适合场景
RAG模型不动,回答前去资料库查私有数据、知识更新频繁、要可追溯
微调在开源模型基础上,用你的数据再训练中(几张 GPU、几天)固定风格、固定格式、特定任务
从头训练从零训一个大模型极高(千万美元级)只有大厂/研究机构

三者可以叠加使用。很多公司是"微调 + RAG"一起用:微调让模型更懂行业术语,RAG 让它能查到最新资料。但纯 RAG 完全能撑起一个 AI 助手,这是最常见的起点。

误解三:不训练模型就是"落后"

错。 对绝大多数企业来说,RAG 不是"退而求其次",是性价比最高、最该先做的那一步

尤其对只有开发、没有算法团队的团队:

  • 微调需要 GPU、训练框架、标注数据、评估能力——这些不在开发团队的能力半径里
  • 从头训练更不用想,那是大厂和研究机构的事
  • 真实的选择只有两个:开源模型 + RAG,或者开源模型 + 什么都不加(退化成普通 ChatGPT)

没有第三个选项。 用开源模型 + 自己的资料库 + RAG,不是落后,是这个体量下最理性、最主流的做法。

一句话总结:RAG 不是被微调替代的旧技术,它是不同体量、不同场景下的不同工具。对大厂,微调是常规操作;对开发团队,RAG 是唯一可行解。


1.4 RAG 和 Agent 的关系

上一节已经破了一个误解:"RAG 是 Agent 的一个方向"——错,RAG 是 Agent 的基础组件。

这一节把关系讲清楚。

RAG 是什么:专注于"检索外部信息 + 辅助生成回答",核心是"信息获取与复用"。它是一个被动的知识工具——你不问,它不查。

Agent 是什么:具备"自主决策、多工具调用"能力的智能体。核心是"自主行为"——它会自己判断"要不要查""查什么""查完够不够""不够再查什么"。

关系

Agent(自主决策)
   ├── 判断:需要检索知识吗?
   ├── 调用:RAG 模块(检索相关知识)
   ├── 判断:检索结果够吗?
   ├── 调用:其他工具(计算器、数据库、API)
   └── 综合:生成最终回答

RAG 是 Agent 工具箱里的一把工具,而且是最基础的那把。 Agent 还能调用计算器、数据库、搜索引擎,但 RAG 是它获取"知识"的主要途径。

所以这一篇的位置:RAG 是 Agent 的前置知识。学完 RAG,下一篇 Agent 就水到渠成——你会发现,Agent 不过是把 RAG 当成众多工具之一,再加上"自主决策"这一层。

小结

RAG 是"检索增强生成"——模型生成回答前,先从外部资料库检索相关内容,拼进 Prompt,再让模型基于这些内容回答。它解决大模型的三个短板:知识滞后、私有数据无法调用、容易幻觉。

RAG 不是 Agent 的方向,是 Agent 的基础组件。这一篇学的是"怎么让模型知道它不知道的东西",下一篇学的是"怎么让模型自己决定要不要去知道"。

2. 建库:把文档变成可检索的向量

RAG 的整条链路可以用一张图概括: mermaid-diagram-2026-09-19-180506.png

图分两区:

  • 上半区(建库,离线) :源数据 → 加载 → 文本分割 → 转向量 → 存向量库。这一步是一次性的,或者定期更新
  • 下半区(查询,在线) :用户问题 → 转向量 → 检索 → 拼 Prompt → LLM 生成 → 返回回答。这一步每次提问都要走一遍

两区通过向量库连接:建库把向量存进去,查询从里面查出来。

这一节讲上半区(建库) ,下一节讲下半区(查询) ,最后第四节把两区串起来跑通。

建库是一条四步流水线:

文档 → 加载 → 切分 → 转向量 → 存库

每一步对应一个组件:

步骤组件解决什么问题
加载文档加载器文档从哪来
切分文本分割器文档太长怎么办
转向量Embedding 模型文本怎么变成可检索的
存库向量数据库存到哪

下面一节讲一个。每节末尾给一个最小示例,只让读者看一眼"长什么样",不展开;完整可运行的链路留到后面的应用节。


2.1 文档加载器:文档从哪来

RAG 的第一步是拿到数据。数据可能来自 PDF、网页、CSV、代码文件、数据库、S3 存储桶……格式五花八门。

文档加载器(Document Loaders) 就是干这个的:从各种数据源加载数据,统一转换成 LangChain 能处理的格式。

LangChain 内置了 100 多种文档加载器,支持:

  • 文件类型:HTML、PDF、Markdown、CSV、代码文件等
  • 数据源:本地文件、公开网站、S3 存储桶、数据库等
加载器的产出物:Document

不管数据从哪来、什么格式,加载器最终都产出统一的 Document 对象。它包含两部分:

  • page_content:文本内容
  • metadata:元数据(来源、页码、作者等)
from langchain_core.documents import Document

doc = Document(
    page_content="猫喜欢吃什么?",
    metadata={"source": "faq.txt", "page": 1},
)

print(doc.page_content)   # 猫喜欢吃什么?
print(doc.metadata)       # {'source': 'faq.txt', 'page': 1}

后面检索出来的片段、拼进 Prompt 的上下文,都是 Document 对象。取文本用 doc.page_content,取元数据用 doc.metadata日常使用只需要关心 Document

加载器内部的抽象组件

加载器不是单个函数,而是一组组件配合完成的。除了 Document,还有三个:

组件作用
BaseLoader加载器基类,把原始数据转成 Document
Blob二进制数据的表示,可能在文件里,也可能在内存里
BaseBlobParserBlob 解析成 Document

它们的关系

Loader 拿数据 → 变成 Blob → Parser 解析 → 变成 Document

这三个是给自定义加载器的人看的。日常用现成的加载器(TextLoaderDirectoryLoader 等),它们已经把这些封装好了,不用自己写,也不用显式用到 BlobBaseBlobParser

一个自定义加载器的最小例子(不用完全看懂,感受流程就行):

from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document

class MyLoader(BaseLoader):
    def __init__(self, path: str):
        self.path = path

    def lazy_load(self):
        with open(self.path, encoding="utf-8") as f:
            text = f.read()
        yield Document(page_content=text, metadata={"source": self.path})

关键点:自定义加载器必须实现 lazy_load,它是核心方法;大多数情况下直接产出 Document 就行,不一定要显式用到 BlobBaseBlobParser

懒加载:不要一次性把所有文档读进内存

如果资料库有几千个文件,一次性全加载进内存会爆。加载器提供了懒加载机制。

load() vs lazy_load()

方法加载方式返回值
load()一次性全部加载list[Document]
lazy_load()按需逐个产出生成器

注意单位是 Document,不是文件。 一个文件可能产出 0 个、1 个或多个 Document,取决于加载器实现:

加载器一个文件产出for 循环几次
TextLoader1 个 Document1 次
CSVLoader(100 行)100 个 Document100 次
PyPDFLoader(10 页)10 个 Document10 次

doc 永远是单个 Document。一个文件产出几个,体现在循环次数上,不是一次拿多个。

懒加载怎么用

for doc in loader.lazy_load():
    print(doc.page_content)

doc 每轮被重新赋值,旧的如果没被引用,会被垃圾回收。所以通常内存里同时只有一个 Document。

但不是保证。如果你把结果累积到列表里:

results = []
for doc in loader.lazy_load():
    results.append(doc)   # 全存起来,内存里 N 个

就又变回非懒加载了。另外,单个文件必须整个读进来才能解析时(比如某些 PDF),这部分内存懒加载省不了——懒加载省的是"文件之间",省不了"文件内部"。

一句话:懒加载是"按需产出",不是"保证只有一个"。用完就丢才省,累积起来就不省。

懒加载 + 存库:避免内存爆炸的典型组合

懒加载最有价值的用法,是边加载边存库

for doc in loader.lazy_load():
    chunks = splitter.split_documents([doc])
    vectorstore.add_documents(chunks)   # 存完就丢

内存峰值从"所有 Document"降到"通常只有一个 Document"。

但这不是零内存:Embedding 模型本身占内存,向量库写入可能有缓冲,加载器内部实现也可能有额外开销。懒加载解决的是"文档数量多导致的内存爆炸",不解决"模型本身占内存"。

更工程化的做法:Indexing API

上面的 for 循环适合一次性、小规模的场景。但真实工程里有两个问题:

  • 没有状态记录:程序跑完就忘了,下次运行会重新处理所有文档
  • 无法增量更新:只想处理新增/修改的文档,做不到

LangChain 提供了 Indexing API,对"加载 → 切分 → 转向量 → 存库"整个流程做了统一封装,用来做数据源和向量数据库的增量同步。它内部默认用懒加载逐条读取,配合记录管理器记住"哪些文档处理过了、内容变没变",已处理过且没变的直接跳过,只处理新增和修改的。

一句话:Indexing API 是建库流水线的"总调度",管好"哪些处理过了、哪些还没处理"。

这一节知道有这个能力即可。后面的实操仍走手动四步,更直观,也更容易理解每一步在干什么。

最小示例

# 单文件:load() 和 lazy_load() 没区别
from langchain_community.document_loaders import TextLoader
loader = TextLoader("notes.txt", encoding="utf-8")
docs = loader.load()

# 目录(多文件):懒加载才有意义
from langchain_community.document_loaders import DirectoryLoader
loader = DirectoryLoader("./docs", glob="**/*.txt")

# 非懒加载:所有 Document 一次性进内存
docs = loader.load()

# 懒加载:读一个算一个
for doc in loader.lazy_load():
    print(doc.metadata["source"])

2.2 文本分割器:文档太长怎么办

建库流程常被抽象成几步:Source(源)→ Load(加载)→ Transform(转换)→ Embed(嵌入)→ Store(存储)→ Retrieve(检索)

其中 Transform(转换) 这一步,在 RAG 建库里主要做的就是文本分割——把加载进来的长文档切成小块。这一节讲的就是 Transform 这一环。

Embedding(嵌入) :把文本映射成高维向量,语义相近的文本,向量也相近。

加载进来的文档可能很长——一份 PDF 几十页,一篇网页几千字。直接整篇转向量有两个问题:

  1. 语义丢失:一整篇文档的向量是"平均语义",检索时匹配不精准
  2. 超出模型限制:Embedding 模型有最大输入长度(比如 2048 字符),超了要截断

所以要把长文档切分成小块(chunks) 。切分的目标是:每一块语义完整,块与块之间尽量不打断话题。

切分方式一:按字数/标点切

最简单的方式,按固定字数或标点符号切。优点是快,缺点是可能把一句话、一个话题从中间切断

切分方式二:语义分割

更聪明的方式,不靠字数/标点,靠语义判断在哪里切。核心逻辑:

切单句 → 窗口组合 → 算向量距离 → 阈值判断 → 分块

具体走一遍。假设原始长文本是:

人工智能快速发展。大模型改变各行各业。向量数据库是知识库核心。明天早上八点开会。记得带上纸质文件。

步骤 1:切单句

按标点切成单句:

A: 人工智能快速发展。
B: 大模型改变各行各业。
C: 向量数据库是知识库核心。
D: 明天早上八点开会。
E: 记得带上纸质文件。

步骤 2:窗口组合

每个句子和前后邻居组合成一个"窗口"(buffer_size=1 表示前后各取 1 句):

A窗口:A + B
B窗口:A + B + C
C窗口:B + C + D
D窗口:C + D + E

这样每个窗口都带了上下文,不是孤立的一句话。

步骤 3:算向量距离

给每个窗口生成 Embedding,然后算相邻窗口的距离:

  • A窗口 ↔ B窗口:距离小(都是 AI 技术话题)
  • B窗口 ↔ C窗口:距离小(还是 AI 技术话题)
  • C窗口 ↔ D窗口:距离(话题从"技术"跳到"开会")

步骤 4:阈值判断

设一个阈值(比如 95 分位),只有距离超过阈值的才算语义断点。这里只有 C-D 被判为断点。

步骤 5:分块

按断点分组:

块1:A + B + C(AI 技术话题)
块2:D + E(会议通知话题)

核心结论:语义分块不靠字数/标点,靠"窗口上下文 + 向量距离",自动识别话题跳转,实现语义连贯的分块。

一句话:切分的目标不是"切得均匀",是"切得准"——每一块是一个完整的语义单元。

最小示例

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,      # 每块最大字符数
    chunk_overlap=50,    # 相邻块重叠字符数,避免切断语义
)
chunks = splitter.split_documents(docs)

chunk_overlap 的作用:让相邻两块有一部分重叠,检索时不会因为切在句子中间而丢上下文。


2.3 Embedding:文本怎么变成可检索的

切分完,下一步是把文本变成向量

Embedding 模型的作用:把普通文字编码成一串纯数字向量。

核心特性

语义越像,向量越接近;语义无关,向量差距越大。

这就是"可检索"的原理——检索时把问题也转成向量,然后找向量空间里距离最近的文本片段。

向量的维度

向量是一串数字,比如 [0.5, 0.2, ..., 0.9]。这串数字的长度叫维度

  • 2 维、3 维:只能表达简单特征
  • 1536 维:可以精细储存词义、语气、话题、逻辑、上下文风格

维度越高,语义表达越精细,判断两段文字"像不像"就越准。

以通义千问的 Embedding 模型为例:

  • 支持最大 2048 字符的输入长度
  • 生成的向量维度为 1536

2048 字符限制的含义

  • 文本 ≤ 2048:直接整段向量化
  • 文本 > 2048:不能一次性处理,必须截断或拆分后再转

这也是为什么上一节要先切分——切分后的块要控制在模型能处理的长度内。

Embedding 缓存

Embedding 计算是有成本的(要么花钱调 API,要么本地跑模型耗算力,默认用 CPU,也可配 GPU)。如果同一段文本反复转向量,就浪费了。

所以 Embedding 可以设置缓存:

  • 本地缓存
  • 内存缓存
  • Redis 缓存

缓存的意义:一次转向量,永久复用。

没有现成的怎么办

LangChain 内置了很多 Embedding 实现,但不是所有模型都有。比如通义千问的 Embedding,LangChain 没有内置,需要自己实现——参考内置的 BaichuanTextEmbeddings 实现即可,比较简单。

选 Embedding 模型的建议

  • 中文场景:bge-large-zh-v1.5 这类中文适配好的模型
  • 免费无配额:本地开源模型(如 HuggingFace 上的模型),不消耗 API 配额
  • 有配额:用大模型供应商的 Embedding API(如智谱 embedding-2、通义千问),但要单独买资源包

一个常见坑:大模型资源包和 Embedding 资源包不互通。你买了 glm-4.5-air 的对话资源包,不代表能用 embedding-2。调用无配额的 Embedding 模型会直接报 429(余额不足或无可用资源包)。

开发调试阶段的推荐做法:用本地开源 Embedding 模型,零配额消耗。

最小示例

from langchain_huggingface import HuggingFaceEmbeddings

# 本地模型,免费无配额
embeddings = HuggingFaceEmbeddings(model_name="bge-large-zh-v1.5")

# 单条转向量
vec = embeddings.embed_query("猫喜欢吃什么?")
# vec 是一个 1024 维的浮点数列表

embed_query 用于把问题转向量,embed_documents 用于把文档批量转向量。检索时两边用同一个 Embedding 模型,否则向量不在同一空间,距离没有意义。


2.4 向量数据库:存到哪

Embedding 生成后,要存起来。存的地方就是向量数据库(Vector Stores)

选择有很多:

  • 本地内存:Annoy、FAISS、Chroma
  • C/S 架构:Milvus、Weaviate、Qdrant

这一节重点对比两个最常用的:FAISSChromaDB

先说结论

FAISS 是"向量检索算法库",ChromaDB 是"向量数据库"。

前者只做"算距离、找最近向量"这一件事,做到极致;后者把"存、管、查、过滤、持久化"整条链都包了,向量检索只是其中一项。

不是谁全面碾压谁,是定位不同:要省心、中小规模,选 ChromaDB;要极致性能、大规模,选 FAISS。

下面展开。

FAISS:单点极致的检索算法库

FAISS 是 Facebook AI Research(FAIR)开发的开源库,主要用于高效的大规模向量搜索和聚类。

核心定位:它是"算法库",不是"数据库"。

它只干一件事:给一堆向量和一个查询向量,算出哪个最接近。 至于向量从哪来、存哪、属于谁、怎么持久化——它一概不管。

它做的事它不管的事
算向量距离向量从哪来
找最近邻存哪
支持多种索引(FlatL2、IVF、HNSW、PQ 等)元数据管理
支持 GPU 加速持久化
支持多线程服务化

这意味着什么:用 FAISS 时,每一步都要自己来——自己准备向量、自己选索引、自己存索引、自己管元数据。检索出来的是向量 ID,不是文本,你还得自己维护"ID → 文本"的映射。

它的强项

  • 索引类型极丰富,能针对不同规模选最优
  • C++ 优化,性能极致
  • 原生 CUDA 支持,GPU 检索快 10–100 倍
  • 千万到亿级向量,依然毫秒级

它的短板

  • 默认纯内存,程序退出就丢,要手动 write_index / read_index
  • 不能按元数据过滤,要过滤得自己先查全部再筛
  • 检索返回 ID,不是文本,要自己映射
  • 易用性差,要自己封装

适合:数据量 ≥ 百万、追求极致查询速度、有 GPU、能自己处理存储和元数据。

ChromaDB:开箱即用的轻量数据库

ChromaDB 是一个开源的、基于 Python 的轻量级全功能向量数据库

核心定位:它是"数据库",向量检索只是它的一项能力。

它把数据库该有的能力都包了:

能力说明
向量、原始文本、元数据一起存
集合、文档、元数据管理
向量相似度检索
过滤原生支持元数据过滤(where={"category": "技术"}
持久化自动存到本地 SQLite 文件
集成LangChain / LlamaIndex 原生支持,一行代码建库

它的强项

  • 开箱即用,给个目录就自动存、自动读
  • 检索直接返回 Document(文本 + 元数据),不用自己映射
  • 支持元数据过滤、全文检索
  • LangChain 零门槛集成

它的短板

  • 索引类型少,主要是 HNSW
  • Python 层 + SQLite,百万级以内流畅,千万级以上变慢
  • 无 GPU 加速
  • 单机为主,无成熟分布式

适合:快速做 RAG 原型、Demo、个人项目,数据量 < 100 万向量,单机运行,追求开发效率。

核心差异:不是谁更强,是定位不同
维度FAISSChromaDB
定位向量检索算法库向量数据库
功能覆盖面窄,只做检索宽,存管查过滤持久化全包
单点性能极致(多索引 + C++ + GPU)够用(HNSW + Python + SQLite)
持久化手动 write_index / read_index自动存本地 SQLite
检索返回向量 ID,要自己映射Document(文本 + 元数据)
元数据过滤不支持,要自己实现原生支持
适用规模百万 → 亿级千 → 百万级
易用性复杂,要自己封装极简,开箱即用

用类比说

  • ChromaDB 像"一体机" :打印、复印、扫描、传真全包,每项不是最顶尖但够用,开箱即用
  • FAISS 像"专业级单反" :只干拍照这一件事,但做到极致,其他功能你自己配

你不能说"一体机能力大于单反" ——一体机功能多,单反在拍照这一项上强得多。取决于你要什么。

选型建议

选 ChromaDB 如果你

  • 快速做 RAG 原型、Demo、个人项目
  • 数据量 < 100 万向量、单机运行
  • 不想折腾存储、元数据、持久化,要开箱即用
  • 用 LangChain/LlamaIndex,追求开发效率

选 FAISS 如果你

  • 数据量 ≥ 百万、追求极致查询速度、有 GPU
  • 做推荐系统、图像/视频检索、大规模生产 RAG
  • 能自己处理存储、元数据、服务化、持久化
  • 需要自定义索引、量化、分布式检索

一句话

ChromaDB 是"功能覆盖面大",FAISS 是"单点性能强"。不是谁全面碾压谁,是定位不同。

要省心、中小规模,选 ChromaDB;要极致性能、大规模,选 FAISS。

这一篇后面的实操用 FAISS,因为它是纯本地运行、无需额外服务,适合快速上手。

最小示例(FAISS)

from langchain_community.vectorstores import FAISS

# 从文档建库
vectorstore = FAISS.from_documents(chunks, embeddings)

# 从文本直接建库(最简)
vectorstore = FAISS.from_texts(
    texts=["Cats love tuna", "Dogs like bones"],
    embedding=embeddings,
)

# 保存到本地 / 从本地加载
vectorstore.save_local("faiss_index")
vectorstore = FAISS.load_local("faiss_index", embeddings, allow_dangerous_deserialization=True)

最小示例(ChromaDB)

from langchain_community.vectorstores import Chroma

vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",   # 自动持久化
)

两行对比就能看出选型差异:FAISS 要手动 save_local / load_local,Chroma 给个目录就自动存。


2.5 Indexing API:把四步串起来

回顾一下,建库是四步:

加载 → 切分 → 转向量 → 存库

每一步都有对应组件。但每次建库都要手动写这四步,很麻烦,而且容易出问题:

  • 重复加载:同一份文档反复处理
  • 重复嵌入:同一段文本反复转向量,浪费算力/钱

Indexing API 就是解决这个问题的:它是对"加载 → 切分 → 转向量 → 存库"整个流程的统一封装,用来做"数据源和向量数据库的同步",避免重复加载、重复嵌入,让整个索引构建过程更高效、更可控。

一句话:Indexing API 是建库流水线的"总调度",帮你管好"哪些文档处理过了、哪些还没处理"。

这一节不展开代码,知道有这么个东西即可。后面的实操会直接走手动四步,更直观。

最小示例(概念性,不展开)

from langchain.indexes import SQLRecordManager, index

record_manager = SQLRecordManager("my_index", db_url="sqlite:///record_manager.db")
index(
    docs,              # 文档
    record_manager,    # 记录管理器
    vectorstore,       # 向量库
    cleanup="incremental",   # 增量同步
)

核心是 cleanup="incremental":只处理新增/变更的文档,已处理过的不重复嵌入。这一节知道有这个能力即可,实操仍走手动四步。

小结

建库是 RAG 的离线部分,把文档处理成向量存进向量库。四步:加载 → 切分 → 转向量 → 存库

步骤组件解决什么问题
加载文档加载器文档从哪来
切分文本分割器文档太长怎么办
转向量Embedding 模型文本怎么变成可检索的
存库向量数据库存到哪

3. 检索:从向量库里查出相关片段

建库是"把文档变成向量存起来",检索是"用户提问时,从向量库里查出相关片段"。这是 RAG 的在线部分——每次用户提问都要走一遍。

检索看着简单("查一下不就行了"),但实际有很多讲究:

问题对应方案
怎么查最相似的?基础相似度检索
查出来全是重复的怎么办?MMR
查回来的太长、有无关内容怎么办?上下文压缩
只靠向量检索漏了关键词怎么办?集成检索(BM25)
一篇文档拆成多个片段,返回哪个?多向量检索
想按元数据筛选(评分、年份、类别)怎么办?Self-querying

这一节一节讲。


3.1 基础相似度检索

最基础的检索方式:把问题转成向量,在向量库里找最接近的 Top-K 个片段。

# 建库时已经建好 vectorstore
retriever = vectorstore.as_retriever()

# 检索
docs = retriever.invoke("猫喜欢吃什么?")
# docs 是 list[Document],按相似度排序

as_retriever() 做了什么:把向量库包装成检索器(Retriever) 。检索器是一个统一接口,不管底层是 FAISS、ChromaDB 还是别的,上层都用 invoke 调。

常用参数

retriever = vectorstore.as_retriever(
    search_kwargs={"k": 4},   # 返回最相似的 4 个片段
)
参数含义默认
k返回几个片段4
search_type检索类型(similarity / mmr 等)similarity

k 怎么选

  • 太小:可能漏掉关键信息
  • 太大:拼进 Prompt 的内容太多,浪费 token,还可能引入噪声

一般 3~5 个起步,根据效果调。

这就是最基础的检索。 简单、够用,但有两个问题:

  1. 可能返回重复内容:Top-K 里如果有几段说的是同一件事,浪费位置
  2. 只按相似度排:最相似的未必是最有用的

下一节的 MMR 就是解决第一个问题的。

最小示例

retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
docs = retriever.invoke("猫喜欢吃什么?")
for doc in docs:
    print(doc.page_content)

3.2 MMR:既要相关,又要不重复

问题:基础相似度检索只按"和问题有多像"排序。如果向量库里有几段内容高度相似(比如同一件事换了几种说法),Top-K 可能全是这些重复内容,浪费了位置。

MMR(Maximal Marginal Relevance,最大边际相关性) 就是解决这个的。

一句话核心

选文档时,既要和用户问题"像",又要和已经选过的文档"不像",避免结果全是重复内容。

怎么做到的

先选出和问题最像的文档,放进已选集合 S。然后每次从剩下的文档里,选"综合得分最高"的那一条:

得分 = λ × 和查询的相似度 − (1−λ) × 和已选文档的最大相似度

拆开看:

  • λ × 和查询的相似度:奖励和问题本身很相关的文档
  • (1−λ) × 和已选文档的最大相似度惩罚和已经选过的文档太像的内容

最后取得分最高的,加入 S。重复,直到选够数量。

λ 参数的作用

λ 值效果
λ=1只看和问题的相似度,和普通检索完全一样,不做去重
λ=0只看和已选文档的差异度,完全不管和问题相不相关,结果会很偏
0.3~0.7平衡相关性和多样性(常用)

生活化例子

你要找"高考成绩查询方式",数据库里有 5 条:

1. 教育局官网查询
2. 教育局官网查询(换了个说法)
3. 学校教务处查询
4. 学校教务处查询(不同措辞)
5. 短信推送查询

普通相似度搜索:可能把 1、2 都排前面,结果全是重复的。

MMR

  • 先把 1 放进 S
  • 选下一条时,2 和 1 太像,被扣分;3 和 1 不像,得分更高 → 选 3
  • 最终结果:官网 → 教务处 → 短信,既相关又不重复

多文档比较的关键规则

已选集合 S 里有多个文档时,每个候选文档只取它和 S 中所有文档的最大相似度来算惩罚项,不是平均值或总和。意思是:只要和其中任何一个已选文档高度相似,就被认为是重复,扣分。

怎么用

retriever = vectorstore.as_retriever(
    search_type="mmr",
    search_kwargs={"k": 4, "lambda_mult": 0.5},
)

lambda_mult 就是上面公式里的 λ,默认 0.5。

一句话:MMR 是"相关 + 多样"的平衡术。要结果不重复,用它。


3.3 上下文压缩:检索回来的太长,先删无关内容

问题:检索回来的片段可能很长,但其中只有一部分和问题相关。整段拼进 Prompt 会浪费 token,还可能引入噪声。

上下文压缩(Contextual Compression) 就是干这个的:修改文档,但只删除无关内容、不改动原文文字、不创作新内容。

核心原则

  • ✅ 只删无关内容
  • ✅ 保留原文文字
  • ❌ 不改写
  • ❌ 不创作

一句话压缩不是"总结",是"裁剪"——把无关的部分剪掉,留下的还是原文。

最小示例(概念性)

from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor

compressor = LLMChainExtractor.from_llm(llm)
compression_retriever = ContextualCompressionRetriever(
    base_retriever=retriever,
    base_compressor=compressor,
)
docs = compression_retriever.invoke("猫喜欢吃什么?")

注意:压缩通常要用 LLM 判断"哪部分相关",所以会多一次 LLM 调用,有成本。不是所有场景都需要,片段本来就不长时可以不用。

关键区别:压缩不是"总结",是"裁剪"。摘要是换一种说法,压缩是把不相关的剪掉——保留原文,只做减法。这是 RAG 要的,因为模型看到的必须是原文,不能是被改写过的二手信息。


3.4 集成检索:BM25 全文检索

问题:向量检索是"语义匹配",擅长找"意思相近"的内容,但对关键词不敏感。

比如你搜"GLM-4.5-air",向量检索可能返回一堆"大模型""AI 模型"的片段,但真正包含这个词的片段未必排最前。

集成检索(Ensemble Retriever) = 多种检索方式混合。 最常见的是"向量检索 + BM25 全文检索":向量管"意思像",BM25 管"词对得上",各补各的盲区。

BM25 是什么

BM25 是全文检索的经典算法,给文档打分,本质做三件事:

  1. 词频越高,分越高:一个词在文档里出现得越多,越相关
  2. 词越稀有,分越高:越有信息量的词(比如专有名词),权重越高
  3. 太长的文档会被"拉平" :不会因为词多就占便宜

集成检索怎么合

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever

# 建库
loader = TextLoader("notes.txt", encoding="utf-8")
docs = loader.load()
chunks = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50).split_documents(docs)

embeddings = HuggingFaceEmbeddings(model_name="bge-large-zh-v1.5")
vectorstore = FAISS.from_documents(chunks, embeddings)

# 两路检索器
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 4

# 集成
ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.5, 0.5], # 各占一半
)

# 检索
docs = ensemble_retriever.invoke("GLM-4.5-air 是什么?")

两个检索器各自返回结果,按权重合并排序。

什么时候用

  • 专有名词、型号、代码等关键词敏感场景
  • 纯向量检索效果不好时,加一路 BM25 兜底
  • 一般 weights=[0.5, 0.5] 起步,根据效果调

一句话:向量检索管"意思像",BM25 管"词对得上",两个一起用更稳。


3.5 多向量检索

问题:一篇长文档切成多个片段后,检索时可能只命中其中一个片段,但用户需要的是完整上下文

多向量检索 就是解决这个的:用细粒度匹配,返回粗粒度上下文。

有三种方式,多向量检索不是"三选一",而是"多套索引 + 多路召回 + 合并"。

同一个文档块,可以同时建多套向量索引:原文索引、摘要索引、假设问题索引。检索时用同一个 Query 分别去查,各自返回命中的文档块,最后按权重合并、去重、排序。

这和 3.4 的集成检索是同一个思路——多路召回,结果合并。区别只在"多路"的来源:集成检索是不同算法(向量 + BM25),多向量检索是同一个块的不同表示(原文 + 摘要 + 假设问题)。

三种方式可以全上,也可以只上其中一两种,看场景和成本。

方式一:分块

原理:把文档块再细切成更小的片段,用细片段做检索匹配(命中更准),命中后返回这个细片段所属的文档块(上下文更完整)。

注意:返回的是"文档块",不是"原始整篇文档"。如果一篇 100 页的文档切成 50 个块,命中一个片段后返回的是它所属的那一个块,不是整篇 100 页。

具体实现

  1. 粗切:先把原始长文本,预先分割成固定块(chunk),这是文档块,也是最终要返回的上下文
  2. 细切:对每一个文档块,再切成更小的片段(比如 100 字),单独向量化存储
  3. 检索:用 Query 去匹配细片段的向量(粒度更细,更容易命中细节)
  4. 返回:命中细片段 → 通过绑定关系,找到它所属的文档块,只返回这个块

一句话:细粒度匹配 + 粗粒度返回,兼顾检索精度和上下文完整度。

方式二:总结

原理:对同一个原始文本块,存两套向量——原文块向量 + 摘要精简向量。用无噪声的摘要向量做高精度检索,命中后映射还原返回原始完整文本块。

执行步骤

  1. 切块:原始长文档,提前分割为标准文本块
  2. 生成摘要:对每个文本块,提炼精简总结(剔除冗余、噪声)
  3. 双向量入库:给"原文块""摘要文本"分别生成 Embedding,绑定关联存储
  4. 摘要检索:用户 Query 向量化,只用摘要向量库做匹配(摘要语义更聚焦,检索更准)
  5. 映射还原:命中摘要向量后,通过绑定关系,找到并返回对应的原始文本块

一句话摘要负责"检索准",原文负责"上下文全"。

方式三:假设问题

场景痛点

  • 用户输入:问句(问题形式)
  • 库里文档:陈述句(答案、说明文、资料)
  • 问题:问句 ↔ 陈述句,句式结构差太大,Embedding 语义匹配拉胯,搜不到、匹配不准

解决方案核心

用户用问题来搜,那就给每份答案文档,让 LLM 反向生成多条:"这篇文档能回答哪些问题?" → 产出一堆人造假设性问题。

整套流程

  1. 原始文档块:陈述句(答案)
  2. LLM 基于当前文档,批量生成多条假设提问
  3. 入库存两组向量:原文答案向量 + LLM 生成的假设问题向量
  4. 用户提问(问句)进来
  5. 优先用"问题向量库"匹配
  6. 匹配到人造问题 → 映射找回原本的答案文档返回

一句话本质

把答案文档,反向翻译成一堆问题;用"问题匹配问题",消除句式差异,大幅提升检索准确率。

应用例子:用多向量搜索的假设问题方式,帮助大模型回答关于时事的问题。


3.6 Self-querying:LLM 自动拆"语义 + 元数据过滤"

问题:普通向量检索只能靠语义匹配,没法处理数值、范围、分类筛选。

比如你搜"2023 年评分 8 分以上的科幻电影",向量检索只会找"意思相近"的,但没法精确过滤"2023 年""评分 ≥ 8""科幻"这些条件。

Self-querying(自查询检索) 就是解决这个的:让 LLM 自动把自然语言问句,拆成两部分, 即Self-querying = 向量检索 + 元数据过滤 + LLM 自动拆解。

拆成哪两部分

部分作用
语义检索关键词用于向量模糊匹配正文
结构化过滤条件元数据精准筛选:评分、年份、导演、类型等

工作流程

  1. 给大模型告知:文档描述 + 所有元数据字段/类型/含义
  2. LLM 将自然语言 → 翻译成标准化结构化查询
  3. 向量库先做语义向量检索,再叠加元数据条件过滤
  4. 最终返回同时满足"语义相似 + 规则筛选"的文档

mermaid-diagram-2026-09-19-180543.png

解决的痛点

  • 普通向量检索只能靠语义匹配,没法处理数值、范围、分类筛选
  • 自查询检索结合"向量语义 + 元数据结构化过滤",适合带明确限制条件的场景(价格、评分、时间、类别)

关键特点

  • 依靠 LLM 理解并生成过滤语法
  • 向量做模糊语义,元数据做精准硬规则
  • 大幅减少手动写过滤代码
  • 天然适合知识库、商品、影视等带字段数据的 RAG

一句话:Self-querying 是"自然语言 → 语义关键词 + 结构化过滤条件"的自动翻译器。


小结

检索是 RAG 的在线部分,从向量库里查出相关片段。六种方式,各解决一个问题,不是顺序关系,是"按需选用"

方式解决什么问题
基础相似度检索最基础的 Top-K
MMR结果重复
上下文压缩片段太长、有无关内容
集成检索只靠向量漏关键词
多向量检索细粒度匹配、粗粒度返回
Self-querying需要按元数据筛选

4. 组装:Retrieval + LLM = RAG

前面三节把零件都讲完了:

内容产出
2建库文档 → 向量库
3检索向量库 → 相关片段
4组装片段 + 问题 → Prompt → LLM → 回答

这一节把前两节串起来,跑通整条链路。

4.1 LangChain 集成智谱 AI 实现 RAG

这一节搭建一个完整的 RAG 链路:智谱对话大模型 + 本地开源嵌入模型 + FAISS 向量库

为什么选这个组合

  • 智谱对话大模型:复用已有资源包,避免额外计费
  • 本地开源嵌入模型:免费无配额,规避智谱 Embedding 配额不足的问题
  • FAISS 向量库:纯本地运行,无需额外服务,适合快速上手
1. 环境准备

核心依赖安装

# 安装 FAISS(CPU 版,无需 GPU)
conda install faiss-cpu -c pytorch

# 安装核心依赖
pip install langchain langchain-community langchain-huggingface zhipuai sentence-transformers

说明

  • faiss-cpu:CPU 版 FAISS,无需 GPU
  • langchainlangchain-community:LangChain 核心和社区组件
  • langchain-huggingface:HuggingFace Embedding 的新版导入路径
  • zhipuai:智谱官方 SDK,LangChain 的智谱模型封装底层强依赖它
  • sentence-transformers:本地嵌入模型的底层依赖
2. 组件导入
import os

# 配置 LangChain 项目(可选,用于追踪)
os.environ["LANGCHAIN_PROJECT"] = "rag_test"

# 1. FAISS 向量库
from langchain_community.vectorstores import FAISS

# 2. 智谱 AI 对话大模型
from langchain_community.chat_models import ChatZhipuAI

# 3. 本地开源嵌入模型
from langchain_huggingface import HuggingFaceEmbeddings

# 4. LangChain 基础组件
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough

导入路径说明

  • 新版 LangChain 把第三方模型(如智谱)收敛到 langchain_community
  • 本地嵌入模型用 langchain_huggingface,替代旧的 langchain_community.embeddings.HuggingFaceEmbeddings
3. 嵌入模型配置

用本地开源模型,免费无配额。中文场景推荐 bge-large-zh-v1.5

# 本地嵌入模型(免费无配额)
# 若已手动下载模型到本地,填写本地路径;若未下载,填模型名会自动下载并缓存
embeddings = HuggingFaceEmbeddings(model_name="bge-large-zh-v1.5")

说明

  • model_name 可以是模型官方编号,也可以是本地文件夹路径
  • 填官方编号时,库会自动下载并缓存到 ~/.cache/huggingface/hub(Windows 为 C:\Users\你的用户名.cache\huggingface\hub),一次下载永久复用
  • 填本地路径时,直接用已下载的模型,不联网
4. 智谱大模型配置
# 智谱大模型配置
ZHIPU_API_KEY = "你的智谱API_KEY"  # 替换为你的智谱API密钥

llm = ChatZhipuAI(
    model="glm-4.5-air",       # 选用自己有资源包的模型,避免配额不足
    api_key=ZHIPU_API_KEY,
    temperature=0.1,           # 温度越低,回答越精准,适合问答场景
)

关键点

  • 选用自己有资源包的模型,避免调用无配额的模型导致 429 报错
  • temperature=0.1:问答场景要精准,温度调低
  • API Key 建议用环境变量注入,不要硬编码在代码里(调试阶段可以直接写)
5. 构建 FAISS 本地向量库

把测试文本转成向量,存进 FAISS:

# 测试文档(可替换为你的私有文档、行业资料等)
texts = ["Cats love tuna", "Dogs like bones", "Rabbit eats grass"]

# 将文本转换为向量,存入 FAISS 向量库
vectorstore = FAISS.from_texts(
    texts=texts,
    embedding=embeddings,      # 使用本地嵌入模型,不消耗智谱配额
)

# 构建检索器(用于后续相似度检索)
retriever = vectorstore.as_retriever()

# 测试检索:输入问题,检索相关上下文
retriever.invoke("What do cats like to eat?")

这一步做了什么

  1. 把 3 条测试文本转成向量
  2. 存入 FAISS 向量库
  3. 包装成检索器(retriever
  4. 测试一下检索是否能命中
6. 组装 RAG 链路

用 LCEL(LangChain Expression Language)把「检索器、提示词、大模型、输出解析器」串起来。

LCEL 的 | 管道在第六篇已详细讲过,这里直接用,不展开。详见《LCEL》篇。

# 配置提示词模板
# 严格根据检索到的上下文回答问题,禁止编造信息
template = """Answer the question based only on the following context:
{context}

Question: {question}
"""
prompt = ChatPromptTemplate.from_template(template=template)


# 格式化检索到的文档(将多个文档拼接为字符串,方便大模型读取)
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)


# 组装 RAG 链路
# 链路逻辑:用户问题 → 检索相关文档 → 格式化文档 → 拼接提示词 → 大模型生成 → 解析输出
rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

链路拆解

步骤代码作用
1retriever | format_docs检索相关文档,拼成字符串
2RunnablePassthrough()把用户问题原样传下去
3prompt把上下文和问题填进提示词模板
4llm智谱 GLM 生成回答
5StrOutputParser()解析成纯文本
6.1 模型怎么用上下文

模型拿到上下文后,到底怎么用?

RAG 不是"把文档塞给模型,模型就照搬"。模型对上下文的使用,有两个常见误解:

误解一:没检索到内容,模型会自己回答吗?

取决于 Prompt 怎么写。

  • 强制基于上下文(based only on the following context):模型可能说"根据提供的上下文,我无法回答"
  • 允许兜底(如果上下文没有相关信息,用你自己的知识回答):模型会自己回答

不能容忍幻觉的场景(企业知识库、医疗、法律),用强制写法,并强化"不知道就说不知道"。

误解二:检索到了内容,模型会直接用吗?

不会直接用,也不会严格检查。 模型做的事是:阅读上下文,然后用自己的话组织答案。

你以为的实际发生的
模型直接复制上下文模型理解后重新组织语言
模型严格检查上下文对不对模型只是"参考",不负责验证

这意味着:

  • 模型可能改写、概括、重组上下文——不是原文照搬
  • 模型可能"过度解读"——上下文说 A,它推出 A+B
  • 模型可能忽略部分上下文——上下文太长时,只看了一部分
  • 模型可能"自信地"说出上下文里没有的——如果它觉得"应该是这样"

怎么让模型更严格地用上下文

template = """严格根据以下上下文回答问题,不要添加上下文之外的信息。
如果上下文中没有相关信息,直接说"根据已有资料,我无法回答"。
{context}

Question: {question}
"""

但即使这样,也不能 100% 保证。 更严格的做法:

  • 要求模型引用来源:回答时标注信息来自上下文的哪一段
  • 后置校验:用另一个模型检查"回答是否真的基于上下文"
  • 降低 temperature:减少模型的"自由发挥"

一句话:RAG 不是"把文档塞给模型,模型就照搬",而是"把文档给模型参考,模型基于它生成回答"。模型有自由度,Prompt 是用来约束这个自由度的。

7. 测试 RAG 链路
# 测试 RAG 链路(基于检索到的上下文回答问题)
result = rag_chain.invoke("What do cats like to eat?")
print("RAG回答:", result)
# 预期输出:Cats love tuna

验证点:模型回答"猫喜欢吃什么"时,不是靠自己"回忆",而是基于检索到的 Cats love tuna 这句原文。

4.2 RAG 链路九步总结

把整条链路拆成九步:

步骤动作对应组件
1数据准备:准备待入库的原始文本内容texts
2文本向量化:调用嵌入模型,把文本转成向量HuggingFaceEmbeddings
3构建内存向量库:新建 FAISS 向量库,存入向量FAISS.from_texts
4生成检索工具:由向量库创建检索器,开启相似度检索能力as_retriever()
5相似度召回:接收用户问题,检索器匹配并取出相关文档retriever.invoke
6文档格式化:把多条检索结果拼接成统一上下文字符串format_docs
7填充提示词:将上下文与问题写入提示词模板ChatPromptTemplate
8模型推理:向智谱 GLM 大模型发送完整提示词进行生成ChatZhipuAI
9结果输出:解析模型返回内容,得到纯文本答案StrOutputParser

九步回顾

数据准备 → 文本向量化 → 构建向量库 → 生成检索器 → 相似度召回
→ 文档格式化 → 填充提示词 → 模型推理 → 结果输出

4.3 实操常见报错排查

报错 1:ValidationError(缺少智谱 API Key)

ValidationError: Did not find api_key, please add an environment variable ZHIPUAI_API_KEY

原因ChatZhipuAI 初始化必须携带智谱 API Key,未配置环境变量、未手动传参都会校验失败。

解决方案

  • 方式 1(调试首选):在代码中直接注入 api_key=ZHIPU_API_KEY
  • 方式 2(全局配置):通过环境变量配置,避免代码中暴露密钥

报错 2:ModuleNotFoundError(缺少 zhipuai 依赖)

ImportError: Could not import zhipuai python package. Please install it with `pip install zhipuai`

原因:LangChain 的智谱模型封装,底层强依赖智谱官方 zhipuai SDK,仅安装 LangChain 无法正常使用。

解决方案:执行 pip install zhipuai,安装官方 SDK。

报错 3:APIReachLimitError(429,配额不足)

APIReachLimitError: Error code: 429, {"error":{"code":"1113","message":"余额不足或无可用资源包,请充值。"}}

核心原因:智谱资源包按模型隔离计费。大模型资源包(如 glm-4.5-air)与嵌入模型资源包(如 embedding-2)不互通,调用无配额的模型会直接拦截。

解决方案

  • 开发调试:使用本地开源嵌入模型(如本节方案),不调用智谱嵌入接口,零配额消耗
  • 正式部署:单独购买智谱 embedding-2 专属资源包,用于线上向量生成

第四节完。

全篇回顾

内容
1RAG 是什么、为什么需要、和 Agent 的关系
2建库:加载 → 切分 → 转向量 → 存库
3检索:基础 / MMR / 压缩 / 集成 / 多向量 / Self-querying
4组装:完整 RAG 实操 + 九步总结 + 报错排查

结尾指向 Agent 篇

RAG 是 Agent 的基础组件,不是 Agent 的方向。这一篇学的是"怎么让模型知道它不知道的东西",下一篇 Agent 学的是"怎么让模型自己决定要不要去知道"。

检索只是 Agent 众多工具中的一把。Agent 还能调用计算器、数据库、API,还能多步推理、自主规划。RAG 是入门 Agent 的第一步,但不是终点。

小结

组装是把建库和检索串起来跑通。完整链路:

数据准备 → 向量化 → 建库 → 检索器 → 召回 → 格式化 → 填 Prompt → 模型推理 → 输出

关键点:模型不是"复制"上下文,是"参考"上下文生成回答。Prompt 是用来约束模型自由度的。

小结:模型不知道的东西,怎么让它知道

回到开头的问题:模型不知道的东西,怎么让它知道?

答案就是 RAG:在模型生成回答前,先从外部资料库检索相关内容,拼进 Prompt,再让模型基于这些内容回答。

整条链路

【建库(离线)】
源数据 → 加载 → 文本分割 → 转向量 → 存向量库

【查询(在线)】
用户问题 → 转向量 → 检索 → 拼 Prompt → LLM 生成 → 返回回答

【连接点】
向量库:建库把向量存进去,查询从里面查出来
阶段关系核心
建库串行四步加载 → 切分 → 转向量 → 存库,一步接一步
检索并列多种方式基础检索是底座,其余按需叠加
组装把两段串起来检索结果 + 问题 → Prompt → LLM

三个核心认知

认知一:RAG 不是"过时技术"

RAG、微调、从头训练,是三个不同层次的手段,不是新旧替代关系。对没有算法团队的开发团队,RAG 不是"退而求其次",是唯一可行解

认知二:RAG 不是 Agent 的方向,是 Agent 的基础组件

RAG 管"信息获取",Agent 管"自主决策"。Agent 要查资料,靠的就是 RAG。学 RAG 是入门 Agent 的必经之路。

认知三:模型不是"复制"上下文,是"参考"上下文

RAG 不是"把文档塞给模型,模型就照搬"。模型会理解、重组、甚至过度解读。Prompt 是用来约束模型自由度的——不能容忍幻觉的场景,要明确写"不知道就说不知道"。

一句话总结

RAG 的本质,是给模型配一个可查询的资料库。

模型负责"理解 + 生成",资料库负责"提供事实依据"。两者结合,模型就能回答它训练时没见过的问题。

和前后篇的关系

  • 前面 LCEL| 管道是组装 RAG 链路的工具
  • 前面 ParserStrOutputParser 是 RAG 链路最后一步
  • 前面 ModelChatZhipuAI 是 RAG 链路负责生成回答的组件
  • 后面 Agent:RAG 是 Agent 的基础组件,Agent 还能调用工具、多步推理

下一篇 Agent,讲的是"怎么让模型自己决定要不要去知道"——检索只是 Agent 众多工具中的一把。