“向量库没装好,RAG 检索还能不能用?”这个问题我估计不少搞过知识库问答的人都心里犯过嘀咕。它出自我手头一个叫 AI工厂管家社区版的项目——一个部署在车间里的知识问答机器人,主要回答设备手册、维修 SOP、安全规程这类问题。交付客户现场时,我们栽了一个相当典型的跟头:向量库因为安装时缺依赖,集合根本没建起来,提问却照样有回答。真正让我后背发凉的不是它答错了,而是它答得“看着像那么回事”——有一条回答把安全阀的复位方向写反了,工人差点照做。
所以结论我先放这儿:RAG 做降级本身不难,难的是降级的时候不返回“看着像那么回事”的错误答案。这篇就来拆解我们围绕这套系统做的三级降级链,包括设计逻辑、实现细节,以及我在实际部署里踩过的坑。适合正在落地 RAG、做企业知识问答,或者准备把大模型服务交付到生产现场的同学参考。
1. 先把问题掰开:向量库“没装好”到底坏了哪一环
1.1 一套 RAG 服务正常跑起来需要哪些件
标准链路并不复杂,大体是:文档入库(解析、清洗、切分)→ 向量化(embedding)→ 写入向量库 → 查询时把问句向量化 → 在向量库里做相似度检索 → 召回 topK 段落 → 与问题一起拼进 prompt → 交给 LLM 生成回答。中间可能还有重排、引文、权限过滤等环节。
向量库在这条链路里的角色,相当于系统的“记忆体”。它负责存储文档片段,并在查询时把语义相近的内容捞出来。RAG 比传统关键词检索强,强就强在这一步支持语义匹配:用户问“这台设备的油多久换一次”,哪怕文档里写的是“液压油更换周期”,向量检索也能召回。可代价是,多了一个独立的服务依赖,而这个依赖往往是部署时最容易被忽略、配置时最容易出错的一环。
1.2 社区版部署时“没装好”的典型姿势
我在现场遇到过的“没装好”,大致有这几种,列出来给大家排查时对照:
- 装是装了,但服务进程根本没起来,systemd 里没配开机自启,机器一重启就失联。
- 安装时依赖冲突,某个底层动态库版本不对,启动直接抛异常。
- 服务起来了,但建集合、建索引那一步没执行,控制台里看着是一个空库。
- 入库任务没跑,或者跑一半失败,库里数据残缺,检索结果总是缺关键段落。
- embedding 模型换过,向量维度跟集合定义对不上,写入直接报错。
- 客户端超时配置太长,探活请求反复等到崩溃,而这个问题只有流量上来才暴露。
这些故障有一个共同点:它们不会在一开始就大声报错,很多是“静默失败”。服务看起来在跑,接口也能通,但检索返回的结果要么为空,要么质量很差。最坑的就是这种状态——系统不会崩,回答却在悄悄变坏,等用户发现的时候,错误答案可能已经被问走好几回了。
1.3 故障边界:为什么不是“挂了就报错”
站在工程角度,最省事的做法是向量库不可用时直接抛错,让用户稍后再试。但对工厂车间这种场景,把问答服务停掉,等于把师傅们日常在用的“掌上手册”也停了,产品上完全不可接受。所以从设计第一天起,我们就接受了“必须降级”这个前提。
但降级的风险恰恰藏在“能答”里。当向量库断供,LLM 就失去了外部依据,只能靠参数里的世界知识硬答。设备操作这种事,世界知识给出的往往是“通用步骤”,而通用步骤跟具体型号的实际要求不一定一致。于是“能继续用”变成了一种伪安全。这也是为什么降级链设计的重心,不是“怎么继续答”,而是“在失去依据的情况下,怎么让错误成本可控”。
2. 三级降级链的整体设计:能用,但要让用户看见“不可信”
2.1 降级前先回答三个问题
设计任何降级方案之前,先问自己三件事:
- 降级后还有没有可用的知识来源?如果完全没有,就不要试图回答。
- 回答是否可能被人照着操作?会的话,必须在话术和展示层面做风险提示,不能让用户误以为这是正式规程。
- 用户能不能判断当前答案的依据?如果界面上看不出来源类型和降级级别,用户就会把降级回答当成正常回答。
这三个问题的答案直接决定了每一级长什么样:有来源,就降级检索方式;没来源,就降级到“承认不知道”。我们后面所有实现,都是围绕这三个问题展开的。
2.2 三级链路各自的角色
三级降级链的设计,本质是把“知识来源的确定性”逐级让渡,换取“服务可用性”不归零:
- 第一级(level1):向量检索不可用,切到关键词检索。语料还是原来那些文档,只是召回方式从语义匹配变成词汇匹配。设备型号、料号、专有名词这类精确查询,它反而更稳;同义改写、口语化问句则召回归零,风险可控。
- 第二级(level2):关键词检索也不可用,切到内置知识快照。快照是提前从历史问答里蒸馏出来的高频问答集,覆盖常见问题和新员工的基础疑问,覆盖面有限。
- 第三级(level3):快照里也没有能命中的意图,明确拒答并给出替代通道。核心是“不提供无依据的回答”。
每一级往下的知识范围都在缩小,回答的置信度也在下降。所以响应里必须带级别标识,让调用方和用户都知道当前答案处于什么可信度。
2.3 贯穿始终的“诚实原则”
我的经验是,降级设计的核心约束不是算法,而是信息透明度。降级时真正的风险不是“召回了差一点的材料”,而是“没有材料却假装有”。所以我们在任何降级回答的响应结构里,都强制携带来源说明和免责声明,比如在答案上方固定加一行灰色提示:“当前为关键词检索降级模式,回答仅供内部参考。”
这个设计成本极低,但能拦住大多数“看着像那么回事”的误导。用户看到提示后,至少会对内容保持合理怀疑,而不是直接拿着答案去操作设备。尤其在生产环境,让用户怀疑,比让模型自信更重要。
3. 三级降级的实现细节与实操要点
3.1 第一级:向量检索切到关键词检索
触发条件是向量库探活失败,或者连续 N 次查询异常。替代方案是关键词检索,做法是复用入库阶段的切分结果,另建一份全文索引。
我们当时没有引入额外服务,直接用嵌入式全文检索库建了一个独立检索表,避免又增加一个故障点。SQL 层面长这样:
-- 用全文索引建一个轻量检索引擎,和向量库并存 CREATE VIRTUAL TABLE doc_chunks_fts USING fts5( content, chunk_id, doc_title ); INSERT INTO doc_chunks_fts(content, chunk_id, doc_title) SELECT content, id, title FROM doc_chunks; -- 查询时按 BM25 打分排序 SELECT chunk_id, doc_title, bm25(doc_chunks_fts) AS score FROM doc_chunks_fts WHERE doc_chunks_fts MATCH ? ORDER BY score LIMIT 8;这里有几个关键参数要重新标定:
- topK 要比向量检索略高。向量检索取 5 个段落够用,关键词检索我建议取 8 个左右,因为词汇匹配的噪声大,多一点候选交给 LLM 自己判别,效果更好。
- 阈值千万不能沿用向量检索的相似度分数。两种检索的分数分布完全不是一个量级,向量那边 0.75 能当阈值,BM25 这边可能是 0 到十几的分布。正确做法是跑一批真实问题,画出分数分布,把阈值定在下四分位附近。
- 切分文本必须带 doc_title 和 chunk_id 元数据。降级回答仍然要能给出引用来源(至少给个文档标题)。如果旧数据入库的时候没存元数据,降级后连“出自哪份文档”都查不到,溯源就断了。
还有一个容易被忽略的点:关键词检索对厂内专业术语非常敏感。我们后来给分词器补了自定义词典,把设备型号、物料编码、零部件俗名都加进去,召回质量立刻上了一个台阶。
3.2 第二级:检索切到知识快照
触发条件是关键词检索引擎也挂了,或者整个语料服务不可达。这时候没有动态检索可用了,只能靠一份静态的知识快照。
快照不是随便存点文档,而是从历史问答日志里蒸馏出来的高频问答集,再叠加人工整理的安全红线、常见故障处置这类核心资料。生成逻辑大概是:
def build_snapshot(history_logs, top_k=50): qa_pairs = [] for question, answer in top_frequent(history_logs, top_k): qa_pairs.append({ "question": question, "answer": answer, "updated_at": "2025-02-01", }) # 导出成结构化 JSON,供 prompt 组装 with open("knowledge_snapshot.json", "w") as f: json.dump({"version": now(), "pairs": qa_pairs}, f, ensure_ascii=False)组装时要注意 token 控制。两三百条以内的快照可以整体拼进 system prompt;更多的话,就要先做一层很轻的意图匹配,命中哪一组就塞哪一组的条目,避免 prompt 过大拖慢首 token 时间。
效果和局限都很明显:高频问题能覆盖到,回答质量可观;但新问题、没见过的问题会直接漏出去,命不中就只能继续往下一级走。
这里要特别提醒:快照里的答案必须是人工审核过的,不能直接把历史模型输出拿来当快照。否则降级等于在用旧的错误答案喂用户。我们在快照里给每条固化了“问题—答案—更新时间”三元组,一旦发现有误导,可以按版本撤回整份快照。
3.3 第三级:无检索时的诚实拒答
触发条件是快照里也没有能匹配的意图。这一级的原则很简单:宁可拒绝,不可乱说。
拒答模板我们迭代过好几版,最后稳定成三段式:
- 说现状:当前知识库暂不可用,我暂时查不到相关设备资料。
- 说限制:基于通用知识回答本主题存在操作风险,我不能给出有依据的答案。
- 给路径:请稍后重试,或联系设备管理员;涉及操作前,务必核对现场最新书面规程。
我见过很多团队在这一级试图“先答一轮再说”,比如让模型自由发挥,最后补一句“仅供参考”。在办公室问答场景也许能接受,但工厂设备操作场景绝对不行。设备型号不同,安全阀复位方向、液压油型号、扭矩值都可能不一样,模型常识和现场实际只要冲突一次,就是事故。
所以第三级的实现要硬气一点。代码上直接短路生成流程,返回预置模板:
def build_refused_response(question): return { "answer": REFUSE_TEMPLATE, "level": "level3", "source_type": "none", "confidence": "low", "disclaimer": "当前未返回任何检索依据,请勿将此回答用于操作决策。" }话术啰嗦一点没关系,宁可让师傅多花半分钟去翻纸质手册,也不能让他在没把握的时候上手操作。
3.4 响应格式与前端透出
降级标识要在协议层统一,不能依赖前端猜。我们的响应 JSON 里固定这几个字段:
{ "answer": "……", "level": "level1", "source_type": "keyword", "confidence": "medium", "source_update_time": "2025-01-01 00:00:00", "disclaimer": "当前为关键词检索降级模式,回答仅供内部参考,操作前请核对最新规程。" }前端拿到后,在回答卡片顶部渲染一行状态条,颜色区分:绿色表示正常,黄色表示关键词降级,橙色表示知识快照,红色表示拒答。这个设计不只是给用户看的,也是给运营看的。日志里只要记录 level 字段,出了问题就能第一时间定位是哪一级回答的,而不是让用户描述半天。
4. 探活、熔断与回切:降级链路能跑起来的关键工程细节
4.1 探活设计:不能只做心跳
探活这件事,最容易犯的错是只 ping 服务进程,不验证真正的检索能力。进程活着不等于索引可用,更不等于能正常返回结果。我们的做法是每次查询前做一次轻量探测,探测内容不是 healthcheck,而是真正执行一次 limit=1 的空查询:
def probe_vector_store(client, timeout_ms=500): try: client.query( embedding=[0.0] * EMBEDDING_DIM, top_k=1, timeout=timeout_ms, ) return True except Exception: return False注意两点。第一,探测结果要带缓存,比如 30 秒内复用,否则每次查询都多一次网络往返,主流程延迟会很难看。第二,探测超时设置不能太激进。我们最早设 200ms,生产环境有一次高负载,探活请求大量超时,系统误判向量库故障,集体降级成了 level1,实际服务是好的。
4.2 熔断与手动开关
降级不能只靠探活结果,还要有熔断机制。我们的状态机是:连续 5 次查询异常,自动进入 level1;后续查询过程中如果关键词检索也连续失败,再往 level2、level3 降。主流程逻辑类似:
def query_with_degrade(question): if DEGRADE_STATE == "normal": ok, result = try_vector_search(question) if ok: return result DEGRADE_STATE = "level1" if DEGRADE_STATE == "level1": ok, result = try_keyword_search(question) if ok: return result DEGRADE_STATE = "level2" # level2 / level3 同理除了自动熔断,控制台必须留手动开关,让运维在清理故障、做实验时可以强制切到某一级,避免自动状态反复跳。这个开关看着不起眼,真出问题的时候能救命。
还有一条血的教训:降级状态必须持久化。我们有一次线上服务进程重启,内存里的降级状态全没了,恢复后系统又去连挂掉的向量库,请求全部超时,等于把刚降级下去的故障又捞了回来。后来状态直接落盘,重启后从磁盘恢复,这个问题才消失。
4.3 恢复与回切:逐级跳,别一步到位
恢复逻辑很多人不写,或者写得很粗。我们的方案是:后台定时探活,连续成功 3 次,升级回上一级,逐级回切。回切时每跳一级,都要用一组固定测试问题跑一遍自检,打分合格才继续往上跳。
理由也简单:降级期间关键词检索可能一直在用,同时有新文档入库,但向量库这边没同步。这时候如果直接切回向量检索,反而会出现“向量库可用了,回答质量却更低”的怪现象。所以回切前必须检查向量库数据完整性,确认降级期间的新文档都已经同步完了,再放量切回。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 现象 | 常见原因 | 排查顺序 | 处理参考 |
|---|---|---|---|
| 向量库进程正常,但检索返回空 | 集合未创建 / 入库任务未跑 / embedding 维度不一致 | 先查集合列表,再查数据量,再比对向量维度 | 重建集合并重新执行入库 |
| 降级后回答变慢 | 快照塞进 prompt 后 token 暴涨,或关键词检索返回大量候选 | 查看日志中的 token 数和检索耗时 | 压缩快照条数、限制候选数量 |
| 关键词检索召回不相关 | 阈值沿用了向量检索的,或厂内术语没进词典 | 打印 topN 分数分布,观察中位分 | 重新标定阈值,补充自定义词典 |
| 熔断误触发 | 探活超时设置太短,或探活本身串行拖慢主流程 | 看探活日志耗时分布 | 探活改并发、加结果缓存 |
| 回切后答案质量变低 | 向量库数据在降级期间没同步 | 对比降级期间的入库日志 | 回切前先做数据完整性检查 |
5.2 我踩过的几个坑
第一个坑是降级开关被写死。有一次我们手动把系统固定在 level1 调试,调完忘了回切,向量库早就恢复了,系统却在关键词检索下整整跑了一周,期间没人发现。后来所有手动操作都要求填理由和过期时间,到点自动恢复自动状态机。
第二个坑是快照过期。厂商更新了某型设备的液压参数,快照还是上一版,回复里的数值全按旧参数给。后来给快照加了版本号、更新时间,并且在快照命中的回答末尾固定追加“请以现场最新规程为准”,这个隐患才算按住。
第三个坑是拒答话术太生硬。第一版拒答只有“抱歉,我无法回答”几个字,车间师傅直接骂这等于没说。改成现在的三段式之后,接受度明显改善,因为用户至少知道了为什么答不了、下一步该找谁。
第四个坑是只测降级不测回切。我们做过混沌测试,杀向量库进程验证降级一切正常,但没验证恢复。结果有一次发布时向量库初始化变慢,回切逻辑一直认为没恢复,系统卡在 level3 大半天,问题反馈上来才反应过来。
5.3 降级链路的验证建议
建议把降级测试变成常态化动作,而不是上线前的一次性演练。我们目前的做法是每周做一次故障演练:杀掉向量库进程、停掉检索引擎、清空快照缓存,分别观察系统各自的表现。
再准备一组固定测试集,20 条左右,覆盖高频问题问法,每一级降级都要跑一遍,结果存档做基线。这样每次改动后跑一遍对比,就知道降级回答有没有劣化。
最后是日志审计。每次响应都记录 level、来源、置信度,争议出现时翻日志能第一时间定位是哪一级的责任。我始终觉得,降级链的价值不在“答得有多快”,而在“错的时候能被识别和追溯”。
6. 最后一点体会
复盘这件事,我最大的体会是,RAG 的降级不能当成“没得选才降”的补救方案,而应该当成一等公民来设计。向量库装好、索引正常、检索顺畅,这谁都会;真正的分水岭是,当组件不再正常工作时,系统还能不能保持诚实。我们这套三级降级链跑过几次现场之后,我最欣慰的不是它没崩,而是用户能看到“当前回答置信度不高”的提示,然后主动去翻旁边的纸质手册。技术再花哨,也比不过让用户知道什么时候该信你、什么时候不该信你。
最后再分享一个小技巧:降级状态和降级日志一定要单独存一份,别混在普通业务日志里。我们后来加了独立的降级事件表,每次链路切换都记一条带时间戳的审计记录,后续做追责和分析都方便得多。等真出事儿了再想补,当时的现场早就被新日志冲走了。