用户问"阿朱的结局是什么",知识库需要先找到小说中的相关片段,再让大模型根据片段回答。这就是 RAG(检索增强生成):先检索资料,再把资料交给模型作为回答依据。
这篇文章用同一个问题贯穿两种架构:先理解 LangChain 如何串起 RAG,再把同样的业务步骤交给 LangGraph 编排。迁移前后,继续使用《天龙八部》、Milvus 和相同的模型,变化集中在流程组织方式上。
项目中的 ebook-write.mjs 负责建库,naive-rag.mjs 已经实现了 LangGraph 版问答。下文的普通 LangChain 版本是为了对比而提炼的教学代码;图版本沿用项目的 Annotation.Root 写法,并简化了节点返回值。片段用于理解和改造现有文件,不是独立的完整程序。
先认识 LangChain 在这个项目中的作用。它提供统一的组件接口,让程序能够加载文档、切分文本、生成向量、检索资料和调用模型。
| 组件 | 作用 | 项目中的实现 |
|---|---|---|
| 文档加载器 | 从文件读取正文 | EPubLoader |
| 文本切分器 | 将长文档拆成小片段 | RecursiveCharacterTextSplitter |
| Embedding | 将文本转换为表示语义的数字向量 | OpenAIEmbeddings |
| 向量库接口 | 对接数据库,检索相似片段 | LangChain 的 Milvus 封装 |
| 对话模型接口 | 把问题和资料交给模型,取得回答 | ChatOpenAI |
Milvus 是实际存储和检索数据的数据库,LangChain 的向量库组件负责调用它。Embedding 模型负责生成向量,对话模型负责生成文字回答,这两个模型承担不同任务。
LangChain RAG 可以分成建库和问答两个阶段:
建库通常在首次导入或资料更新时执行,问答则随用户请求执行。每次提问都重新读取整本电子书、重新生成文档向量,会造成不必要的开销。
ebook-write.mjs 先按章节加载 EPUB,再把章节拆成目标大小为 500、重叠大小为 50 的文本片段:
js
const loader = new EPubLoader(EPUB_FILE, { splitChapters: true });
const chapters = await loader.load();
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
切块控制每段资料的长度,重叠尽量保留切分处的上下文。代码中的尺寸按文本长度计算,不是 token 数;切分后的每块也不保证都恰好等长。
每块生成向量后,程序把三类信息写入 ebook_collection:
| 信息 | 字段举例 | 用途 |
|---|---|---|
| 原文 | content |
检索后交给模型阅读 |
| 向量 | vector |
与问题向量比较相似度 |
| 元数据 | id、book_id、chapter_num、index |
标识和定位片段 |
查询端使用 Milvus.fromExistingCollection() 连接已有集合。字段映射要与建库脚本一致:
js
const vectorStore = await Milvus.fromExistingCollection(embeddings, {
collectionName: "ebook_collection",
url: "localhost:19530",
textField: "content",
primaryField: "id",
vectorField: "vector",
});
假设用户问"阿朱的结局是什么",在线问答可以归纳为两个业务函数:retrieve 找资料,generate 写答案。先把它们写清楚,后面迁移到图时可以直接复用。
下面的检索函数使用现有的 vectorStore。similaritySearchWithScore() 会通过配置的 Embedding 组件生成问题向量,再查询数据库,返回文档和分数:
js
async function retrieve(question, k) {
const matches = await vectorStore.similaritySearchWithScore(question, k);
return matches.map(([doc, score]) => ({
document: doc.pageContent,
score,
chapter_num: doc.metadata?.chapter_num ?? "未知",
}));
}
这里的 k=5 表示最多取 5 个相似片段,不表示找到 5 个正确答案。检索结果是否能支持回答,还需要结合片段内容判断。相似度分数也不是答案正确率。
生成函数接收问题与检索结果,组织 Prompt,并沿用项目里的模型流式输出:
js
async function generate(question, documents) {
if (documents.length === 0) {
return "知识库中没有找到可供回答的片段。";
}
const context = documents
.map((doc, i) =>
`[片段${i + 1}] 章节:${doc.chapter_num}\n${doc.document}`)
.join("\n\n");
const prompt = `请根据以下《天龙八部》片段回答问题。
资料不足时说明无法从片段确认,不要补充未经支持的情节。
引用依据时标明片段编号。
资料:
${context}
问题:${question}`;
let generation = "";
const stream = await model.stream(prompt);
for await (const chunk of stream) {
if (typeof chunk.content === "string") {
generation += chunk.content;
process.stdout.write(chunk.content);
}
}
return generation;
}
这个教学版本在调用模型前处理空结果。原代码在整张图执行完后才检查 documents,那时生成节点已经运行,检查无法阻止此前的模型调用。
资料、问题和回答约束共同组成 Prompt。"检索增强"就发生在这里:把数据库中找出的原文加入模型本次输入。提示词要求引用片段有助于核对来源,但仍需检查答案是否确实由片段支持。
有了这两个函数,一个普通的 LangChain RAG 流程就很直观:
js
async function answerWithLangChain(question, k = 5) {
const documents = await retrieve(question, k);
const generation = await generate(question, documents);
return { question, k, documents, generation };
}
const result = await answerWithLangChain("阿朱的结局是什么?");
读这段代码时,只需要沿着变量追踪:question 进入检索,检索得到 documents,资料进入生成,生成得到 generation。执行顺序由函数内的两次 await 决定。
固定的"检索一次、生成一次"可以一直使用这种写法。引入 LangGraph,是为了把执行步骤、数据状态和连接关系显式表达出来,方便后续扩展与观察。LangChain 本身也能组合链和分支;这里选择 LangGraph,是为这个系列统一流程编排方式。
迁移时,先记住三个概念:
| 概念 | 本例中的含义 | 对应原流程 |
|---|---|---|
| State:状态 | 本次问答的数据 | 问题、检索数量、文档、答案 |
| Node:节点 | 读取状态、执行操作、返回更新的函数 | 检索函数、生成函数 |
| Edge:边 | 节点之间的执行顺序 | 先检索,后生成 |
LangGraph 节点接收当前状态,再返回需要更新的字段。节点内部仍可以调用 LangChain 的模型和检索组件。LangGraph 官方说明
先把刚才的四个变量定义为图状态:
js
import { Annotation, StateGraph, START, END } from "@langchain/langgraph";
const GraphState = Annotation.Root({
question: Annotation({
default: () => "",
reducer: (_prev, next) => next,
}),
k: Annotation({
default: () => 5,
reducer: (_prev, next) => next,
}),
documents: Annotation({
default: () => [],
reducer: (_prev, next) => next,
}),
generation: Annotation({
default: () => "",
reducer: (_prev, next) => next,
}),
});
default 提供初始值,reducer 决定已有值与节点更新怎样合并。本例的 (_prev, next) => next 使用新值覆盖旧值。例如检索节点返回 { documents: [...] } 后,只更新文档字段,问题和检索数量仍保留。
这里的 State 用于一次图执行中的数据传递。当前代码没有配置 checkpointer,因此不能把它理解为自动保存到磁盘的长期记忆,也没有自动获得重启后恢复任务的能力。
接着,为两个已有函数增加节点包装:
js
const retrieveNode = async (state) => {
const documents = await retrieve(state.question, state.k);
return { documents };
};
const generateNode = async (state) => {
const generation = await generate(state.question, state.documents);
return { generation };
};
普通函数用参数和返回值传递数据;节点通过 state 读取输入,返回一个状态更新对象。原项目节点会同时返回未改变的 question、k 等字段,这里省略它们,便于看清每个节点真正写入了什么。
最后,注册节点并连接执行顺序:
js
const graph = new StateGraph(GraphState)
.addNode("retrieve", retrieveNode)
.addNode("generate", generateNode)
.addEdge(START, "retrieve")
.addEdge("retrieve", "generate")
.addEdge("generate", END)
.compile();
const result = await graph.invoke({
question: "阿朱的结局是什么?",
k: 5,
});
addNode 注册工作步骤,addEdge 规定下一步,compile() 得到可执行的图,invoke() 传入状态并运行。上面省略的 documents 和 generation 会使用默认值。
项目中的 a.md 正好对应这张基础图:
仍以"阿朱的结局是什么"为例,状态经历以下变化。表中的文档与答案只是数据形状示意:
| 执行位置 | question | k | documents | generation |
|---|---|---|---|---|
| 初始输入 | 阿朱的结局是什么? | 5 | 空数组 | 空字符串 |
| 检索完成 | 保留 | 保留 | 检索到的片段及分数 | 空字符串 |
| 生成完成 | 保留 | 保留 | 保留 | 根据片段生成的答案 |
最终 result.documents 可以用来检查证据,result.generation 是完整回答。示例已在生成时逐块打印答案,若再打印完整 generation,终端就会出现重复内容;可以只打印检索日志,按需要读取最终答案。
原代码里的 model.stream() 表示模型文本流式输出。即使外层使用 graph.invoke(),节点内部仍可以逐块打印。它与按节点观察图状态变化是两个层次,不要因为看到流式文字,就认为所有图状态也已经向调用方逐步输出。
迁移前后的对应关系可以留作复习:
| 复习点 | LangChain 组件直接串联 | 使用 LangGraph 编排 |
|---|---|---|
| 流程入口 | answerWithLangChain(question) |
graph.invoke({ question }) |
| 中间数据 | 函数局部变量 | GraphState 中的字段 |
| 检索实现 | retrieve(question, k) |
retrieveNode 调用同一函数 |
| 生成实现 | generate(question, documents) |
generateNode 调用同一函数 |
| 执行顺序 | 函数体中的 await 顺序 |
addEdge() 定义的连接 |
| 数据基础 | EPUB、Embedding、Milvus | 继续复用 |
运行前,还要核对代码里的几项配置。这些配置会影响 RAG 能否正常工作,与是否使用图编排无关。
- 建库的向量维度是 1024 。查询端应使用同一 Embedding 模型和兼容配置,确认实际输出也是 1024 维。若服务支持
dimensions参数,两端统一设置;仅把不同模型设成同维度,并不能让向量语义空间一致。 - 建库脚本创建 IVF_FLAT 索引,查询代码却出现 HNSW 和
ef配置。应以 Milvus 集合的实际索引为准,统一对应搜索参数,不能认为连接已有集合就完成了索引切换。 - 原检索函数捕获所有异常后返回空数组,会把数据库故障当成没有结果。应明确报错,让"检索失败"和"检索成功但为空"可以区分。
book_id在集合中定义为VarChar,写入脚本却使用数字1,应统一为字符串。chapter_num来自加载后的文档顺序,正式展示章节来源时还需核对 EPUB 目录信息。
从 advanced-rag 目录运行,可以让 EPUB 相对路径与代码中的约定一致:
powershell
cd D:\workspace\ysh_ai\ai\agent\agentic_rag\advanced-rag
npm install
项目根目录的 .env 使用这些变量:
dotenv
OPENAI_API_KEY=你的密钥
OPENAI_BASE_URL=模型服务地址
MODEL_NAME=对话模型名称
EMBEDDINGS_MODEL_NAME=向量模型名称
启动 Milvus、核对配置后,首次导入资料时运行建库脚本,再运行问答脚本:
powershell
node .\src\ebook-write.mjs
node .\src\naive-rag.mjs
已有可用集合时,直接运行问答脚本即可。建库脚本尚未提供完整的重复导入管理,不要把建库当成每次问答前必做的步骤。以上代码与流程依据源文件整理,未实际调用模型或数据库验证。
日后复习时,可以用三个问题检查是否掌握:documents 是谁写入的?为什么生成节点不需要重新检索?如果调整执行顺序,应修改业务函数还是图中的边?答案分别是检索节点、共享状态已经保留了结果,以及调整边的连接关系。
本篇把"问题 → 检索 → 生成"整理成了固定状态图。下一篇继续沿用这套知识库、字段和节点,再讨论如何根据运行中的信息选择下一步,让系列中的架构演进有明确的起点。