文章目录

零基础入门 LangChain 与 LangGraph(六):核心组件下篇——Document、加载器、文本切分、Embedding、向量存储、检索器与 RAG

💬 开篇:上一篇我们讲了消息、提示词模板、少样本、输出解析器,本质上解决的是“模型输入输出如何组织”。但这还只是让模型更会“说”,还没解决真正决定 AI 应用上限的问题:模型不知道的知识,到底怎么接进来?

👍 这一篇的核心任务:LangChain 核心组件:Document、文档加载器、文本切分器、Embedding、向量存储、检索器,以及它们最终如何落到 RAG 上。LangChain 官方现在也把 retrieval 这条线明确拆成 document loaders、text splitters、embeddings、vector stores、retrievers,并在教程里直接串成最小可运行的 RAG workflow。(LangChain 文档)

🚀 写完这一篇,我真正应该学会什么

  1. 为什么外部知识进入 LangChain 之前,要先被抽象成 Document
  2. 为什么文档不能直接整本喂给模型,而要先做加载、切分、向量化
  3. 为什么向量存储和检索器不是一个东西,却总是一起出现
  4. 为什么 RAG 不是什么“高级功能”,而是知识型 AI 应用的基本工作流

这一篇写完,LangChain 这部分的主线其实就基本闭环了。因为从“调模型”到“接知识”,最核心的一条链路,到这里就全部打通了。


一、从“会聊天”到“会查资料”:这一篇才真正进入知识型 AI 应用

1.1 只会调模型,还远远不够

我前面几篇已经把聊天模型调用、LCEL、提示词模板、结构化输出这些东西基本讲清楚了。
但说实话,到那一步,做出来的东西仍然主要是一个“更会聊天的模型接口”。

它的问题也很明显:

  • 它不知道我的本地 PDF
  • 它不知道我公司的私有文档
  • 它不知道我项目里的真实代码和说明材料
  • 它不知道最新补充进去的知识
  • 它也不知道哪些回答必须严格依据资料,不能自由发挥

换句话说,模型再强,也不等于它天然知道我的世界。

这就是为什么,真正有业务价值的 AI 应用,几乎都会走到“检索 + 生成”这条路上。


1.2 理解 RAG:先查,再答

RAG,全称是 Retrieval-Augmented Generation,一般翻译成“检索增强生成”。

用一句人话来概括:

RAG 就是:先把和问题最相关的资料找出来,再让模型基于这些资料回答。

注意这和纯聊天模型的区别:

1. 纯聊天模型

更像一个“靠训练时学到的知识和语言能力来回答问题的人”。

2. RAG 系统

更像一个“会先翻资料、再整理答案的人”。

这个差别非常关键。因为很多时候,我们真正需要的不是“一个很会说的人”,而是:

  • 一个能先找到资料,再据资料作答的人
  • 一个知道自己依据什么回答的人
  • 一个面对私有知识库也能工作的系统

1.3 RAG 流程:两段

RAG 看起来组件很多,但本质上我现在只把它分成两段:

1. 第一段:离线索引阶段

也就是先把资料准备好,让它变得“可检索”。

2. 第二段:在线问答阶段

用户提问后,系统去检索最相关内容,再交给模型生成答案。

我建议先记住这张图:

原始资料
PDF Markdown 网页 数据库

Document Loader

Document

Text Splitter

Chunks

Embedding

Vector Store

用户问题

Retriever

相关文档块

LLM

最终回答

这条链路,LangChain 官方 retrieval 教程也基本就是按这个思路来讲:文档加载、文本切分、Embedding、向量存储、检索器,最后再接到最小 RAG workflow 上。(LangChain 文档)


1.4 “知识如何进入模型”

外部知识进模型之前,必须先完成“统一表示、合理切分、向量化存储、可检索访问”这四步。

对应到组件就是:

  • Document
  • Loader
  • Splitter
  • Embedding
  • Vector Store
  • Retriever
  • RAG

所以你看到这里,不要把它当成一堆零散 API。
它们其实是在完成一个非常完整的工程任务:

把原本不属于模型的知识,变成模型可以在运行时临时借用的上下文。


二、Document:为什么 LangChain 先把世界抽象成“文档对象”

