【灶台导航】 RAG系统的容错设计:从向量搜索到关键词降级,一个都不能少

当三个外部依赖都可能随时挂掉时,如何保证用户永远有响应?

问题:完美主义害死人

做RAG系统时,我们很容易陷入一种思维定势:向量检索要准、LLM要强、整个链路要丝滑。但现实是------任何一个外部服务挂了,用户就得不到响应

在微信小程序这种C端场景,可用性比准确性重要得多。用户不在乎你用的是Qdrant还是Pinecone,他只知道:点进来,白屏,关掉,再也不会用。

我们系统依赖三个外部服务:

  • SiliconFlow Embedding API:将用户问题转成向量
  • Qdrant向量数据库:存储和检索菜谱向量
  • DeepSeek LLM:基于检索结果生成回复

任何一个挂了,如果按传统思路直接报错,用户就流失了。

本文分享我们如何在云函数环境中,用四级降级策略保证系统永远有响应------宁可推荐不够精准,也不能白屏无响应。

容错架构:四层兜底,层层递进

整体架构如下:

复制代码
用户消息
    │
    ▼
┌─────────────────────┐
│ 第1级: Qdrant向量搜索 │ ──超时/失败──┐
└─────────┬───────────┘              │
           │                          ▼
     有高分结果?              ┌─────────────────────┐
      │ │                    │ 第2级: TF-IDF关键词检索 │ ──超时/失败──┐
      │ └────── 成功 ──────→ │ (纯JS,无外部依赖)     │              │
      │                      └─────────┬───────────┘              │
      │                                │                            ▼
      │                          有高分结果?                 ┌──────────────┐
      │                           │ │                       │ 第3级: 无RAG  │
      │                           │ └── 成功 ──→ 用TF-IDF结果 │ LLM直接回答   │
      │                           └──── 失败 ──→             └──────┬───────┘
      │                                                              │
      └────────────────────── 统一送入DeepSeek ──────────────────────┘
                                                                      │
                                                          DeepSeek也挂了?
                                                                      │
                                                                      ▼
                                                          ┌──────────────────────┐
                                                          │ 第4级: fallbackResponse │
                                                          │ 本地关键词+数据库查询   │
                                                          └──────────────────────┘

核心设计原则:每一级都不依赖上一级,每一级都有自己的超时控制,失败后静默降级。

第1级→第2级:向量检索失败时的TF-IDF降级

向量检索失败可能的原因:

  • Embedding API 超时(5秒无响应)
  • Qdrant 查询超时(3秒无响应)
  • 网络错误(ETIMEDOUT、ECONNREFUSED)
  • Qdrant 服务完全不可用

降级逻辑的核心代码:

javascript 复制代码
// chat/index.js - retrieveContext()
async function retrieveContext(message, openid) {
  let systemRagRecipes = []
  let contextText = ''

  // ═══ 尝试向量搜索 ═══
  try {
    const vectorResults = await withTimeout(
      qdrant.vectorSearch(message, 3),
      TIMEOUT_CONFIG.embedding + TIMEOUT_CONFIG.qdrant,  // 8秒总超时
      '向量检索超时'
    )
    const relevant = vectorResults.filter(r => r.similarity > 0.3)
    if (relevant.length > 0) {
      systemRagRecipes = relevant.map(r => r.recipe)
      contextText = formatContext(relevant)
    }
  } catch (e) {
    // Qdrant挂了或超时,静默降级 --- 用户完全无感知
    console.warn('[RAG-Vector] 向量检索失败,降级为 TF-IDF:', e.message)
  }

  // ═══ 仅在向量搜索无结果时走TF-IDF ═══
  if (systemRagRecipes.length === 0) {
    const { data: recipes } = await db.collection('recipes')
      .where({ isPrivate: _.neq(true) })
      .limit(50).get()
    const results = search(message, recipes, 3)    // TF-IDF检索
    const relevant = results.filter(r => r.similarity > 0.05)  // 阈值更宽松
    if (relevant.length > 0) {
      systemRagRecipes = relevant.map(r => r.recipe)
      contextText = formatContext(relevant)
    }
  }

  return { contextText, ragRecipes: systemRagRecipes }
}

关键设计细节:

1. 静默降级:catch块中只打日志不抛异常,用户永远看不到"系统繁忙"之类的错误提示。

2. 阈值差异:TF-IDF用0.05,向量用0.3。因为两者的分数分布不同,TF-IDF的相似度天然更低,阈值需要调低。

