☰
RAG检索为空还调不调模型?回退分支设计与幻觉风险控制
2026/10/31 4:23:24 网站建设 项目流程

做RAG落地也有一段时间了,今天聊聊一个看似很小、但几乎每个项目都会撞上的问题:检索为空之后,到底还调不调模型?有一次我在本地知识库问答系统上做演示,用户问了一个和技术文档完全无关的问题,向量检索返回0条,系统按最省事的逻辑直接把问题丢给了大模型。结果大模型一本正经地编了一段内部并不存在的“标准流程”,当场翻车。从那之后我就意识到,检索引擎返回空结果,绝不是“没匹配到”这么简单;它背后藏着一整套关于回退分支设计、判定口径和风险控制的决策。这篇博文就把这块掰开揉碎讲清楚,适合正在搭建或维护RAG系统的开发者、数据工程师,以及那些被“看起来能跑但一上线就露馅”困扰的团队参考。

1. 先搞清楚:什么才是真正的“检索为空”

很多项目上线后第一周就出现“用户问什么都说不知道”的抱怨,开发一看日志:检索返回空,然后走了拒答。但把用户的问题拿过来一测,知识库里明明有相关内容,问题出在检索链路自身。所以“检索为空”这件事,必须先区分是“真空”还是“伪空”。

1.1 三种“伪空”和一种“真空”

第一种伪空是向量检索返回0条。这种情况多见于向量索引配置异常、集合名写错、过滤条件把结果全滤掉,或者embedding服务临时故障返回了空数组。第二种伪空是返回了TopK条结果,但相似度分全部低于阈值。比如知识库里的文本切得不合理,导致语义被拦腰截断,用户问的是“设备保修多久”,命中的片段只讲“设备保修期延长政策”中的一小段,相似度偏低,被阈值砍光。第三种伪空是召回正常、分数也够,但到了重排阶段被排到topK之外,或者因为元数据权限过滤(比如只看某部门文档)把结果清空。这三种情况,知识库里本来是有答案的,只是检索链路把它弄丢了。

真正意义上的“真空”是知识库里确实没有相关内容。比如企业内部知识库只收录产品手册,用户问“昨天午餐吃什么”,检索为空是正常的。区分“伪空”和“真空”之所以关键,是因为后续回退策略截然不同:伪空需要修检索或宽松重试,真空才需要讨论“调不调模型”的问题。

1.2 检索为空的五个常见根因

基于我排查过的问题,检索为空通常绕不开这五个根因。

第一,文本切分方式不合理。chunk切太小,一句完整语义被切成两半,embedding后两个半截向量都偏离原意;chunk切太大,一个块里混了多个主题,向量方向被“平均”掉了,相似度普遍偏低。第二,embedding模型和领域不匹配。通用模型对专业缩写、产品型号、内部黑话的编码效果很差,比如“RMA流程”这种词,在通用模型里可能被编码成完全无关的语义。第三,查询改写失败。RAG系统通常会把原始query做改写,但如果改写模型把口语问题改成了书面语,反而丢失了原文关键词,或者错把“帮我看看XX”改成“XX是什么”,导致召回率骤降。第四,元数据过滤条件过严。时间范围、文档类型、部门标签只要有一个条件写错,结果就可能被整体过滤。第五,混合检索配置问题。很多系统只开向量通道没开BM25关键词通道,或者路由逻辑有bug,导致某些查询类型永远走错通道。

排查这类问题,我的习惯是先做“照抄原文测试”:把知识库里一段原文的完整句子直接作为query去检索,如果相似度也不高,那基本可以断定是切分或embedding的问题;如果原文query能命中,再换用户真实问题来对比,这时问题多半出在查询改写或阈值设置上。

2. 四种回退分支设计:调不调模型背后的取舍

确定是真空之后,核心问题才真正浮现:调模型,还是不调?我的结论是,这不能拍脑袋决定,它本质上是回答率、幻觉风险、成本和体验之间的平衡。下面四种方案是我在真实项目中见过或亲自用过的,各有明确适用场景。

2.1 方案A:硬拒答,完全不调模型