2.1 文件不是 Document,页也不是 Document,块也不是 Document——但它们都可以变成 Document

Document 不是“某种具体文件格式”,而是 LangChain 用来统一承载外部知识的基础数据结构。

在 LangChain 官方 retrieval 教程里,Document 被定义成“表示一段文本及其关联元数据的抽象”,它有三个核心属性:

  • page_content:文本内容
  • metadata:任意元数据
  • id:可选的标识符

并且官方明确提醒:一个 Document 往往只是更大文档里的一个块,而不是整个原始文件。 (LangChain 文档)

这句话非常重要,因为它直接决定了你后面对“切分”的理解。


2.2 把 Document 理解成“知识运输箱”

如果用最朴素的类比来讲,我会把 Document 理解成一个知识运输箱

这个运输箱里至少装两样东西:

  1. 真正要被检索的文字内容
  2. 跟这段文字相关的附加信息

例如:

from langchain_core.documents import Document

documents = [
    Document(
        page_content="狗是很好的伴侣,以忠诚和友好而闻名。",
        metadata={"source": "pets-doc"}
    ),
    Document(
        page_content="猫更独立,通常更喜欢自己的空间。",
        metadata={"source": "pets-doc"}
    ),
]

这两段代码表达的事情非常简单:

  • 把字符串和来源信息一起装进了统一对象里
  • 后面不管是切分、向量化、检索,都会围绕这个对象进行

所以 Document 的意义在于:

让后续整个 RAG 流程有统一的数据载体。


2.3 page_contentmetadataid 到底分别有什么用

1. page_content:真正参与检索和喂给模型的文本

这是最核心的内容。
Embedding 向量化、相似度搜索、最终上下文拼接,几乎都围绕它展开。

2. metadata:给这段文本加“标签”

metadata 不是可有可无的附件,它经常决定系统能不能做得更精细。

比如常见会放进去的就有:

  • 来源文件名
  • 页码
  • 标题层级
  • 创建时间
  • 章节名
  • 文档类别
  • 作者
  • 起始位置

为什么这很重要?

因为后面检索时,我不一定只想“按相似度搜”。
我还可能想:

  • 只搜某个文件
  • 只搜某个时间范围
  • 只搜某个模块
  • 只搜某一类文档

那这时,metadata 就成了过滤条件。

3. id:用于唯一标识和后续管理

官方把 id 设计成可选字段。
它的意义主要在于:

  • 唯一标识某个文档块
  • 方便删除、更新、去重
  • 让向量库存取更稳定

如果只是最小 Demo,不一定非写不可;
但一旦做成真实系统,id 往往很快就会变得重要。(LangChain 文档)


2.4 一个 Document 往往不是一个文件,而是一个文件的一部分

这个认知是理解 RAG 的关键。

比如一个 PDF 有 200 页。
你当然可以粗暴地把整个 PDF 当成一个大字符串。
但这么做的问题是:

  • 太长,不利于检索
  • 语义太杂,不利于定位
  • 直接塞给模型,上下文也很浪费

所以更常见的流程是:

  • 先按页加载成多个 Document
  • 再把每页切成更小块
  • 每一块仍然是一个 Document

也就是说:

原始文件 -> 多个 Document -> 更细的多个 Document

所以后面一定要把这个区分开:

  • 文件 是原始载体
  • Document 是 LangChain 流程里的标准数据单元

2.5 为什么不直接用字符串,而要多包一层 Document?

因为字符串只解决“内容是什么”,而 RAG 真正需要的是“内容是什么 + 内容来自哪儿 + 这段内容和原始文档有什么关系 + 后续怎么过滤和回溯”。


三、文档加载器(Loader):先把乱七八糟的数据源,统一读进来

3.1 Loader 到底在解决什么问题

现实里的知识来源,从来不会整齐统一。

它可能来自:

  • PDF
  • Markdown
  • Word
  • CSV
  • 网页
  • Notion
  • Google Drive
  • Slack
  • 数据库
  • 本地代码文件

如果没有 Loader,我们就得为每种数据源自己写一套读取逻辑,再自己转成统一格式。

这显然很麻烦。

所以 LangChain 的做法很自然:

