全流程架构图

用户输入
文字/语音

① 前端: index.js
onSend / callAI

② 云函数入口: chat/index.js
exports.main → processChat

③ RAG检索: retrieveContext

第1路: 向量语义搜索
qdrant.js:89-117

第2路: TF-IDF降级
tfidf.js:191

第3路: 私有菜谱
qdrant.js:167

结果合并

④ 上下文注入: callDeepSeekAPI
System Prompt + RAG Context

⑤ LLM调用: DeepSeek API
chat/index.js:395-410

⑥ 响应解析: parseAIReply
+ resolveRecipeIds

⑦ 前端展示: index.js
callAI:428

⑧ 保存到私人菜谱
saveToMyRecipes


逐环节详解 + 对应代码

① 前端:用户输入 → 云函数调用

文件: miniprogram/pages/index/index.js:377-407

// 用户点击发送
onSend() {
  const userInput = this.data.inputText.trim();
  this.callAI(userInput);
}

// 调用云函数
async callAI(userInput) {
  const userProfile = this.data.currentMember ? {
    roleName: this.data.currentMember.name,
    preferences: this.data.currentMember.preferences || [],
    allergies: this.data.currentMember.allergies || []
  } : {};

  const res = await callFunction('chat', {
    sessionId: this.data.sessionId,
    message: userInput,
    userProfile: userProfile
  });
}

要点: 前端只负责采集输入和上下文,不参与任何AI逻辑。


② 云函数入口:路由 + 会话管理 + 超时控制

文件: cloudfunctions/chat/index.js:205-248

exports.main = async (event, context) => {
  const openid = context.OPENID || 'anonymous'
  return await withTimeout(processChat(event, openid), 34000, '云函数执行超时')
}

文件: cloudfunctions/chat/index.js:251-357

async function processChat(event, openid) {
  // 1. 加载/创建会话(多轮对话历史)
  // 2. 截取最近20条历史,防止token溢出
  // 3. 构建用户上下文字符串
  let userContext = ''
  if (userProfile) {
    if (userProfile.roleName) parts.push(`当前用餐人:${userProfile.roleName}`)
    if (userProfile.preferences.length) parts.push(`口味偏好:${userProfile.preferences.join('、')}`)
    if (userProfile.allergies.length) parts.push(`忌口:${userProfile.allergies.join('、')}`)
  }

  // 4. 【核心】RAG检索 + LLM调用
  const { contextText, ragRecipes } = await retrieveContext(message, openid)
  const aiResult = await callDeepSeekAPI(..., contextText, ragRecipes, ...)

  // 5. 保存会话历史到数据库
  // 6. 返回结果给前端
}

③ RAG三级检索:retrieveContext()

文件: cloudfunctions/chat/index.js:116-201

async function retrieveContext(message, openid) {
  let systemRagRecipes = []
  let contextText = ''

  // ═══ 第1路:Qdrant 向量语义搜索(主链路) ═══
  try {
    const vectorResults = await withTimeout(
      qdrant.vectorSearch(message, 3),
      TIMEOUT_CONFIG.embedding + TIMEOUT_CONFIG.qdrant,
      '向量检索超时'
    )
    if (vectorResults && vectorResults.length > 0) {
      const relevant = vectorResults.filter(r => r.similarity > 0.3)
      if (relevant.length > 0) {
        systemRagRecipes = relevant.map(r => r.recipe)
        contextText = formatContext(relevant)
      }
    }
  } catch (e) {
    console.warn('[RAG-Vector] 向量检索失败,降级为 TF-IDF:', e.message)
  }

  // ═══ 第2路: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)
    const relevant = results.filter(r => r.similarity > 0.05)
    // ... 赋值 systemRagRecipes + contextText
  }

  // ═══ 第3路:私有菜谱语义检索(仅当前用户) ═══
  let privateRagRecipes = []
  if (openid && openid !== 'anonymous') {
    const privateResults = await withTimeout(
      qdrant.searchPrivateRecipes(message, openid, 2),
      ...
    )
    const relevant = privateResults.filter(r => r.similarity > 0.3)
    if (relevant.length > 0) {
      privateRagRecipes = relevant.map(r => r.recipe)
      contextText += '\n\n以下是用户的私人菜谱库中的相关菜谱:\n'
      contextText += formatContext(relevant)
    }
  }

  return { contextText, ragRecipes: [...systemRagRecipes, ...privateRagRecipes] }
}
第1路细节:Qdrant向量搜索

文件: cloudfunctions/chat/qdrant.js:89-117

async function vectorSearch(message, topK = 3) {
  const queryVector = await getEmbedding(message)  // 调用SiliconFlow API
  const hits = await searchQdrant(queryVector, topK)
  return hits.map(hit => ({
    recipe: { _id: hit.payload.recipeId, name: hit.payload.name, ... },
    similarity: hit.score
  }))
}

Embedding调用 (qdrant.js:27-48):

async function getEmbedding(text) {
  const response = await got.post('https://api.siliconflow.cn/v1/embeddings', {
    json: {
      model: 'BAAI/bge-m3',     // 开源Embedding模型
      input: text,
      encoding_format: 'float'
    }
  })
  return result.data[0].embedding  // 返回1024维float数组
}

Qdrant搜索 (qdrant.js:58-79):

async function searchQdrant(queryVector, topK = 3) {
  const response = await got.post(
    'http://47.122.88.208:6333/collections/recipes/points/query',
    {
      json: {
        query: queryVector,
        limit: topK,
        with_payload: true
      }
    }
  )
  return hits
}
第3路细节:私有菜谱带过滤的向量搜索

文件: cloudfunctions/chat/qdrant.js:167-207