检索为空后直接返回固定文案,比如“当前知识库中未找到相关答案,请联系管理员”,甚至把这句文案做成卡片,附带几个你可能想问的问题。这个方案的最大优点是安全和可控,不产生幻觉,也不消耗额外token,响应延迟完全可预期。缺点是体验很差,“问啥啥没有”会让用户迅速流失。

硬拒答适合合规敏感或回答错误代价高的场景,比如医疗问诊、金融客服、法律咨询。这类场景里,一个编造的答案带来的风险远大于“答不上来”。如果你把知识库定位成“唯一权威事实源”,并且有“答错不如不答”的业务诉求,硬拒答就是默认选项。

2.2 方案B:直接调模型,让LLM自由发挥

检索为空后直接把用户query丢给LLM,不拼任何知识库上下文。好处是接口永远有回复,用户体验顺滑,适合FAQ机器人里“答不上来就闲聊”的混合场景。但风险很大:LLM在没有知识库支撑时给出的回答只是“听起来合理的文本”,不是事实。它不会告诉你“知识库里没有”,而是非常自信地编一个答案。我在演示翻车那次,用的就是这种策略。

这个方案适合的知识库定位是“辅助参考型”,比如企业内部的制度问答助手,员工问错了也无所谓,答错方向也有参考价值。但如果系统面向外部客户,或者回答内容会进入合同、处方、维修单这类正式流程,我强烈不建议无约束直接调模型。

2.3 方案C:先宽松召回,再做二次判定

这是我最推荐的一个中间态,也是处理“伪空”的有效手段。检索为空后,不立刻决定调不调模型,而是先做一轮“放宽条件”的召回尝试。具体做法包括:把相似度阈值下调一档,比如从0.65降到0.55;把TopK从5扩大到20;开启BM25关键词通道做词法补充;对query做一次改写后重新检索;去掉部分元数据过滤条件再查一次。这一轮如果能召回内容,再走正常的上下文组装和生成流程;如果还是空,再进入最终策略。

这个方案的好处是大量“伪空”被救了回来,真正“真空”的比例会显著下降。代价是多了一次检索延迟和一点额外开销,但相比幻觉风险,这个代价非常值得。我实际项目中大约有六成“空结果”能在宽松召回阶段被救回来,所以强烈建议所有RAG系统至少把C作为默认回退。

2.4 方案D:分级兜底,允许回答但明确标注

如果宽松召回后确实是真空,又不想让用户吃闭门羹,可以考虑“分级兜底”。具体做法是:允许LLM基于自身知识回答用户问题,但在回答前插入一句“该回答基于通用知识,未经过企业内部知识库校验,仅供参考”,或者在回答边栏打一个“非知识库来源”的标签。更进一步,可以在prompt里强约束LLM地说:“如果你不确定答案,明确说出你不知道,并严禁编造数字、日期、型号。”

这种方案适合综合助手类产品。比如一个面向员工的统一办公入口,一部分问题来自知识库,一部分问题其实是通用常识甚至闲聊。完全拒答会让助手显得很蠢,完全放开又有幻觉风险,那就用“标签化”的方式在体验和风险之间划线。

2.5 四种方案对比

维度方案A 硬拒答方案B 直接调LLM方案C 宽松召回二判方案D 分级兜底带标注
回答率最低最高较高高
幻觉风险无极高低中
响应延迟最低中中高
实现复杂度最低低中高
适用典型场景医疗/金融等强合规场景闲聊/泛化助手企业知识库通用场景(默认推荐)综合助手/办公入口

所以,“检索为空后还调不调模型”的答案不是简单的yes或no,而是分层次的设计:第一层,先通过宽松召回排除“伪空”;第二层,在真正“真空”时,根据业务对幻觉的容忍度,决定走拒答、自由发挥还是带标注的兜底。

3. 判定条件怎么定:阈值、多路召回与“空”的口径

回退分支设计得再好,如果“空”的判定口径是乱的,一切白搭。我在项目里见过最典型的问题:两个开发各写了一段判定逻辑,一个只看返回列表是否为空,另一个看最高分是否超阈值,结果同一个query在接口A走回退、在接口B走正常,行为完全不一致。所以“空”的标准必须统一,且要能解释得清。

3.1 相似度阈值不能一套走天下