先给各种来源的数据提供统一读取接口,读进来后统一转成 Document

官方文档现在对 document loaders 的描述也很明确:它们为不同数据源提供标准接口,把来自 Slack、Notion、Google Drive 等来源的数据统一转成 LangChain 的 Document 格式,所有 loader 都实现统一的 BaseLoader 接口。(LangChain 文档)


3.2 Loader 公共 API :load()lazy_load()

这也是我很喜欢 LangChain 的地方之一:
它的核心抽象一般都不会搞得很吓人。

官方文档里,loader 最常见的两个方法就是:

  • load():一次性全部加载
  • lazy_load():惰性加载,适合更大数据集
1. load()

适合文件不大、一次性读完没压力的场景。

2. lazy_load()

适合大数据集、长文档流、或者我不想一次性把所有内容都塞进内存的时候。

所以 Loader 本身并不复杂,它做的事很单纯:

把外部数据转成 Document 列表。


3.3 PyPDFLoader

对于后面做知识库问答来说,PDF 一定是最常见的来源之一。

LangChain 官方 retrieval 教程里也直接用 PyPDFLoader 做示例:
把一个 PDF 加载成一组 Document 对象,每一页对应一个 Document,并且 metadata 里会保留文件来源和页码。(LangChain 文档)

from langchain_community.document_loaders import PyPDFLoader

file_path = "docs/项目说明.pdf"
loader = PyPDFLoader(file_path)

docs = loader.load()

print(f"总页数:{len(docs)}")
print(f"第一页前 200 个字符:\n{docs[0].page_content[:200]}")
print(f"第一页元数据:\n{docs[0].metadata}")

你可以这么理解:

  • PyPDFLoader(file_path):创建一个 PDF 加载器
  • loader.load():把 PDF 读进来
  • docs:得到的是一个 Document 列表,而不是一个字符串
  • docs[0]:通常对应 PDF 的第一页

这时候,RAG 的第一步其实就已经完成了:

原本模型完全不知道的本地 PDF,终于被系统接进来了。


3.4 Loader 只是“读”,不是“切”,也不是“搜”

这个地方非常容易混淆

  • Loader 负责读取
  • Splitter 负责切分
  • Embedding 负责向量化
  • Vector Store 负责存储与相似度搜索
  • Retriever 负责以统一方式取回结果

也就是说,Loader 并不负责把一份 PDF 自动变成一个完整知识库。
它只是帮你完成第一步:接入原始资料


官方文档里现在列了很多 loader 集成,来源覆盖非常广。(LangChain 文档)

但我不建议一开始死记哪些类名。
因为这件事的重点不是背“有多少个 Loader”,而是理解它们共享的模式:

不同来源的数据 -> 用对应 Loader 读入 -> 统一变成 Document

只要这个模式明白了,后面遇到 Markdown、网页、数据库、Notion,本质都只是“换一个 Loader”而已。


四、文本切分(Text Splitter):RAG 能不能做好,第一关键往往不在模型,而在切分

4.1 为什么文档不能直接整页甚至整本去做向量化

你可能会有这样一个问题:

既然已经把 PDF 读进来了,那为什么不直接拿整页、整章甚至整本去做检索?

这样做通常会有三个问题:

1. 粒度太粗

用户问的是一个很具体的问题,
但你返回的是一整页甚至一整章,里面杂了太多无关信息。

2. 语义被冲淡

官方 retrieval 教程里有一句话我觉得特别好:
如果一个 page 太粗,相关部分的意义很容易被周围大量无关文本“冲淡”。(LangChain 文档)

这句话非常形象。

3. 模型上下文是有限的

你后面最终还要把检索结果喂给模型。
如果每个 chunk 都太大,那上下文很快就浪费完了。

所以切分的本质,就是在做一件事:

把大文档变成更适合检索、也更适合喂给模型的小块。


4.2 chunk_sizechunk_overlap

文本切分器最核心的两个参数,几乎一定会出现:

  • chunk_size
  • chunk_overlap

它们的含义并不难。

1. chunk_size

每个文本块大概希望多大。

2. chunk_overlap

相邻块之间保留多少重叠内容。

为什么需要 overlap?

