☰
向量库没装好时,RAG如何靠三级降级链避免返回“似对实错”的答案
2026/10/10 4:49:34 网站建设 项目流程

做 AI 工厂管家社区版的时候,我最怕的不是模型跑飞,而是向量库没装好,RAG 检索居然还能返回结果。那些结果看着像那么回事,来源、上下文、引用格式都齐全,但仔细一看,全是语义上擦边的内容。先摆结论:RAG 做降级本身不难,难的是降级的时候不能返回“看着像那么回事”的内容。如果你也在做知识库问答、内部文档助手或者生产日志问答,这套从 AI 工厂管家社区版里沉淀出来的三级降级链可以直接抄作业。

1. 为什么先纠结“向量库没装好”,而不是模型效果

1.1 RAG 检索链路里,向量库到底承担什么角色

RAG 这个词这几年被聊烂了,但真正跑过生产的都知道,RAG 的瓶颈不在生成,而在召回。向量库负责做的事,是把用户 query 转成 embedding,然后去文档分块后的向量集合里做最近邻搜索,把 top-k 片段捞回来,拼进 prompt 给 LLM。这个环节里,向量库是整条链路最容易出问题的一段,而且出问题的方式也很隐蔽:不是直接崩,而是“看起来正常,找到的内容却不正常”。

社区版最开始只是做设备维修记录的问答。用户问“上次给空压机换机油是什么时候”,系统先定位到“空压机维护记录”的文档块,再由模型抽取时间、结论,最后回复。听起来不复杂,但一旦向量检索失真,后面所有生成都建立在错误上下文上,模型只会把错误说得更圆。

1.2 “没装好”的四种常见情况

我排查过一批“向量库没装好”的问题,大体逃不出四种:

  • 服务没起来:连接失败,直接抛异常,链路炸掉。
  • 索引没建:服务活着,但 collection 是空的,查询返回空列表。
  • 索引写入和查询用的 embedding 模型不一致:写入是 1536 维,查询用的是另一个模型,维度不对,查出乱码级别的结果。
  • chunk 没打对:清洗脚本只跑了一半,知识全被切碎,看起来有结果,但召回质量极差。

第一种最好处理,抛个异常就能触发降级;第二种也不难,空结果判断一下就行;真正麻烦的是第三种和第四种,索引存在、服务正常、返回的 points 数量也够,但内容全是“相似的垃圾”。这种状态最容易被忽略,因为它不报错,只会在回答质量上慢慢毒害你。

1.3 一个把“看着像那么回事”变成事故的真实例子

社区版有次用户问:“3 号车间的 ABB 机械臂最近有没有报警记录?”正常情况下应该召回机械臂的报警日志分块,然后归纳最近几条记录。结果向量库索引里的写入任务中途失败,索引内容还停留在上一个版本的设备手册。检索回来的是《机械臂报警处理手册》的片段,模型看到里面有“报警”一词,就一本正经回答:“请按手册第 3 章排查报警原因。”

这句话格式规范、语气专业,甚至标点都完整,但用户要的是“有没有报警记录”,得到的却是操作手册。如果系统直接报错,用户至少知道出了问题;现在系统报了一个“看起来很专业”的错误答案,反而更难发现。这就是我反复强调的那句话:降级不怕降,怕的是降完之后不告诉任何人,还假装自己还是原来的高精度通道。

2. 三级降级链的设计:从语义检索退回字面检索,再退回结构化查询

2.1 先约定“三级”分别是哪三级

我做这套方案时,内部把整个召回链路定义成三个降级级别,外加一个兜底:

  • 正常态:向量召回,语义匹配,质量最高。
  • 第一级降级:BM25 关键词召回,字面匹配,保可用。
  • 第二级降级:结构化与规则查询,只保事实类问题。
  • 兜底:不召回到任何可靠内容,明确告诉模型“无资料”。

严格说“三级降级”是指三次从高保真通道切换到低保真通道的过程。向量失败退关键词,关键词失败退结构化,结构化也无结果就进入兜底空结果。每一级降级不只是换一个检索器,还要同步改变 prompt 里的证据说明,不然模型不知道上下文来源已经变了。

2.2 第一级降级:BM25 关键词召回

向量检索失效时,退到关键词是最划算的选择。Elasticsearch 或 OpenSearch 里通常已经有一份倒排索引,或者你可以快速从文本 chunk 重建一份。BM25 的优点是稳定、不依赖外部 embedding 服务,缺点是召回结果容易“字面上像但语义不对”。

