Docker部署Qdrant向量数据库,初始化向量数据库,重构RAG逻辑

背景

灶台导航的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部署在云服务器上,而云函数运行在腾讯云内网。需要确保:

  1. 云服务器安全组开放6333端口
  2. 云函数中使用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基于真实数据回答,而非凭空想象。核心三步:

  1. Embedding将文本转为向量,实现语义层面的匹配
  2. Qdrant存储向量并提供高速相似度搜索
  3. 将检索结果注入LLM的System Prompt,引导其基于真实数据输出

作者:「倒灶了队」

项目:灶台导航 - 微信小程序

更新时间:2026-05-12

相关推荐
funnycoffee1231 小时前
Cisco Firewpower 4100 9300 FXOS change management ip address
linux·数据库·tcp/ip
Chase_______1 小时前
Java 基础语言 ③:流程控制与数组——从条件分支到数组遍历,一篇通关
java·数据库·python
2501_921939261 小时前
MySQL(备份恢复、主从复制读写分离)
数据库·mysql
阿kun要赚马内1 小时前
SQLAlchemy的类型定义语法
数据库·oracle
互联科技报1 小时前
能做表格的 AI 软件:数以轻舟Agent,AI 原生重构表格数据分析全流程
人工智能·重构·数据分析
星纬智联技术2 小时前
给 Amp 配置自定义 API:CLIProxyAPI 接入教程
运维·服务器·数据库
浩~~2 小时前
极客大挑战2019-LoveSQL
数据库
码农阿豪2 小时前
Go 语言操作金仓数据库(上篇):环境搭建与连接管理
开发语言·数据库·golang
码农阿豪2 小时前
Go 语言操作金仓数据库(下篇):SQL 执行、类型映射与超时控制
数据库·sql·golang