因为如果两段文本刚好在边界处被切开,很可能一句话前半段在上一个 chunk,后半段在下一个 chunk。
这会导致语义断裂。

官方教程里给的示例就是把文档切成 1000 个字符大小、200 个字符重叠,并明确说明 overlap 可以降低把一句话和其关键上下文硬生生拆开的风险。(LangChain 文档)


4.3 我最推荐的默认起点:RecursiveCharacterTextSplitter

官方文档对这件事其实给得很明确:
对于大多数通用文本场景,优先从 RecursiveCharacterTextSplitter 开始。因为它能较好地在“保持上下文完整”和“控制块大小”之间取得平衡。(LangChain 文档)

它的核心思路不是一刀切,而是以递归的方式:

  1. 先尽量按更大的自然结构切,比如段落
  2. 如果还太大,再退一步按更细粒度切,比如句子
  3. 还不行,再继续往更小单位退

也就是说,它不是先想着“把文本切碎”,
而是先想着“能不能尽量保住语义边界”。

这个设计非常符合 RAG 的真实需求。


4.4 一个最常见的切分示例

from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    add_start_index=True,
)

splits = text_splitter.split_documents(docs)

这里我建议注意三点:

1. split_documents(docs)

输入的是 Document 列表,不是单个大字符串。

2. 输出仍然是 Document 列表

只是变成了更小、更细的块。

3. add_start_index=True 很有用

它会把每个切分块在原文中的起始位置保留到 metadata 里。
这对调试、回溯来源、做引用定位都很有帮助。官方 retrieval 教程里也用了这个参数。(LangChain 文档)


4.5 中文文本切分

如果文档是中文,请格外注意分隔符。

因为英文文本很多时候靠空格、段落就能切得还可以。
但中文没有天然空格边界,如果你切得太粗暴,就容易把一个完整词组或者一句语义完整的话拆坏。

所以在中文场景里,留意这些分隔符:

  • \n\n
  • \n

必要时,会自定义 separators,让切分器优先按中文语义边界退让,而不是只按英文式思路来切。

这在中文知识库里非常实用。


4.6 切分不是越碎越好,也不是越大越好

切太大
  • 检索结果太糊
  • 无关信息太多
  • 上下文浪费严重
切太小
  • 语义不完整
  • 检索命中片段太碎
  • 模型看到的信息断裂,回答反而更差

所以 chunk size 的本质不是“越小越精确”,
而是:

在“信息完整性”和“检索精度”之间找到平衡。

不要一开始就迷信某个绝对数值。
先用一个合理默认值跑起来,再看实际召回出来的 chunk 长什么样,再调。


4.7 特殊文档结构,应该优先按结构切

LangChain 官方 splitters 文档里还专门提到:
对于 HTML、Markdown、JSON 这类天然有结构的文档,按结构切往往更合适,因为它能保留逻辑组织和上下文。(LangChain 文档)

比如:

  • Markdown:适合按标题层级切
  • HTML:适合按标签结构切
  • JSON:适合按对象或数组切
  • 代码:适合按函数、类、逻辑块切

因为这些文本的“天然边界”本来就比普通长文更清楚。
如果你硬拿纯字符数去切,很多时候反而会破坏原有结构。


五、Embedding:把文本变成“可以比较”的数学对象

5.1 对 Embedding 的理解

前面我其实已经讲过 Embedding 的概念了。
但到了这一篇,它不再只是“一个抽象名词”,而是 RAG 里必须真的动起来的一环。

RAG 的核心前提是:

我得先有办法衡量“用户问题”和“文档块”在语义上像不像。

而计算机不理解“像不像”这种自然语言概念,
它更擅长的是比较数字。

所以我们需要把:

  • 文档块
  • 用户问题

都转换成向量。

这一步,就是 Embedding。


5.2 为什么说 Embedding 模型不是在“回答问题”,而是在“编码意义”

这一点一定要和聊天模型区分开。

聊天模型

目标是生成回答。

Embedding 模型

目标是把文本映射成一个固定维度的向量。

也就是说,你把一句话送进 Embedding 模型,
它不会给你答案,
它会给你一串数字。

例如这种感觉:

[0.023, -0.147, 0.392, ...]

这串数字本身不是答案,
它表示的是这段文本在语义空间里的位置。