很多初版系统喜欢拍脑袋定一个0.6或者0.7的阈值,然后所有embedding模型共用。这其实是个坑,不同模型的相似度分数分布差异很大。有的模型正常命中分数是0.55,有的模型命中分数普遍在0.75以上,同一套阈值在第一个模型上全部拒答,在第二个模型上塞进去一堆不相关内容。

正确的做法是校准,不是拍脑袋。具体操作是拉取用户真实query,跑一遍检索链路,记录所有query的召回分数分布,然后按分位数来定阈值。比如取所有“人工标注为有效命中”的query的分数P10作为阈值下限,意思是“至少要覆盖90%的有效命中”。这个校准过程每换一次embedding模型、每做一次重新向量化都要重跑,不能一劳永逸。

另外,阈值应该是“双阈值”而不是单阈值。我的习惯是设置一个较低阈值(比如0.5)用于宽松召回阶段的“有无判定”,再设置一个较高阈值(比如0.65)用于“正常引用”阶段的“质量判定”。两段之间是灰色地带,可以进入“宽松提示”逻辑,比如“以下内容可能相关但不完全匹配,请用户确认后再采纳”。这样比单一阈值更符合实际检索质量分布。

3.2 多路召回下如何判空

如果系统做了多路召回(向量+BM25+知识图谱),判空逻辑更要注意口径。我的建议是:不要用“每一路都空才算空”,而要用“最终能被组装进prompt的有效片段是否为零”来判定。因为有些路返回了结果,但分数太低,或者重排后被挤掉,这些结果对最终回答没有意义,不算“有效”。

举个例子,向量路返回3条,BM25路返回0条,但向量路的3条最高分只有0.35,连宽松阈值都过不了,那这3条就不该算有效。此时应该走宽松召回,而不是因为“有结果”就直接进生成阶段——否则LLM会在低质量上下文的引导下,让回答更不可控。有效片段以“进入prompt上下文之前的最后一版”为准,这是我建议团队统一的判定口径。

3.3 调参时的两个典型陷阱

第一个陷阱是把阈值调得过高。系统看起来“答得很准”,但实际是大量问题都被拒答了,回答率低得可怜。这种系统在人工抽查时很容易被骗,因为样本少且都是高分命中,会显得很优秀,一上线就被用户骂。第二个陷阱恰好相反,把阈值调得特别低,结果检索永远有结果,回退分支形同虚设,很多低质量片段被塞进上下文,LLM反而被错误信息带偏,回答质量肉眼可见地下降。

我的建议是:不要试图用阈值解决一切问题。“空”比“错”好处理,空可以走回退,错在系统里非常隐蔽——用户可能根本不会指出,只会默默流失。所以调参的原则应该是:宁可让“空判率”稍微高一点,也不要让低质量上下文混进去;同时空判率高了,就靠宽松召回和多路召回来找补,而不是靠放宽阈值。

4. 回退分支的工程落地:流程、代码与观测

设计层面讲完了,说说具体怎么落地。这部分给出一套我验证过的流程、一个核心回退判定网关的最小实现示例,以及上线后必须盯的观测指标。

4.1 推荐的回退完整流程

我在项目里最终跑的流程是七步,每步都有明确产出和判定条件。

第0步,query预处理与改写。做拼写纠错、同义词扩展、指代消解,产出改写后的query和原始query两版。第1步,多路召回。向量路+BM25路并行执行,各自返回TopK。第2步,粗排与分数过滤。用embedding分数、BM25得分的加权融合作为粗排分,先按宽松阈值做一遍“有无”过滤。第3步,判定是否为空。按“有效片段数是否为0”判定,若不为空,走正常生成流程;若为空,进入第4步。第4步,宽松召回。降阈值、扩大TopK、开启BM25补充、改写再查。第5步,再次判定。如果宽松后有效片段数仍为0,进入最终策略;如果不为0,但分数不高,打上“低置信度命中”标签后再走生成。第6步,最终策略。按业务配置选拒答、通用回答或带标注兜底。第7步,记录完整日志。这一步经常被忽略,但它和前面六步同样重要——没有日志,你根本不知道回退分支在工作还是在乱跑。

4.2 用代码实现一个回退网关

下面给一个精简但可以直接用的Python骨架。它不依赖任何特定的向量库和LLM框架,关注点只在“判定-回退-二次判定-策略执行”这个核心链路上。

