☰
生产级RAG实战:Haystack与LangGraph的检索优化、工具合约与上下文工程
2026/10/1 1:44:48 网站建设 项目流程

1. 从"能跑通"到"敢上线":生产级 RAG 的分水岭在哪

很多人第一次用 Haystack 或 LangGraph 搭 RAG,跑通一个 demo 只需要一个下午:文档切一切、向量库塞一塞、检索拼进 prompt、丢给模型出答案。但真到了要上线的场景,问题就全冒出来了——检索回来的内容答非所问、工具调用时参数传错、多轮对话里上下文越滚越乱、模型偶尔开始"编"。这一篇是系列第三部分,重点就落在这些"跑通之后"的硬骨头上:生产级 RAG 的检索质量、工具合约的约束设计、以及上下文工程。

先把定位说清楚。这个系列面向的是已经能跑通基础 RAG 流程、准备往生产环境推进的开发者。如果你还在纠结"什么是向量检索",建议先回看前两篇。本篇的关键词是 Haystack、LangGraph、RAG、LLM、上下文工程,核心要解决三件事:第一,怎么让检索从"能召回"变成"召回得准";第二,怎么用 LangGraph 把工具调用做成有合约约束的可靠节点,而不是让模型自由发挥;第三,怎么在有限的上下文窗口里做取舍,让每一段塞进去的内容都值钱。

我自己的经验是,demo 和生产之间隔着的不是技术栈,而是对失败模式的预判。demo 阶段你只关心"成功路径长什么样",生产阶段你得关心"失败路径有多少种、每种怎么兜底"。这篇文章就按这个思路展开,把每个环节的失败模式、排查方法、以及我踩过的坑都摊开讲。

提示:本篇所有代码示例基于 Haystack 2.x 和 LangGraph 的稳定版本 API,如果你用的是更早的版本,部分接口名会有差异,注意对照官方文档核对。

2. 检索质量:RAG 真正的瓶颈从来不在生成端

2.1 为什么"检索不准"比"模型不行"更致命

先抛一个反直觉的结论:大部分 RAG 系统答得烂,锅不在 LLM,在检索。我做过一个粗略统计,在十几个实际项目里,用户反馈"答非所问"的 case,八成以上能追溯到检索环节——要么该召回的文档没召回(召回率问题),要么召回了一堆不相关的(精确率问题),要么召回了但排序把真正有用的排到了后面(排序问题)。

生成端的能力其实是被检索端"喂"出来的。你给模型三段不相关的上下文,再强的模型也只能硬编或者拒答。所以生产级 RAG 的第一优先级,是把检索这条链路做扎实。Haystack 在这块给的工具比较全,从DocumentStore到Retriever再到Ranker,每一层都可以单独调优。

2.2 分块策略:切得对不对,直接决定召回上限

分块(chunking)是最容易被忽视、又最影响召回的一步。新手常犯的错是拍脑袋定一个chunk_size=512,然后就不管了。实际上分块策略要跟你的文档类型强绑定。

我一般按文档结构分三类处理:

  • 结构化文档(带标题层级的 Markdown、HTML):按标题层级切,保留父级标题作为上下文前缀。这样每个 chunk 自带"我在讲什么"的语义锚点。
  • 半结构化文档(合同、报告):按段落切,但设置一个最小长度阈值,太短的段落跟前一段合并,避免出现"孤块"。
  • 纯文本/对话记录:按语义切分,用滑动窗口加重叠(overlap),重叠比例一般取 chunk 的 10%~20%。

Haystack 里可以用DocumentSplitter配合不同的split_by参数实现。举个我常用的配置:

from haystack.components.preprocessors import DocumentSplitter splitter = DocumentSplitter( split_by="word", split_length=200, split_overlap=30, split_threshold=10 )

这里的split_overlap=30就是重叠,split_threshold=10是"小于 10 个词的分块直接丢弃或合并"。为什么要重叠?因为语义边界往往落在两块的接缝处,没有重叠的话,一个完整的句子可能被切成两半,检索时两边都召不全。

