学习了 LangGraph 之后,我用《天龙八部》做了一个最小 RAG:EPUB 切片、Embedding、Milvus 向量检索,再通过 LangGraph 串起 Retrieve 和 Generate。本文不堆 API,而是把 RAG 的数据流和 LangGraph 在其中扮演的角色彻底理顺。
前段时间刚把 LangGraph 的基础流程过了一遍。
State、Node、Edge、Conditional Edge......
单独看这些概念其实并不难,但学完之后很容易产生一个问题:
这些东西在真正的 AI 应用里,到底怎么串起来?
这次我拿 RAG 做了一个很小的 Demo。
知识库就一本《天龙八部》,向量数据库用 Milvus,流程用 LangGraph 编排。
最后要实现的事情也很简单:
markdown
用户:阿朱的结局是什么?
↓
从《天龙八部》中找到相关片段
↓
把相关片段交给 LLM
↓
基于原文回答
代码跑通之后,我反而觉得这可能是理解 LangGraph 最合适的 Demo 之一。
因为它把几个之前比较抽象的概念一下串起来了。
一、先说清楚:这不是 GraphRAG
这里很容易出现一个术语误区。
我们现在做的是:
LangGraph + RAG
并不能直接叫:
GraphRAG
真正意义上的 GraphRAG,比如 Microsoft GraphRAG,会从原始文本中抽取实体和关系,构建知识图谱、社区层级和社区摘要,再利用这些结构完成检索。
而我们现在做的事情简单得多:
StateGraph
↓
retrieve
↓
generate
也就是:
使用 LangGraph 编排一个传统 RAG 工作流。
这篇文章讨论的也是这个东西。
二、RAG 到底解决什么问题?
假设我直接问大模型:
阿朱的结局是什么?
模型可能本身就知道。
但换个场景:
我们公司去年 Q4 的退款规则是什么?
模型大概率不知道。
因为这份规则可能只存在于:
公司 PDF
内部 Wiki
数据库
会议文档
私有知识库
LLM 有推理能力,但它没有天然访问这些私有数据的能力。
RAG 做的事情,本质上就是在模型回答之前增加一步:
先查资料,再回答。
也就是 Retrieval-Augmented Generation:
diff
Retrieval
检索
+
Generation
生成
一个最朴素的 RAG,可以浓缩成:
用户问题
↓
检索相关文档
↓
把文档放进 Prompt
↓
LLM 回答
听起来非常简单。
但这里其实少了一半。
因为在"检索"之前,我们得先把知识库准备好。
三、一个完整 RAG,其实有两条流水线
这是我这次写 Demo 后最大的一个认知。
RAG 不应该只看成:
问题 → 向量搜索 → LLM
它实际上有两个完全不同的阶段。
第一条:离线索引
EPUB
↓
解析
↓
切分文本
↓
Embedding
↓
Vector
↓
Milvus
作用是:
把书变成可以被搜索的数据。
第二条:在线查询
css
用户问题
↓
Embedding
↓
Milvus 相似度搜索
↓
Top K 文档片段
↓
Prompt
↓
LLM
↓
回答
作用是:
根据问题,从已经准备好的知识库中查资料。
把这两条线分开,整个 RAG 一下就清晰了。
四、第一步:先把《天龙八部》变成知识库
我的原始数据就是:
天龙八部.epub
首先使用 EPubLoader 解析:
ini
const loader = new EPubLoader(EPUB_FILE, {
splitChapters: true,
});
const documents = await loader.load();
这一步之后,一整本书被拆成若干 Document。
大概可以理解成:
css
[ { pageContent: "第一章内容..." }, { pageContent: "第二章内容..." }]
但问题来了。
一整章还是太长。
所以还需要继续切。
五、Chunk:为什么不能直接把整本书存进去?
我这里使用:
ini
const textSplitter =
new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
把一个章节继续拆成:
erlang
chapter
↓
chunk 1
chunk 2
chunk 3
chunk 4
...
每块大约 500 个字符。
同时让前后两个 chunk 重叠 50 个字符。
为什么要重叠?
假设有一句话刚好被切在边界:
diff
chunk 1:
......阿朱为了救萧峰,决定假扮成段正淳
-----------------
chunk 2:
最终死在萧峰掌下......
如果完全没有 overlap,两个片段之间的语义就可能被硬生生切断。
所以加入一点重叠:
markdown
chunk1
───────────────████
████──────────────
chunk2
让上下文不会因为切块完全断掉。
这也是 RAG 里一个很重要的问题:
Chunk 怎么切,会直接影响后面的检索质量。
六、Embedding:把文字变成可以计算的东西
Milvus 不是真的拿两段中文直接比较。
我们需要先把文本转换成向量。
ini
const vector =
await embeddings.embedQuery(chunk);
比如:
erlang
"阿朱最后死在萧峰掌下"
↓ Embedding
[
0.123,
-0.438,
0.771,
...
]
我的 Demo 使用的是:
yaml
1024 维向量
这里真正重要的不是 1024。
而是:
语义相近的文本,在向量空间中的距离通常也会比较接近。
于是:
阿朱最后怎么样了?
和:
阿朱最终死在萧峰掌下
虽然文字并不完全相同,但 Embedding 后可能依然比较接近。
这就是语义检索能够成立的基础。
七、Milvus:真正存进去的不是只有 Vector
最终写入 Milvus 的一条数据,大概是:
yaml
{
id: "1_20_3",
book_id: "1",
book_name: "天龙八部",
chapter_num: 20,
index: 3,
content: "阿朱......",
vector: [...]
}
这里最值得注意的是:
arduino
content
+
vector
它们是一一对应的。
arduino
原始文本
│
├──────────────→ content
│
↓
Embedding
│
└──────────────→ vector
Vector 用来搜索。
Content 用来给 LLM 看。
所以向量数据库不是:
一个只存一堆数字的地方。
我们最终仍然需要通过向量找到对应的原文。
八、到这里,离线阶段结束
现在整个知识库准备过程就结束了:
markdown
《天龙八部》
↓
EPubLoader
↓
章节
↓
TextSplitter
↓
Chunks
↓
Embedding
↓
Vectors
↓
Milvus
注意:
这时候还没有任何 AI 问答发生。
我们只是把数据处理成了一个"可以被搜索的知识库"。
接下来才是真正的 RAG。
九、LangGraph 开始登场
我的 RAG State 非常简单:
php
const GraphState = Annotation.Root({
question: Annotation,
k: Annotation,
documents: Annotation,
generation: Annotation
});
不用把它想复杂。
运行过程中,本质上就是维护这样一个对象:
yaml
{
question: "阿朱的结局是什么?",
k: 5,
documents: [],
generation: ""
}
四个字段分别代表:
question
用户的问题
k
准备检索几个片段
documents
Milvus 找回来的内容
generation
模型最终生成的答案
这就是 LangGraph 的 State。
十、第一个 Node:Retrieve
第一个节点负责:
找资料。
核心只有一句:
ini
const docsWithScores =
await vectorStore.similaritySearchWithScore(
question,
k
);
假设:
ini
question = "阿朱的结局是什么?";
k = 5;
它会做:
css
问题
↓
Embedding
↓
问题向量
↓
Milvus
↓
计算向量相似度
↓
Top 5
最后拿回:
片段 1
片段 2
片段 3
片段 4
片段 5
于是 State 从:
{
question,
k
}
变成:
{
question,
k,
documents
}
这就是:
retrieve Node
干的全部事情。
十一、第二个 Node:Generate
现在 State 里已经有资料了。
接下来:
c
const context = state.documents
.map(...)
.join("\n\n");
把文档整理成:
css
[片段 1]
章节:第 20 章
内容:......
----------------
[片段 2]
章节:第 21 章
内容:......
然后把它们塞进 Prompt:
你是一个《天龙八部》小说助手。
下面是从小说中检索出的内容:
{context}
用户问题:
阿朱的结局是什么?
请基于提供的小说内容回答。
注意这里真正发生的事情。
LLM 之前只有:
Question
现在变成:
Context + Question
也就是:
原本:
Question
↓
LLM
RAG:
Question
↓
Retrieve
↓
Context
↓
Context + Question
↓
LLM
这就是所谓:
Retrieval-Augmented Generation。
Generation 被 Retrieval 增强了。
十二、最后用 LangGraph 把两个 Node 串起来
代码反而是最简单的:
sql
const graph = new StateGraph(GraphState)
.addNode("retrieve", retrieveNode)
.addNode("generate", generateNode)
.addEdge(START, "retrieve")
.addEdge("retrieve", "generate")
.addEdge("generate", END)
.compile();
得到:
sql
START
↓
retrieve
↓
generate
↓
END
然后:
php
await graph.invoke({
question: "阿朱的结局是什么?",
k: 5,
documents: [],
generation: ""
});
整个工作流就开始运行。
所以如果把这份代码压缩到极致:
State
↓
retrieve
↓
更新 documents
↓
generate
↓
更新 generation
LangGraph 官方对 Graph API 的抽象其实也是类似的三个核心:
State
Node
Edge
Node 负责干活,Edge 决定下一步去哪。
十三、等等------这么简单的 RAG,为什么还要 LangGraph?
这是我写完之后觉得最值得问的问题。
因为目前:
retrieve
↓
generate
完全可以不用 LangGraph。
普通 JavaScript:
ini
const documents = await retrieve(question);
const answer =
await generate(question, documents);
一样能跑。
所以:
如果只是为了实现一个两步 RAG,LangGraph 并不是必须的。
那为什么还要把它画成 Graph?
因为这只是第一版。
现在我们的流程是:
sql
START
↓
retrieve
↓
generate
↓
END
但真实业务很快就会变成:
markdown
┌→ 直接回答
│
用户问题 → 判断问题 ┤
│
└→ RAG
↓
retrieve
↓
评估结果
↙ ↘
足够 不够
↓ ↓
generate 重写问题
↓
再次检索
这时候普通的一条 Chain 就会开始越来越别扭。
而 Graph 的价值开始出现了。
十四、Naive RAG 最大的问题:太听话了
目前这套 RAG 有一个非常明显的特点:
问题进来
↓
一定检索
↓
一定生成
它不会思考。
比如用户问:
你好
它还是:
Embedding
↓
Milvus
↓
搜索《天龙八部》
显然没必要。
再比如:
段誉和乔峰两个人的人生经历有什么共同点?
可能一次 TopK 检索并不足以得到完整答案。
还有一种情况:
用户问题
↓
Milvus
↓
检索到了 5 个片段
系统现在默认:
"搜出来了,那肯定就是对的。"
但实际上可能 5 个全是垃圾。
当前系统没有任何节点去判断:
这次检索质量到底行不行?
这也是 Naive RAG 最大的问题之一。
它是一条非常固定的 Pipeline:
Retrieve
↓
Generate
能工作。
但不会判断。
不会纠错。
不会重新规划。
十五、这时候 Agentic RAG 就开始变得合理了
假设我们继续往图里加节点。
markdown
START
↓
route
↓
retrieve
↓
grade
↓
┌───────────────────┐
│ │
相关 不相关
│ │
↓ ↓
generate rewrite
↓
retrieve
这里突然出现了几个以前没有的能力:
arduino
route
判断到底需不需要知识库
grade
判断检索结果质量
rewrite
搜索不好就重写 Query
loop
重新检索
于是 RAG 从:
一条固定流水线
开始变成:
一个会根据结果选择下一步的系统。
这也是我现在对 Agentic RAG 最直观的理解:
不是简单地给 RAG 加一个 Agent 名字,而是让检索流程拥有"决策权"。
十六、重新看 LangGraph,就完全不一样了
刚学 LangGraph 时:
State
Node
Edge
Conditional Edge
很容易变成几个需要记忆的 API。
但把它放到 RAG 里面:
ini
State
= 当前问题、检索结果、生成答案
Node
= Retrieve、Grade、Rewrite、Generate
Edge
= 下一步执行谁
Conditional Edge
= 根据检索质量决定下一步去哪
它们一下都有了实际意义。
所以我现在反而不太想把 LangGraph 理解成:
"一个画流程图的库。"
更准确一点应该是:
把一个复杂 AI 应用里的状态、步骤和决策显式化。
十七、最后用一张图总结整个 Demo
这次实现可以完整拆成:
scss
RAG
│
┌─────────────┴─────────────┐
│ │
Offline Indexing Online Query
│ │
EPUB Question
↓ ↓
Loader Embedding
↓ ↓
Documents Milvus
↓ ↓
Text Splitter Top K
↓ ↓
Chunk Documents
↓ ↓
Embedding Context
↓ ↓
Vector Prompt
↓ ↓
Milvus LLM
↓
Answer
而在线部分又被 LangGraph 表达成:
markdown
GraphState
│
│ question
↓
retrieveNode
│
│ documents
↓
generateNode
│
│ generation
↓
END
到这里,一个最基础的 RAG 就彻底跑通了。
写在最后
以前看 RAG 时,很容易记住:
sql
Embedding
Vector Database
Chunk
Similarity Search
Prompt
但这些词单独记下来没有太大意义。
真正重要的是把数据流串起来:
文档
↓
Chunk
↓
Embedding
↓
Vector DB
问题
↓
Embedding
↓
Retrieve
↓
Context
↓
LLM
而这次用 LangGraph 再把在线流程表达成:
retrieve → generate
之后,我对两件事情都清楚了很多。
RAG 解决的是"模型回答前去哪里找知识"。
LangGraph 解决的是"整个 AI 系统下一步应该执行什么"。
现在的:
sql
START → retrieve → generate → END
还很简单。
真正有意思的部分,是下一步:
问题是否需要 RAG?
检索结果是否可信?
搜不到怎么办?
是否需要改写 Query?
能不能重新检索?
什么时候搜索网络?
什么时候直接回答?
当这些判断开始进入流程:
Naive RAG,也就开始向 Agentic RAG 演进了。
而这,才是 LangGraph 真正开始发挥价值的地方。