5.3 LangChain 里 Embeddings 接口最关键的两个方法

Embeddings 接口有两个核心方法:

  • embed_documents(texts: List[str]) -> List[List[float]]
  • embed_query(text: str) -> List[float]

并且官方还特别提到:查询和文档在接口层被分成两条路径,是为了允许不同提供商对 query 和 document 使用不同策略优化,虽然很多提供商在实践中对两者处理得很像。(LangChain 文档)

1. embed_documents

用于批量处理文档块。
本质更像“建索引”。

2. embed_query

用于处理用户当前问题。
本质更像“发起搜索”。

这个区分特别重要,因为它刚好对应了 RAG 的两阶段:

离线索引 -> embed_documents
在线查询 -> embed_query

5.4 一个最简单的 Embedding 定义方式

以 OpenAI 为例,常见写法是:

from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

这一步本身并不复杂。
真正重要的是你要知道:

  • 这个对象后面既可能直接被你手动调用
  • 更常见的情况是,它会被交给向量存储
  • 再由向量存储和检索器在内部调用

也就是说,很多时候你自己甚至不需要亲手去写 embed_documents()embed_query()
但你必须知道它们在背后发生了什么。


5.5 为什么embed_documents 是“索引阶段”,embed_query 是“搜索阶段”

索引阶段

我先把知识库里的内容都切好,
然后一块块转成向量,
再放进向量库里。

这就是:

embeddings.embed_documents([...])
搜索阶段

用户提了一个问题,
我把这个问题也转成向量,
再拿它去向量库里找最相似的文档块。

这就是:

embeddings.embed_query("数据库表怎么设计的?")

所以不要把 Embedding 理解成一次孤立动作。
它其实天然嵌在“建索引”和“做检索”这两个阶段里。


六、向量存储(Vector Store):不要自己手搓向量列表和相似度循环

6.1 为什么有了 Embedding 之后,还要有 Vector Store

很多人学到这里会问:

既然我已经能把文本变成向量了,那我自己用一个 Python 列表存起来,再写个余弦相似度比较,不就行了吗?

理论上当然可以。
但一旦数据稍微多一点,这种做法立刻会暴露问题:

  • 不好管理
  • 不好更新
  • 不好删除
  • 不好过滤
  • 搜索效率差
  • 难以扩展到更大规模

所以 Vector Store 的意义并不是“再包一层名字”,而是:

把向量的存储、索引、相似度搜索、过滤这些事情统一管理起来。

官方文档对向量存储的定义也非常直接:
它就是存放已经嵌入的数据,并执行 similarity search。(LangChain 文档)


6.2 LangChain 向量存储的统一接口

官方文档里列得非常清楚,LangChain 的统一向量存储接口至少包括:

  • add_documents
  • delete
  • similarity_search

并且这套抽象的核心价值就在于:你可以切换不同向量存储实现,而不必重写业务逻辑。 (LangChain 文档)

我觉得这正是框架最有价值的地方之一。

也就是说,你现在可以先用:

  • InMemoryVectorStore

等以后想换成:

  • Chroma
  • FAISS
  • Milvus
  • PGVector
  • Pinecone
  • Qdrant

你的上层调用思路并不用推倒重来。(LangChain 文档)


6.3 建议初学者先从 InMemoryVectorStore 开始

原因非常简单:

  • 不需要额外服务
  • 最小可运行
  • 先把流程走通
  • 最容易看清楚到底发生了什么

一个最基础的初始化大概是这样:

from langchain_core.vectorstores import InMemoryVectorStore
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
vector_store = InMemoryVectorStore(embedding=embeddings)

这时候,vector_store 已经具备了“接收文档、向量化、存储、相似搜索”的基本能力。


6.4 添加文档,本质上就是“建索引”

ids = vector_store.add_documents(documents=splits)

它做了两件事:

  1. 文档块被向量化
  2. 向量及其对应文档被存入向量存储

所以 add_documents() 并不是“普通 append 一下”。
它其实是在完成索引建立。

LangChain 官方 retrieval 教程里也是这个思路:先实例化向量存储,再通过 add_documents 把切好的文档块索引进去。(LangChain 文档)


