背景
灶台导航的AI对话最初只依赖TF-IDF关键词匹配来检索菜谱,效果有限------用户说"家里有土豆和鸡蛋怎么办",关键词搜索很难匹配到"土豆鸡蛋饼"这样的菜谱,因为词频重叠太少。要实现真正的语义理解,需要引入向量数据库,而Qdrant是目前最轻量的开源选择。
为什么选Qdrant
主要原因是我们的阿里云轻量服务器上安装了docker,轻松可以部署Qdrant容器。
| 对比项 | Qdrant | Milvus | Pinecone |
|---|---|---|---|
| 部署方式 | Docker一行命令 | 依赖重,组件多 | 纯SaaS,不可自建 |
| 资源占用 | 200MB内存起步 | 1GB+ | N/A |
| API友好度 | RESTful,简洁 | gRPC为主 | SDK |
| 过滤查询 | 原生支持payload filter | 支持 | 有限 |
对于100+菜谱的小型项目,Qdrant是最佳选择。
Docker部署
1. 拉取镜像并启动
bash
docker pull qdrant/qdrant:latest
docker run -d --name qdrant -p 6333:6333 -v $(pwd)/qdrant_storage:/qdrant/storage qdrant/qdrant
6333:REST API端口(云函数调用)6334:gRPC端口(可选)- 数据持久化到宿主机
$(pwd)/qdrant_storage - 在服务器
2. 验证服务
bash
curl http://localhost:6333/collections
返回{"result":{"collections":[]}}即成功。

3. 创建集合
bash
curl -X PUT http://localhost:6333/collections/recipes \
-H 'Content-Type: application/json' \
-d '{
"vectors": {
"size": 1024,
"distance": "Cosine"
}
}'
关键参数:
- size: 1024 --- 与bge-m3模型的输出维度一致
- distance: Cosine --- 余弦相似度,适合语义搜索场景
可以访问UI控制台来查看数据库状态

