ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从LangChain到LangGraph:RAG知识库流程编排改造实战

从LangChain到LangGraph:RAG知识库流程编排改造实战 最近我把维护了大半年的 LangChain RAG 知识库项目彻底重构了一遍核心动作就是把它从链式调用迁移到 LangGraph。这次知识库改造不是临时起意而是被业务需求逼出来的——最初版本其实挺顺文档上传、切片、向量化、检索问答跑得都很流畅但等产品经理开始提“多轮追问”“检索完先给我确认再生成”“不同部门的知识库走不通的路由”这类需求时旧代码的维护成本肉眼可见地涨。如果你正在用 LangChain 做 RAG 知识库或者在犹豫要不要上 LangGraph这篇文章会把我的改造思路、踩坑记录和最终效果完整摊开讲。1. 为什么把 RAG 从 LangChain 迁到 LangGraph1.1 最初的 LangChain 实现长什么样我最早做这个知识库问答项目时用的是非常标准的 LangChain 写法加载文档、切块、向量化入库然后在问答环节用RetrievalQA或者ConversationalRetrievalChain把检索结果和用户问题拼成一个 prompt丢给大模型生成答案。核心代码差不多长这样qa_chain RetrievalQA.from_chain_type( llmllm, retrievervector_store.as_retriever(search_kwargs{k: 4}), chain_typestuff, return_source_documentsTrue, )这种写法在 demo 阶段非常爽代码少、逻辑直白跑起来就能看到效果。但随着业务深入问题开始冒头当用户连续追问“刚才说的那个方案在第三章节的具体参数是什么”时ConversationalRetrievalChain对对话历史的处理非常粗糙经常把上一轮答案搅进当前检索当检索结果质量不高时系统没有任何纠错机制模型只能硬着头皮在错误上下文里生成答案。最难受的是这类链式 API 把流程封装得太严了。我想在“检索之后、生成之前”插入一个人工确认环节看看检索到的文档到底对不对结果发现要么得 hack 内部方法要么只能放弃封装重写。那时候我才意识到这种便利的封装只适合快速验证不适合承载复杂业务逻辑。1.2 流程式编排的痛点LangChain 经典的链式编排本质上是把多个步骤串成一个线性管道输入进、输出出中间过程对开发者来说是个黑盒。这带来几个非常实际的问题。第一状态管理不透明。RAG 流程里有用户问题、改写后的问题、检索到的文档、重排后的结果、最终答案、引用来源这些中间状态散落在链路的各个阶段出了问题根本不知道从哪一步开始查。我调试时最常干的事就是到处打印中间变量打印完还得手动删。第二分支逻辑很难写。检索结果好就走生成检索结果差就重写问题再检索这种“带条件的流程”在链式模型里实现起来非常别扭。你可以用LangChain的RunnableLambda硬塞判断逻辑但代码结构会迅速恶化最后变成一堆嵌套回调自己都看不懂。第三Human-in-the-Loop 几乎没法做。让流程暂停在某个节点、等人工确认后再继续这在传统链式编排里没有原生的支持。你只能靠额外的事件机制去模拟复杂且脆。业务方说“检索出来的资料有些涉及敏感条款要人工确认才能发给客户”这个需求在旧架构上基本推不动。第四可测试性和可恢复性差。链式调用跑完就结束了没有持久化没有断点续跑。如果生成到一半 LLM 超时整个流程从头再来浪费时间和 token。1.3 LangGraph 到底改进了什么LangGraph 本质上是一个基于图状态机的编排框架它把流程建模成“状态 节点 边”。状态用类似字典的结构在整个图中传递每个节点是处理函数边决定节点之间的流转关系。这个思路和 RAG 天然合拍。最直接的改观是状态显式化了。我把question、rewritten_question、docs、reranked_docs、answer、sources全部定义在状态里每个节点各取所需、各改其改中间任何一步出问题直接把状态打印出来就能定位。第二点是条件路由变成了原生能力。LangGraph 允许你在节点执行完后根据返回值决定下一条边走哪个节点。比如检索节点返回一个retrieval_score分数低于阈值就走“问题重写节点”高于阈值就直接走“生成节点”。这个在链式模型里令人头疼的逻辑在图里只是几行配置。第三点是支持打断和恢复。LangGraph 提供了interrupt_before这类机制可以指定图运行到某个节点前暂停外部确认完成后再从暂停点继续。这不光解决了人工确认的问题还为后续做审计、权限审批留了空间。第四是可以配合 checkpoint 做持久化。每次运行状态会被记录下来调试时可以回放任何一轮的完整状态这在以前是奢望。对我这种靠经验和日志找问题的人来说这个改动的幸福感提升非常明显。2. RAG 知识库改造的整体设计从数据处理到检索链路2.1 文档加载与切块参数怎么定切块是 RAG 知识库改造中最容易被低估的环节。很多项目检索效果差根因不是模型不够强而是知识库的“物理结构”没做好。我平时处理 PDF、Word、Markdown 三种格式的文档。PDF 用PyMuPDFLoader它对扫描件的支持不行但处理电子版 PDF 速度很快Word 用Docx2txtLoaderMarkdown 我强烈推荐MarkdownHeaderTextSplitter因为它能按照文档本身的标题结构切块而不是无脑按固定长度切。切块的核心矛盾是块太大检索会带入大量无关信息生成阶段容易被噪声干扰块太小语义不完整检索召回率下降。我在项目里的经验值是这样一组参数文档类型切块方式块大小重叠长度合同/规章制度按章节标题切600~800字80~120字技术方案/说明书按 Markdown 标题切400~600字50~80字问答记录/对话日志固定长度300~400字不设重叠固定长度切块加重叠最关键的是重叠长度不能设太大。我之前在切合同文档时试过chunk_size800, chunk_overlap200结果相邻块之间大量重复内容检索时同一份合同被召回好几次生成的答案频繁出现“根据上文”这种无意义的指代。后来把重叠降到 100 以内配合ParentDocumentRetriever才解决这个尴尬。切块之后还建议做一步清洗去重、去空白字符、过滤明显无意义的内容。这一步看似简单但能显著降低向量化时的浪费。测试阶段可以写一个小的统计脚本看看切出来的块数量、平均长度、是否有空块做到心里有数。2.2 向量库选型与 Embedding 方案向量库我对比过 Chroma、Milvus、pgvector 和 Qdrant。对不同项目来说选型逻辑差别很大没有绝对的最好。个人项目或小团队验证原型直接用 Chroma 就够了零部署成本一个目录搞定用起来跟 SQLite 一样轻松。生产环境如果已经有 PostgreSQL我建议优先考虑 pgvector少维护一套组件事务也方便。数据量超过几百万条、并发查询压力大再上 Milvus 或 Qdrant它们对分片、索引、过滤的支持更加成熟。Embedding 方案上早先我用过 OpenAI 的text-embedding-ada-002效果稳定但数据出域这件事在很多企业内部是过不了合规这一关的。后来切到开源的bge-m3中文场景表现不错而且支持多语言和稀疏检索可以用一个模型同时兼顾稠密和稀疏向量省心不少。如果你们的领域非常垂直比如医学、法律、机械图纸说明建议在自建语料上微调一下 Embedding 模型否则检索的第一跳就偏了后面再怎么优化都有限。选 Embedding 模型有个容易忽略的指标输出向量的维度。维度高意味着更细粒度的语义表达但也意味着更高的存储成本和更慢的检索速度。像bge-m3默认 1024 维而你之前的 OpenAI 模型是 1536 维切换后向量库里的老数据需要重新生成这个迁移成本要考虑清楚。2.3 检索层改造从相似度到重排最初版检索就是简单的向量相似度 top-k代码一行搞定。但在真实场景里纯向量检索有几个坑一是领域术语和同义词的匹配不稳定二是按段落切出来的块有时会被错误召回。后来我做了一个改造将 BM25 稀疏检索和向量稠密检索结合用 RRF 算法融合两路结果。这个融合逻辑非常直观两路检索各返回一个排序列表每个文档根据它所在的名次得到一个分数最后按融合分排序。RRF 的好处是简单、鲁棒不需要调权重。但融合检索还不能解决“召回的文档是否真的相关”这个问题。尤其在合同、技术规范这类文档里向量相似度高的段落可能只是在词语层面接近语义上并不相关。所以我在检索链路后面加了一层 reranker用的是交叉编码器模型直接对“问题 候选文档”这个组合打分。这个环节带来的准确率提升非常明显但代价是耗时增加实测单次重排大约增加 100~200 毫秒。如果延迟敏感可以只在召回数大于 6 时才触发重排或者重排只在凌晨离线任务中做缓存。这里插一句网上不少讨论提到 LangChain 和 LangChain4j 默认的 RRF 实现在去重逻辑上存在缺陷。我自己的验证结果是不同代码库里的融合实现确实有重复文档去重不干净的情况导致同一文档被重复送入生成模块既浪费 token 又容易让答案出现矛盾。改造时最好自己维护一个按文档 ID 去重的步骤别完全信任框架默认实现。3. LangGraph 改造实操节点、状态机与条件路由3.1 图构建核心状态定义与节点编排LangGraph 里的“状态”是一个 TypedDict 结构负责在节点之间传递数据。我最初设计时犯过一个错把状态定义得特别大什么字段都往里塞结果节点之间的耦合度变得很高改一个字段就要改一串函数。后来我把状态设计成“最小必要集合”只放那些必须在节点间共享的数据。我最终的 RAG 状态结构大致如下from typing import TypedDict, List class RAGState(TypedDict): question: str rewritten_question: str docs: List[dict] retrieval_score: float answer: str sources: List[str]图本身用StateGraph来构建核心动作是add_node注册节点add_edge连接节点set_entry_point和set_finish_point标记出入口。LangGraph 会自动按照拓扑排序执行但更让我喜欢的是它允许你在任意边之间加条件判断这就把原来写死在代码里的流程控制权交还给了数据本身。3.2 条件路由让流程自己决定下一步这次改造里我最满意的是加了一个“检索质量评估”节点。检索完成后不是盲目进生成而是先算一个分数召回的文档和用户问题的平均相关度加上召回文档数量覆盖度综合判断是否需要重写问题。用 LangGraph 实现这个路由核心是add_conditional_edgesfrom langgraph.graph import StateGraph, END def route_after_retrieval(state: RAGState): if state[retrieval_score] 0.5: return rewrite return generate graph.add_conditional_edges( retrieve, route_after_retrieval, {rewrite: rewrite, generate: generate}, )这个语法看起来简单但它真正解决了以前链式代码里“塞满 if-else”的问题。我在实际操作里还加了一个“次数上限”的机制问题重写节点最多执行两次超过之后强制走生成避免死循环。这个用状态里的rewrite_count字段控制实现非常直接。3.3 关键节点代码与运行效果检索节点的核心代码没有用框架自带的 retriever 直接塞进去而是手动封装成函数方便加日志和埋点def retrieve_node(state: RAGState): query state.get(rewritten_question) or state[question] bm25_docs bm25_index.search(query, top_k10) vector_docs vector_store.similarity_search(query, k10) fused_docs rrf_fusion(bm25_docs, vector_docs, k5) reranked_docs reranker.rerank(query, fused_docs) return { docs: reranked_docs, retrieval_score: compute_score(query, reranked_docs), }生成节点则把检索到的文档按统一模板拼进 prompt并明确要求模型在答案末尾以列表形式输出引用的来源文档 ID。这个设计对用户侧的信任感帮助巨大也让后续的审计有据可查。运行效果方面改造后我在 30 份测试文档上的字段级准确率大概从 72% 提升到 83%其中最明显的改善来自无法检索到正确答案时系统能主动重写问题再试一次而不是直接编个答案出来。3.4 可观测性与调试LangGraph 对调试的改进是我决定迁移的第二大原因。它可以把每步的 state 持久化下来甚至在运行完成后不销毁。用 LangSmith 或者自建日志都能实现但更轻量的做法是手动在每个节点里打结构化日志logging.info(json.dumps({ node: retrieve_node, question: state[question], doc_ids: [d[id] for d in state[docs]], score: state[retrieval_score], }, ensure_asciiFalse))排查问题时我不再需要脑补整个流程只需要把一轮请求所有节点日志捞出来就能看到问题具体出在“检索不到”“排序不合理”还是“模型生成了幻觉”。这个体验比链式代码好了几个量级。4. 从单轮到多轮对话记忆、Agentic RAG 与人工介入4.1 多轮对话怎么办多轮对话是 RAG 知识库改造里必然遇到的需求但没有想象中那么难。核心问题是用户的当前问题经常省略上下文比如“那第三种方案的报价呢”如果不结合历史检索器根本不知道“第三种方案”是什么。我的解决方案是在状态里维护一个messages列表每次新问题进来先走一个“对话重建节点”让 LLM 根据历史消息生成一句可以独立检索的 query再拿这个 query 去检索。这比直接把整个历史丢给检索器高效得多因为检索器对长文本的匹配能力并不好。def rewrite_query(state: RAGState): history state.get(messages, []) new_query llm.invoke([ system_prompt, *history[-6:], HumanMessage(contentf基于以上对话生成一个独立的检索问题{state[question]}), ]) return {rewritten_question: new_query}这里有个细节历史消息最多保留 6 轮避免无限膨胀。超出部分要么截断要么先交给 LLM 做摘要。我试过不设上限地把全部历史丢进去结果 token 成本翻了好几倍检索效果反而下降。4.2 Human-in-the-Loop 的真正价值我这次改造最硬核的需求是在某些高价值问答场景中插入人工确认环节。LangGraph 的interrupt_before可以指定图在执行到生成节点前停下来等待外部调用graph.update_state()注入人工审核结果。实际用法是这样在graph.compile()时设置断点外部系统收到“待审核”事件后把检索出的文档列表推送给审核员审核员反馈通过或者修改检索条件后再恢复图运行。在医疗、法律、合同审查这类对准确性要求极高的场景里这个能力不是锦上添花而是能不能交付的关键。需要提醒的是不要为所有请求都开启人工介入否则效率会非常低。我最后的策略是只对命中特定敏感关键词的问题开启人工确认其他问题自动跑完整流程。这个逻辑放在条件路由里改动成本很低。4.3 哪些场景不要硬上 Agentic RAG热词里 Agentic RAG 很火但我个人的判断是它不一定适合所有场景。Agentic RAG 是让 LLM 作为 agent自主决定调什么工具、查哪些知识库、甚至多次迭代检索这带来更强的灵活性也带来失控风险。我尝试过把 RAG 流程全部 agent 化结果遇到几个问题一是 token 消耗暴涨一次问答平均烧掉原先 3 倍的 token二是流程不确定性变高业务方不喜欢“这次返回结构跟上次不一样”三是调试成本上升agent 内部决策路径不透明出了问题不好复盘。我的建议是如果你只是做单知识库问答、文档精读、客服辅助传统的确定性 RAG 流程完全够用LangGraph 这类图编排工具来提可控性就够了只有当你要在十几个知识库之间动态路由、需要多步骤推理、或者检索条件高度依赖用户意图时才值得引入 Agentic 的设计。能把确定性的流程先用好已经很能打了。5. 改造过程中踩过的坑与排查方法5.1 检索效果差先查切块、Embedding、重排改造过程中我花了大量时间在“检索结果为什么偏”这个问题上。总结下来排查顺序最好是切块方式 → Embedding 模型 → 重排策略 → Prompt 质量这四个因素按影响程度从大到小排列。最典型的案例是有一批 PDF 文档是扫描版直接切块后向量化检索效果奇差无比。后来发现是PyMuPDFLoader对扫描件返回了空文本切出来的块全是空白。换成 OCR 方案后问题立刻消失。所以排查时先确认文本抽取这一步是否正常别一上来就怪 Embedding。另一个常见问题是领域术语嵌入偏移。比如“ETFE 膜结构”这种专业词汇通用 Embedding 模型在语义空间里的表示可能和通俗表达“一种建筑用薄膜材料”距离很远检索召回不到。这时候靠重排也不一定救得回来最有效的办法是在知识库构建阶段加入同义词扩展或者对同义词对做数据增强。我在改造中添加了一个轻量的术语表在切块前先做一轮术语替换检索召回率提升非常明显。5.2 LangGraph 状态更新容易踩的坑用 LangGraph 时最容易踩的坑是节点返回的字段没有和状态 schema 完全对齐。LangGraph 会严格检查状态字段你多返回一个 schema 里没声明的字段运行时会直接报错反过来schema 里有的字段节点没返回它也不会自动用旧值填充。另一个坑是列表字段的累加。默认情况下LangGraph 对状态的更新是覆盖式的如果你希望某个字段在多个节点之间不断追加内容需要显式指定 reducer。我在多轮对话消息处理里就用了幂等追加器不然消息列表每跑一轮都会丢失前面的内容。最后一点建议不要在图组件里直接操作全局变量或外部可变对象尤其是在并发场景下状态的隔离性会失效。所有需要跨节点共享的数据都应该通过 state 传递否则调试时会看到非常诡异的数据串扰。5.3 成本与性能权衡这次知识库改造之后我的一个直观感受是LangGraph 本身的开销很小真正烧钱的地方在模型调用和检索链路设计。首先是 token 管理。多轮对话里如果每次都把所有历史消息丢进 LLM成本会直线上升。我的做法是只保留最近几轮完整消息更早的轮次压缩成一段摘要能省掉约 40% 的 token。其次是重排策略的耗时。我实测 reranker 在中等规模数据下每次调用要 100~200 毫秒这让整体延迟从 800 毫秒涨到 1 秒出头。后来我把重排改成只在向量检索置信度不足时触发效果几乎不变延迟回落到了 800 毫秒以内。还有一个小经验是关于 checkpoint 持久化的。LangGraph 的持久化默认会保存每步完整状态长时间运行后存储会膨胀。我设置了定期清理策略只保留最近 7 天的运行记录历史数据单独导出到冷存储避免数据库无限增长。如果你也想做类似改造我个人建议不要一次性把整个系统从 LangChain 推倒重来而是先找一个频率高、逻辑相对清晰的 RAG 流程做试点跑通后再逐步迁移其他模块。我自己就是先迁移了合同问答这一个场景验证效果和稳定性后才扩展到其他知识库。这样既控制风险也能让团队逐步适应新的编排方式。踩过的这些坑希望你不用再踩一遍。
返回列表