import logging from dataclasses import dataclass, field from typing import Optional logger = logging.getLogger("rag_fallback") @dataclass class RetrievalResult: query: str fragments: list = field(default_factory=list) scores: list = field(default_factory=list) @property def best_score(self) -> Optional[float]: return max(self.scores) if self.scores else None def effective_fragments(self, threshold: float) -> list: return [ frag for frag, score in zip(self.fragments, self.scores) if score >= threshold ] class FallbackGateway: def __init__( self, normal_threshold: float = 0.65, loose_threshold: float = 0.50, top_k_normal: int = 5, top_k_loose: int = 20, ): self.normal_threshold = normal_threshold self.loose_threshold = loose_threshold self.top_k_normal = top_k_normal self.top_k_loose = top_k_loose def _retrieve(self, query: str, top_k: int) -> RetrievalResult: # 实际项目里替换为向量库 + BM25 的混合召回 # 这里返回一个空结果,表示检索链路没有命中 return RetrievalResult(query=query) def _rewrite_query(self, query: str) -> str: # 用 LLM 或规则管线做查询改写,返回改写后的 query return query def decide_and_generate(self, raw_query: str, policy: str = "reject") -> dict: # 第0步:预处理与改写 rewritten_query = self._rewrite_query(raw_query) # 第1步:正常召回 first_pass = self._retrieve(rewritten_query, top_k=self.top_k_normal) effective = first_pass.effective_fragments(self.normal_threshold) # 第2步:非空则正常生成 if effective: return { "stage": "normal", "fragments": effective, "fallback_reason": None, } # 第3步:第一次判定为空,进入宽松召回 loose_result = self._retrieve(rewritten_query, top_k=self.top_k_loose) loose_effective = loose_result.effective_fragments(self.loose_threshold) if loose_effective: logger.info("loose recall rescued query: %s", raw_query) return { "stage": "loose_hit", "fragments": loose_effective, "fallback_reason": "low_confidence_but_hit", } # 第4步:宽松后仍为空,按策略执行 if policy == "reject": return {"stage": "reject", "fragments": [], "fallback_reason": "empty"} if policy == "direct_llm": return {"stage": "llm_free", "fragments": [], "fallback_reason": "empty_direct"} if policy == "tagged_llm": return { "stage": "llm_tagged", "fragments": [], "fallback_reason": "empty_tagged", } return {"stage": "reject", "fragments": [], "fallback_reason": "invalid_policy"}

这个网关类的关键在于“有效片段”的判定是独立方法,normal_threshold和loose_threshold分开维护,后续调参时只动配置,不动逻辑。实际项目里,我会把_retrieve替换成完整的混合检索实现,并在返回的RetrievalResult里多带一个metadata字段(命中的文档ID、路由来源),方便排查时追踪是哪路召回贡献了结果。

4.3 必须埋的六个观测点

回退分支是RAG系统里最容易“悄悄跑偏”的部分,不观测的话你根本不知道它是不是在按设计工作。六个指标我建议必须埋。

第一,空判率。正常召回后判定为空的请求占比,过高说明检索链路或阈值可能有基础问题。第二,宽松召回挽救率。在空判请求中,经过宽松召回后有命中的占比,这个数值直接反映检索链路“收紧”是否过度。第三,兜底触发占比。真正走到拒答或LLM兜底的请求占比,这个指标如果持续走高,说明知识库覆盖度和问题域不匹配。第四,按policy分布的请求比例。动态观察走direct_llm和tagged_llm的比例是否超预期。第五,兜底回答的负面反馈率。用户点踩、重复提问、转人工的比例是否明显高于正常回答。第六,平均响应延迟与P99延迟。宽松召回和兜底都会显著加长链路时间,延迟指标必须和回退逻辑关联统计,便于及时发现性能隐患。

这六个指标配合一个简单的看板,每周拉一版数据。不需要复杂APM系统,日志记录结构化字段,用Grafana或Excel透视表就能分析。

5. 检索为空后的常见问题排查

最后一部分,把我在项目中踩过的坑和排查经验整理成几个高频问题的排查路径。

5.1 知识库里明明有内容,为什么还是空