3. 只查系统菜谱 :降级时过滤isPrivate,避免查到其他用户的私人菜谱造成隐私问题。

TF-IDF的纯JS实现:零依赖才能真兜底

为什么TF-IDF能成为可靠的第二级?因为它是纯JavaScript实现,不依赖任何外部服务、不依赖C++扩展、不依赖网络。

云函数环境中,很多npm包需要编译(如jieba分词),部署困难。我们实现了一个轻量级的中文TF-IDF:

中文分词:二元组+停用词

javascript 复制代码
// tfidf.js
const STOP_WORDS = new Set(['的', '了', '是', '在', '和', '也', '都', '不', '就', '有'])

function tokenize(text) {
  const chineseChunks = text.match(/[\u4e00-\u9fa5]+/g) || []
  const tokens = []
  for (const chunk of chineseChunks) {
    // 双字组合(匹配菜名、食材名)
    for (let i = 0; i < chunk.length - 1; i++) {
      const bigram = chunk[i] + chunk[i + 1]
      if (!STOP_WORDS.has(bigram)) tokens.push(bigram)
    }
  }
  return tokens
}

为什么用bigram而不是jieba?因为云函数装不了C++扩展,而bigram对短文本(菜名、食材名)效果足够好。像"宫保鸡丁"会被切分为["宫保","保鸡","鸡丁"],足够匹配用户查询。

增强归一化TF

javascript 复制代码
function computeTFIDF(tokens, idf) {
  const tf = {}
  for (const t of tokens) tf[t] = (tf[t] || 0) + 1

  const maxTf = Math.max(...Object.values(tf))
  const tfidf = {}
  for (const [term, freq] of Object.entries(tf)) {
    // 增强归一化:避免长文档的单字频率被过度归一化
    const normalizedTf = 0.5 + 0.5 * (freq / maxTf)
    tfidf[term] = normalizedTf * (idf[term] || 1)
  }
  return tfidf
}

0.5 + 0.5 * (tf/maxTf)比简单的tf/maxTf更柔和,避免长文档中重复词失去权重。

IDF平滑

javascript 复制代码
// 预处理:计算IDF
function precomputeIdf(recipes) {
  const N = recipes.length
  const df = {}
  
  for (const recipe of recipes) {
    const tokens = tokenize(recipe.name + ' ' + (recipe.ingredients || []).join(' '))
    const unique = new Set(tokens)
    for (const term of unique) {
      df[term] = (df[term] || 0) + 1
    }
  }
  
  const idf = {}
  for (const [term, freq] of Object.entries(df)) {
    // +1平滑防止除零,再+1偏移保证IDF不为0
    idf[term] = Math.log((N + 1) / (freq + 1)) + 1
  }
  return idf
}

超时控制:每个环节都有底线

没有超时控制的降级是假降级------一个请求卡住3分钟,用户早走了。

javascript 复制代码
const TIMEOUT_CONFIG = {
  embedding: 5000,       // Embedding API: 5秒
  qdrant: 3000,          // Qdrant查询: 3秒
  ragQuery: 3000,        // TF-IDF查询: 3秒
  deepseekApi: 30000,    // DeepSeek: 30秒
  cloudFunction: 34000   // 云函数总超时: 34秒(留1秒缓冲)
}

// 用Promise.race实现
function withTimeout(promise, ms, errorMsg) {
  return Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error(errorMsg)), ms)
    )
  ])
}

关键原则:

  1. 每个外部调用独立超时:Embedding超时不影响后续降级
  2. 总超时兜底:云函数34秒超时,保证不会无限等待
  3. 超时即降级:超时被当作失败,进入下一级

第3级:LLM降级

向量检索和TF-IDF都失败了怎么办?走纯LLM生成(无RAG上下文):

javascript 复制代码
// chat/index.js - callDeepSeekAPI()
try {
  const response = await got.post(deepseekUrl, { 
    timeout: { request: TIMEOUT_CONFIG.deepseekApi }
  })
  // 解析响应...
} catch (err) {
  console.warn('[DeepSeek] API调用失败,走本地兜底:', err.message)
  return await fallbackResponse(userMessage)
}

此时依赖就只剩下DeepSeek API了。但如果DeepSeek也挂了呢?

第4级:本地关键词兜底

最后一道防线:不依赖任何外部API,只依赖云数据库。

