最近负责维护小程序「灶台导航」AI菜谱推荐后端,上线至今跑了三个多月,线上稳定性、用户匹配度基本达标。这套服务核心依托自研RAG检索管线做菜谱召回,区别于市面上大部分只做向量检索的RAG方案,我们最终落地了一套语义优先、关键词兜底、全链路超时降级的双通道架构。

写这篇复盘不是照搬架构文档,更多聊聊中小体量AI业务做RAG的务实取舍:不用追求极致高精尖模型,优先保证线上可用;不用堆砌复杂架构,小数据量下做足降级容错,远比优化检索精度更重要。全文会拆解架构流程、代码细节、踩坑点、参数调优逻辑,都是微信云函数+Qdrant线上实测后的结论,可直接复用。


一、业务背景:为什么不能用极简RAG?

先简单说下业务场景:用户在小程序输入做菜需求,比如「清淡减脂家常菜」「家里只有土豆鸡蛋做什么」,AI调取平台菜谱库做个性化推荐,核心约束两点:

  1. 禁止AI凭空编造菜谱食材、做法,所有推荐必须绑定平台真实菜谱ID,前端可溯源;

  2. 微信云函数有硬性60s执行时限,第三方Embedding、向量库随时可能限流、超时、节点波动,服务绝对不能报错熔断。

项目最早期做过最简版RAG:用户输入→生成向量→Qdrant检索→喂给大模型。上线一周直接暴雷:SiliconFlow Embedding晚间限流超时、Qdrant轻量节点偶发宕机、小众口语化输入向量匹配为空,直接导致近15%对话请求超时报错。

基于线上故障迭代,最终敲定三层降级双通道检索架构:优先语义向量检索,向量链路任意环节失败,无缝降级本地TF-IDF关键词检索,极端双链路失效,直接放行纯大模型闲聊回答,全程用户无感知。

二、整体链路:通俗易懂的三层降级逻辑

放一张打磨后的业务执行链路,去掉专业架构话术,按用户请求流转顺序梳理,同时全链路埋超时、异常捕获:

用户输入对话
    │
    ▼
意图判断shouldUseRAG()  ──闲聊/无做菜意图──→ 跳过检索,直接大模型回复
    │
有菜谱推荐意图
    │
    ▼
调用Embedding生成向量(5s超时) ──超时/接口失败──→ 直接切TF-IDF兜底
    │
向量生成成功
    │
    ├──并行检索→公共菜谱Qdrant搜索(top5,相似度阈值0.3,3s超时)
    │
    └──并行检索→用户私有菜谱Qdrant搜索(top3,绑定用户openid隔离,3s超时)
    │
向量检索有有效结果?
├─有→合并结果格式化
└─无→触发TF-IDF本地关键词检索(top5,阈值0.05)
    │
    ▼
结果去重合并、标准化格式,注入大模型上下文,后置校验菜谱ID防幻觉

这套架构的核心底线:绝不允许检索环节卡死请求。向量检索是加分项,关键词检索是保命项,闲聊放行是底线项。上线后线上检索故障降级率稳定在1.2%,全部为Embedding限流触发兜底,用户完全感知不到服务降级。

三、向量链路核心实现:BGE-M3+轻量缓存,适配中文菜谱场景

向量检索第一步,就是把用户自然语言转为1024维语义向量,项目全程托管使用SiliconFlow的BAAI/bge-m3模型,没有自建Embedding模型,中小业务没必要投入算力微调。

3.1 选型复盘:为什么死守BGE-M3?

前期对比过text-embedding、小型中文embedding模型,踩坑很直观:

  • 外文基底向量模型,对「少盐清炒」「下饭家常菜」这类中式口语语义识别极差,同品类菜谱相似度极低;

  • 轻量化中文小模型,泛化性差,用户换同义话术(推荐菜品/推荐菜式)无法命中同款菜谱;

而BGE-M3原生优化中文语义,适配菜谱短文本、口语化查询,是目前性价比最高的托管向量模型,没有之一。

3.2 极简LRU缓存:低成本减少Embedding调用

Embedding接口按调用量计费,小程序用户高频重复输入同义话术,比如「推荐素菜」「来点素菜」,没必要重复调用接口生成向量。我们没有引入专业缓存库,依托原生Map手写百条容量LRU缓存,够用且零依赖,完整业务代码如下:

// qdrant-core.js 向量生成核心代码
async function getEmbedding(text) {
  // 轻量化文本指纹:前100字符+文本长度,规避短文本语义歧义
  const cacheKey = text.substring(0, 100) + '|' + text.length;
  // 命中缓存直接返回向量,省去接口调用
  if (embeddingCache.has(cacheKey)) {
    return embeddingCache.get(cacheKey);
  }

  // 5s接口超时,适配网络波动+平台限流
  const response = await got.post(`${baseUrl}/embeddings`, {
    headers: { 'Authorization': `Bearer ${apiKey}` },
    json: { model: 'BAAI/bge-m3', input: text, encoding_format: 'float' },
    timeout: { request: 5000 }
  });

  const vector = JSON.parse(response.body).data[0].embedding;
  embeddingCache.set(cacheKey, vector);

  // 简易LRU淘汰:超100条自动删除最早缓存数据
  if (embeddingCache.size > 100) {
    embeddingCache.delete(embeddingCache.keys().next().value);
  }
  return vector;
}

补充两个线上调优细节:

1、缓存指纹不取全文,只取前100字符+长度:菜谱场景下,用户短查询几乎不会出现前缀一致语义不同的情况,大幅降低哈希计算成本;

2、固定5s超时阈值:正常Embedding响应仅200-800ms,5s是预留峰值缓冲,一旦超时直接放弃向量链路,不重试、不阻塞。

3.3 Qdrant双集合并行检索:区分公有+私有菜谱

平台菜谱分为两类:全站共享505道官方菜谱、用户自建私房菜谱,我们在Qdrant拆分两个独立集合,采用Promise.all并行检索,互不影响可用性。

// 并行检索核心代码,独立超时、独立异常兜底
const [systemResults, privateResults] = await Promise.all([
  // 官方菜谱:top5,3s超时,失败返回空数组
  withTimeout(
    qdrant.vectorSearchWithVector(queryVector, 5),
    3000
  ).catch(() => []),

  // 匿名用户不检索私有菜谱,登录用户按openid隔离数据
  openid !== 'anonymous'
    ? withTimeout(
        qdrant.searchPrivateRecipesWithVector(queryVector, openid, 3),
        3000
      ).catch(() => [])
    : Promise.resolve([])
]);

关键设计点:所有检索方法复用用户单次生成的queryVector,也就是WithVector模式。很多新手会踩坑:公私菜谱检索分别调用一次Embedding,双倍消耗接口额度,我们一次生成、两处复用,直接减半向量调用成本。

私有菜谱依托Qdrant原生payload过滤器做数据隔离,精准绑定用户openid,杜绝跨用户菜谱泄露:

{
  "query": queryVector,
  "limit": 3,
  "with_payload": true,
  "filter": {
    "must": [{ "key": "openid", "match": { "value": "用户openid" } }]
  }
}

四、极易踩坑:双库ID映射适配问题

这是开发阶段卡最久的细节坑,也是很多对接Qdrant+文档数据库项目的通用问题:

微信云数据库自带菜谱_id,为字符串格式(自定义r001/自动MongoID),但Qdrant要求点位ID只能是uint64整数或UUID,字符串原生ID无法入库。

最终折中方案:顺序整数做Qdrant点位ID,原始数据库ID存入payload业务字段,双向映射绑定:

// 数据同步至Qdrant点位结构
{
  id: sequentialIndex,          // Qdrant专属自增数字ID
  vector: embeddingVector,      // 1024维菜品向量
  payload: {
    recipeId: recipe._id,       // 原始云数据库原生ID,业务溯源用
    name: recipe.name,
    description: recipe.description,
    category: recipe.category
  }
}

检索结束后反向解析还原原始ID,同时增加后置校验:大模型输出的菜谱ID,必须匹配检索白名单内ID,陌生ID直接判定AI幻觉丢弃,从代码层面根治编造菜谱的问题。

五、零依赖TF-IDF兜底:适配云函数的极简中文检索

当Embedding接口挂掉、Qdrant节点宕机、向量相似度全部不达标时,系统无缝切到自研TF-IDF检索。重点说明:这套TF-IDF完全原生JS手写,零第三方分词、检索依赖,完美适配微信云函数受限环境。

5.1 放弃jieba分词的务实原因

一开始打算引入jieba中文分词,上线测试直接否决:微信云函数安装原生分词依赖、打包体积超标、冷启动速度翻倍。针对平台仅500条量级的小菜谱库,我们用单字+双字bigram滑动分词平替专业分词,效果完全够用。