遇到这个现象,先按住“调阈值”的冲动,按顺序排查。第一步查切分质量:把知识库原文抽出来,和命中失败的问题一起人工看,看是不是语义边界被切断。第二步查embedding模型:用原文query测试相似度,如果原文query分数也很低,问题多半出在向量化环节。第三步查元数据过滤:把过滤条件全部去掉重跑一次,看是否恢复命中。第四步查混合检索配置:确认是不是只走了向量通道,BM25通道没有生效。第五步查查询改写:把改写前后的query都打出来对比,经常能发现改写模型把关键词改丢了。

有一个很容易被忽略的细节:很多系统在做权限过滤时,是在召回的“最后一步”过滤的。这意味着你看日志时,向量库返回了3条结果,但权限过滤后只剩0条,于是被判定为空。这类问题不是检索问题,是权限策略问题。

5.2 允许LLM兜底后回答幻觉严重怎么办

如果你选择了方案B或方案D,发现幻觉严重,第一步不是取消兜底,而是在prompt层面加强限制。兜底prompt里至少要写明三点:你不需要使用检索到的知识库内容;如果你不确定答案,直接说不知道;严禁编造具体数字、日期、型号、专有名词。还可以让LLM在回答末尾追加一句置信度自评,比如“我的回答基于通用知识,置信度较低”。

第二步是给兜底回答降低“权威感”。正常回答的答案上方显示“来自知识库”,兜底回答显示“来自通用知识”。这一步在UI层做,不需要改生成逻辑,但用户体验差异非常大。第三步才是考虑策略回退,比如把兜底改成“先反问用户确认问题意图”,或者给用户展示几个知识库内最相近的问题(即使分数低),让用户自己选。

5.3 阈值怎么调都别扭怎么办

如果你发现阈值调来调去,要么空判率太高、要么低质量命中太多,大概率问题不是阈值,而是你把所有query都指望用一个阈值解决。不同query的本身就是不同难度的。一个用户问“怎么申请年假”,一个用户问“我上个月加班时间能不能折现”,前者可能一查就中,后者涉及复杂政策叠加,两个query的合理分数分布完全不同。

这种情况下,我建议用“条件阈值”代替单一阈值。比如对长度小于20个字的query用高标准0.7,对超过50个字的长query用低标准0.5;或者根据query是否包含产品型号/编号这类强标识符来决定阈值档位。更简单的一种做法是引入BM25的“词法命中率”作为第二条件:如果向量分数低,但BM25命中了两个以上关键词,也可以进宽松召回。

5.4 回退拖慢接口响应怎么办

宽松召回和兜底都会增加一次或多次检索调用,接口延迟实测下来通常会增加200到500毫秒。如果系统对响应时间有硬性要求,可以做三件事。第一,把宽松召回和正常召回合并到同一次请求里——“正常召回同时就把宽松阈值那版也查了”,省去一轮网络请求。第二,把宽松召回做成异步预取,在正常召回阶段并行把低阈值结果也拉回来,如果正常判定为空,直接从缓存里取宽松结果。第三,给兜底LLM调用设置独立超时,比如正常生成限制25秒,兜底生成限制10秒,超时直接返回“暂无法回答”而不是无限等待。

这三件事做完,回退分支引入的额外延迟基本会被压缩到100毫秒以内,用户体验不会明显感知。

6. 我在实际项目中的最终配置

讲完方法论,分享一下我在企业内部知识库项目里的最终配置作为参考。默认走方案C+方案A的组合:正常阈值0.65、宽松阈值0.5,宽松召回后再判定为空就拒答,不直接调LLM自由发挥。同时单独开了一个“通用助手”入口,走方案D的带标注兜底,入口处明确告诉用户“这个入口的回答基于通用知识,可能未经过企业内部资料核验”。三个线上指标每周盯一次:空判率、宽松召回挽救率、兜底触发占比。配置上线后,空判率从初版的百分之三十几降到了个位数,宽松召回挽救率稳定在六成以上,兜底触发占比不到百分之五,幻觉投诉基本销声匿迹。我的经验是,回退分支这种逻辑听起来简单,但它实际上是RAG系统的“安全气囊”——设计得好,用户无感;设计得差,翻车是迟早的事。如果你正在优化RAG的回退逻辑,先别急着调模型,把判定口径理清楚,再决定要不要让模型出场。

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

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

立即咨询