从 LangChain 迁移到 LangGraph:重构 RAG 知识库流程

作者:袖梨 2026-09-19

一个知识库问答系统要可靠回答问题,不能只依赖模型记忆,而应先检索能够支撑答案的原始资料。以电子书问答为例,检索、上下文组装和模型生成可以直接串联,也可以交给图结构统一编排。接下来将沿用同一套数据与业务函数,对比LangChain流程并完成向LangGraph的迁移。

用户问“阿朱的结局是什么”,知识库需要先找到小说中的相关片段,再让大模型根据片段回答。这就是 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 可以分成建库和问答两个阶段:

flowchart TD
    A[电子书] --> B[加载正文并切块]
    B --> C[Embedding:片段转向量]
    C --> D[(Milvus:原文、向量、元数据)]
    E[用户问题] --> F[Embedding:问题转向量]
    F --> G[检索最相似的 k 个片段]
    D --> G
    G --> H[拼接问题、片段和回答要求]
    H --> I[对话模型生成答案]

建库通常在首次导入或资料更新时执行,问答则随用户请求执行。每次提问都重新读取整本电子书、重新生成文档向量,会造成不必要的开销。

ebook-write.mjs 先按章节加载 EPUB,再把章节拆成目标大小为 500、重叠大小为 50 的文本片段:

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与问题向量比较相似度
元数据idbook_idchapter_numindex标识和定位片段

查询端使用 Milvus.fromExistingCollection() 连接已有集合。字段映射要与建库脚本一致:

const vectorStore = await Milvus.fromExistingCollection(embeddings, {
  collectionName: "ebook_collection",
  url: "localhost:19530",
  textField: "content",
  primaryField: "id",
  vectorField: "vector",
});

假设用户问“阿朱的结局是什么”,在线问答可以归纳为两个业务函数:retrieve 找资料,generate 写答案。先把它们写清楚,后面迁移到图时可以直接复用。

下面的检索函数使用现有的 vectorStoresimilaritySearchWithScore() 会通过配置的 Embedding 组件生成问题向量,再查询数据库,返回文档和分数:

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,并沿用项目里的模型流式输出:

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("nn");

  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 流程就很直观:

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 官方说明

先把刚才的四个变量定义为图状态:

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,因此不能把它理解为自动保存到磁盘的长期记忆,也没有自动获得重启后恢复任务的能力。

接着,为两个已有函数增加节点包装:

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 读取输入,返回一个状态更新对象。原项目节点会同时返回未改变的 questionk 等字段,这里省略它们,便于看清每个节点真正写入了什么。

最后,注册节点并连接执行顺序:

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() 传入状态并运行。上面省略的 documentsgeneration 会使用默认值。

项目中的 a.md 正好对应这张基础图:

flowchart LR
    S([START]) --> R[retrieve]
    R --> G[generate]
    G --> E([END])

仍以“阿朱的结局是什么”为例,状态经历以下变化。表中的文档与答案只是数据形状示意:

执行位置questionkdocumentsgeneration
初始输入阿朱的结局是什么?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 索引,查询代码却出现 HNSWef 配置。应以 Milvus 集合的实际索引为准,统一对应搜索参数,不能认为连接已有集合就完成了索引切换。
  • 原检索函数捕获所有异常后返回空数组,会把数据库故障当成没有结果。应明确报错,让“检索失败”和“检索成功但为空”可以区分。
  • book_id 在集合中定义为 VarChar,写入脚本却使用数字 1,应统一为字符串。chapter_num 来自加载后的文档顺序,正式展示章节来源时还需核对 EPUB 目录信息。

advanced-rag 目录运行,可以让 EPUB 相对路径与代码中的约定一致:

cd D:workspaceysh_aiaiagentagentic_ragadvanced-rag
npm install

项目根目录的 .env 使用这些变量:

OPENAI_API_KEY=你的密钥
OPENAI_BASE_URL=模型服务地址
MODEL_NAME=对话模型名称
EMBEDDINGS_MODEL_NAME=向量模型名称

启动 Milvus、核对配置后,首次导入资料时运行建库脚本,再运行问答脚本:

node .srcebook-write.mjs
node .srcnaive-rag.mjs

已有可用集合时,直接运行问答脚本即可。建库脚本尚未提供完整的重复导入管理,不要把建库当成每次问答前必做的步骤。以上代码与流程依据源文件整理,未实际调用模型或数据库验证。

日后复习时,可以用三个问题检查是否掌握:documents 是谁写入的?为什么生成节点不需要重新检索?如果调整执行顺序,应修改业务函数还是图中的边?答案分别是检索节点、共享状态已经保留了结果,以及调整边的连接关系。

本篇把“问题 → 检索 → 生成”整理成了固定状态图。下一篇继续沿用这套知识库、字段和节点,再讨论如何根据运行中的信息选择下一步,让系列中的架构演进有明确的起点。

相关文章

精彩推荐