6.5 相似性搜索:从“关键词匹配”切到了“语义匹配”

向量存储最核心的调用之一就是:

results = vector_store.similarity_search("数据库表怎么设计的?")

它的意义不是“看这几个字是否出现过”,
而是:

把这个查询转成向量,再去找语义上最接近的文档块。

官方文档里也明确把 similarity_search 作为统一接口的一部分,并指出很多向量存储还支持:

  • k:返回前几个结果
  • filter:按 metadata 过滤

这两个参数在真实项目里都非常常用。(LangChain 文档)


6.6 为什么 metadata filter 很重要

只靠语义搜索,有时候还不够。

比如我可能想:

  • 只搜某个知识库
  • 只搜某个时间范围
  • 只搜某类文档
  • 只搜某个模块

这时就要先按 metadata 缩小范围,再做相似性搜索。

官方向量存储文档里也把 metadata filtering 单独列了出来,并给了类似:

vector_store.similarity_search(
    "query",
    k=3,
    filter={"source": "tweets"}
)

这样的例子。(LangChain 文档)


6.7 相似分数、按向量查询、MMR,这些东西以后会越来越常见

LangChain 官方 retrieval 教程还展示了更多查询方式,比如:

  • similarity_search_with_score
  • similarity_search_by_vector
  • as_retriever(search_type="similarity" / "mmr")

其中 similarity_search_with_score 很适合调试,
因为你能看到“为什么它觉得这段更像”。

而 MMR(最大边际相关性)则很适合避免结果过度重复:
它不只是要“相关”,还想“多样”。
官方教程里也明确把 MMR 归到向量存储/检索的常见能力里。(LangChain 文档)


七、检索器(Retriever):为什么还要在 Vector Store 外面再包一层

7.1 既然 Vector Store 已经能搜了,还要 Retriever 干嘛?

这是一个非常自然的问题。

后来看官方文档,我一下就明白了:

Retriever 比 Vector Store 更抽象。

官方对 retriever 的定义是:

  • 它接收一个非结构化查询字符串
  • 返回一组 Document
  • 它比 vector store 更一般化
  • 它不一定能存文档,它只需要能“取回文档”

而且官方还明确说:vector store 可以转成 retriever,但 retriever 也可以来自别的系统,比如 Wikipedia 搜索、Amazon Kendra 等。(LangChain 文档)

这句话特别关键,因为它说明:

  • Vector Store 更偏底层数据存取
  • Retriever 更偏统一检索接口

7.2 区别

Vector Store 关注“怎么存、怎么搜”;Retriever 关注“给我一个问题,我返回相关文档”。

这就像什么?

  • 数据库负责存储和查询能力
  • Repository / Service 层负责提供更统一的业务接口

所以当我写链式调用时,我通常更喜欢面对 retriever
而不是每次都直接在 vector_store 上手敲各种搜索方法。


7.3 as_retriever():把向量存储转换成统一检索接口

这也是最常见的写法:

retriever = vector_store.as_retriever()

官方 retrieval 教程里也明确写到:vector stores 实现了 as_retriever,它会生成一个 VectorStoreRetriever,并且你可以通过:

  • search_type
  • search_kwargs

来指定底层搜索方式和参数。(LangChain 文档)

例如:

retriever = vector_store.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 4},
)

或者:

retriever = vector_store.as_retriever(
    search_type="mmr",
    search_kwargs={"k": 4, "fetch_k": 10},
)

这两段代码的意义都非常清楚:

  • 第一种:按相似度返回最相关结果
  • 第二种:先找候选,再做多样化筛选

7.4 RAG链

官方 retrieval 教程里专门强调了一点:

  • VectorStore 本身不是 Runnable
  • RetrieversRunnable

也就是说,retriever 可以天然接到 LangChain 标准链式协议里,支持像 invokebatch 这样的统一调用方式。(LangChain 文档)

这就是为什么,真正开始写 RAG 链时,retriever 会比 vector store 更顺手。

举个最简单的例子:

docs = retriever.invoke("数据库表怎么设计的?")

风格一下就统一起来了。


7.5 一句话总结 Retriever

如果要让我用一句话解释 Retriever,我会这么说:

它是检索系统对外暴露的标准提问接口。