这个问题在工厂场景里特别明显。比如用户问“最近还有什么设备需要保养”,BM25 会把“保养”匹配到《保养记录表填写规范》,但规范文档里根本没有“到期时间”字段,模型就答不出“哪台设备该保养”。所以第一级降级不能只做关键词匹配,还要做一层查询改写和过滤。把“最近”“待保养”“到期”这类词拆出来,组合成可执行的条件,再丢给倒排索引。

2.3 第二级降级:结构化查询保住事实类问题

工厂管家里有大量问题不是语义问题,而是事实问题。“3 号车间空压机最近一次保养时间是什么时候”“B 线贴片机的厂商型号是多少”,这些问题本来就不该靠向量库,应该靠 MySQL 或 PostgreSQL 里的结构化字段。当向量和关键词都不可用时,直接用结构化表按设备 ID、时间字段查询,反而更可靠。

我把这层设计成“规则路由 + SQL 模板”。先判断问题里是否包含已知实体,比如设备编号、车间编号、日期表达,然后映射到对应的 SQL 模板。命中模板就执行查询,查询结果作为上下文给模型。这层降级的价值是保住“能查到确切答案”的问题,而不是硬凑文本片段。

2.4 第三级降级:显式返回不可用,不给脏上下文

如果第二级也没结果,不要返回空字符串,更不要随机丢一个片段进 prompt。社区版的做法是返回一个显式的不可用标记:

[unavailable] 当前知识库暂未收录相关内容。

同时强制 model 在 prompt 里看到这个标记时,只能回答“没有找到相关资料”,不能编造。这一层虽然不算真正的“检索”,但它是整条降级链最后一堵墙。很多团队舍不得做这一步,总觉得机器人都说不知道很丢人。但实际用下来,用户对一个诚实说“不知道”的机器人的容忍度,远高于对一个胡说八道的机器人的容忍度。

2.5 各级降级的风险对照

通道召回逻辑优点主要风险
向量召回embedding 最近邻语义匹配好依赖向量库、依赖模型一致性
BM25 召回倒排索引词频稳定、可解释召回内容语义可能不相关
结构化查询SQL/规则模板事实准确覆盖范围窄,无法处理开放问题
兜底不可用无上下文不产生幻觉用户会感受到能力上限

这张表我在做方案评审时贴过很多次,核心信息就一句话:任何降级都是拿某种确定性换来的,你必须知道换了什么。

3. 实操实现:降级判定、路由切换与结果标记

3.1 给向量检索设置健康检查和超时

降级链第一个关键点是怎么判断“向量库没装好”。不能只看进程活没活,还要看单次查询能不能在合理时间内完成。我在社区版实现里给 Qdrant 客户端加了一个超时参数:

from qdrant_client import QdrantClient vector_client = QdrantClient( url="http://localhost:6333", timeout=1.5, ) def vector_search_available(question, top_k=5): try: points = vector_client.query_points( collection_name="factory_docs", query=embed_text(question), limit=top_k, ) if not points.points: return None return points.points except Exception: return None

这里的 timeout 值很有讲究。设太短,比如 0.3 秒,向量库在高峰期正常响应 0.5 秒也会被误判成故障,导致频繁降级;设太长,比如 10 秒,用户在页面上等出白屏。我实测下来,内网部署的向量库 1.5 秒是一个比较稳的阈值,外网环境要根据历史响应时间分位数来定。

3.2 统一召回结果结构

降级链路最容易踩的坑是每个检索函数返回的数据结构不一样。向量库返回ScoredPoint,Elasticsearch 返回hits,SQL 查询返回dict,如果每个函数各自为政,后面拼 prompt 的逻辑会越写越乱。

我把所有召回结果统一包装成一个结构:

from dataclasses import dataclass @dataclass class Evidence: text: str source: str channel: str # vector / bm25 / sql / unavailable score: float

向量召回也好,BM25 召回也好,SQL 查询也好,最后都转成 Evidence。这样路由函数只处理一种类型,后续做日志、做评估、做 prompt 拼装都很顺。

3.3 降级路由的完整实现

路由函数的核心逻辑不复杂,就是一层层试:

def retrieve(question, top_k=5): vec_results = vector_search_available(question, top_k) if vec_results is not None: return format_evidence(vec_results, channel="vector") bm25_results = bm25_search(question, top_k) if bm25_results: return format_evidence(bm25_results, channel="bm25") sql_results = structured_query(question) if sql_results: return format_evidence(sql_results, channel="sql") return [ Evidence( text="当前知识库暂未收录相关内容。", source="fallback", channel="unavailable", score=0.0, ) ]

这个 demo 看起来短,真正费时间的是每个检索函数内部的质量过滤。我加了一个规则:BM25 命中结果的分数低于某个阈值时,不能直接当证据用,必须进入下一级。道理很简单,关键词匹配分数低说明只是字面擦边,送进 prompt 就是“看着像那么回事”的源头。

3.4 把降级状态写进上下文和日志

不能只换内部逻辑,返回给模型的内容里也要标注来源。我在拼装 prompt 的环节写了这样的来源说明:

以下内容来自 {channel} 通道召回,可能仅包含字面匹配,请谨慎判断其中的时间、地点、状态信息。

向量通道不需要这句话,但 BM25 和 SQL 通道一定要有。模型看到这句话之后,如果发现上下文里确实没有足够信息,更容易选择说“不确定”,而不是强行生成。日志里我也要求每次都记录retrieval_channel和degraded字段,方便事后统计降级率,比如两周内到底有多少请求走了 BM25,多少走了 SQL。

3.5 prompt 层面的防幻觉设计

最后是 system prompt 里的约定。我在社区版里写了四条规则:

  • 检索结果带vector标记,表示经过语义召回,可以当作高置信度参考。
  • 检索结果带bm25标记,表示字面召回,需要人工判断。
  • 检索结果带sql标记,表示结构化查询,事实准确但覆盖范围有限。
  • 检索结果带unavailable标记,不要编造答案,明确告知没有找到相关资料。

这一步看起来只是文本约定,但它实际上改变了模型对着证据时的“默认信任度”。我后来做过对比,不加这类标记时,模型会把 BM25 的擦边结果当成金科玉律;加了之后,模型会主动提出“该结果来自关键词匹配,可能不是精确答案”。这一点就是标题里“看着像那么回事”的解法核心:让所有参与者都知道当前证据的可信度。

4. 常见故障与排查实录

4.1 向量库活着但查不到任何内容

有次客户反馈系统“变笨了”,所有问题都答得很空。我登上去看,Qdrant 服务正常,collection 也在,但查询返回的 points 永远是空的。查了半天发现,是入库脚本在写入时把文本清洗成了空字符串,chunk 里全是换行符和空格。向量索引建的没问题,但内容是空的,等于白建。

这种问题靠健康检查发现不了,只能做数据体检。我后来在启动脚本里加了一个自检:随机取 10 条入库记录,统计平均文本长度和向量维度。如果平均长度低于 20 个字符,直接报警,说明清洗脚本可能把正文过滤掉了。

4.2 降级到 BM25 后答非所问概率更高

降级链刚上线时,业务方反馈“向量库挂了之后回答质量下降得特别明显”。这不是废话吗,都降级了质量肯定下降。但他们说的下降是“完全答非所问”,而不是“质量下降”。我复盘后发现,问题出在查询改写。

用户问“最近还有什么设备需要保养”,BM25 会把“保养”匹配到《保养记录表填写规范》,但规范里根本没有“到期时间”字段。后来我在第二级加了规则:如果问题里含时间词、设备编号或车间编号,直接跳过 BM25,先进结构化查询。这样至少能回答带明确参数的问题,开放性问题再退回 BM25 去碰运气。

4.3 超时时间设太短导致正常查询被误降级

这也是我踩过的坑。有段时间线上频繁出现降级日志,但向量库本身很健康。查到最后发现,是因为我把超时设成了 0.8 秒,而有一次大文档批量导入导致 CPU 占用升高,正常查询响应时间涨到 1.2 秒,于是大范围误触发降级。

这个问题的教训是:健康检查的阈值要和业务高峰期的性能指标挂钩,不能拍脑袋设一个固定值。我后来改成记录近 15 分钟查询响应时间的 P95,用 P95 乘以 1.5 作为超时阈值,误降级率立刻降下来了。

4.4 如何设计故障注入测试

降级链不能等到真出故障再验证。我建议把故障注入写进发布流程,至少覆盖四个场景:

  • 停掉向量库进程,验证自动切 BM25。
  • 停止 embedding 服务,验证向量查询抛异常后能进下一级。
  • 清空向量库 collection,验证空结果触发降级。
  • 同时停掉向量库和倒排索引,验证能落到结构化查询或兜底。