javascript 复制代码
async function fallbackResponse(userMessage) {
  const msg = userMessage.toLowerCase()
  
  // 1. 问候语直接回复(不需要查库)
  const greetings = ['你好', 'hello', 'hi', '嗨']
  if (greetings.some(g => msg.includes(g))) {
    return { reply: '你好!我是灶台导航助手,可以帮你推荐菜谱~', action: 'ask' }
  }

  // 2. 关键词→分类映射
  const categoryMap = { 
    '牛': 'beef', '猪': 'pork', '鸡': 'chicken', 
    '鱼': 'fish', '素': 'vegetarian', '甜': 'dessert'
  }
  let matchedCategory = null
  for (const [keyword, category] of Object.entries(categoryMap)) {
    if (msg.includes(keyword)) { matchedCategory = category; break }
  }

  // 3. 从数据库按分类或关键词查菜谱
  const db = cloud.database()
  const _ = db.command
  
  let query = db.collection('recipes')
  if (matchedCategory) {
    query = query.where({ category: matchedCategory })
  } else {
    // 模糊匹配菜名
    const keywordArr = msg.split(/[\s,,、]+/).filter(k => k.length > 0)
    const orConditions = keywordArr.map(k => ({ name: db.RegExp({ regexp: k, options: 'i' }) }))
    query = query.where(_.or(orConditions))
  }
  
  const { data: recipes } = await query
    .limit(3)
    .orderBy('views', 'desc')
    .get()

  if (recipes.length > 0) {
    const reply = `为您推荐以下${matchedCategory ? matchedCategory : '相关'}菜谱:\n` +
      recipes.map(r => `${r.name}(${r.difficulty || '中等'})`).join('\n')
    return { reply, recommendations: recipes, action: 'recommend' }
  }
  
  // 连数据库都查不到------兜底的兜底
  return { 
    reply: '暂时没找到相关菜谱,可以说说你想吃什么食材吗?', 
    action: 'ask' 
  }
}

这个兜底层有三大特点:

  • 零外部依赖:只调用云数据库,即便Embedding/Qdrant/DeepSeek全挂也能工作
  • 规则驱动:关键词映射不依赖AI,100%确定
  • 渐进式降级:问候语直接回复 → 分类匹配 → 模糊匹配 → 通用提示

降级触发条件速查表

降级级别 触发条件 用户感知
向量→TF-IDF ① Embedding API 超时(5s) ② Qdrant 查询超时(3s) ③ 网络错误 ④ 所有结果相似度 < 0.3 无感知,推荐精度略降
TF-IDF→纯生成 ① 数据库查询超时(3s) ② 所有结果相似度 < 0.05 ③ 数据库为空 无感知,回答无菜谱引用
纯生成→兜底 DeepSeek API 超时(30s) 或报错 回复变简短,可能答非所问
兜底内部降级 关键词/分类匹配失败 返回通用提示,继续对话

为什么不合并多路结果?

有人会问:为什么不做向量检索和TF-IDF的并行召回+融合排序?

当前设计选择串行降级 而非并行融合,原因如下:

  1. 数据规模小:菜谱数量在百级,单路召回已经足够覆盖
  2. 分数不可比 :向量相似度(01)和TF-IDF分数(0几十)尺度不同,简单线性融合会引入噪声
  3. 延迟更低:向量检索成功时不需要等待TF-IDF执行,串行反而更快
  4. 代码简单:逻辑清晰,维护成本低

如果后续扩展到万级数据,可以考虑升级为多路并行召回+Cross-Encoder重排序。

完整的请求生命周期

以一次完整对话为例,追踪数据路径:

复制代码
1. 用户输入:"家里有鸡肉和土豆,想做点下饭的"
   
2. 小程序调用云函数
   wx.cloud.callFunction({ name: 'chat', data: { message: "..." } })
   
3. 云函数入口 exports.main()
   ├─ qdrant.setApiKey("sk-xxx")
   └─ processChat(event, openid)

4. retrieveContext()
   ├─ 尝试向量搜索
   │   ├─ getEmbedding() → SiliconFlow API
   │   └─ searchQdrant() → 返回宫保鸡丁(0.72)、土豆烧鸡(0.68)、辣子鸡丁(0.51)
   ├─ 过滤相似度 > 0.3 → 3条通过
   └─ 返回 RAG 上下文

5. callDeepSeekAPI()
   ├─ System Prompt = 基础词 + RAG上下文
   └─ DeepSeek返回 → {"reply":"推荐宫保鸡丁...","action":"recommend"}

6. resolveRecipeIds()
   └─ "宫保鸡丁" → 匹配 ragRecipes 中的真实 _id