你不再关心:

  • 下面是不是向量数据库
  • 是不是外部搜索引擎
  • 是不是知识库服务
  • 是不是自定义召回逻辑

你只关心一件事:

给它一个问题,它返回相关 Document 列表

这对后面做链式组合非常关键。


八、把这些组件真正串起来:写一个最小可理解的 RAG 链

8.1 完整代码

给一个最小但完整的 RAG 代码骨架。
它的目标很明确:

  • 读取一个 PDF
  • 切成小块
  • 建向量索引
  • 把向量存储转成 retriever
  • 用户提问时先检索,再回答
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

# 1. 加载文档
loader = PyPDFLoader("docs/项目说明.pdf")
docs = loader.load()

# 2. 切分文档
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    add_start_index=True,
)
splits = text_splitter.split_documents(docs)

# 3. 定义嵌入模型与向量存储
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
vector_store = InMemoryVectorStore(embedding=embeddings)

# 4. 建立索引
vector_store.add_documents(splits)

# 5. 转为检索器
retriever = vector_store.as_retriever(
    search_type="mmr",
    search_kwargs={"k": 4, "fetch_k": 10},
)

# 6. 定义提示词
prompt = ChatPromptTemplate.from_template(
    """你是一个负责问答的助手。
请严格基于给定上下文回答问题。
如果上下文中没有答案,就直接说“我不知道”,不要编造。

问题:
{question}

上下文:
{context}

答案:"""
)

# 7. 把检索到的文档列表拼成字符串
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

# 8. 定义聊天模型
model = ChatOpenAI(model="your-chat-model")

# 9. 组装 RAG 链
rag_chain = (
    {
        "context": retriever | format_docs,
        "question": RunnablePassthrough(),
    }
    | prompt
    | model
    | StrOutputParser()
)

# 10. 发起提问
answer = rag_chain.invoke("这个项目的技术栈有哪些?")
print(answer)

8.2 这段代码真正发生了什么

我把它拆成五步来理解:

第一步:把原始 PDF 接进系统
loader = PyPDFLoader("docs/项目说明.pdf")
docs = loader.load()

原始资料第一次变成 LangChain 世界里的 Document

第二步:把大文档切成合适粒度
splits = text_splitter.split_documents(docs)

这一步的目标不是“切碎”,而是让检索更准、上下文更省。

第三步:建立语义索引
vector_store.add_documents(splits)

这里背后发生的是:
文档块被 Embedding,并进入向量存储。

第四步:把检索能力标准化
retriever = vector_store.as_retriever(...)

后面链里就只管:

  • 给问题
  • 拿回相关文档
第五步:把“问题 + 检索结果”一起交给模型
{
    "context": retriever | format_docs,
    "question": RunnablePassthrough(),
}
| prompt
| model
| StrOutputParser()

8.3 RunnablePassthrough() 在这里到底做了什么

如果你前面几篇已经把 Runnable 和 LCEL 理解了,这里其实不难。

RunnablePassthrough() 的作用,是把原始输入问题原封不动继续往后传。

因为这条链里我们同时需要两份东西:

  1. retriever | format_docs 生成的上下文
  2. 原始问题本身

所以这里它不是在做复杂逻辑,
只是确保问题本身不会丢掉。


8.4 为什么 format_docs() 这个小函数非常常见

因为 Retriever 返回的是 Document 列表,
而提示词模板最终需要的是字符串上下文。

所以我们通常要做一步转换:

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

这个函数本身很小,但非常必要。
它完成了:

Document 列表 -> 可拼进 Prompt 的上下文字符串

你以后几乎在所有简单 RAG 示例里都会看到它的变体。


8.5 为什么 prompt 里一定要写“如果不知道就说不知道”

因为 RAG 最大的意义之一,就是减少幻觉
但它不是魔法。

如果你只是把上下文塞进去,却不明确约束模型,模型仍然可能:

  • 基于一点点相关内容过度发挥
  • 把没写的部分脑补出来
  • 用训练记忆补全上下文没有的信息

所以我现在写 RAG prompt 时,几乎都会加上类似约束:

请严格基于上下文回答
如果上下文中没有答案,就明确说不知道
不要编造