async function searchPrivateRecipes(message, openid, topK = 2) {
  const queryVector = await getEmbedding(message)
  const response = await got.post(
    '.../collections/private_recipes/points/query',
    {
      json: {
        query: queryVector,
        limit: topK,
        with_payload: true,
        filter: {
          must: [{ key: 'openid', match: { value: openid } }]
        }
      }
    }
  )
  return hits.map(hit => ({ recipe: {..., isPrivate: true}, similarity: hit.score }))
}
RAG上下文格式化

文件: cloudfunctions/chat/tfidf.js:224-239

function formatContext(searchResults) {
  const lines = ['以下是系统菜谱库中的相关菜谱,请优先推荐...']
  searchResults.forEach((item, i) => {
    const r = item.recipe
    const idTag = r._id ? `[ID:${r._id}] ` : ''          // ID标签,让LLM引用
    const privateTag = r.isPrivate ? '[私人菜谱] ' : ''
    lines.push(`${i + 1}. ${privateTag}${idTag}${r.name}${r.difficulty}${r.cookTime}分钟):${r.description}`)
    lines.push(`   食材:${ingredients}`)
  })
  return lines.join('\n')
}

④ 上下文注入:构建LLM的Prompt

文件: cloudfunctions/chat/index.js:371-392

async function callDeepSeekAPI(history, userMessage, userContext, ragContext, ragRecipes, generateMode) {
  let systemContent = SYSTEM_PROMPT          // 第57-106行,定义角色+输出格式
  if (ragContext) {
    systemContent += '\n\n' + ragContext
  }

  const messages = [{ role: 'system', content: systemContent }]
  
  for (const item of history) {
    messages.push({ role: item.role, content: item.content })
  }

  const fullMessage = userMessage + userContext
  messages.push({ role: 'user', content: fullMessage })
  // → 发送给 DeepSeek
}

System Prompt (chat/index.js:57-106) 定义了:

  • 角色定位(烹饪助手)
  • 强制JSON输出格式: response_format: { type: 'json_object' }
  • 4种动作类型: recommend | ask | generateRecipe | cook
  • 菜谱推荐规则(系统菜谱 vs AI生成 vs 未收录)
  • fullRecipe 的数据结构定义

⑤ LLM调用:DeepSeek API

文件: cloudfunctions/chat/index.js:395-410

const response = await got.post('https://api.deepseek.com/chat/completions', {
  headers: { 'Authorization': `Bearer ${DEEPSEEK_CONFIG.apiKey}` },
  json: {
    model: 'deepseek-chat',
    messages: messages,
    temperature: 0.7,
    max_tokens: generateMode ? 2048 : 512,
    response_format: { type: 'json_object' }
  },
  timeout: { request: 30000 }
})

⑥ 响应解析:LLM输出 → 结构化数据

文件: cloudfunctions/chat/index.js:412-443

const aiContent = responseBody.choices[0].message.content
const parsed = parseAIReply(aiContent)
// 返回: { reply, action, recommendations, cookData }

parsed.recommendations = await resolveRecipeIds(parsed.recommendations, ragRecipes)

resolveRecipeIds (chat/index.js:493-520):

情况 来源 处理
AI返回了[ID:xxx] database 直接映射
AI生成了完整菜谱 ai-generated 分配临时ID
菜名能匹配RAG结果 database 映射到真实ID
完全无匹配 not-found 前端显示"未收录"

⑦ 前端展示

文件: miniprogram/pages/index/index.js:442-467

const aiReply = res.data;
const aiMessage = {
  role: 'ai',
  content: aiReply.reply,
  recommendations: aiReply.recommendations || []
};
this.setData({ chatMessages: [...this.data.chatMessages, aiMessage] });

WXML (index.wxml:14-34): 根据每个推荐菜谱的 source 字段显示不同标签(AI生成 / 未收录),以及"保存菜谱"按钮。


⑧ 保存到私人菜谱(反向写入向量库)

文件: miniprogram/pages/index/index.js:536-610

async saveToMyRecipes(e) {
  // 查找匹配的推荐菜谱
  const recipeData = { name, description, prepTime, cookTime, ingredients, steps, ... }
  const res = await callFunction('userProfile', {
    action: 'addPrivateRecipe',
    data: recipeData
  });
}

userProfile云函数 (cloudfunctions/userProfile/index.js:426-461):

async function addPrivateRecipe(openid, recipeData) {
  // 1. 写入云数据库
  const recipe = { ...recipeData, openid, isPrivate: true, ... }
  const { id: recipeId } = await db.collection('recipes').add({ data: recipe })

  // 2. 【异步】生成Embedding并写入Qdrant
  qdrantPrivate.upsertPrivateRecipe(recipe, openid).catch(err => { ... })
}

向量入库 (cloudfunctions/userProfile/qdrant-private.js:74-118):

async function upsertPrivateRecipe(recipe, openid) {
  const text = parts.join(' ')  // 名称+描述+标签+食材
  const vector = await getEmbedding(text)
  await got.put('.../collections/private_recipes/points', {
    json: {
      points: [{
        id: pointId,
        vector: vector,
        payload: { recipeId: recipe._id, openid, name, ... }
      }]
    }
  })
}

这样下次用户聊天时,③中的第3路检索就能从 private_recipes 集合中语义召回这道私人菜谱。


降级容错链路总结

DeepSeek API超时/失败

fallbackResponse

本地关键词匹配 + 数据库分类查询

仍然无法匹配?

兜底提示
'请换种方式描述'

返回结果

Qdrant向量搜索超时/失败

TF-IDF关键词检索
纯JS,无外部依赖

仍然无高分结果?

DeepSeek不带RAG
上下文直接回答

每一层都有独立的超时控制(withTimeout),保证云函数35秒内必须返回结果。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