1. 从"能跑通"到"敢上线":RAG 项目真正的分水岭在哪
很多人第一次搭 RAG,流程都差不多:文档切块、向量化、塞进向量库、检索、拼 prompt、丢给 LLM。本地跑个 demo,问几个问题,答案看着还挺像那么回事,于是就觉得"RAG 不过如此"。可真到了要上生产、要接真实用户、要处理几千上万份文档的时候,问题就全冒出来了——检索召回忽高忽低、多轮对话上下文丢失、工具调用参数对不上、模型偶尔胡编乱造、成本还压不下来。
我自己踩过最典型的一个坑:早期用最朴素的"固定长度切块 + 单一向量检索"做知识库,测试集上命中率看着有 80%,上线后用户投诉不断。后来复盘才发现,那 80% 是"检索到了相关段落",但真正"能回答对问题"的比例只有一半出头。检索命中不等于回答正确,这是 RAG 里最容易被忽略的认知差。
这篇要聊的,就是怎么用Haystack和LangGraph这两个框架,把 RAG 从"玩具"做成"生产级"。核心围绕三件事展开:RAG 流水线的工程化、工具合约(Tool Contract)的设计、以及上下文工程(Context Engineering)。关键词里的 Haystack、LangGraph、RAG、LLM、上下文工程,会贯穿全文。适合已经跑通过基础 RAG、想往生产环境推进的开发者,也适合正在选型、纠结用哪个框架的朋友。
先说结论性的判断:Haystack 负责"数据与检索"这条确定性流水线,LangGraph 负责"决策与编排"这条带状态的智能体流水线。两者不是竞争关系,而是分工关系。理解这一点,后面所有的设计都会顺。
2. 为什么是 Haystack + LangGraph 这套组合,而不是二选一
2.1 两个框架的定位差异,决定了它们天然互补
Haystack 的强项在于组件化的检索流水线。它把 Document Store、Retriever、Ranker、Reader、Generator 这些环节拆成标准组件,用 Pipeline 串起来,每个组件可以单独替换、单独测试。这种设计对"确定性流程"极其友好——给定一个 query,走哪几步、每步输出什么,都是可预测、可断点调试的。
LangGraph 的强项在于带状态的有向图编排。它把 LLM 应用建模成一张图,节点是操作,边是流转条件,还能维护跨轮次的 State。这对"需要根据中间结果动态决策"的场景是刚需——比如先判断用户意图,再决定是走检索、走工具调用、还是直接闲聊;检索结果不够好时,要不要改写 query 重试。
我见过不少人硬要用一个框架干所有事。用 LangGraph 硬写检索流水线,结果把简单的 ETL 搞得无比复杂;或者用 Haystack 硬做多轮决策,结果状态管理写得一团乱。工具选型的第一原则是:让确定性的部分保持确定性,让需要决策的部分才引入智能体。
2.2 一张表看清职责边界
| 维度 | Haystack 负责 | LangGraph 负责 |
|---|---|---|
| 核心抽象 | Pipeline + Component | Graph + Node + State |
| 典型任务 | 文档清洗、切块、嵌入、检索、重排 | 意图路由、多轮对话、工具编排、重试 |
| 状态管理 | 无状态为主,单次请求内流转 | 显式 State,跨节点、跨轮次持久化 |
| 调试方式 | 逐组件打印中间输出 | 逐节点追踪、可视化图执行路径 |
| 适合场景 | 数据侧、检索侧 | 决策侧、交互侧 |
这张表不是绝对的,但能帮你快速判断"这段逻辑该放哪边"。我的经验是:凡是能用固定步骤描述清楚的,放 Haystack;凡是需要"看情况"的,放 LangGraph。
2.3 组合后的整体架构长什么样
一个生产级 RAG 系统,我通常拆成三层:
- 数据层:文档接入、解析、清洗、切块、嵌入、入库。这层用 Haystack 的 indexing pipeline,离线跑,可重跑。
- 检索层:query 改写、多路召回、重排、上下文组装。这层用 Haystack 的 query pipeline,在线跑,低延迟。
- 编排层:意图识别、工具选择、多轮状态、失败重试、结果校验。这层用 LangGraph,把检索层当成一个"工具节点"来调用。
这样分层的好处是:检索层可以独立做评测和优化,编排层可以独立做逻辑迭代,两边互不干扰。上线后要调检索召回,不用动编排代码;要加新工具,不用碰检索流水线。
3. 用 Haystack 搭一条经得起压测的检索流水线
3.1 切块策略:别再用固定长度了
固定长度切块(比如每 512 token 一刀切)是最省事、也是最容易埋雷的做法。它会把一个完整的语义单元拦腰截断,导致检索到的片段"半句话",LLM 拿到残缺上下文自然答不准。
我在实际项目里更推荐语义感知切块,具体做法是:
- 优先按文档结构切(标题、段落、列表项),保留层级信息;
- 段落过长时,按句子边界切,而不是按字符数切;
- 每个 chunk 保留一定的重叠(overlap),通常 10%~20%,防止边界信息丢失;
- 给每个 chunk 附上元数据:来源文档、章节路径、页码、时间戳。
元数据这块特别重要,后面做过滤检索、做引用溯源、做权限控制,全靠它。我见过太多项目一开始不存元数据,后期想加"只检索某个部门文档"的功能,只能全量重跑嵌入,成本极高。
3.2 多路召回:单一向量检索的天花板很低
纯向量检索擅长语义相似,但对精确匹配(比如产品型号、专有名词、数字)很弱。生产环境里,用户的问题往往两者混杂。我的做法是混合检索:
- 向量召回:负责语义相近的内容;
- 关键词召回(BM25):负责精确词命中;
- 两路结果融合:用 RRF(Reciprocal Rank Fusion)或加权分数合并。
RRF 的好处是不需要归一化不同检索器的分数,直接按排名融合,工程上很省心。实测下来,混合检索相比纯向量,在含专有名词的 query 上召回提升非常明显。
3.3 重排(Rerank):把"相关"变成"最相关"
召回阶段追求的是"不漏",通常会取 top 20~50;但塞给 LLM 的上下文有限,必须精选。这时候就需要重排模型,对召回结果做精细打分,取 top 3~5。
重排模型(cross-encoder 类)比向量检索慢,但精度高得多。我的经验是:召回用便宜快速的,重排用精准但慢的,各司其职。如果延迟敏感,可以把重排放在异步或缓存层。
3.4 上下文组装:给 LLM 的信息要"刚刚好"
检索到 5 个 chunk,不是简单拼接就完事。我通常按这个顺序组织:
- 系统指令(角色、约束、输出格式);
- 检索到的上下文,每个 chunk 标注来源编号;
- 用户问题;
- 输出格式要求(比如"引用来源时用 [1][2]")。
这里有个反直觉的点:上下文不是越多越好。塞太多无关内容,反而会稀释关键信息,还会推高 token 成本。我一般控制在 2000~4000 token 的上下文区间,具体看模型窗口和任务复杂度。
提示:上下文里每个 chunk 都带上来源编号,让 LLM 在回答时引用,既方便用户核查,也方便你事后做归因分析——哪个 chunk 被引用了、哪个从没被用过,一目了然。
4. 工具合约:让 LLM 调用工具不再"靠猜"
4.1 什么是工具合约,为什么它比 prompt 更重要
工具合约(Tool Contract)指的是:对每个可被 LLM 调用的工具,明确定义它的名称、用途、参数结构、返回结构、以及失败时的行为。很多人写工具调用,就在 prompt 里写一句"你可以调用 search 工具",然后祈祷模型参数填对。这在 demo 里能跑,在生产里必崩。
合约的核心价值是把"模型自由发挥"变成"模型在约束内选择"。参数类型、必填项、取值范围都定义清楚,模型填错的概率会大幅下降;返回结构固定,下游解析才不会崩。
4.2 参数设计:三个关键点
我在设计工具参数时,会反复问三个问题:
- 这个参数模型能可靠地填出来吗?如果参数需要复杂推理才能得出,不如让模型先输出中间结果,再由代码转换。
- 参数有没有默认值?有默认值的参数设为可选,减少模型负担。
- 参数要不要做枚举约束?比如"时间范围"只允许
day/week/month,用枚举比让模型自由填字符串可靠得多。
举个具体例子,一个检索工具的参数我会这样设计:
{ "name": "search_knowledge_base", "description": "在内部知识库中检索相关文档片段,用于回答事实性问题", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "检索用的自然语言查询,应聚焦单一主题" }, "top_k": { "type": "integer", "description": "返回的文档片段数量", "default": 5, "minimum": 1, "maximum": 20 }, "filter_dept": { "type": "string", "description": "限定检索的部门范围", "enum": ["tech", "hr", "finance", "all"], "default": "all" } }, "required": ["query"] } }注意description的写法——它不是给用户看的,是给模型看的。要写清楚"什么时候该用这个工具",而不是"这个工具是什么"。模型判断要不要调用,靠的就是这段描述。
4.3 返回结构:固定 schema 是稳定性的前提
工具返回给模型的内容,我坚持用结构化 JSON,而不是一段自由文本。原因很简单:结构化返回可以被代码校验、被日志记录、被下游程序消费;自由文本只能靠模型自己理解,出错无从排查。
一个检索工具的返回我会这样设计:
{ "status": "success", "results": [ { "id": "doc_123_chunk_4", "content": "……", "source": "员工手册_v2.pdf", "score": 0.87 } ], "total_found": 12, "truncated": true }status字段让模型知道这次调用成没成功;truncated告诉模型结果被截断了,可能需要缩小范围重试。这些细节,都是让智能体"知道自己处境"的关键。
4.4 失败处理:工具报错时模型该怎么办
工具调用失败是常态——网络超时、参数非法、无结果返回。关键是让模型知道失败了,并且知道怎么补救。我的做法是:
- 失败时返回
status: "error"加error_type和message; - 在系统 prompt 里明确告诉模型:遇到
error_type: "no_results"时,应该改写 query 重试;遇到error_type: "invalid_params"时,应该检查参数后重试。
这样模型就有了"自我修复"的依据,而不是一失败就摆烂或者胡编。
5. 上下文工程:决定 RAG 上限的隐形战场
5.1 上下文工程到底在工程什么
上下文工程(Context Engineering)这个词最近很热,但很多人理解得比较窄,以为就是"把检索结果拼进 prompt"。实际上它涵盖的范围大得多:
- 选什么进上下文:哪些信息该给模型,哪些不该给;
- 以什么形式进上下文:结构化还是自然语言,带不带来源标注;
- 什么时候进上下文:一次性给全,还是分步给;
- 上下文怎么随对话演进:多轮对话里,历史信息怎么压缩、怎么取舍。
我把它理解成"给模型设计一个恰到好处的信息环境"。信息太少,模型答不准;信息太多,模型抓不住重点还费钱;信息给错,模型直接被带偏。
5.2 多轮对话里的上下文压缩
多轮对话最头疼的是历史越滚越长。全量保留,token 成本爆炸;粗暴截断,早期关键信息丢失。我的做法是分层处理:
- 最近 N 轮:完整保留,保证对话连贯;
- 更早的历史:用 LLM 做摘要压缩,保留关键事实和结论;
- 检索到的文档:每轮独立检索,不跨轮累积,避免上下文污染。
这里有个细节:摘要压缩要保留实体和数字,比如"用户提到预算 50 万"这种信息,压缩时不能丢。我一般会在摘要 prompt 里明确要求"保留所有具体数字、名称、时间"。
5.3 用 LangGraph 的 State 管理上下文流转
LangGraph 的 State 是上下文工程的好帮手。我把 State 设计成包含这几块:
messages:对话历史;retrieved_docs:当前轮检索到的文档;tool_calls:本轮工具调用记录;summary:历史摘要;user_profile:用户偏好、权限等长期信息。
每个节点读写 State 的特定字段,职责清晰。比如检索节点只写retrieved_docs,生成节点只读retrieved_docs和messages。这种"读写分离"让调试变得非常容易——出问题时,打印 State 就知道哪一步出了岔子。
5.4 上下文里的"噪音"怎么清
检索难免带回无关内容。我的清理策略有三条:
- 重排后设阈值:分数低于阈值的直接丢弃,宁缺毋滥;
- 去重:多个 chunk 内容高度重叠时,只保留信息量最大的;
- 冲突检测:如果两个 chunk 对同一事实说法矛盾,要么都保留让模型判断,要么标记出来提示模型注意。
第三条尤其重要。知识库更新不及时时,新旧文档冲突很常见。与其让模型悄悄选一个,不如显式提示"存在冲突信息",让模型在回答里说明。
6. 把检索层接进 LangGraph:一个可复现的编排骨架
6.1 图结构设计:从意图到回答的完整链路
我常用的图结构是这样的:
- 入口节点:接收用户输入,初始化 State;
- 意图路由节点:判断是"知识问答"、"工具操作"还是"闲聊";
- 检索节点:调用 Haystack 检索流水线;
- 工具节点:执行具体工具调用;
- 生成节点:组装上下文,调用 LLM 生成回答;
- 校验节点:检查回答是否有引用、是否偏离问题;
- 出口节点:返回结果,更新 State。
条件边负责在节点间流转,比如意图路由后根据结果走不同分支,校验不通过时回到生成节点重试。
6.2 意图路由:别让所有问题都走检索
一个常见误区是"所有问题都先检索一遍"。实际上很多问题(比如"你好"、"帮我算个数")根本不需要检索,硬走一遍既慢又浪费。意图路由节点就是干这个的——用一次轻量 LLM 调用(或小模型)做分类,把问题分流。
分类的类别我一般设这几类:knowledge_query(走检索)、tool_action(走工具)、chitchat(直接生成)、clarify(需要反问用户)。分类 prompt 要写得具体,给出每类的判断标准和例子。
6.3 检索节点的实现要点
检索节点本质上是"把 LangGraph 的 State 翻译成 Haystack 的 query,再把结果翻译回 State"。要点有三个:
- query 改写:用户口语化的问题,先改写成适合检索的形式。比如"那个报销的事咋弄"改成"报销流程 报销标准"。
- 多路召回 + 重排:前面讲过的混合检索,在这里落地。
- 结果封装:把 Haystack 返回的 Document 对象转成 State 里的结构化格式,带上来源、分数、元数据。
6.4 生成节点的 prompt 模板
生成节点的 prompt 我通常这样组织:
你是企业内部知识助手。请基于以下检索到的资料回答用户问题。 规则: 1. 只使用资料中的信息回答,不要编造。 2. 如果资料不足以回答,明确说明"资料中未找到相关信息"。 3. 引用资料时,用 [编号] 标注来源。 4. 回答要简洁,直接给结论,再给依据。 资料: [1] {chunk_1_content}(来源:{source_1}) [2] {chunk_2_content}(来源:{source_2}) 用户问题:{user_question}这个模板的关键是规则前置、资料居中、问题后置。规则放最前面,模型注意力最集中;问题放最后,紧挨着生成位置,符合模型的"就近原则"。
6.5 校验节点:给回答加一道保险
校验节点做两件事:引用检查和相关性检查。引用检查看回答里有没有[编号],没有的话可能是模型在编;相关性检查用一次轻量 LLM 调用,判断回答是否真的回应了问题。任一不通过,就回到生成节点重试,最多重试 2 次,避免死循环。
这道保险在早期能挡掉不少低级错误。上线后你会发现,用户对"答非所问"的容忍度极低,宁可回答"没找到",也不要答一堆无关内容。
7. 上线前必须做的几件事:评测、监控与成本控制
7.1 检索评测:别凭感觉判断好坏
检索质量必须量化。我通常准备一个评测集:50~200 条真实问题,每条标注"应该命中的文档 ID"。然后跑检索,算两个指标:
- Recall@K:top K 里有没有命中正确文档;
- MRR:正确文档排在第几位。
这两个指标能客观反映检索好坏。我见过团队凭感觉调参,改了半天其实没提升,有了评测集,每次改动都能看到数字变化。
7.2 回答评测:LLM as Judge 的正确用法
回答质量评测,可以用 LLM 当裁判(LLM as Judge),但要设计好评测维度。我一般评四项:准确性(有没有事实错误)、完整性(有没有漏关键点)、引用正确性(引用是否对应)、简洁性(有没有废话)。每项 1~5 分,让裁判模型逐项打分并给理由。
要注意的是,LLM 裁判本身有偏差,比如偏爱长回答。所以我会同时保留人工抽检,用人工结果校准裁判模型。
7.3 监控指标:上线后盯什么
上线后我重点盯这几个指标:
| 指标 | 含义 | 异常信号 |
|---|---|---|
| 检索命中率 | 有结果返回的请求占比 | 突然下降说明索引或 query 改写出问题 |
| 平均延迟 | 端到端响应时间 | 上升说明某环节变慢 |
| 工具调用成功率 | 工具正常返回占比 | 下降说明工具或参数设计有问题 |
| 重试率 | 触发重试的请求占比 | 上升说明生成或校验环节不稳定 |
| Token 消耗 | 每请求平均 token | 上升说明上下文膨胀 |
这些指标配合日志,能快速定位问题。我习惯把每次请求的 State 快照存下来,出问题时直接回放。
7.4 成本控制:三个立竿见影的手段
- 缓存:相同或相似 query 的检索结果缓存,命中直接返回;
- 分级模型:简单任务用小模型,复杂任务才用大模型;
- 上下文裁剪:严格控制塞进 prompt 的 token 数,定期审查有没有冗余。
这三条做下来,成本通常能降一半以上,而且不影响体验。
8. 几个我踩过的坑和对应的解法
8.1 切块重叠导致的重复引用
早期我把 overlap 设得太大(30%),结果检索时经常召回内容高度重叠的多个 chunk,LLM 引用时出现"同一句话引用两次"。后来把 overlap 降到 15%,并在重排后加了去重逻辑,问题解决。overlap 不是越大越好,够用就行。
8.2 工具描述写得太"技术",模型不会用
有次我写了个工具,描述是"执行向量相似度检索,返回 top-k 文档"。结果模型很少调用它,因为它不知道"什么时候该用"。改成"当用户询问公司政策、流程、产品信息等事实性问题时使用"之后,调用率立刻上来了。工具描述要写"使用场景",不是"技术实现"。
8.3 多轮对话里检索结果污染
有段时间我发现,第二轮对话的回答经常混进第一轮检索的文档。排查后发现是 State 里retrieved_docs没清空,跨轮累积了。后来改成每轮检索前先清空该字段,问题消失。State 字段的生命周期要明确,该清的必须清。
8.4 校验节点导致的死循环
校验节点刚上线时,偶尔出现无限重试。原因是重试时没有改变任何输入,模型每次都生成同样的回答,校验每次都不过。后来加了"重试时降低温度"和"最多重试 2 次"两个约束,彻底解决。任何带重试的循环,都必须有终止条件。
9. 关于这套组合,我个人的几点体会
Haystack 和 LangGraph 这套组合,我用了大半年,最大的感受是:它逼着你把"确定性"和"不确定性"分开思考。Haystack 那边,每一步都是确定的,可以写测试、可以做评测;LangGraph 那边,每一步都可能分支,需要设计好状态和兜底。这种分离,让整个系统的可维护性上了一个台阶。
另一个体会是,上下文工程的价值被严重低估了。很多人把精力全花在换模型、调参数上,却忽略了"给模型什么信息"才是决定上限的关键。同样的模型,上下文组织得好和差,回答质量能差出一个档次。
最后说个实操建议:先跑通最小闭环,再逐步加复杂度。别一上来就上多路召回、重排、多智能体,先把"检索 + 生成"这条主线跑稳,有了评测基线,再一项项加。每加一项,都用评测集验证有没有真的提升。这样迭代,方向不会跑偏。
这套东西后续还能往几个方向扩展:比如接入更细粒度的权限控制,让不同用户检索到不同范围的内容;比如把工具合约标准化成一套内部规范,团队共用;比如引入缓存层和异步处理,进一步压延迟。这些等主线稳定了再逐个上,不急。