示例:西红柿炒蛋 → 拆分【西、红、柿、炒、蛋、西红、红柿、柿炒、炒蛋】,既能覆盖食材关键词,又不用维护复杂分词词库,搭配80个中文虚词停用词过滤,精准度满足兜底需求。

5.2 相似度阈值差异化逻辑(很多人调不对参数)

线上两套检索阈值完全不同,不是随意配置,是基于分数分布实测敲定:

  • BGE-M3向量余弦相似度:阈值0.3,分数集中0.2-0.8,语义匹配容错高;

  • TF-IDF短文本余弦相似度:阈值0.05,分数普遍偏低,仅精准关键词匹配才能拉高分值;

同时做了两项经典IR优化,避免检索失真:

  1. IDF加1平滑:IDF(t) = log((N+1)/(df+1)) + 1,防止小众食材词权重无限放大;

  2. TF归一化抑制高频词:normalizedTF = 0.5 + 0.5 * (freq / maxTF),避免「做菜」「食用」这类通用词霸占检索权重。

5.3 降级链路性能优化

菜谱全量文本做5分钟TTL内存缓存,降级触发时,无需重复请求云数据库拉取菜谱,高峰期批量降级请求,数据库压力直接降低90%。

六、离线数据同步:小体量库放弃增量,选择全量重写

平台总共505道菜谱,体量极小,我们直接写离线云函数syncToQdrant做手动全量同步,放弃业界主流增量同步,工程取舍理由很直白:

增量同步要维护菜品更新标记、向量更新队列、脏数据幂等逻辑,开发维护成本极高;全量同步逻辑简单、零数据错乱风险,单次同步仅2-3分钟,完全适配后台低频更新菜谱的业务节奏。

同步标准化流程:

  1. 校验Qdrant集合,不存在则新建1024维余弦向量集合;

  2. 分页100条批量拉取云数据库全量菜谱;

  3. 按32条分批调用Embedding接口(适配SiliconFlow单次调用上限),拼接【菜名+描述+食材+标签】生成向量;

  4. 清空原有集合数据,20条批量upsert新点位,完成覆盖更新。

七、全链路超时预算:卡死云函数60s红线

微信云函数硬性上限60s,一旦超时直接强制终止请求,这也是整套降级架构的底层约束,我们提前分配每一段操作超时,预留缓冲余量,全链路局部超时、局部兜底,绝不阻塞主流程:

业务操作

限定超时

兜底规则

Embedding向量生成

5s

超时直接切TF-IDF

Qdrant公私菜谱检索

各3s

并行执行,单链路失败不影响另一端

TF-IDF菜谱库查询

3s

5分钟内存缓存兜底

DeepSeek Flash大模型调用

30s

极速回复轻量化推荐

DeepSeek Pro完整生成

55s

完整菜谱详情输出

云函数全局预留

59s

预留1s系统缓冲,规避强制超时

所有接口统一封装withTimeout工具方法,基于Promise.race实现超时捕获,超时不抛顶层异常,仅返回空数组交给降级逻辑,这是线上服务稳得住的核心细节。

八、最后复盘:中小业务做RAG,韧性远大于精度

做完这套架构最大的感悟:大厂RAG追求检索精度、召回率、增量更新、向量优化,小体量ToC小程序RAG,第一优先级是服务可用、成本可控、运维简单

总结项目三个核心设计思想,可直接复用同类轻量AI业务:

  1. 门控按需检索:新增shouldUseRAG意图判断,闲聊对话直接跳过全链路检索,节省向量库、接口配额,降低延迟;

  2. 双通道分层降级:语义向量做优质推荐,本地关键词做故障兜底,双链路失效放行裸大模型,三层容错零报错;

  3. 拒绝过度设计:500条菜谱不做增量同步、不引入重型分词、不自建Embedding,能用原生能力解决的问题,绝不新增依赖。

后续迭代计划:优化bigram分词词库、新增用户热门查询向量预热缓存,暂时不会重构现有双通道架构——够用、稳定、好维护,就是最好的架构。

本文中涉及的所有设计思路,均经过「灶台导航」实际项目测试,贴合菜谱类小程序的业务场景,可直接参考应用到同类项目中;如需根据具体业务调整参数或扩展功能,可参考文中的决策矩阵和设计清单,快速适配自身需求。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