上周我调一个RAG项目,用户连着问了几个库里根本没存的问题。系统倒是不客气,每次检索出来的结果都是空列表,但还是照常把空结果传给了大模型。模型也挺配合,一本正经地编了一段“专业”答案。用户截图过来问:这答案是哪来的?我顺着日志一查,心里咯噔一下——不是模型不行,是检索为空后的回退分支压根没设计,直接把空检索当成“没找到”塞给了生成环节。
这个场景在RAG实战里太常见了。很多人搭RAG时把精力放在Embedding、切分、向量库选型上,但“检索为空后还调不调模型”这个决定,才是决定系统靠不靠谱的分水岭。调了,模型可能一本正经地胡诌;不调,用户可能觉得系统是个呆子。这里面的尺度,不是拍脑袋定的,而是要设计一套明确的“回退分支”,并且靠日志和指标去检查它到底工作得怎么样。
这篇文章我就从分支语义、决策逻辑、工程实现到踩坑排查,把这条链路完整拆开。无论你是用LangChain、LangFlow还是自己手写Pipeline,这套思路都能直接落地参考。
1. 检索为空到底意味着什么:先搞懂RAG的分支语义
1.1 空检索不等于“没有答案”,而是“没有证据”
很多人一看到检索结果为空,第一反应是“知识库里没有答案”。但严格说,RAG里的空检索只说明了一件事:当前查询在现有索引里没有找到匹配的“证据片段”。它不代表知识库里真的不存在相关内容,也不代表这个问题没有答案,更不代表模型应该闭嘴。
理解这件事,需要回到RAG的定位。RAG不是搜索引擎,也不是让模型去“查资料”,它的核心是用检索到的段落给生成模型提供事实依据,让模型的输出有源可溯。所以检索为空时,系统面临的状态不是“没有答案”,而是“没有证据”。没有证据的情况下让模型继续生成,本质上就是让模型闭卷考试。模型闭卷也能写出东西,但写出来的内容没有锚点,错对全凭模型自己发挥。
我习惯用一个类比:把RAG想象成让一个实习生去写行业分析报告。他手头有资料库可以查,查得到就摘抄、归纳、引用;查不到呢?如果他硬着头皮写,那全是自己的想象,你可能看不出破绽,但出了事责任得你担。所以,检索为空后“调不调模型”,本质上是在问:允许不允许这个实习生在没资料的情况下自由发挥?
1.2 空检索的五种典型成因,别一股脑丢给模型
“检索为空”是一个结果,但它背后可能有完全不同的原因。不排查原因就决定是否回退给模型,很容易把分支设计成“摆设”。常见的成因至少有五种。
第一,知识库本身确实没有相关内容。这种最直接,库就是没存,怎么查都是空。第二种,查询表述与库内内容语义差异太大。用户说“怎么退款”,库里写的是“售后退款流程”,如果Embedding模型对同义表达不敏感,就可能查不到。第三种,切分粒度问题。文档被切得太大,一句话的语义被埋在一堆噪声里;切得太小,又缺乏上下文,相似度都低。第四种,相似度阈值设得过高。很多向量检索默认阈值比如0.8,但实际业务里大部分相关内容的分数都在0.7左右,一过滤就全空了。第五种,索引或Embedding服务异常,比如新数据没提交、索引段未更新、模型服务超时。
这五种成因里,如果只是查询表述问题,回退给模型就是偷懒,更合理的做法是重写查询再检索一次。如果是切分或阈值问题,应该去调索引而不是调模型。只有确认知识库缺内容,才需要考虑“让模型总结一句‘未收录’”或者“让模型基于自身知识作答”。所以,在设计回退分支之前,先给空检索做个归因,远比直接调模型重要。
2. 回退分支设计:三个决策维度和一张决策表
2.1 第一层决策:是否允许模型裸答
关于“调不调模型”,最先要看的是业务风险。金融、医疗、法律这类对事实性要求极高的场景,检索为空后直接让模型生成答案,等于在生产环境里开了一个幻觉后门。用户问“XX药品有什么副作用”,库没查到,模型却凭借训练记忆输出了一段,万一适应证或剂量错了,后果不是一句“仅供参考”能兜住的。
我一般把场景分成两类:强事实性场景和弱事实性场景。强事实性场景下,空检索后禁止裸答,必须返回“暂未收录”的固定话术,或者引导用户联系人工客服。弱事实性场景比如内部知识问答、闲聊型助手,可以允许模型作答,但在输出上要明确加上“未检索到相关知识,以下内容为模型推测”的前缀,把责任边界划清楚。
还有一种更细的判断维度:产品是否承诺“答案可溯源”。如果产品界面上有“引用来源”按钮,用户默认看到的答案都该有出处,那么空检索后还调模型就是毁灭性的。用户会指着没有引用的段落问“凭什么这么说”。在设计第一层决策时,我建议直接问自己一个问题:这个答案如果错了,最坏情况是什么?能承受,就允许裸答;不能承受,就别调。
2.2 第二层决策:是否触发重写查询或重新检索
空检索不一定意味着必须进入兜底话术。在放弃之前,值得再给检索一次机会,但这次要换个方式。我在项目里最常用的是“查询重写”策略:让LLM先把用户问题改写成一个更适合向量检索的表达,去掉口语化、补全简称、扩展同义词,然后再检索一次。
举个例子,用户问“咱们公司这几天文档好卡,是咋回事”,原始query丢进向量库可能检索不到“系统性能下降”相关段落。但让模型改写为“公司内部文档系统近期访问缓慢,性能下降原因,排查方案”后,命中率会高很多。这个改写动作本身也消耗一次模型调用,但成本远低于让模型在无证据下胡编。
要注意的是,重写后依然为空,就不能无限重试。我一般设置最多重试一次,少数场景可以重试两次。因为每轮重写都带来额外的模型调用延迟和费用,而且如果知识库本身缺内容,重写十次也没用。这个分支可以这样走:第一次检索为空 -> LLM重写查询 -> 第二次检索 -> 仍然为空 -> 进入最终回退。如果第二次检索命中,就走正常生成链路。这样设计等于给系统多了一次“抢救”机会,但不会陷入递归泥潭。
2.3 第三层决策:是否返回护栏话术,以及话术的粒度
如果确认走到最终回退,接下来要决定返回什么内容。这里也有讲究。最简单的是返回固定话术,比如“知识库中暂未检索到相关答案,请换一种问法或联系管理员”。固定话术的好处是可控、不会漂移、也不会被模型发挥,缺点是比较生硬,用户体验一般。
还有一种做法是让模型生成一个“带着歉意的引导式回答”:系统提示词里明确告诉模型“你已经检索过知识库,没有找到相关资料,必须如实告知用户,可以请用户补充信息或提供关键词”。这种动态话术显得更自然,但前提是提示词写得非常强硬,否则模型很容易跑偏,又回去自由发挥。
我倾向于一个折中方案:第一层先用固定话术,同时把用户原始问题、空检索的信息包装成一条“可理解的反馈”,比如“关于‘XXX’的内容,当前知识库尚未收录。您可以尝试补充产品名称、现象描述或相关截图,我会再为您查找。”这样既不违反安全原则,又给了用户继续对话的抓手。
为了把这几个决策落到实操里,整理成一张决策表会更直观:
| 检索结果条数 | 分数情况 | 是否调模型 | 返回内容 |
|---|---|---|---|
| 0条,且重写后仍为0 | 无 | 否(强事实场景);是(弱事实场景) | 固定话术;或模型带免责声明作答 |
| 0条,但重写后命中 | 命中分数达标 | 是 | 正常RAG生成,引用检索结果 |
| 有结果,但全部低于阈值 | 分数低于设定阈值 | 建议否 | 同样视为空,执行兜底话术 |
| 有结果,部分达标 | 部分分数高 | 是 | 截取达标片段生成,并补充“部分参考”说明 |
这张表不是死的,但它的价值在于让“调不调模型”变成一个可配置、可检查的判断,而不是每次临场发挥。
3. 上手实现:LangChain 与 LangFlow 里的回退分支怎么接
3.1 基于LangChain的Branch/路由判断写法
在LangChain里实现回退分支,最直接的方式是用RunnableBranch做条件路由。它的逻辑很像编程语言里的if/else,只不过作用于Runnable链路上。假设我们已经有了一个retriever和一个llm,可以这样组织代码:
from langchain_core.runnables import RunnableBranch def has_docs(state: dict) -> bool: return len(state.get("docs", [])) > 0 def generate_answer(state: dict) -> dict: # 正常生成分支:把docs拼进上下文 context = "\n\n".join(state["docs"]) response = llm.invoke(f"根据以下资料回答问题:\n{context}\n\n问题:{state['question']}") return {"answer": response} def fallback_no_result(state: dict) -> dict: # 回退分支:不调模型,返回固定话术 return {"answer": "当前知识库尚未收录相关内容,请换个说法或补充更多信息。"} pipeline = RunnableBranch( (lambda state: has_docs(state), generate_answer), fallback_no_result )这段代码的核心在于:先跑检索,把结果放进一个字典里,然后交给RunnableBranch判断。如果docs非空,走generate_answer正常生成;如果为空,直接走fallback_no_result。注意fallback_no_result没有模型调用,所以不管业务场景怎么切换,这条分支的响应速度都很快,也不会产生幻觉。
如果你想在空检索后先做一次查询重写再决定,可以把链路拆成两步。第一步判断是否为空,为空时调用一个rewrite_chain,把改写后的句子重新喂给retriever,然后再走一次同样的分支判断。这里特别提醒:重写后的查询再次为空时,一定要有终止条件,否则一旦检索持续失败,整个流程会反复调用模型,变成一个隐形“死循环”。
3.2 基于LangFlow的Conditional分支配置思路
如果你更习惯用LangFlow这类可视化工具,回退分支同样可以实现,只是它换成了节点连线。整体思路是:把检索节点的输出接一个“条件路由器”节点,判断检索结果列表的长度。条件设为len(result) == 0或result is empty,然后分出两条路径,一条接生成节点,一条接兜底文本节点。
LangFlow里的条件节点在不同版本里名字略有不同,早期版本叫Conditional Router,新版本可能叫Branch或Routing。配置时重点看两个输入端口:一个代表“条件成立”,一个代表“条件不成立”。检索节点输出既包含结果内容,也包含原始查询,条件节点通常能拿到一个JSON结构,你把判断表达式写到对应字段上就行。
使用LangFlow的好处是团队里不懂代码的人也能直观看到分支逻辑,但有个坑:可视化节点的“空值判断”在不同组件里表现不一致。有的组件会把空列表序列化成[],有的会序列化成null,判断条件写错一个,分支就永远走不到你想走的那一侧。建议在接条件节点之前,先用一个调试输出节点把检索结果的类型和内容打出来,再做判断。
3.3 别忘了记录空检索日志,这是排查关键
无论用哪种框架实现,回退分支都一定要留日志。我见过太多项目,分支设计得挺合理,但上线后没人知道它发生了多少次,也没人知道触发理由。等到用户投诉了,再去翻对话记录,发现“检索为空”只是系统内部状态,日志里根本没存,排查成本极高。
我这边的做法是在回退分支入口记录一条结构化日志,字段至少包括:原始问题、检索query(可能是重写后的)、检索命中的条数、命中的分数列表、相似度阈值、触发分支类型(空检索兜底/重写后命中/模型裸答)、生成结果摘要。把这些数据落到日志系统里,定期统计“空检索率”和“回退率”。如果空检索率突然从5%涨到30%,一定是索引、切分或Embedding在某个环节出了问题,这时候再去定位,有日志比猜快得多。
4. 进阶细节与踩坑实录:让回退分支更稳
4.1 阈值怎么定:score、top_k与空结果的关系
回退分支一个常被忽略的细节是“阈值”。很多向量数据库返回的是相似度分数,不是简单的有/无结果。你觉得检索结果为空,可能不是数据库没返回数据,而是你过滤的时候把分全都过滤掉了。比如默认score_threshold=0.8,但你的Embedding模型产出的分数普遍在0.6到0.75之间,那这个阈值一设,所有查询都会变成“空检索”,然后全都跑到回退分支里去了。
所以,定阈值之前一定要先看分数分布。拿一批真实用户query去检索,把hit分数画出来,观察一下相关结果集中在什么区间。我通常的做法是先不设阈值,把Top 20的结果都拉出来人工标一下相关性,再选一个既能过滤明显噪声、又不至于把所有东西都砍掉的分位数作为阈值。不同Embedding模型、不同文档领域,这个值都不一样,抄是抄不来的。
另外top_k也别拍脑袋设。设太小比如3,本来就容易漏;设太大比如50,又会把一堆弱相关的片段塞给模型。我的习惯是结合业务上下文长度和文档切分长度,保证模型输入上下文里至少有2-3个有质量的片段,同时不超模型窗口。top_k和阈值是配合着调的,不单独看。
4.2 多路召回时的空分支:混合检索对冲
纯向量检索在遭遇专有名词、缩写、代码片段时经常翻车,这也是“空检索”的重灾区。一个非常实用的思路是设计多路召回:除了向量检索,再加一路关键词检索(比如BM25),把两路结果做融合。这样当向量检索为空时,如果关键词那一路返回了结果,就可以不急着走模型回退,而是先用关键词结果走生成。
实际项目里,我经常遇到用户问内部系统代号“Hawk”,这词在文档里可能因为Embedding分得特别散导致向量检索为空,但BM25能精准命中“Hawk系统”。这时候直接走回退话术就太可惜了。把两路结果做RRF(Reciprocal Rank Fusion)融合,即使向量侧空,关键词侧也有内容。只有当两路都为空的极端情况下,才进入最终模型兜底。
这里有个小细节:多路召回后判断“为空”的逻辑要跟着改。不是等向量检索返回空就立刻回退,而是等所有召回源都汇总完,再统一判断总结果集是否为0。这个“汇总后判断”尤其重要,否则可能第一路一空就跳分支,把第二路的有效结果白白浪费掉。
4.3 实际项目中常见的三个坑和排查技巧
第一个坑:切分导致的空检索假象。案例:某一批PDF文档,每页切一个chunk,但很多页的开头是图表、目录、页眉,正文语义在向量空间里被稀释了。用户问正文里的细节,检索出来全是页眉页脚,相关性分数很低,被阈值过滤后变成空。排查技巧很简单:把空检索命中的原始chunk(即使分数低)打印出来看一眼,如果内容是“目录”“图1”这种,问题不在阈值,在切分策略,建议换成按段落结合语义切分。
第二个坑:回退分支启用后,模型越来越“懒”。这是我自己踩过的。最开始做内部助手,为了安全把所有空检索都改成不回退,只返回“没有找到”。结果一段时间后,用户反馈回答质量下降。查日志发现,因为切分质量差,大量有相关资料的query也被判成了空,系统直接放弃生成。模型是被分支“养懒”了。解决办法:优化切分和阈值,同时把“空检索但重写后非空”的比例纳入质量指标,如果这个比例高,就说明检索本身需要调优,而不是回退策略的问题。
第三个坑:回退分支里调了模型,但用的还是正常RAG的System Prompt。这种情况最隐蔽。你决定允许弱事实场景下裸答,但直接在原RAG链路上生成了,并没有切换提示词,模型仍然以为它是在根据资料回答问题,于是生成时继续编来源、编引用。正确的做法是,裸答分支里单独写一套提示词,明确告诉模型:检索未命中,不要编造任何来源,如果必须回答,请以“根据模型自身知识”开头。这个提示词的差别,直接决定了回退回答是“坦诚”还是“谎话连篇”。
根据我个人的经验,回退分支不是一个一次配好就完事的组件,它需要隔一段时间就检查一轮。我一般每月把历史日志拉出来,统计空检索率和回退率,随机抽几十条判一遍,看看有多少误杀。只要这个指标稳定在合理区间,RAG整体表现就不太会出大问题。最后分享一个小技巧:在日志里给每条回退回答加一个标签字段,记录触发原因是“真无结果”还是“阈值过滤”,后续调优时直接按标签分组分析,比逐个翻对话记录高效得多。