注意:overlap 不是越大越好。重叠太大,向量库里会有大量近似重复的块,检索时挤占 top-k 名额,反而降低有效信息的密度。我实测下来 10%~20% 是个比较稳的区间。

2.3 混合检索:把关键词和语义两条腿都用上

纯向量检索有个天然短板:对精确匹配不敏感。用户问"XX-2024 型号的参数",向量检索可能召回一堆"XX 系列"的泛泛内容,就是抓不住那个具体型号。这时候关键词检索(BM25)就派上用场了。

生产环境我基本都用混合检索:BM25 负责精确命中,向量负责语义泛化,两路结果用加权融合或者 RRF(Reciprocal Rank Fusion)合并。Haystack 里可以用InMemoryBM25Retriever和InMemoryEmbeddingRetriever分别跑,再用DocumentJoiner合并:

from haystack.components.joiners import DocumentJoiner joiner = DocumentJoiner( join_mode="reciprocal_rank_fusion", top_k=10 )

RRF 的好处是不需要调权重,它只看排名不看分数,对两路检索的分数量纲差异天然免疫。如果你的两路检索分数尺度差很多,用 RRF 比加权求和稳得多。

2.4 重排序:把真正有用的顶到最前面

检索召回 top-50,但真正能进 prompt 的可能只有 top-5。这中间的筛选就靠重排序(reranking)。重排序模型(cross-encoder)比向量检索(bi-encoder)慢,但精度高得多,因为它能同时看 query 和 document 做交互计算。

我的常规做法是:检索阶段放宽到 top-30~50,重排序阶段压到 top-5~8。这样既保证了召回率,又保证了进 prompt 的内容质量。Haystack 里用TransformersSimilarityRanker或者接外部 rerank 服务都行。

这里有个经验值:重排序的收益在文档量大、语义相近文档多的时候最明显。如果你的知识库只有几十篇文档,重排序提升有限;但一旦上千篇,重排序几乎是必选项。

2.5 检索评估:没有度量就没有优化

优化检索最怕"凭感觉"。你得有一套评估集:一批 query,每个 query 标注哪些文档是真正相关的。然后算召回率、精确率、MRR、NDCG 这些指标。Haystack 有EvaluationRunResult相关的工具可以帮你跑这套评估。

我一般会维护一个 50~100 条的评估集,每次调整分块、检索、重排序参数,都跑一遍看指标变化。没有这个,你的"优化"很可能是在瞎调。

3. 工具合约:让 LLM 调用工具从"碰运气"变成"走流程"

3.1 工具调用的本质是"结构化输出 + 校验"

LangGraph 里做工具调用,很多人第一反应是"让模型自己决定调哪个工具、传什么参数"。这在 demo 里很酷,在生产里很危险。模型可能传错参数类型、漏传必填字段、甚至编造一个不存在的工具名。

生产级的做法是把工具调用当成一次带合约的 RPC:工具有明确的输入 schema、输出 schema、以及失败时的错误约定。LangGraph 的ToolNode配合 Pydantic 模型,可以把这套合约固化下来。

from pydantic import BaseModel, Field from langchain_core.tools import tool class SearchInput(BaseModel): query: str = Field(description="检索关键词,必填") top_k: int = Field(default=5, ge=1, le=20, description="返回条数,1-20") @tool(args_schema=SearchInput) def search_knowledge(query: str, top_k: int = 5) -> str: """在知识库中检索相关内容""" # 实际检索逻辑 return results

关键在Field里的约束:ge=1, le=20直接限制了取值范围,模型传超范围的值会被 schema 校验拦下来。这比在函数体里写 if-else 判断优雅得多,也更可靠。

3.2 工具设计的"三要素":key、query、value