这看似简单,但其实是 RAG 是否真正“grounded”的关键一步。


九、怎么判断一个 RAG 系统到底有没有写对

9.1 先别急着看答案,先看检索出来的块对不对

RAG 最容易犯的错,就是一上来盯着最终回答看。

但真实排查顺序应该是:

先看 retriever 返回了什么
再看 prompt 里最终拼进去的上下文是什么
最后再看模型答案

因为很多问题根本不是模型回答错了,
而是:

  • 根本没检索到对的块
  • chunk 切得太碎
  • chunk 切得太大
  • metadata 过滤把正确结果滤掉了
  • k 太小,没召回足够信息

9.2 如果回答经常不准,优先排查这四件事

1. 文档切分是不是不合理

这是最常见的问题。
很多时候不是模型不行,而是 chunk 粒度错了。

2. 检索 top-k 是不是太少

比如答案本来要两三个片段拼起来,
结果你只召回了 1 个 chunk,那肯定不够。

3. Prompt 有没有约束“基于上下文作答”

如果没有,模型就很容易自由发挥。

4. metadata 有没有被好好利用

很多知识库其实天然分来源、时间、类别、项目、模块。
如果不利用 metadata,检索空间可能太大,噪声也会变多。


9.3 RAG 不是万能补丁,它只能补“知识缺口”,不能替你补“任务设计缺口”

这一点我现在越来越确定。

RAG 很强,但它主要解决的是:

  • 模型不知道你的私有知识
  • 模型知识可能过时
  • 回答需要基于具体材料

它并不能自动解决:

  • 复杂工作流编排
  • 多步决策
  • 长生命周期状态管理
  • 人工审核节点
  • 中断恢复
  • 多智能体协作

而这些问题,后面恰恰就是 LangGraph 要接手的地方。

所以你会发现,LangChain 这部分学到第六篇,主线已经很完整了;
但真正更复杂的“系统行为控制”,还在后面。


9.4 “会调模型”和“会做知识型 AI 应用”的差别

会调模型

通常意味着:

  • 会写 prompt
  • 会调用 chat model
  • 会拿到输出
会做知识型 AI 应用

通常意味着:

  • 能把资料接进来
  • 能设计 chunk 策略
  • 能建立索引
  • 能调检索
  • 能做 grounded generation
  • 能排查“错在检索还是错在生成”

说白了:

会调模型,只是有了嘴;会做 RAG,才开始长出脑子和记忆。


十、本篇总结

这一篇我真正想讲透的,不是一堆组件名字,而是 LangChain 在“外部知识接入”这件事上,到底做了怎样一套分层设计。

核心结论:

  1. Document 是 LangChain 里承载外部知识的统一数据结构。
    它至少包含文本内容和元数据,必要时还带唯一标识。

  2. Loader 的职责是把不同来源的数据,统一读成 Document
    它只负责“接入”,不负责切分、向量化和搜索。

  3. 文本切分器的作用,是把大文档变成更适合检索和喂给模型的小块。
    切分粒度往往直接决定 RAG 质量。

  4. Embedding 负责把文本和问题都转成可比较的向量。
    embed_documents 更偏索引阶段,embed_query 更偏查询阶段。

  5. Vector Store 负责管理这些向量,并提供相似性搜索能力。
    它让我们不需要自己手写海量向量管理与检索逻辑。

  6. Retriever 是比 Vector Store 更统一的检索接口。
    它接收问题,返回文档列表,非常适合进入 LangChain 链式调用体系。

  7. RAG 本质就是:先检索,再生成。
    它不是高级花活,而是知识型 AI 应用最基础也最关键的工作流。

如果用一句话总结这一篇,那就是:

到这一篇为止,我终于把 LangChain 的另一半地基补齐了:前一篇解决“怎么把模型输入输出组织规范”,这一篇解决“怎么把模型原本不知道的知识接进来”。


💬 后续承接方向:如果说 LangChain 到这里主要是在教我“组件怎么拼、RAG 怎么做”,那么后面的 LangGraph,真正要进入的就是另外一个层次:**状态怎么传、节点怎么拆、边怎么走、流程怎么恢复、人怎么介入。进入“智能体工作流编排。🚀


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