零基础入门 LangChain 与 LangGraph(六):核心组件下篇——Document、加载器、文本切分、Embedding、向量存储、检索器与 RAG
文章目录
-
- 零基础入门 LangChain 与 LangGraph(六):核心组件下篇——Document、加载器、文本切分、Embedding、向量存储、检索器与 RAG
- 一、从“会聊天”到“会查资料”:这一篇才真正进入知识型 AI 应用
- 二、Document:为什么 LangChain 先把世界抽象成“文档对象”
- 三、文档加载器(Loader):先把乱七八糟的数据源,统一读进来
- 四、文本切分(Text Splitter):RAG 能不能做好,第一关键往往不在模型,而在切分
- 五、Embedding:把文本变成“可以比较”的数学对象
- 六、向量存储(Vector Store):不要自己手搓向量列表和相似度循环
- 七、检索器(Retriever):为什么还要在 Vector Store 外面再包一层
- 八、把这些组件真正串起来:写一个最小可理解的 RAG 链
- 九、怎么判断一个 RAG 系统到底有没有写对
- 十、本篇总结
零基础入门 LangChain 与 LangGraph(六):核心组件下篇——Document、加载器、文本切分、Embedding、向量存储、检索器与 RAG
💬 开篇:上一篇我们讲了消息、提示词模板、少样本、输出解析器,本质上解决的是“模型输入输出如何组织”。但这还只是让模型更会“说”,还没解决真正决定 AI 应用上限的问题:模型不知道的知识,到底怎么接进来?
👍 这一篇的核心任务:LangChain 核心组件:
Document、文档加载器、文本切分器、Embedding、向量存储、检索器,以及它们最终如何落到 RAG 上。LangChain 官方现在也把 retrieval 这条线明确拆成 document loaders、text splitters、embeddings、vector stores、retrievers,并在教程里直接串成最小可运行的 RAG workflow。(LangChain 文档)🚀 写完这一篇,我真正应该学会什么:
- 为什么外部知识进入 LangChain 之前,要先被抽象成
Document- 为什么文档不能直接整本喂给模型,而要先做加载、切分、向量化
- 为什么向量存储和检索器不是一个东西,却总是一起出现
- 为什么 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. 第二段:在线问答阶段
用户提问后,系统去检索最相关内容,再交给模型生成答案。
我建议先记住这张图:
这条链路,LangChain 官方 retrieval 教程也基本就是按这个思路来讲:文档加载、文本切分、Embedding、向量存储、检索器,最后再接到最小 RAG workflow 上。(LangChain 文档)
1.4 “知识如何进入模型”
外部知识进模型之前,必须先完成“统一表示、合理切分、向量化存储、可检索访问”这四步。
对应到组件就是:
DocumentLoaderSplitterEmbeddingVector StoreRetrieverRAG
所以你看到这里,不要把它当成一堆零散 API。
它们其实是在完成一个非常完整的工程任务:
把原本不属于模型的知识,变成模型可以在运行时临时借用的上下文。
二、Document:为什么 LangChain 先把世界抽象成“文档对象”
2.1 文件不是 Document,页也不是 Document,块也不是 Document——但它们都可以变成 Document
Document不是“某种具体文件格式”,而是 LangChain 用来统一承载外部知识的基础数据结构。
在 LangChain 官方 retrieval 教程里,Document 被定义成“表示一段文本及其关联元数据的抽象”,它有三个核心属性:
page_content:文本内容metadata:任意元数据id:可选的标识符
并且官方明确提醒:一个 Document 往往只是更大文档里的一个块,而不是整个原始文件。 (LangChain 文档)
这句话非常重要,因为它直接决定了你后面对“切分”的理解。
2.2 把 Document 理解成“知识运输箱”
如果用最朴素的类比来讲,我会把 Document 理解成一个知识运输箱。
这个运输箱里至少装两样东西:
- 真正要被检索的文字内容
- 跟这段文字相关的附加信息
例如:
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_content、metadata、id 到底分别有什么用
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 到底在解决什么问题
现实里的知识来源,从来不会整齐统一。
它可能来自:
- 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_size 和 chunk_overlap
文本切分器最核心的两个参数,几乎一定会出现:
chunk_sizechunk_overlap
它们的含义并不难。
1. chunk_size
每个文本块大概希望多大。
2. chunk_overlap
相邻块之间保留多少重叠内容。
为什么需要 overlap?
因为如果两段文本刚好在边界处被切开,很可能一句话前半段在上一个 chunk,后半段在下一个 chunk。
这会导致语义断裂。
官方教程里给的示例就是把文档切成 1000 个字符大小、200 个字符重叠,并明确说明 overlap 可以降低把一句话和其关键上下文硬生生拆开的风险。(LangChain 文档)
4.3 我最推荐的默认起点:RecursiveCharacterTextSplitter
官方文档对这件事其实给得很明确:
对于大多数通用文本场景,优先从 RecursiveCharacterTextSplitter 开始。因为它能较好地在“保持上下文完整”和“控制块大小”之间取得平衡。(LangChain 文档)
它的核心思路不是一刀切,而是以递归的方式:
- 先尽量按更大的自然结构切,比如段落
- 如果还太大,再退一步按更细粒度切,比如句子
- 还不行,再继续往更小单位退
也就是说,它不是先想着“把文本切碎”,
而是先想着“能不能尽量保住语义边界”。
这个设计非常符合 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_documentsdeletesimilarity_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)
它做了两件事:
- 文档块被向量化
- 向量及其对应文档被存入向量存储
所以 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_scoresimilarity_search_by_vectoras_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_typesearch_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本身不是RunnableRetrievers是Runnable
也就是说,retriever 可以天然接到 LangChain 标准链式协议里,支持像 invoke、batch 这样的统一调用方式。(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()的作用,是把原始输入问题原封不动继续往后传。
因为这条链里我们同时需要两份东西:
retriever | format_docs生成的上下文- 原始问题本身
所以这里它不是在做复杂逻辑,
只是确保问题本身不会丢掉。
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 在“外部知识接入”这件事上,到底做了怎样一套分层设计。
核心结论:
-
Document是 LangChain 里承载外部知识的统一数据结构。
它至少包含文本内容和元数据,必要时还带唯一标识。 -
Loader 的职责是把不同来源的数据,统一读成
Document。
它只负责“接入”,不负责切分、向量化和搜索。 -
文本切分器的作用,是把大文档变成更适合检索和喂给模型的小块。
切分粒度往往直接决定 RAG 质量。 -
Embedding 负责把文本和问题都转成可比较的向量。
embed_documents更偏索引阶段,embed_query更偏查询阶段。 -
Vector Store 负责管理这些向量,并提供相似性搜索能力。
它让我们不需要自己手写海量向量管理与检索逻辑。 -
Retriever 是比 Vector Store 更统一的检索接口。
它接收问题,返回文档列表,非常适合进入 LangChain 链式调用体系。 -
RAG 本质就是:先检索,再生成。
它不是高级花活,而是知识型 AI 应用最基础也最关键的工作流。
如果用一句话总结这一篇,那就是:
到这一篇为止,我终于把 LangChain 的另一半地基补齐了:前一篇解决“怎么把模型输入输出组织规范”,这一篇解决“怎么把模型原本不知道的知识接进来”。
💬 后续承接方向:如果说 LangChain 到这里主要是在教我“组件怎么拼、RAG 怎么做”,那么后面的 LangGraph,真正要进入的就是另外一个层次:**状态怎么传、节点怎么拆、边怎么走、流程怎么恢复、人怎么介入。进入“智能体工作流编排。🚀
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)