7. 保存会话到数据库

8. 返回小程序

如果第4步向量检索失败,会自动走TF-IDF降级,后续流程完全一样,用户无感知。

降级效果实测

场景1:正常情况(所有服务正常)

  • 用户:"想吃点下饭的"
  • Qdrant返回宫保鸡丁(0.72)、鱼香肉丝(0.68)
  • 阈值0.3通过,直接使用向量结果
  • 总耗时:~2秒

场景2:Qdrant临时宕机

  • Embedding API超时(5s)→抛异常
  • 自动降级TF-IDF,按"下饭"关键词匹配
  • 找到标签含"下饭"的菜谱
  • 用户无感知,耗时增加约1秒(降级开销)

场景3:用户说"你好"

  • Qdrant返回低分结果(最高0.15)
  • TF-IDF也无有意义匹配
  • 进入纯生成模式,DeepSeek直接回复
  • 用户得到正常问候,无报错

场景4:DeepSeek也挂了

  • 进入fallbackResponse,关键词匹配
  • 回复"您好!请说食材名称..."
  • 虽然不够智能,但用户不会看到白屏

关于Qdrant的安全提醒

目前Qdrant直接暴露在公网且无认证,存在风险:

风险

  • 任何人都可以查询/修改/删除你的向量数据
  • 数据泄露或被恶意清空

两种加固方案

方案一:API Key认证(推荐)

yaml 复制代码
# docker-compose.yml
services:
  qdrant:
    image: qdrant/qdrant
    environment:
      - QDRANT__SERVICE__API_KEY=your-secure-key-here

方案二:防火墙白名单(更安全)

bash 复制代码
# 云厂商控制台设置入站规则
# 只允许云函数所在网段访问 Qdrant 端口(6333,6334)

后续扩展方向

当前架构满足小规模场景,后续可以迭代的方向:

  1. 查询改写:先调LLM将模糊提问("晚上吃啥")改写为精确query("家常菜 简单 快手 30分钟"),提高召回率

  2. 多路召回+重排序:向量检索、TF-IDF、元数据过滤三路并行,用Cross-Encoder重排序

  3. 增量同步:菜谱新增/修改后自动触发Qdrant更新,而非全量重新同步

  4. 缓存层:对高频查询的Embedding结果做缓存,减少SiliconFlow API调用

  5. 混合检索:利用Qdrant的filtering能力,先按分类/难度过滤,再做向量匹配

  6. 监控告警:增加各级降级的metrics上报,及时发现问题

小结

容错不是锦上添花,而是RAG系统上线的必备条件。核心思路:

  1. 分级降级:向量搜索→关键词搜索→LLM直答→本地兜底,每一级都不依赖上一级
  2. 独立超时:每个外部调用有自己的超时,不互相阻塞,超时即降级
  3. 静默失败:用户永远看不到技术错误,只是推荐精度逐步降低
  4. 零依赖兜底:最后一道防线只依赖云数据库,保证最低可用性

在C端场景,用户要的不是完美的推荐,而是一个永远能用的产品

项目地址 :Gitee/ZaoTaiNavigation
团队名称 :倒灶了队
更新时间:2026年5月

相关推荐
Sanri.1 小时前
JavaScript基础语法6
开发语言·javascript·ecmascript
hhb_6181 小时前
JavaScript核心技术要点梳理与实战应用案例解析
开发语言·javascript·ecmascript
阿里巴啦1 小时前
微信小程序实战:基于原生框架 + 云开发实现 干饭足迹小程序,美食打卡、地图探索与消费报告
微信小程序·小程序开发·微信云开发·云函数·小程序项目实战·美食打卡记录
廖松洋(Alina)2 小时前
03主入口页面与导航结构-鸿蒙PC端Electron开发
前端·javascript·华为·electron·开源·harmonyos·鸿蒙
廖松洋(Alina)2 小时前
09词根分解与水印展示-鸿蒙PC端Electron开发
前端·javascript·华为·electron·开源·harmonyos·鸿蒙
matrixmind82 小时前
sindresorhustype-fest:TypeScript 工具类型集合
前端·javascript·其他·typescript
故事和你912 小时前
洛谷-【数据结构2-2】线段树1
开发语言·javascript·数据结构·算法·动态规划·图论
ZC跨境爬虫2 小时前
跟着 MDN 学 HTML day_43:(DocumentFragment 接口详解)
前端·javascript·vue.js·ui·html·音视频
2301_815645382 小时前
JavaScript 核心
javascript