每次故障注入都要带标准问题集,记录每个问题走的通道和最终答案是否可接受。只测“有没有降级”不够,还要测“降级后答复质量是否在可接受范围”。

4.5 排查表格:常见故障定位速查

故障表现常见原因排查方向
向量库服务正常,查询返回空索引数据缺失或清洗后文本为空抽查数据记录,检查 chunk 长度
查询报维度不匹配写入和查询 embedding 模型不一致对比模型名和维度,全量重建索引
BM25 召回结果不相关查询改写弱,关键词太泛增加实体识别和时间/地点过滤
频繁触发降级超时阈值过短或健康检查误判用 P95 响应时间动态计算阈值
模型面对 BM25 结果自信回答prompt 缺少来源可信度说明在 system prompt 中加入 channel 规则

这张表我贴在项目文档里,组里新同学拿到之后,遇到这类问题基本不需要我重复讲。

5. 从降级链延伸出去的 RAG 工程化问题

5.1 向量知识库、知识图谱、结构化知识库怎么选

很多团队一提到 RAG 就默认是向量库,但知识库类型应该跟着问题类型走。简单语义检索适合向量库;实体关系和多跳查询适合知识图谱;事实性查询和参数查询适合结构化表格。AI 工厂管家社区版最终用的是“向量 + 结构化”双通道,知识图谱在查询关系链条时才启用。降级链实际上逼着我把知识分类型了:哪些问题该用哪种通道,在系统设计阶段就要想清楚,而不是等故障了才临时找替代方案。

这也是目前 RAG 瓶颈的一个缩影:更多人关注 embedding 模型和向量库选型,忽略了“知识本身有不同类型”这件事。没有任何一种检索方式能同时处理好开放语义、精确事实和多跳关系,所以生产系统要做的是按场景拆通道,而不是把一个向量库神话成万能答案。

5.2 图文检索的降级有什么不同

如果检索内容包括图片,向量库没装好时,关键词检索只能匹配图片的标题或 OCR 文本,完全无法理解视觉内容。这种场景下,我建议降级时不要硬上 BM25,而是把图片当作“有标题但内容未知”的资源,明确告诉模型“只能根据文件名和 OCR 文字判断,不保证内容匹配”。

图文检索还多一层风险:用户会看到图片,然后默认图片内容是系统理解过的。实际上降级后的图片可能只是根据文件名字面匹配到的。这时候需要在界面展示“低置信度结果”的标记,不要给用户造成错误的确定性。

5.3 在 Mac 上搭建本地 RAG 的几条提醒

社区版有不少贡献者在 Mac 上跑本地 RAG,我把最常见的几个坑提前说一下:

  • 优先用 Docker Compose 跑 Qdrant 或 Elasticsearch,别直接 brew 装,版本和配置不一致很容易出现“索引没装好”。
  • embedding 模型选本地 small 版本,比如 all-MiniLM-L6-v2 或 bge-small-zh,Mac 内存压力小很多。
  • 换模型之后一定要全量重建索引。很多人在 Mac 上测试时换了 embedding 模型,但旧的向量还在库里,导致查询报维度错误,然后一脸懵。

Mac 本地环境最大的价值是用来把降级链跑通。向量库、倒排索引、结构化数据库都可以在同一台机器上启动,故障注入也方便。先把降级逻辑验证好,再上生产环境,会省掉很多半夜看日志的时间。

6. 上线后的效果与我的个人体会

这套三级降级链上线之后,我做了半个月的降级率统计,发现向量库真正触发降级的次数其实不多,但每一次触发,系统都没有返回“看着像那么回事”的幻觉答案。用户能明显感觉到回答收敛了,碰到没把握的问题,系统会主动说“当前资料不足”,而不是硬编一段流程出来。

我个人最大的体会是:RAG 工程里,检索通道的诚实度比检索通道的数量更重要。你可以在向量库没装好的时候继续用 RAG,但一定让模型知道它现在看到的资料来自哪里、可信度如何。最后再分享一个小技巧:降级链不要做成代码里硬编码的三层 if,而是做成可配置的策略列表。这样后续接知识图谱、图文检索引擎的时候,只需要增加策略条目,不用重写整个链路。

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

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

立即咨询