1. 选择框架前先选择流程
手写最小 RAG 以后,我们已经知道检索和生成分别做什么。接下来使用 LangChain 和 LangGraph,重点不再是减少几行代码,而是选择合适的执行方式。
公司制度问答通常希望每次都先检索,调用次数和延迟容易控制,适合 2-Step RAG。研究助手面对普通闲聊时可能不需要查资料,遇到事实问题时又要在多个知识源之间选择,适合 Agentic RAG。对权限、质量和重试有明确要求的系统,则更适合用 LangGraph 把步骤固定下来。
这三种方式没有高低之分。流程越自主,灵活性越高,测试空间和故障路径也会随之增加。
2. 使用当前依赖
本章按照当前 LangChain JavaScript 文档使用 createAgent(),向量存储使用 @langchain/classic 中的 MemoryVectorStore,文本切分使用独立的 @langchain/textsplitters 包。LangGraph 使用 StateSchema、GraphNode 和 StateGraph。
1yarn add langchain @langchain/core @langchain/classic \2@langchain/openai @langchain/textsplitters \3@langchain/langgraph zod
网上仍然能看到 createReactAgent()、旧版 chain helper 和 Annotation.Root() 等示例。它们可能对应旧版本或不同层级的 API,复制前要先核对当前官方文档和项目锁定版本。
LangChain 当前的 Retrieval 文档 将 2-Step、Agentic 和 Hybrid 作为不同 RAG 架构;LangGraph 当前 Graph API 使用 StateSchema 定义图状态。
3. 准备共享知识库
三种架构可以共用同一个 Retriever。我们先准备一份内存知识库:
01import { Document } from '@langchain/core/documents'02import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory'03import { OpenAIEmbeddings } from '@langchain/openai'0405const embeddings = new OpenAIEmbeddings({06model: 'text-embedding-3-small',07})0809export const vectorStore = await MemoryVectorStore.fromDocuments(10[11new Document({12id: 'leave-policy:v4:1',13pageContent:14'少于两天的年假,由直属负责人审批。',15metadata: {16source: '员工考勤制度',17version: 4,18topic: 'leave',19},20}),21new Document({22id: 'leave-policy:v4:2',23pageContent:24'连续两天及以上的年假,需要直属负责人和部门负责人共同审批。',25metadata: {26source: '员工考勤制度',27version: 4,28topic: 'leave',29},30}),31new Document({32id: 'expense-policy:v2:1',33pageContent:34'差旅报销应在费用发生后 30 天内提交。',35metadata: {36source: '差旅报销制度',37version: 2,38topic: 'expense',39},40}),41],42embeddings,43)4445export const retriever = vectorStore.asRetriever({46k: 6,47searchType: 'mmr',48searchKwargs: {49fetchK: 20,50},51})
内存向量库只用于教学。替换为持久化服务时,Retriever 以上的流程仍然可以保留。
4. 2-Step RAG
2-Step RAG 的检索一定发生在生成之前。它执行一次 Retriever 和一次聊天模型,最适合作为第一版。
01import type { Document } from '@langchain/core/documents'02import { ChatOpenAI } from '@langchain/openai'03import { retriever } from './knowledge-base'0405const model = new ChatOpenAI({06model: 'gpt-4.1-mini',07temperature: 0,08})0910function formatDocuments(documents: Document[]) {11return documents12.map((document, index) => {13return [14`[S${index + 1}]`,15`来源:${document.metadata.source}`,16document.pageContent,17].join('\n')18})19.join('\n\n')20}2122export async function runTwoStepRag(question: string) {23const documents = await retriever.invoke(question)24const context = formatDocuments(documents)2526const response = await model.invoke([27{28role: 'system',29content: `你是公司制度助手。30只能依据用户消息中提供的资料回答。31资料不足时说明无法确认。32回答事实时标注来源编号。`,33},34{35role: 'user',36content: `资料:37${context}3839问题:40${question}`,41},42])4344return {45answer: response.content,46documents,47}48}
这段代码没有把 Retriever 藏在 Agent 内部,因此容易测试和追踪。检索失败时查看 documents,回答失败时再检查 Prompt 和模型。
对于公司知识库、客服 FAQ 和固定领域问答,优先使用这种结构通常更稳。
5. Agentic RAG
Agentic RAG 把检索变成工具。模型可以根据对话判断要不要查、用什么查询词查,以及是否再次检索。
当前 LangChain v1 使用 tool() 定义工具,使用 createAgent() 创建 Agent:
01import * as z from 'zod'02import { createAgent, tool } from 'langchain'03import { ChatOpenAI } from '@langchain/openai'04import { retriever } from './knowledge-base'0506const searchPolicy = tool(07async ({ query }) => {08const documents = await retriever.invoke(query)0910if (documents.length === 0) {11return '没有找到相关制度。'12}1314return documents15.map((document, index) => {16return [17`[S${index + 1}]`,18`来源:${document.metadata.source}`,19document.pageContent,20].join('\n')21})22.join('\n\n')23},24{25name: 'search_company_policy',26description:27'查询公司的请假、考勤和报销制度。涉及公司规则时使用;普通闲聊不要调用。',28schema: z.object({29query: z30.string()31.min(2)32.describe('独立完整、适合检索制度的中文问题'),33}),34},35)3637const agent = createAgent({38model: new ChatOpenAI({39model: 'gpt-4.1-mini',40temperature: 0,41}),42tools: [searchPolicy],43systemPrompt: `你是公司助手。44涉及公司制度时,必须先调用 search_company_policy。45只能根据工具返回的资料陈述制度。46工具没有找到资料时,不要自行编造。`,47})4849const result = await agent.invoke({50messages: [51{52role: 'user',53content: '我想休两天年假,需要哪些人审批?',54},55],56})
工具描述很重要。描述过于宽泛,Agent 可能在普通聊天中频繁调用;描述过于狭窄,又会漏掉同义表达。
Agentic RAG 还要限制循环次数、工具权限和返回数据量。模型如果连续改写并检索五次,答案未必更好,延迟和成本却会明显增加。
6. 使用 LangGraph 固定质量流程
当流程中加入查询改写、检索判断、重试和降级后,把所有逻辑放进一个函数会越来越难读。LangGraph 适合把这些步骤明确成节点。
下面定义一个受控 RAG 状态:
01import * as z from 'zod'02import { StateSchema } from '@langchain/langgraph'0304export const RagState = new StateSchema({05question: z.string(),06retrievalQuery: z.string().default(''),07documents: z08.array(09z.object({10id: z.string(),11content: z.string(),12source: z.string(),13}),14)15.default(() => []),16retrievalPassed: z.boolean().default(false),17answer: z.string().default(''),18})
图状态只保存后续节点确实需要的数据。完整向量、数据库连接和模型客户端不适合塞进可持久化状态,可以通过模块依赖或 Runtime Context 传入。
接下来实现节点:
001import * as z from 'zod'002import type { GraphNode } from '@langchain/langgraph'003import { ChatOpenAI } from '@langchain/openai'004import { RagState } from './rag-state'005import { retriever } from './knowledge-base'006007const model = new ChatOpenAI({008model: 'gpt-4.1-mini',009temperature: 0,010})011012const rewriteQuery: GraphNode<typeof RagState> = async (state) => {013const response = await model.invoke([014{015role: 'system',016content:017'把用户问题改写成独立、完整的制度检索问题。只输出改写结果。',018},019{020role: 'user',021content: state.question,022},023])024025return {026retrievalQuery:027typeof response.content === 'string'028? response.content.trim()029: state.question,030}031}032033const retrieve: GraphNode<typeof RagState> = async (state) => {034const documents = await retriever.invoke(state.retrievalQuery)035036return {037documents: documents.map((document, index) => ({038id: document.id ?? `candidate-${index}`,039content: document.pageContent,040source: String(document.metadata.source ?? 'unknown'),041})),042}043}044045const gradeSchema = z.object({046passed: z.boolean(),047reason: z.string(),048})049050const gradeModel = model.withStructuredOutput(gradeSchema, {051name: 'grade_retrieval',052})053054const gradeRetrieval: GraphNode<typeof RagState> = async (state) => {055if (state.documents.length === 0) {056return { retrievalPassed: false }057}058059const grade = await gradeModel.invoke([060{061role: 'system',062content:063'判断候选资料是否包含回答用户问题所需的信息。不要补充资料之外的知识。',064},065{066role: 'user',067content: `问题:${state.question}068069候选资料:070${state.documents.map((document) => document.content).join('\n\n')}`,071},072])073074return {075retrievalPassed: grade.passed,076}077}078079const generate: GraphNode<typeof RagState> = async (state) => {080const context = state.documents081.map((document, index) => {082return `[S${index + 1}] ${document.source}\n${document.content}`083})084.join('\n\n')085086const response = await model.invoke([087{088role: 'system',089content:090'只根据提供的资料回答,并在事实后标注来源编号。',091},092{093role: 'user',094content: `资料:095${context}096097问题:098${state.question}`,099},100])101102return {103answer:104typeof response.content === 'string'105? response.content106: JSON.stringify(response.content),107}108}109110const fallback: GraphNode<typeof RagState> = () => {111return {112answer: '现有制度资料不足以回答这个问题。',113}114}115116export {117rewriteQuery,118retrieve,119gradeRetrieval,120generate,121fallback,122}
最后连接节点:
01import {02END,03START,04StateGraph,05} from '@langchain/langgraph'06import { RagState } from './rag-state'07import {08rewriteQuery,09retrieve,10gradeRetrieval,11generate,12fallback,13} from './rag-nodes'1415export const ragGraph = new StateGraph(RagState)16.addNode('rewrite_query', rewriteQuery)17.addNode('retrieve', retrieve)18.addNode('grade_retrieval', gradeRetrieval)19.addNode('generate', generate)20.addNode('fallback', fallback)21.addEdge(START, 'rewrite_query')22.addEdge('rewrite_query', 'retrieve')23.addEdge('retrieve', 'grade_retrieval')24.addConditionalEdges('grade_retrieval', (state) => {25return state.retrievalPassed ? 'generate' : 'fallback'26})27.addEdge('generate', END)28.addEdge('fallback', END)29.compile()3031const result = await ragGraph.invoke({32question: '我想休两天年假,需要哪些人审批?',33})
这个图仍然是固定流程,只是把质量判断显式放进状态和节点。后面可以继续增加“改写后重试一次”,但必须设置明确终止条件,避免图在低质量结果上无限循环。
7. 三种方式怎样选择
| 方式 | 适合场景 | 优点 | 主要风险 |
|---|---|---|---|
| 2-Step RAG | 制度、FAQ、垂直知识库 | 快、可预测、容易评测 | 每次都会检索,灵活性较低 |
| Agentic RAG | 多数据源研究助手 | Agent 能决定何时和怎样查 | 路径不稳定,成本和延迟波动 |
| LangGraph 工作流 | 有质量门禁、重试和审批 | 状态清楚,流程可控 | 节点和状态设计成本更高 |
不要因为项目名称里有 Agent,就默认选择 Agentic RAG。很多 Agent 产品中的知识问答仍然适合固定检索;Agent 可以负责更高层的任务决策,检索内部保持确定性。
8. 常见实现问题
检索工具返回太多内容
Agent 工具一次返回几十个 chunk,会迅速占满上下文。工具应当返回经过筛选的结果和来源,完整调试数据放在追踪系统中。
查询改写改变原意
用户问“那超过两天呢”,改写有帮助;用户问“不要查公司制度,只说一般情况”,改写模型可能擅自补成制度问题。应保留原始问题、记录改写结果,并在评测集中加入否定和边界案例。
质量判断只看有没有结果
向量库几乎总能返回最相近的内容,documents.length > 0 不代表资料足够。需要结合分数、规则或评测模型判断相关性,并允许走无答案分支。
Agent 反复调用检索
工具描述冲突、结果格式不清或 Prompt 没有限制时,Agent 可能重复查询。应设置最大步骤数、记录工具轨迹,并让工具结果清楚表达来源与是否命中。
9. 面试中常见追问
为什么不全部使用 Agentic RAG?
固定知识问答的检索步骤明确,2-Step RAG 的延迟、成本和测试更可控。只有问题确实需要动态选择数据源或多轮探索时,Agentic RAG 的灵活性才值得额外复杂度。
LangGraph 对 RAG 有什么价值?
LangGraph 不负责提高向量相似度,它负责把查询改写、召回、质量判断、重试、生成和降级组织成可观察、可持久化的状态流程。
检索结果为空才降级吗?
不是。向量搜索通常不会真正为空,更重要的是候选是否相关、权限是否正确、分数是否达到经过校准的范围,以及资料是否覆盖问题所需事实。
10. 总结
LangChain 提供文档、Embedding、VectorStore、Retriever、Tool 和 Agent 抽象;LangGraph 负责把多步检索过程组织成明确状态和节点。
第一版知识库优先采用 2-Step RAG。需要动态选择检索工具时再使用 Agentic RAG;出现质量判断、重试和人工介入等稳定流程后,用 LangGraph 将它们显式建模。
下一篇不再增加框架能力,而是专门处理最棘手的问题:资料明明已经入库,为什么检索仍然不准,以及怎样用混合召回、查询改写和重排逐步改善结果。