【灶台导航】 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、ECONNREFUSEDQdrant 服务完全不可用降级逻辑的核心代码// chat/index.js - retrieveContext()asyncfunctionretrieveContext(message,openid){letsystemRagRecipes[]letcontextText// ═══ 尝试向量搜索 ═══try{constvectorResultsawaitwithTimeout(qdrant.vectorSearch(message,3),TIMEOUT_CONFIG.embeddingTIMEOUT_CONFIG.qdrant,// 8秒总超时向量检索超时)constrelevantvectorResults.filter(rr.similarity0.3)if(relevant.length0){systemRagRecipesrelevant.map(rr.recipe)contextTextformatContext(relevant)}}catch(e){// Qdrant挂了或超时静默降级 — 用户完全无感知console.warn([RAG-Vector] 向量检索失败降级为 TF-IDF:,e.message)}// ═══ 仅在向量搜索无结果时走TF-IDF ═══if(systemRagRecipes.length0){const{data:recipes}awaitdb.collection(recipes).where({isPrivate:_.neq(true)}).limit(50).get()constresultssearch(message,recipes,3)// TF-IDF检索constrelevantresults.filter(rr.similarity0.05)// 阈值更宽松if(relevant.length0){systemRagRecipesrelevant.map(rr.recipe)contextTextformatContext(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中文分词二元组停用词// tfidf.jsconstSTOP_WORDSnewSet([的,了,是,在,和,也,都,不,就,有])functiontokenize(text){constchineseChunkstext.match(/[\u4e00-\u9fa5]/g)||[]consttokens[]for(constchunkofchineseChunks){// 双字组合匹配菜名、食材名for(leti0;ichunk.length-1;i){constbigramchunk[i]chunk[i1]if(!STOP_WORDS.has(bigram))tokens.push(bigram)}}returntokens}为什么用bigram而不是jieba因为云函数装不了C扩展而bigram对短文本菜名、食材名效果足够好。像宫保鸡丁会被切分为[“宫保”,“保鸡”,“鸡丁”]足够匹配用户查询。增强归一化TFfunctioncomputeTFIDF(tokens,idf){consttf{}for(consttoftokens)tf[t](tf[t]||0)1constmaxTfMath.max(...Object.values(tf))consttfidf{}for(const[term,freq]ofObject.entries(tf)){// 增强归一化避免长文档的单字频率被过度归一化constnormalizedTf0.50.5*(freq/maxTf)tfidf[term]normalizedTf*(idf[term]||1)}returntfidf}0.5 0.5 * (tf/maxTf)比简单的tf/maxTf更柔和避免长文档中重复词失去权重。IDF平滑// 预处理计算IDFfunctionprecomputeIdf(recipes){constNrecipes.lengthconstdf{}for(constrecipeofrecipes){consttokenstokenize(recipe.name (recipe.ingredients||[]).join( ))constuniquenewSet(tokens)for(consttermofunique){df[term](df[term]||0)1}}constidf{}for(const[term,freq]ofObject.entries(df)){// 1平滑防止除零再1偏移保证IDF不为0idf[term]Math.log((N1)/(freq1))1}returnidf}超时控制每个环节都有底线没有超时控制的降级是假降级——一个请求卡住3分钟用户早走了。constTIMEOUT_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实现functionwithTimeout(promise,ms,errorMsg){returnPromise.race([promise,newPromise((_,reject)setTimeout(()reject(newError(errorMsg)),ms))])}关键原则每个外部调用独立超时Embedding超时不影响后续降级总超时兜底云函数34秒超时保证不会无限等待超时即降级超时被当作失败进入下一级第3级LLM降级向量检索和TF-IDF都失败了怎么办走纯LLM生成无RAG上下文// chat/index.js - callDeepSeekAPI()try{constresponseawaitgot.post(deepseekUrl,{timeout:{request:TIMEOUT_CONFIG.deepseekApi}})// 解析响应...}catch(err){console.warn([DeepSeek] API调用失败走本地兜底:,err.message)returnawaitfallbackResponse(userMessage)}此时依赖就只剩下DeepSeek API了。但如果DeepSeek也挂了呢第4级本地关键词兜底最后一道防线不依赖任何外部API只依赖云数据库。asyncfunctionfallbackResponse(userMessage){constmsguserMessage.toLowerCase()// 1. 问候语直接回复不需要查库constgreetings[你好,hello,hi,嗨]if(greetings.some(gmsg.includes(g))){return{reply:你好我是灶台导航助手可以帮你推荐菜谱~,action:ask}}// 2. 关键词→分类映射constcategoryMap{牛:beef,猪:pork,鸡:chicken,鱼:fish,素:vegetarian,甜:dessert}letmatchedCategorynullfor(const[keyword,category]ofObject.entries(categoryMap)){if(msg.includes(keyword)){matchedCategorycategory;break}}// 3. 从数据库按分类或关键词查菜谱constdbcloud.database()const_db.commandletquerydb.collection(recipes)if(matchedCategory){queryquery.where({category:matchedCategory})}else{// 模糊匹配菜名constkeywordArrmsg.split(/[\s,、]/).filter(kk.length0)constorConditionskeywordArr.map(k({name:db.RegExp({regexp:k,options:i})}))queryquery.where(_.or(orConditions))}const{data:recipes}awaitquery.limit(3).orderBy(views,desc).get()if(recipes.length0){constreply为您推荐以下${matchedCategory?matchedCategory:相关}菜谱\nrecipes.map(r${r.name}${r.difficulty||中等}).join(\n)return{reply,recommendations:recipes,action:recommend}}// 连数据库都查不到——兜底的兜底return{reply:暂时没找到相关菜谱可以说说你想吃什么食材吗,action:ask}}这个兜底层有三大特点零外部依赖只调用云数据库即便Embedding/Qdrant/DeepSeek全挂也能工作规则驱动关键词映射不依赖AI100%确定渐进式降级问候语直接回复 → 分类匹配 → 模糊匹配 → 通用提示降级触发条件速查表降级级别触发条件用户感知向量→TF-IDF① Embedding API 超时(5s)② Qdrant 查询超时(3s)③ 网络错误④ 所有结果相似度 0.3无感知推荐精度略降TF-IDF→纯生成① 数据库查询超时(3s)② 所有结果相似度 0.05③ 数据库为空无感知回答无菜谱引用纯生成→兜底DeepSeek API 超时(30s) 或报错回复变简短可能答非所问兜底内部降级关键词/分类匹配失败返回通用提示继续对话为什么不合并多路结果有人会问为什么不做向量检索和TF-IDF的并行召回融合排序当前设计选择串行降级而非并行融合原因如下数据规模小菜谱数量在百级单路召回已经足够覆盖分数不可比向量相似度(01)和TF-IDF分数(0几十)尺度不同简单线性融合会引入噪声延迟更低向量检索成功时不需要等待TF-IDF执行串行反而更快代码简单逻辑清晰维护成本低如果后续扩展到万级数据可以考虑升级为多路并行召回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秒场景2Qdrant临时宕机Embedding API超时(5s)→抛异常自动降级TF-IDF按下饭关键词匹配找到标签含下饭的菜谱用户无感知耗时增加约1秒降级开销场景3用户说你好Qdrant返回低分结果(最高0.15)TF-IDF也无有意义匹配进入纯生成模式DeepSeek直接回复用户得到正常问候无报错场景4DeepSeek也挂了进入fallbackResponse关键词匹配回复您好请说食材名称…虽然不够智能但用户不会看到白屏关于Qdrant的安全提醒目前Qdrant直接暴露在公网且无认证存在风险风险任何人都可以查询/修改/删除你的向量数据数据泄露或被恶意清空两种加固方案方案一API Key认证推荐# docker-compose.ymlservices:qdrant:image:qdrant/qdrantenvironment:-QDRANT__SERVICE__API_KEYyour-secure-key-here方案二防火墙白名单更安全# 云厂商控制台设置入站规则# 只允许云函数所在网段访问 Qdrant 端口(6333,6334)后续扩展方向当前架构满足小规模场景后续可以迭代的方向查询改写先调LLM将模糊提问“晚上吃啥”改写为精确query“家常菜 简单 快手 30分钟”提高召回率多路召回重排序向量检索、TF-IDF、元数据过滤三路并行用Cross-Encoder重排序增量同步菜谱新增/修改后自动触发Qdrant更新而非全量重新同步缓存层对高频查询的Embedding结果做缓存减少SiliconFlow API调用混合检索利用Qdrant的filtering能力先按分类/难度过滤再做向量匹配监控告警增加各级降级的metrics上报及时发现问题小结容错不是锦上添花而是RAG系统上线的必备条件。核心思路分级降级向量搜索→关键词搜索→LLM直答→本地兜底每一级都不依赖上一级独立超时每个外部调用有自己的超时不互相阻塞超时即降级静默失败用户永远看不到技术错误只是推荐精度逐步降低零依赖兜底最后一道防线只依赖云数据库保证最低可用性在C端场景用户要的不是完美的推荐而是一个永远能用的产品。项目地址Gitee/ZaoTaiNavigation团队名称倒灶了队更新时间2026年5月
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2608562.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!