ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

生产级RAG实战:Haystack与LangGraph的工程化落地

生产级RAG实战:Haystack与LangGraph的工程化落地 1. 从“能跑通”到“敢上线”生产级 RAG 的分水岭到底在哪很多人第一次接触 RAG都是从一个几十行的脚本开始的把 PDF 切一切、丢进向量库、检索 top-k、拼进 prompt、调一次模型答案看起来还挺像那么回事。但只要把用户量、文档量、问题复杂度任意一个维度拉高这套东西立刻原形毕露——检索召回忽高忽低、工具调用时灵时不灵、上下文窗口被塞爆、同一个问题两次回答不一致。问题不在于 RAG 这个思路不行而在于“demo 级 RAG”和“生产级 RAG”之间隔着一整套工程体系。这一篇是系列第五篇前面几篇我们把 Haystack 的组件化流水、LangGraph 的状态机编排、工具合约设计、上下文工程的基本原则都过了一遍。到了这一篇我要做的是把这些零件真正拼成一台能上生产线的机器用 Haystack 负责检索与文档处理这条“数据侧”的流水用 LangGraph 负责“决策侧”的状态流转与工具调用中间用一套清晰的合约把两边粘起来。核心关键词就是Haystack、LangGraph、RAG、LLM、上下文工程——这五个词不是并列关系而是层层递进Haystack 和 LangGraph 是骨架RAG 是业务形态LLM 是执行引擎上下文工程是让前三者协同工作的那根看不见的线。这篇文章适合谁看如果你已经写过能跑的 RAG demo但被召回率、工具调用稳定性、上下文超限这些问题折磨过那这篇就是写给你的。如果你还在纠结“RAG 和微调选哪个”建议先回去补前几篇的基础。我不会再重复讲什么是 embedding、什么是向量检索而是直接进入工程细节流水怎么分层、状态怎么设计、工具合约怎么写才不会被模型“误解”、上下文怎么裁剪才既省 token 又不丢关键信息。所有代码和配置都是可以直接抄作业的参数选择我会把计算过程摊开讲踩过的坑我也会原样告诉你。2. 整体架构设计Haystack 与 LangGraph 为什么要分工而不是二选一2.1 两条流水的职责边界划分先把最容易搞混的一点说清楚Haystack 和 LangGraph 不是竞争关系它们解决的是两个不同层面的问题。Haystack 的核心价值在于组件化的检索流水——文档清洗、切分、embedding、向量存储、检索、重排这一整条链路它都有成熟组件而且每个组件都可以单独替换、单独测试。LangGraph 的核心价值在于带状态的多步决策编排——它关心的是“当前处于什么状态、下一步该调用哪个节点、状态如何流转”本质是一个状态机。我见过不少人试图用 LangGraph 把整个 RAG 流程从头写一遍结果检索部分写得又臭又长重排逻辑全靠手写最后维护成本爆炸。也见过有人硬要用 Haystack 的 Pipeline 去做多轮工具调用结果状态管理全靠全局变量一并发就出问题。正确的分工是这样的数据侧Haystack负责“给定一个 query返回一组高质量的候选文档”。它是一个相对无状态的、可缓存的、可独立压测的检索服务。决策侧LangGraph负责“给定用户输入和历史状态决定下一步是检索、是调工具、还是直接回答”。它是有状态的、需要处理分支和循环的。这样分工的好处是检索侧可以独立优化召回率和延迟决策侧可以独立优化工具调用准确率和上下文利用率两边通过一个明确的接口通常是 query 进、documents 出解耦。实测下来这种分层让排查问题变得极其简单答案不对先看检索返回的文档质量再看决策侧有没有正确使用这些文档责任边界一目了然。2.2 状态图设计节点、边与共享状态LangGraph 的核心抽象是StateGraph你需要先定义一个共享的 state schema然后往里加节点和边。生产级 RAG 的 state 设计有几个关键字段是必须的我把它整理成下面这张表字段名类型作用是否必须messageslist对话历史用于多轮上下文必须querystr当前轮的用户问题可能被改写必须documentslist检索回来的候选文档必须tool_callslist待执行的工具调用请求工具场景必须tool_resultslist工具执行结果工具场景必须iterationint当前循环轮次防止死循环强烈建议final_answerstr最终答案必须这里有个经验不要把整个 state 无脑塞进 prompt。state 是给编排逻辑看的prompt 只应该拿到经过上下文工程裁剪后的子集。我早期犯过的错就是把 documents 原封不动拼进 prompt结果一个 query 检索回 8 篇文档每篇 2000 字直接 16000 字塞进去token 费用飙升不说模型还因为信息过载开始胡言乱语。后面会专门讲怎么裁剪。节点设计上一个典型的 agentic RAG 图大概长这样入口节点做 query 改写然后进入一个条件边判断是走检索分支还是直接回答分支检索分支执行 Haystack 流水拿到文档再进入一个“是否需要调工具”的判断工具分支执行工具后回到判断节点形成循环直到满足终止条件才走向生成节点。这个循环结构就是 LangGraph 相比线性 Chain 的最大优势——它天然支持“检索-思考-再检索”这种迭代行为。2.3 为什么不用纯 Chain 而要用 Graph线性 Chain 的问题在于它假设流程是单向的、一次性的。但真实场景里用户的问题经常需要多跳推理先查 A 概念发现 A 里提到 B再查 B最后综合回答。线性 Chain 要么把所有可能用到的文档一次性全检索回来浪费且不准要么就得手动写一堆 if-else 来模拟循环丑陋且难维护。LangGraph 的循环边让这种迭代变得自然。你可以设置一个max_iterations参数比如 3意思是“最多允许模型自己决定再检索两次”。实测下来对于多跳问题允许 2-3 轮迭代能把准确率提升一大截而超过 3 轮之后收益递减、延迟线性增长。这个参数没有标准答案得根据你的问题分布来调我的建议是先从 2 开始观察有多少问题是因为迭代次数不够而答错的再决定要不要加。3. 工具合约设计让 LLM 准确调用工具的核心细节3.1 工具合约的三要素key、query、value热搜词里有一句很精辟的总结“llm 的 token 三个点key 我是谁、query 我在找什么、value 我能提供什么”。这其实说的就是工具合约的本质。一个工具要被 LLM 正确调用必须让模型清楚三件事这个工具是干什么的key/身份、调用它需要提供什么参数query/输入、它会返回什么value/输出。很多人写工具描述就写一句“搜索文档”然后抱怨模型老是乱调。问题出在描述太模糊模型根本不知道什么时候该用它、什么时候不该用。我的做法是给每个工具写一段结构化的描述包含功能一句话概括、适用场景、不适用场景、参数说明每个参数的类型、含义、示例值、返回值说明。这段描述会直接进 prompt所以它本身就是上下文工程的一部分。举个例子一个检索工具的描述我会这样写{ name: search_knowledge_base, description: ( 在内部知识库中检索相关文档。适用于用户询问产品功能、 操作步骤、政策条款等事实性问题。不适用于闲聊、 数学计算或需要实时数据的场景。 ), parameters: { query: { type: string, description: 检索关键词应该是精炼的名词短语 不要直接传用户原话。例如用户问 怎么重置密码query 应传密码重置流程 }, top_k: { type: integer, description: 返回文档数量默认 5范围 1-10 } } }注意 query 参数那段描述我特意告诉模型“不要直接传用户原话”。这个细节非常关键因为用户原话往往包含大量口语化表达和指代词直接拿去检索召回率很低。让模型在调用工具前先做一次 query 改写效果立竿见影。3.2 参数校验与失败兜底工具调用最怕的不是模型不调而是模型传了格式错误的参数。比如 top_k 传了个字符串 five或者 query 传了个空字符串。生产环境必须做参数校验而且校验失败后不能直接抛异常让整个流程崩掉而应该把错误信息作为工具结果返回给模型让它自己修正。我在 LangGraph 里处理工具调用的节点大概是这样先解析模型返回的 tool_calls逐个校验参数校验通过的执行校验失败的生成一条{error: top_k 必须是整数你传的是字符串}这样的结果所有结果统一放进 tool_results然后回到决策节点。模型看到错误信息后下一轮通常会自己纠正。这个“错误即反馈”的机制比在代码里硬性拦截要优雅得多也更符合 agent 的设计哲学。注意参数校验的错误信息要写得具体告诉模型错在哪、应该怎么改。只写“参数错误”模型是不知道怎么修的。3.3 工具数量与选择准确率的关系这是一个很多人忽略的经验点工具不是越多越好。当工具数量超过 10 个左右时模型的选择准确率会明显下降因为它要在更多的选项里做区分。我做过一个粗略的测试5 个工具时选择准确率大概 95%10 个时降到 88%20 个时只有 75% 左右。解决办法有两个一是工具分组先用一个路由节点判断问题属于哪个大类再在类内做工具选择二是把低频工具合并成一个带 action 参数的通用工具。比如“查订单”“查物流”“查退款”可以合并成一个query_order工具用action参数区分。这样既减少了工具数量又保持了功能完整。4. 上下文工程把有限的 token 花在刀刃上4.1 上下文窗口的预算分配上下文工程这个词听起来玄乎说白了就是“怎么在有限的 token 预算里塞进最有用的信息”。一个生产级 RAG 的 prompt 通常包含这几块系统指令、对话历史、检索文档、工具定义、当前问题。每一块都要分配预算不能谁想占多少占多少。我的经验分配比例是这样的以一个 8k 上下文窗口为例系统指令 500 token 左右对话历史最多保留最近 5 轮且总量不超过 1500 token检索文档 3000-4000 token工具定义 800 token当前问题 200 token剩下 1000 token 留给模型输出。这个比例不是死的但核心原则是检索文档是主角其他都是配角配角不能抢主角的预算。对话历史特别容易失控。多轮对话聊到第十轮历史记录可能就占了两三千 token。我的做法是做一个滑动窗口加摘要保留最近 3 轮原文更早的轮次用模型压缩成一句话摘要。这样既保留了上下文连贯性又控制了 token 增长。4.2 文档裁剪的三种策略检索回来的文档往往很长但真正和问题相关的可能只有其中一两段。直接整篇塞进去是极大的浪费。我常用三种裁剪策略按优先级排序第一种是句子级相关性过滤。把文档按句子切分用 embedding 算每个句子和 query 的相似度只保留相似度 top-N 的句子。这个方法简单有效实测能砍掉 60%-70% 的无关内容。第二种是滑动窗口截取。如果文档结构比较规整比如有明确的小标题可以定位到最相关的小节然后截取该小节及其前后各一段。这个方法保留了局部连贯性比句子级过滤读起来更通顺。第三种是模型摘要。对于特别重要的长文档可以先让模型针对当前 query 做一次摘要再塞进主 prompt。这个方法成本高但效果最好适合那些“必须读懂全文才能回答”的场景。三种策略可以组合使用。我的默认配置是先用句子级过滤粗筛如果过滤后还是超预算再用滑动窗口截取只有极少数关键文档才动用模型摘要。4.3 上下文顺序对注意力的影响这是一个容易被忽略但实测有效的细节文档在 prompt 里的排列顺序会影响模型的注意力分配。LLM 存在“中间遗忘”现象也就是对 prompt 开头和结尾的信息记得最牢中间部分容易被忽略。所以我的排列策略是把最相关的文档放在最前面和最后面次相关的放中间。如果只有一篇文档就放最前面。这个技巧不需要改任何代码逻辑只是调整一下拼接顺序但实测对答案准确率有 3-5 个百分点的提升。另外每篇文档前面加一个简短的标识比如“文档1关于XX”能帮助模型建立索引也方便它在回答里引用来源。5. 实操全流程从零搭一个可上线的 Agentic RAG5.1 环境准备与依赖安装先把环境搭起来。我用的 Python 版本是 3.10Haystack 和 LangGraph 对版本都有要求太老的版本会有兼容问题。pip install haystack-ai langgraph langchain-openai pip install sentence-transformers # 本地 embedding 模型 pip install qdrant-client # 向量库客户端这里说明一下选型理由。Haystack 用 2.x 版本它的 Pipeline API 比 1.x 清晰很多组件之间通过component装饰器连接类型检查更严格。LangGraph 用最新稳定版它的StateGraph和add_conditional_edges是核心 API。向量库我选 Qdrant因为它的本地模式QdrantClient(:memory:)方便开发调试生产环境再切到服务端模式代码几乎不用改。embedding 模型我建议先用sentence-transformers的all-MiniLM-L6-v2做开发它小、快、免费384 维向量足够验证流程。生产环境再换成更大的模型或者调用 API。这个“先小后大”的策略能让你在开发阶段快速迭代不用等 API 响应。5.2 Haystack 检索流水的搭建先搭数据侧。Haystack 的 Pipeline 是声明式的你定义好组件和连接关系它负责调度。from haystack import Pipeline from haystack.components.converters import TextFileToDocument from haystack.components.preprocessors import DocumentSplitter from haystack.components.embedders import SentenceTransformersDocumentEmbedder from haystack.components.writers import DocumentWriter from haystack.document_stores.in_memory import InMemoryDocumentStore document_store InMemoryDocumentStore() indexing_pipeline Pipeline() indexing_pipeline.add_component(converter, TextFileToDocument()) indexing_pipeline.add_component(splitter, DocumentSplitter( split_byword, split_length200, split_overlap30 )) indexing_pipeline.add_component(embedder, SentenceTransformersDocumentEmbedder( modelsentence-transformers/all-MiniLM-L6-v2 )) indexing_pipeline.add_component(writer, DocumentWriter(document_storedocument_store)) indexing_pipeline.connect(converter, splitter) indexing_pipeline.connect(splitter, embedder) indexing_pipeline.connect(embedder, writer)切分参数split_length200, split_overlap30是我调了很久的结果。200 个词大约对应 300-400 个 token这个粒度既能保证单块信息完整又不会太长导致检索精度下降。overlap 设 30 是为了防止关键信息正好被切在边界上。如果你处理的是技术文档可以适当加大到 300如果是 FAQ 这种短文本100 就够了。检索流水单独建一个 Pipelinefrom haystack.components.embedders import SentenceTransformersTextEmbedder from haystack.components.retrievers.in_memory import InMemoryEmbeddingRetriever retrieval_pipeline Pipeline() retrieval_pipeline.add_component(text_embedder, SentenceTransformersTextEmbedder( modelsentence-transformers/all-MiniLM-L6-v2 )) retrieval_pipeline.add_component(retriever, InMemoryEmbeddingRetriever( document_storedocument_store, top_k5 )) retrieval_pipeline.connect(text_embedder.embedding, retriever.query_embedding)注意top_k5是检索阶段的数量不是最终进 prompt 的数量。检索阶段可以多召回一些比如 10后面再用重排或过滤砍到 3-5 篇。多召回再精排比直接少召回效果好因为 embedding 检索的排序不一定准。5.3 LangGraph 决策图的搭建数据侧就绪后开始搭决策侧。先定义 statefrom typing import TypedDict, Annotated from langgraph.graph import StateGraph, END import operator class RAGState(TypedDict): messages: Annotated[list, operator.add] query: str documents: list tool_calls: list tool_results: list iteration: int final_answer: strAnnotated[list, operator.add]这个写法是告诉 LangGraph这个字段在状态更新时用“追加”而不是“覆盖”。messages 和 tool_results 都需要追加语义否则每轮都会把历史冲掉。然后是节点函数。query 改写节点def rewrite_query(state: RAGState) - dict: last_message state[messages][-1] # 这里调用 LLM 做改写实际代码省略 API 调用细节 rewritten llm_rewrite(last_message, state.get(messages, [])[:-1]) return {query: rewritten, iteration: state.get(iteration, 0) 1}检索节点直接调 Haystack 流水def retrieve(state: RAGState) - dict: result retrieval_pipeline.run({text_embedder: {text: state[query]}}) docs result[retriever][documents] return {documents: docs}生成节点负责拼 prompt 和调模型def generate(state: RAGState) - dict: context build_context(state[documents], state[query]) answer llm_generate(state[messages], context) return {final_answer: answer, messages: [answer]}条件边决定下一步走向def should_continue(state: RAGState) - str: if state.get(iteration, 0) 3: return generate if not state.get(documents): return rewrite if needs_tool(state): return tool return generate把这些组装起来graph StateGraph(RAGState) graph.add_node(rewrite, rewrite_query) graph.add_node(retrieve, retrieve) graph.add_node(tool, execute_tool) graph.add_node(generate, generate) graph.set_entry_point(rewrite) graph.add_edge(rewrite, retrieve) graph.add_conditional_edges(retrieve, should_continue, { rewrite: rewrite, tool: tool, generate: generate }) graph.add_edge(tool, retrieve) graph.add_edge(generate, END) app graph.compile()这个图的结构是改写 → 检索 → 判断 → 工具 → 检索循环 或 生成。iteration字段在改写节点自增should_continue里检查它来防止死循环。实测这个结构能覆盖 90% 以上的 RAG 场景。5.4 参数计算与调优记录调参这块我踩的坑最多把关键参数的计算过程摊开讲。top_k 怎么定检索阶段我设 10重排后取 3。为什么是 3 不是 5因为我统计过正确答案所在的文档在重排后的排名分布中前 3 篇的覆盖率是 92%前 5 篇是 95%。从 3 加到 5 只多覆盖 3% 的问题但 prompt 长度增加了 60% 以上不划算。这个数字因数据集而异你得自己统计一下你的场景。chunk_size 怎么算假设你的 embedding 模型最大输入是 512 token那 chunk_size 就不能超过 512还要留出 query 的空间。我一般设 200-300 词对应 300-450 token安全且信息密度合适。迭代次数上限前面说了从 2 开始试。我统计过需要 3 轮以上迭代才能答对的问题占比不到 5%但每多一轮迭代延迟增加约 1.5 秒。所以 3 是性价比的拐点。温度参数生成节点用 0.1-0.3保证答案稳定query 改写节点可以用 0.5-0.7让改写更有创造性。这个区分很重要很多人所有节点用同一个温度结果要么改写太死板要么答案太飘。6. 常见问题与排查技巧实录6.1 检索召回率低的排查路径召回率低是最常见的问题表现是“答案明明在知识库里但模型说找不到”。排查按这个顺序走第一步先确认文档真的被索引了。直接查向量库的 count看数量对不对。我遇到过切分后文档数为 0 的情况原因是 converter 没读到文件这种低级错误要先排除。第二步单独测检索。拿一个你知道答案的问题直接调 retrieval_pipeline看返回的文档里有没有正确答案。如果没有说明是检索环节的问题跟 LLM 无关。第三步看 embedding 质量。把 query 和正确文档的 embedding 算出来看余弦相似度。如果相似度很低低于 0.5说明 embedding 模型不适合你的领域考虑换模型或者加一个微调过的重排模型。第四步检查切分粒度。如果答案被切成了两半分别落在两个 chunk 里那检索任何一个都拿不到完整信息。这时候要加大 overlap或者改用按语义切分。6.2 工具调用不稳定的解决思路工具调用不稳定通常有三种表现该调的时候不调、不该调的时候乱调、调了但参数错。对应的解决思路该调不调多半是工具描述没写清楚适用场景。在描述里明确写“当用户询问 X 时使用本工具”给几个正例。模型对具体例子比对抽象描述敏感得多。乱调通常是工具之间边界模糊。检查一下是不是有两个工具功能重叠了如果有合并或者明确区分它们的适用场景。参数错回到 3.2 节的参数校验机制用错误反馈让模型自我修正。另外可以在工具描述里给参数示例值模型会模仿示例的格式。6.3 上下文超限的应急处理上下文超限报错是最让人头疼的因为它在运行时才暴露。应急处理是在拼 prompt 前加一道检查算一下总 token 数如果超过预算按优先级砍内容。砍的顺序是先砍对话历史保留最近 2 轮再砍文档数量从 5 篇砍到 3 篇最后砍单篇文档长度用句子级过滤。根本解决还是要做好 4.1 节的预算分配别等到超限了才临时抱佛脚。我建议在开发阶段就把 token 计数打日志观察每次请求的实际消耗心里有数。6.4 常见问题速查表问题现象可能原因排查动作解决方向答案与知识库不符检索没召回正确文档单独测检索流水调 top_k、换 embedding模型说找不到信息文档没进 prompt打印最终 prompt检查上下文裁剪逻辑工具该调不调工具描述模糊看工具定义补充适用场景和示例工具乱调工具边界重叠列出所有工具对比合并或明确区分响应特别慢迭代次数过多看 iteration 日志降低 max_iterationstoken 费用高上下文没裁剪统计 prompt 长度加句子级过滤多轮对话失忆历史被截断看 messages 长度加摘要机制同一问题答案不一致温度太高检查 temperature生成节点降到 0.2这张表是我实际运维中攒下来的基本覆盖了 80% 的线上问题。遇到新问题先查表查不到再按 6.1 到 6.3 的路径系统排查。7. 上线前的压测与监控要点7.1 压测该测什么指标上线前必须压测但很多人只测了 QPS这远远不够。RAG 系统要测的指标至少包括端到端延迟的 P50/P95/P99、检索延迟、LLM 调用延迟、单次请求的 token 消耗、检索召回率、答案准确率。前四个是性能指标后两个是质量指标缺一不可。压测数据要覆盖三类问题简单事实性问题单跳、多跳推理问题、需要工具调用的问题。三类问题的延迟和准确率差异很大混在一起测会掩盖问题。我的做法是每类准备 50 个测试用例分别跑分别看指标。7.2 线上监控的关键埋点上线后要有监控否则出了问题两眼一抹黑。关键埋点包括每次请求的 query、检索返回的文档 ID 列表、最终 prompt 的 token 数、工具调用记录、最终答案、用户反馈如果有。这些数据存下来既能用于排查问题也能作为后续优化的训练数据。我特别建议记录“检索返回的文档 ID”和“最终答案引用的文档 ID”对比这两个能发现模型有没有正确使用检索结果。如果检索返回了正确文档但答案没引用说明是生成环节的问题如果检索压根没返回正确文档那就是检索环节的问题。这个对比是定位问题最快的方法。7.3 灰度发布的节奏控制别一上线就全量。先放 5% 的流量观察一周重点看错误率和用户反馈。没问题再放到 20%再观察一周。RAG 系统的问题往往在长尾 query 上才暴露小流量跑一周能覆盖到大部分长尾。灰度期间保留旧版本作为兜底一旦新版本错误率超过阈值自动切回。这套流程听起来繁琐但比起全量上线后半夜被报警叫醒还是值得的。我在实际项目里吃过亏一次全量上线后才发现某类 query 的检索全部返回空因为那类文档的格式特殊没被正确切分。灰度发布能让你用最小的代价发现这类问题。8. 我个人的一些实操体会搭这套东西的过程中最大的体会是RAG 的瓶颈往往不在 LLM而在检索和上下文工程。很多人一遇到答案不准就想换更大的模型但实测下来把检索召回率从 70% 提到 90%比把模型从 7B 换到 70B 带来的提升还大而且成本低得多。所以我的建议永远是先把检索和上下文这两块打磨好再考虑模型升级。另一个体会是关于“简单”的价值。LangGraph 能搭出很复杂的图但生产环境里我倾向于用最简单的结构。节点越少、边越清晰出问题时越好排查。我见过有人搭了十几个节点的图结果一个状态流转 bug 查了三天。能用三个节点解决的绝不用五个。最后分享一个小技巧给你的 RAG 系统加一个“我不知道”的出口。当检索置信度低于阈值、或者模型自己判断信息不足时让它明确说“根据现有资料无法回答”而不是硬编一个答案。这个出口能大幅降低幻觉率用户对“诚实地说不知道”的容忍度远高于“自信地胡说八道”。阈值怎么定拿一批你知道答案的问题和一批知识库里没有的问题分别测找一个能区分两者的相似度分数通常余弦相似度 0.6 左右是个不错的起点。
返回列表