热词里有个说法我觉得总结得很到位:一个工具要回答三个问题——我是谁(key)、我在找什么(query)、我能提供什么(value)。

  • 我是谁:工具名和描述要清晰到模型一眼能判断"这个工具适不适合当前任务"。描述里最好带上使用场景和反例。
  • 我在找什么:输入参数要精确。参数名别用input、data这种含糊词,用search_query、user_id这种自解释的。
  • 我能提供什么:输出要结构化。别返回一大坨自然语言,返回 JSON 或者带明确字段的文本,方便下游节点消费。

我见过太多工具描述写成"搜索工具"四个字,模型根本不知道什么时候该用它。好的描述应该像这样:

在内部知识库中检索技术文档。适用于回答产品功能、API 用法、 配置参数类问题。不适用于实时数据查询(用 weather_tool) 或数学计算(用 calculator)。

3.3 用 LangGraph 编排工具节点:状态机比链式调用更可控

LangGraph 的核心价值是把 agent 的执行流程建模成状态图。相比 LangChain 的链式调用,状态图的好处是:每个节点做什么、什么条件下走哪条边,都是显式定义的,可调试、可回放。

一个典型的工具调用图长这样:

from langgraph.graph import StateGraph, END from langgraph.prebuilt import ToolNode workflow = StateGraph(AgentState) workflow.add_node("agent", call_model) workflow.add_node("tools", ToolNode(tools)) workflow.set_entry_point("agent") workflow.add_conditional_edges( "agent", should_continue, {"continue": "tools", "end": END} ) workflow.add_edge("tools", "agent") app = workflow.compile()

should_continue这个条件函数决定了"模型输出是最终答案还是工具调用请求"。这个判断逻辑要写严谨,别让模型输出格式稍微一变就卡死。

3.4 工具调用的失败模式与兜底

工具调用最常见的失败模式有四种,我按出现频率排:

失败模式表现兜底策略
参数校验失败类型错、超范围、缺必填schema 校验拦截,返回错误信息让模型重试
工具执行超时外部 API 慢或无响应设置超时,超时后返回降级结果
工具返回空检索无结果明确告知模型"无结果",引导它换策略或拒答
无限循环调用模型反复调同一工具设置最大迭代次数,超限强制结束

无限循环这个坑我踩过。模型有时候会陷入"调工具→结果不满意→再调同一个工具"的死循环。LangGraph 里可以设recursion_limit,但更根本的是在 prompt 里明确告诉模型"同一工具最多调 N 次"。

4. 上下文工程:窗口有限,怎么塞才值钱

4.1 上下文工程不是"提示词工程"的换皮

很多人把上下文工程等同于写 prompt,其实不是。提示词工程关注"怎么措辞让模型理解任务",上下文工程关注的是在有限的 token 预算里,怎么分配每一类信息的配额。

一个生产级 RAG 的上下文通常包含这几块:系统指令、对话历史、检索到的文档、工具返回结果、当前用户输入。每一块都要占 token,加起来很容易超窗口。上下文工程就是决定"谁多谁少、谁先谁后、谁可以压缩"。

4.2 上下文预算的分配思路

我一般按这个优先级分配:

  1. 系统指令:固定占用,不可压缩。这是模型的行为准则。
  2. 当前用户输入:完整保留,不可压缩。
  3. 检索文档:弹性最大的一块,按相关性动态截断。
  4. 工具返回:按需保留,历史工具结果可以摘要化。
  5. 对话历史:最容易被压缩的一块,用摘要或滑动窗口。

具体比例没有标准答案,但有个经验法则:检索文档别超过总预算的 50%,否则模型容易被文档淹没,反而忽略系统指令。

4.3 对话历史的压缩:摘要 + 滑动窗口

多轮对话里,历史会越滚越长。全量保留不现实,全丢又丢上下文。我的做法是"近期原文 + 远期摘要":

  • 最近 3~5 轮对话保留原文,保证连贯性。
  • 更早的对话压缩成一段摘要,只保留关键事实和决策。

