ARTICLE DETAIL

资讯详情

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

RAG检索为空时怎么办?回退分支设计与落地实践

RAG检索为空时怎么办?回退分支设计与落地实践 做 RAG 系统的同学基本都撞过这个情况用户问题抛过来向量检索返回一个空列表或者分数低到没法用。此时最纠结的问题就一句话——检索为空后还调不调模型调吧怕模型在没有知识支撑的情况下信口开河不调吧又怕一句话“知识库中未找到答案”把用户噎回去。我在自建和帮朋友改造的几套 RAG 项目里把这条回退链路翻来覆去折腾过好几轮今天就把这块的设计思路、分支模式、代码示例和排查经验一次性捋清楚。这套东西适合正在搭建 RAG 应用的开发者、想优化检索效果的算法工程师以及被线上空检索率搞得头秃的维护者。内容不整花活全是可直接抄作业的落地细节。1. 检索为空的根因拆解先弄明白为什么没人命中再谈要不要回退很多团队一上来就急着设计回退分支但回退分支的走向完全取决于“为什么为空”。同样是空有的该做查询改写有的该调底栏知识库有的就该直接拒绝回答。跳过根因分析直接拍分支策略后面必然要返工。1.1 检索为空的几种真实成因先过我整理的六类最常见原因你排查时按这个顺序捋基本不会漏文档切片质量差。切片太大时一个 chunk 里塞了好几段不同主题的内容query 向量和整个 chunk 向量的相似度被无关文本拉低切片太小又可能把关键实体、上下文截断导致任何查询都打不中。这块是检索为空的头号元凶。查询与知识库的语言/术语不一致。用户问“怎么退保险”知识库里写的是“犹豫期退保操作指引”。embedding 模型对同义词、口语化表达、缩写和行业黑话的表征能力有限字面不匹配直接分数踩线。单路向量检索天然漏检。只跑了 embedding 召回没接关键词或 BM25。用户查询里的精确实体词比如型号、单号、人名在向量空间里根本不被重视结果就是明明数据库有这条记录向量召回却给了空集。Embedding 模型能力不足。通用 embedding 模型对垂直领域的长尾实体、冷门术语表征质量差。很多项目为了省成本用了一个小维度模型空检索率肉眼可见地高。知识库内容本身缺失。这个最扎心但必须正视——用户问的东西库里真的没有或者文档根本没被正确解析入库。元数据硬过滤误伤。按部门、时间、来源做了权限过滤或 metadata 过滤条件写得过严把明明命中的文档全滤掉了。此时检索链路返回空但底层向量库里数据是有的。判断回退策略前先确认你属于哪一类。如果是第 1、2、3、4、6 类说明检索链路本身有救回退分支可以往“改写重试”“多路召回”方向设计如果是第 5 类库真的没有任何回退都不该让模型硬编答案。1.2 空结果的判断标准别把“低分但有效”误判为空“空”的判定在代码里看着简单——len(results) 0但在真实业务里远远不够。我见过不少系统把“返回了 3 条但分数都在 0.3 以下”的结果当有效结果用结果生成阶段全靠模型幻觉输出比直接判空更危险。推荐用三重判断而不是单一条件数量条件检索返回的候选结果数为 0。分数条件最高相似度分数低于动态阈值。内容条件候选文本去除空字符、纯标点、无意义短句后有效长度不足比如小于 20 个字符。关于分数阈值这里有个容易踩坑的点不同 embedding 模型的分数分布差异极大有的模型相似度普遍在 0.7 以上有的模型集中在 0.2~0.5。固定写死if score 0.5: return empty是会误伤的。我后来改成基于最近 N 天线上查询的分数分位数来算动态阈值比如取 10 分位数作为空判定线。这个改动让我一个项目的“误判为空”情况明显减少。伪代码如下直接表达了多重判定逻辑def is_empty_result(query, results, thresholdNone): if not results: return True max_score max(r.score for r in results) if threshold and max_score threshold: return True # 内容长度检查 valid_text [r.text.strip() for r in results if len(r.text.strip()) 20] if not valid_text: return True return False提示无论你怎么判定“空”一定要把 query、判定类型、分数、策略命中等信息全部透出到日志。空检索不是 bug是你做回退决策的最重要依据。2. 回退分支的四种设计模式从硬拒绝到模型兜底确认检索结果为空之后接下来的分支设计就是整个系统的核心。我的经验是把回退策略做成可配置的链路而不是写死在代码里。四种模式按“知识库不参与程度”从低到高排列你在实际项目中可以单独用也可以组合成一条降级链路。2.1 模式A直接拒绝生成不调模型这是最保守的模式。检索为空时不调用 LLM 生成直接返回预设文案比如“知识库中没有找到相关内容请换个说法或补充上下文”。适用场景企业内部合规问答、医疗政策咨询、金融条款查询这类明确规定“模型不能回答库外内容”的业务。跟我合作的一个保险客户就是这样他们宁可损失一点体验也绝不允许模型在无知识支撑的情况下给客户讲错理赔规则。优点幻觉概率基本为零LLM 调用成本最低响应延迟最短。缺点体验单调生硬知识库不全时用户会觉得系统很蠢。实操建议拒绝文案里务必包含“引导用户补充信息”的动作。比如“抱歉知识库中暂未找到与‘XX’相关的资料。您可以补充产品型号、时间范围等更多信息我再帮您查询一次。”这比一句话打回要友好得多。2.2 模式B调用LLM但严格限定上下文空背景生成检索为空时仍然调用 LLM但注意——不是让它随便答而是给它传递一个“空上下文”和一段强约束的 system prompt明确告诉它“当前没有检索到参考资料你可以依靠自身知识回答但必须提示用户回答可能不准确”。适用场景通用知识库或 FAQ 系统知识库本身不完善但你又希望模型尽量帮用户解决问题。比如个人知识库助手、内部吐槽机器人这种对严谨度要求没那么高的场景。关键点区分“有上下文生成”和“无上下文生成”两条 prompt 链。很多项目图省事检索为空时仍然把空列表塞进原来带上下文的 prompt结果模型要么假装看到了资料要么漏掉“未检索到”这个关键提示直接开启幻觉模式。我自己做法是if is_empty: prompt build_prompt_without_context(user_query) # 明确告诉模型未提供文档内容请基于自身知识回答 # 若不确定务必说明禁止编造来源和引文。 else: prompt build_prompt_with_context(user_query, retrieve_results)为什么这条分支值得留“调不调模型”这个问题单纯二选一其实太粗暴。检索为空时模型仍有一定价值尤其是用户问的是常识性、经验性问题时它能兜住一部分体验。代价是成本增加和回答不可控所以模式 B 一定不能放到链路最前面否则就失去 RAG 的意义了。2.3 模式C查询改写加一次重试这个模式专门对付第 2、4 类成因。检索为空后先把用户 query 交给一个轻量模型做改写比如补充同义词、去掉限定词、转换成更规范的表述然后带着改写后的 query 重新检索一次。改写方向举例原 query“这个月工资为啥少了”改写“工资条 扣款 异常 查询”原 query“应届生怎么落户口”改写“应届毕业生 落户 办理 流程”注意改写成本要控制住。我建议最多重试一次因为连续改写两次还检索不到大概率就是知识库真没有再耗下去纯粹烧钱。重试仍为空时接着走后续分支。实现方案可以用 LLM 做改写也可以直接用小一点的专用改写模型。我的经验是如果已经有 LLM 调用链路没必要为改写单独引入模型直接让 LLM 输出一个改写后的 query并强制要求保留原始实体词。防止模型把“XX型号”改写成“某设备”这种灾难性操作。def rewrite_query(user_query): prompt f 请将用户问题改写成更利于知识库检索的查询语句。 要求保留所有实体词、型号、数字不要扩充背景只输出改写后的语句。 用户问题{user_query} return llm_completion(prompt)模式 C 本质是在“空检索”和“放弃/硬答”之间给检索链路一次自救机会。很多线上空检索问题罪魁祸首根本不是模型不行而是用户问法和库里文档的语言风格差太远改写一次命中率能提上来一截。2.4 模式D多路召回兜底换一种检索方式再试向量检索返回空后不急着让模型答题先换召回方式——跑 BM25 关键词检索、全文索引或者缩小切片窗口重新向量化一遍。多路召回的思路是“向量不行靠词法词法不行靠缩小粒度”。适合放模式 D 的原因它能应对第 3、6 类成因尤其适合生产环境稳定性要求高的系统。你可以这样组织回退链路策略模式适用根因是否调用LLM幻觉风险响应成本A 直接拒绝知识库确实无内容、合规要求高否极低最低C 改写重试查询表述不匹配、语义鸿沟修改后同步重跑检索命中再调低中D 多路召回兜底向量漏检、关键词有真值命中后用原文上下文生成低中B 空上下文模型兜底库不完善、希望模型尽力回答是较高高个人建议的生产链路顺序是主检索空判断 → 模式 D换多路/缩粒度重试→ 模式 C改写重试→ 仍未命中的根据业务严谨度选择模式 A 或模式 B。把 B 放在最后是因为它是对“知识缺失”的最终妥协不应该成为默认路径。3. 带检查清单的落地实现一个可参考的回退分支代码有了策略怎么落地成代码我提供一个经过生产验证的骨架实现核心思路是把“检索”和“生成”彻底解耦让空结果有独立判断和分支出口。你不需要照抄重点是看结构。3.1 代码结构说明建议直接用函数式流程不要把回退逻辑写到业务 Controller 里。核心函数分为四层retrieve(query, top_k, filters)负责向量 关键词多路召回返回统一结构。judge_empty(results, methodhybrid, thresholdNone)负责判断是否为空。rewrite_and_retry(query)负责改写重试。generate(query, context, source_tag)负责调用 LLM 生成最终回答并标记回答来源。整体流程是这样的SUPPORTED_MODES [A, B, C, D] def rag_fallback_pipeline(user_query, cfg): # 1. 第一轮检索 results retrieve(user_query, top_kcfg.top_k) empty_type judge_empty(user_query, results, thresholdcfg.score_threshold) # 2. 如果命中了多路兜底开关先换方式重跑 if empty_type and D in cfg.fallback_modes: alt_results retrieve_hybrid_fallback(user_query, top_kcfg.top_k) if not judge_empty(user_query, alt_results, thresholdcfg.score_threshold): # 标记使用D策略源为多路兜底 return generate(user_query, alt_results, source_tagfallback_D) # 3. 尝试查询改写后重试 if empty_type and C in cfg.fallback_modes: new_query rewrite_query(user_query) retry_results retrieve(new_query, top_kcfg.top_k) if not judge_empty(user_query, retry_results, thresholdcfg.score_threshold): return generate(user_query, retry_results, source_tagfallback_C) # 4. 仍然为空进入最后兜底 if empty_type: source_tag None if B in cfg.fallback_modes: # 模式B空上下文生成 answer generate(user_query, [], source_tagfallback_B) elif A in cfg.fallback_modes: answer DEFAULT_EMPTY_MESSAGE # 记录最终分支为empty return {answer: answer, strategy: source_tag or fallback_A} # 正常路径 return generate(user_query, results, source_tagnormal)代码本身不复杂但有几个细节必须处理好必要重构retrieve 内部必须支持切换“向量 BM25 缩小切片”三种模式否则模式 D 没办法实现。改写保护rewrite_query 的 prompt 里强制“保留实体词”否则会把“iPhone 15 电池寿命”改写成一团乱麻。Source 标记返回结构里必须包含 strategy 字段是用的哪条分支。没有这个字段后面排查线上问题你根本不知道回答是来自知识库还是模型自由发挥。3.2 关键参数选择阈值、Top_K 和日志落位这里给出几个经过实际测试的参数建议但这还不是唯一标准你要根据自己库的数据分布做回归调优Top_K默认20比较稳。太小容易漏太大会把低质量文本塞进上下文影响生成质量。score 阈值不推荐写死固定值。建议离线切一批“标准问题集”计算历史分数的 10 分位数作为初始阈值后续每两周根据线上日志重新校准一次。重试次数上限模式 C 最多一次。两次以上改写重试的收益断崖式下降成本倒翻倍。空内容长度阈值20 个字符比较合理低于这个数的文本基本没有信息量。日志落位是回退链路设计的灵魂。我要求每条完整链路至少打印以下字段{ event: rag_fallback, query_id: uuid, query: 用户问题原文, round: 1, empty_type: score_low, strategy_used: fallback_C, llm_called: true, response_source: knowledge_base, latency_ms: 432 }没有日志你根本不知道线上用户有多少请求走了回退分支更不知道回退分支里有多少最终是靠模型空想硬答的。这块缺失后续所有策略优化都是盲人摸象。3.3 检查清单上线前逐项核对有了代码和参数上线前最后过一遍检查清单。这不是形式主义每一条都是我踩过坑之后总结出来的空检索判断是否同时覆盖“数量为0”“分数过低”“有效内容过短”三种情况回退分支是否按业务允许的次序配置有没有出现“模型兜底 B 被放在第一位”的配置错误查询改写 prompt 是否强制保留实体词与数字多路兜底是否真正实现还是仅仅调了一个重新检索函数但检索引擎没变每次回退是否记录 query、empty_type、strategy_used、llm_called、response_source 五个字段模式 B 使用的 system prompt 是否明确写入“未检索到相关资料”的提示模式 A 的拒绝文案是否带引导用户补充信息的话术模式 C 的 LLM 改写调用是否计入整体延迟与成本监控提示如果项目中回退分支连策略命中的统计都拉不出来先别急着优化阈值把可观测性补齐再说。4. 常见问题与排查实录回退分支上线只是开始。我在多个项目里被同样的坑绊倒过好多次挑四个最典型的记录在这里希望你能提前绕开。4.1 明明知识库里有答案检索却返回空排查顺序有讲究先确认 Embedding 模型和切片方式再查召回参数最后看过滤条件。我曾经遇到一个案子用户问“XX金融产品的风险等级”知识库里明明有“风险测评结果说明”这个文档却一直返回空。最后定位到是权限过滤条件里时间范围写错了把更新时间早于 2024 年的文档全部过滤掉。建议排查时先临时关掉所有 metadata 过滤条件跑一遍同样的 query如果能查到问题就出在过滤逻辑上还是查不到再排查切片和模型。4.2 回退分支让系统变成了“自由发挥”很多团队上线回退时图省事把所有空检索全部配置成模式 B让 LLM 空上下文生成。结果用户的反馈是“这系统一本正经地胡说八道”。原因很简单B 模式被放在了第一优先级所有检索失败的用户问题都变成了模型自由回答知识库形同虚设。我的改进方法是回退顺序固定为“D → C → A/B”其中 A/B 看业务选一个作为终局并且设置一个熔断指标如果空检索占比连续一小时超过 30%自动把链路锁死在模式 A避免模型乱答扩大风险。这个设计保护了不少项目。4.3 线上日志看不出每个问题走了哪条分支没有 strategy_used 字段的系统遇到线上问题基本靠猜。比如用户说“回答太敷衍”你打开日志只看到 request 和 response不知道是不是走了回退分支更不知道走的哪一条。建议回退分支的日志里除了 strategy_used再把完整 prompt 和检索结果的 top 5 片段打印出来方便复查。日志冗余一点没关系排查问题的时候你就知道有多香了。4.4 常见问题速查表现象可能原因处理建议空检索率高但人工抽查发现库里有内容分块粒度不合理 / 过滤条件过严调小 chunk size检查 metadata 过滤改写重试反复不命中改写模型把实体词改丢了 / 库里确实缺失强化改写 prompt不允许删实体词模式 B 输出常出现带幻觉的引文上下文为空但 prompt 未明确说明切换专用无上下文 prompt禁止编造来源回退分支成本居高不下B 模式调用过于频繁增加 D/C 前置比例限制 B 在所有分支中占比分数阈值改一次坏一次固定阈值与模型分布不匹配改用近 N 日分位数动态阈值最后再分享一点个人感受RAG 系统里“检索为空”不是一个需要消灭的 bug而是一个需要被设计的正常业务分支。与其祈祷永远不会空不如早早把回退链路设计清楚把可观测性做到位。一旦这两个前提满足了后面不管是调阈值、换模型还是改分块策略你都有数据可以依着做判断而不是靠拍脑袋不断试错。这套流程我用了很长一段时间稳定性和排查效率都明显上来了希望对正在折腾 RAG 回退方案的你有用。
返回列表