云函数网络打通
Qdrant部署在云服务器上,而云函数运行在腾讯云内网。需要确保:
- 云服务器安全组开放6333端口
- 云函数中使用got库发起HTTP请求(云函数环境不支持axios等浏览器库)
javascript
// cloudfunctions/chat/qdrant.js
const got = require('got')
const QDRANT_CONFIG = {
baseUrl: 'http://**********:6333',
collection: 'recipes'
}
async function searchQdrant(queryVector, topK = 3) {
const response = await got.post(
`${QDRANT_CONFIG.baseUrl}/collections/${QDRANT_CONFIG.collection}/points/query`,
{
headers: { 'Content-Type': 'application/json' },
json: {
query: queryVector,
limit: topK,
with_payload: true
},
timeout: { request: 3000 }
}
)
const result = JSON.parse(response.body)
return result.result?.points || result.result || []
}
踩坑记录
云函数超时
云函数默认超时3秒,Qdrant查询+Embedding生成至少需要4-5秒。需要在package.json中配置:
json
{
"cloudfunctionRoot": "cloudfunctions/",
"functions": {
"chat": { "timeout": 35 }
}
}
Qdrant Point ID要求正整数
Qdrant的point ID必须是正整数或UUID,不能直接用MongoDB的_id字符串。解决方案:
javascript
// 用hashCode将字符串转为正整数
const pointId = Math.abs(hashCode(recipe._id)) % 2147483647
function hashCode(str) {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i)
hash |= 0
}
return hash
}
数据初始化---将原本云数据库中的菜谱数据迁移至向量数据库
1. Embedding生成:文本→向量
选择SiliconFlow的BAAI/bge-m3模型,1024维,中文效果优秀,并且免费
访问硅基流动网站,注册账户并创建自己的api key:
javascript
// cloudfunctions/chat/qdrant.js
const EMBEDDING_CONFIG = {
baseUrl: 'https://api.siliconflow.cn/v1',
model: 'BAAI/bge-m3',
dimensions: 1024
}
async function getEmbedding(text) {
const response = await got.post(`${EMBEDDING_CONFIG.baseUrl}/embeddings`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${EMBEDDING_CONFIG.apiKey}`
},
json: {
model: EMBEDDING_CONFIG.model,
input: text,
encoding_format: 'float'
},
timeout: { request: 5000 }
})
const result = JSON.parse(response.body)
return result.data[0].embedding // 1024维float数组
}
Embedding文本的构建很关键------不是简单把菜名丢进去,而是拼接多字段:
javascript
// 菜名 + 描述 + 标签 + 分类 + 食材名
const text = [recipe.name, recipe.description, tags.join(' '), recipe.category, ingredientNames].join(' ')
这样搜索"有鸡肉和土豆"时,向量空间中"土豆炖鸡"的Embedding就会离得很近。
2. 菜谱数据同步到Qdrant
写了一个独立的云函数syncToQdrant,这是一个离线脚本,只执行一次,批量将数据库中的菜谱生成向量并写入:
javascript
// 核心流程
for (const recipe of allRecipes) {
const text = buildRecipeText(recipe) // 构建检索文本
const vector = await getEmbedding(text) // 生成向量
await upsertToQdrant(recipe, vector) // 写入Qdrant
}
每个Qdrant Point的payload结构:
json
{
"recipeId": "数据库_id",
"name": "红烧肉",
"description": "经典家常菜...",
"category": "pork",
"difficulty": "中等",
"cookTime": 60,
"tags": ["家常", "下饭"],
"ingredients": ["五花肉", "酱油", "冰糖"]
}
payload在检索时一并返回,省去二次查数据库。
构建真正的RAG检索增强生成架构:Embedding + Qdrant + DeepSeek LLM
RAG是什么?为什么需要它?
没有RAG时,LLM的回答完全依赖训练数据中的知识,会产生幻觉------推荐不存在的菜谱、编造做法。RAG的思路很简单:
用户提问 → 先从知识库检索相关内容 → 把检索结果塞进Prompt → LLM基于真实数据回答
这样LLM的推荐就有据可查,不再胡编。
完整链路实现
向量语义搜索
将用户消息转成向量到向量数据库中查找相似内容
javascript
// cloudfunctions/chat/qdrant.js
async function vectorSearch(message, topK = 3) {
// Step1: 用户消息 → Embedding
const queryVector = await getEmbedding(message)
// Step2: Qdrant余弦相似度搜索
const hits = await searchQdrant(queryVector, topK)
// Step3: 格式统一(与TF-IDF模块一致)
return hits.map(hit => ({
recipe: {
_id: hit.payload.recipeId,
name: hit.payload.name,
// ...
},
similarity: hit.score // Cosine 0~1
}))
}
RAG检索 + 上下文注入
retrieveContext是RAG的"R"(Retrieval),callDeepSeekAPI是"G"(Generation):
javascript
// chat/index.js
async function processChat(event, openid) {
// ① RAG检索
const { contextText, ragRecipes } = await retrieveContext(message, openid)
// ② 注入LLM
const aiResult = await callDeepSeekAPI(
conversationHistory, message, userContext, contextText, ragRecipes
)
}
上下文注入的具体方式------拼接到System Prompt后面:
javascript
let systemContent = SYSTEM_PROMPT // 角色定义 + 输出格式要求
if (ragContext) {
systemContent += '\n\n' + ragContext // 拼接检索到的菜谱
}
RAG上下文的实际文本长这样:
以下是系统菜谱库中的相关菜谱,请优先推荐。
1. [ID:abc123] 红烧肉(中等,60分钟):经典家常菜...
食材:五花肉500g、酱油2勺、冰糖30g
2. [ID:def456] 可乐鸡翅(简单,30分钟):甜咸口味...
食材:鸡翅8个、可乐1罐
[ID:xxx]标签很关键------让LLM在推荐时引用正确的recipeId,而不是自己编。
DeepSeek LLM结构化输出
javascript
const response = await got.post('https://api.deepseek.com/chat/completions', {
json: {
model: 'deepseek-chat',
messages: messages,
temperature: 0.7,
max_tokens: generateMode ? 2048 : 512,
response_format: { type: 'json_object' } // 强制JSON输出
}
})
response_format: { type: 'json_object' } 确保LLM返回合法JSON,而不是自然语言+JSON混排。
LLM返回的结构:
json
{
"reply": "家里有鸡肉和土豆的话,推荐这两道菜~",
"action": "recommend",
"recommendations": [
{ "name": "土豆炖鸡", "reason": "食材完全匹配", "recipeId": "abc123" },
{ "name": "可乐鸡翅", "reason": "鸡翅做法简单", "recipeId": null }
]
}
菜谱ID解析(LLM输出→真实数据库ID)
LLM可能返回菜名而非ID,需要反向映射:
javascript
async function resolveRecipeIds(recommendations, ragRecipes) {
return recommendations.map(rec => {
if (rec.recipeId) return { ...rec, source: 'database' } // LLM从[ID:xxx]中看到了
if (rec.fullRecipe) return { ...rec, source: 'ai-generated' } // LLM自己生成的
// 尝试名字匹配RAG结果
const matched = ragRecipes.find(r => r.name === rec.name)
if (matched) return { ...rec, recipeId: matched._id, source: 'database' }
return { ...rec, source: 'not-found' } // 系统未收录
})
}
这个source字段决定了前端展示:database显示正常卡片,ai-generated显示"AI生成"标签,not-found显示"未收录"并提供生成入口。
超时与容错设计
每个外部调用都有独立超时:
javascript
const TIMEOUT_CONFIG = {
embedding: 5000, // SiliconFlow: 5秒
qdrant: 3000, // Qdrant查询: 3秒
deepseekApi: 30000, // DeepSeek: 30秒
cloudFunction: 34000 // 云函数总超时: 34秒
}
用Promise.race实现超时控制:
javascript
function withTimeout(promise, ms, errorMsg) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error(errorMsg)), ms))
])
}
效果对比
改造前(纯关键词):
- 用户:"家里有土豆和鸡蛋" → 命中率低,经常匹配不到
- 用户:"做点下饭的" → 完全无法理解"下饭"的语义
改造后(RAG语义搜索):
- 用户:"家里有土豆和鸡蛋" → 准确返回"土豆鸡蛋饼""土豆炒蛋"
- 用户:"做点下饭的" → 返回"红烧肉""麻婆豆腐"等高匹配菜谱
小结
RAG的本质是让LLM基于真实数据回答,而非凭空想象。核心三步:
- Embedding将文本转为向量,实现语义层面的匹配
- Qdrant存储向量并提供高速相似度搜索
- 将检索结果注入LLM的System Prompt,引导其基于真实数据输出
作者:「倒灶了队」
项目:灶台导航 - 微信小程序
更新时间:2026-05-12