摘要的生成可以单独用一个轻量模型跑,别占用主模型的上下文。LangGraph 里可以加一个专门的"摘要节点",在历史超过阈值时触发。

4.4 检索文档的"重排 + 截断"

检索回来的文档不是全塞进去就好。我一般做两步处理:

  • 重排:按相关性排序,最相关的放最前面。模型对开头和结尾的内容注意力更高("中间迷失"现象)。
  • 截断:每篇文档只保留跟 query 最相关的片段,而不是整篇。可以用句子级的相关性打分来选片段。

这样能在同样的 token 预算里塞进更多"有效信息",而不是被无关段落稀释。

4.5 上下文里的"噪声"比"缺失"更可怕

一个反直觉的点:给模型塞不相关的上下文,比什么都不塞更糟。因为模型会试图从噪声里"找规律",结果就是编。所以宁可少塞,也别塞垃圾。这也是为什么重排序和截断这么重要——它们的作用就是过滤噪声。

5. 把三者串起来:一个可复现的生产级流程

5.1 整体架构

把检索、工具、上下文三块串起来,一个生产级 RAG 的流程大致是:

  1. 用户输入进入,先做 query 改写(把口语化问题转成检索友好的 query)。
  2. 混合检索(BM25 + 向量)召回候选文档。
  3. 重排序,取 top-k。
  4. 上下文组装:系统指令 + 压缩后的历史 + 重排后的文档 + 当前输入。
  5. 进入 LangGraph 状态图,模型决定是直接回答还是调工具。
  6. 如果调工具,走工具节点,结果回填上下文,再回到模型。
  7. 输出最终答案,同时记录 trace 用于后续评估。

5.2 关键节点的可观测性

生产环境一定要有 trace。每个节点的输入输出、耗时、token 消耗都要记下来。不然出了问题你根本不知道是哪一环崩的。LangGraph 配合 LangSmith 或者自建的日志系统都行,核心是每个节点都要有可回放的记录。

5.3 我踩过的三个坑

第一个坑:分块时没保留标题上下文。结果检索出来的块全是"如上所述,该参数应设置为……",完全不知道在讲哪个参数。后来在每块前面加了标题路径前缀才解决。

第二个坑:工具描述写得太泛。模型经常在不需要检索的时候调检索工具,浪费 token 还拖慢响应。后来在描述里加了明确的反例("不适用于实时数据查询"),误调率明显下降。

第三个坑:上下文没做截断。早期直接把 top-20 文档全塞进去,结果模型开始"中间迷失",答案质量反而比 top-5 还差。后来改成重排后取 top-5 并做片段截断,质量稳定多了。

5.4 评估与迭代

上线不是终点。我一般会持续收集线上 bad case,定期补充到评估集里,然后跑回归测试。每次调整检索参数、工具描述、上下文策略,都跑一遍评估集看指标。这套闭环跑起来,系统才会越用越准。

6. 一些零散但实用的经验

关于 Haystack 和 LangGraph 的选型,我的看法是:Haystack 在检索链路上更成熟,组件化程度高,适合把 RAG 的"检索-重排-生成"这条线做扎实;LangGraph 在 agent 编排上更灵活,状态图模型适合处理带分支、带循环的复杂流程。两者不是二选一,我经常是 Haystack 负责检索层,LangGraph 负责编排层,各取所长。

关于本地部署,如果你有数据不出域的要求,Ollama 配合本地向量库能搭一套完全离线的 RAG。代价是模型能力比云端大模型弱一些,检索和重排序的调优就得更下功夫。

关于 token 成本,上下文工程做得好,能省下相当可观的 token。我做过对比,同样的问答质量,优化上下文策略后 token 消耗能降三到四成。这笔账在大规模调用时很可观。

最后说一个心态上的体会:生产级 RAG 没有"一劳永逸"的配置。文档在变、用户在变、模型在变,检索策略和上下文策略都得跟着调。把它当成一个需要持续运营的系统,而不是一次性的项目,心态会稳很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询