从零搭建 AI 日记本:用 Milvus 向量数据库实现 RAG 检索增强生成

0 阅读20分钟

从零搭建 AI 日记本:用 Milvus 向量数据库实现 RAG 检索增强生成

本文从一个学习者的视角,记录了从「连接 Milvus」到「跑通完整 RAG 链路」的全过程。不是教程搬运,而是真实踩坑后的代码深挖。读完本文,你将理解向量数据库的核心概念、RAG 的完整实现链路,以及在实际开发中会遇到的 5 个典型陷阱。

为什么需要向量数据库?

先说一个最朴素的类比。

Web 应用把数据存在 MySQL 里,通过 idLIKE 关键词查询。这够用了吗?对于传统 CRUD 业务,够了。但 AI 场景下,你面对的是这样的问题:

「我最近做了什么让我感到快乐的事情?」

这不是关键词匹配能解决的。"快乐" 这个词可能根本没出现在任何一篇日记里——日记里写的是"心情愉快"、"感觉很有成就感"、"享受大自然"。语义检索,关键词匹配做不到。

这就需要向量数据库:把文本转成高维向量,通过相似度计算找到语义最接近的内容。而 Milvus 就是一款专为海量高维向量数据设计的开源向量数据库。

从传统数据库到向量数据库

理解向量数据库最好的方式,是和传统数据库做对比:

维度MySQLMilvus
存储内容结构化数据(数字、字符串、日期)高维向量 + 标量字段
查询方式精确匹配(WHERE id = 1)近似最近邻搜索(ANN)
相似度计算不支持COSINE / L2 / IP
索引类型B+ Tree / HashIVF_FLAT / HNSW / DiskANN
典型场景用户管理、订单系统语义搜索、推荐系统、图片搜索

一个关键区别:MySQL 的查询是确定性的——WHERE id = 1 永远返回同一条记录。而 Milvus 的查询是概率性的——返回的是"最相似"的 Top-K 条结果,相似度分数会随着数据量变化而浮动。

Milvus 在 AI Agent 架构中的位置

在 AI Agent 产品中,向量数据库扮演着「记忆」和「知识库」的角色:

  • 短期记忆:对话历史,用完即弃
  • 长期记忆:用户偏好、历史行为,持久化存储
  • 知识库:文档、FAQ、产品手册,RAG 检索的数据源

本项目实现的「AI 日记本」就是知识库场景的一个缩影:日记存到 Milvus,用户提问时先检索相关日记,再让 LLM 基于检索结果回答。

技术选型

组件选择理由
向量数据库Zilliz Cloud(Milvus 托管版)免去本地部署,开箱即用
Embedding 模型通义千问 text-embedding-v31024 维,中文效果好
LLMqwen-plus阿里云 DashScope,国内访问稳定
框架LangChain.js统一的 Embedding/Chat 接口

整个项目的依赖只有三个:

{
  "dependencies": {
    "@zilliz/milvus2-sdk-node": "^2.6.0",
    "@langchain/openai": "^1.5.0",
    "dotenv": "^16.4.0"
  }
}

为什么选 Zilliz Cloud 而不是本地部署 Milvus?

本地部署 Milvus 需要 Docker + etcd + MinIO,对初学者门槛较高。Zilliz Cloud 是 Milvus 官方的全托管云服务,免费额度足够学习和原型开发。更重要的是,SDK 完全兼容——本地部署的代码一行不用改就能连云端。

为什么用 LangChain 而不是直接调 API?

LangChain 封装了 OpenAIEmbeddingsChatOpenAI,屏蔽了不同模型供应商的 API 差异。今天用通义千问,明天想换成智谱 GLM,只需要改 baseURLmodel,业务代码不动。这种抽象层在原型阶段非常有价值。

第一步:连上 Zilliz Cloud

万事开头难,但连接 Milvus 这一步其实很直接。

import 'dotenv/config'
import { MilvusClient } from '@zilliz/milvus2-sdk-node'

const client = new MilvusClient({
  address: process.env.MILVUS_ADDRESS,
  token: process.env.MILVUS_TOKEN
})

const checkHealth = await client.checkHealth()
if (!checkHealth.isHealthy) {
  console.error('连接失败', checkHealth.reasons)
  return
}
console.log('连接成功')

checkHealth() 是 Milvus SDK 提供的健康检查方法。C/S 架构下,先确认服务端活着,再往下走,这是好习惯。

Zilliz Cloud 的连接地址格式:

https://in03-xxxxx.serverless.ali-cn-hangzhou.cloud.zilliz.com.cn

in03 是实例前缀,serverless 表示 Serverless 实例(按用量计费),ali-cn-hangzhou 是区域。Token 是一串长字符串,在 Zilliz 控制台创建。这些敏感信息都放在 .env 里:

MILVUS_ADDRESS=https://in03-xxxxx.serverless.ali-cn-hangzhou.cloud.zilliz.com.cn
MILVUS_TOKEN=db_xxxxx:xxxxx
OPENAI_API_KEY=sk-xxxxx
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_NAME=qwen-plus
EMBEDDING_MODEL_NAME=text-embedding-v3

踩坑点: .env 文件必须放在项目根目录(即运行脚本的 cwd),dotenv/config 默认从 cwd 查找。如果文件放错位置,process.env.MILVUS_ADDRESS 就是 undefined,拼到 client 配置里会报 address is missing

这个坑我踩过——把 .env 放在了 demo/src/ 子目录里,结果从根目录运行脚本时环境变量全空。更稳健的做法是用显式路径加载:

import dotenv from 'dotenv'
import { fileURLToPath } from 'url'
import { dirname, resolve } from 'path'

const __dirname = dirname(fileURLToPath(import.meta.url))
dotenv.config({ path: resolve(__dirname, '../.env') })

这样无论从哪个目录运行脚本,都能准确找到 .env

第二步:建集合——向量数据库的「建表」

Milvus 里的 Collection 类比 MySQL 里的 Table。但和 MySQL 不同的是,你需要在一开始就声明向量字段及其维度。

await client.createCollection({
  collection_name: 'ai_dairy',
  fields: [
    { name: 'id', data_type: DataType.VarChar, max_length: 50, is_primary: true },
    { name: 'vector', data_type: DataType.FloatVector, dim: 1024 },
    { name: 'content', data_type: DataType.VarChar, max_length: 5000 },
    { name: 'date', data_type: DataType.VarChar, max_length: 50 },
    { name: 'mood', data_type: DataType.VarChar, max_length: 50 },
    { name: 'tags', data_type: DataType.Array, element_type: DataType.VarChar, max_capacity: 10, max_length: 50 },
  ]
})

逐字段拆解:

字段类型作用
idVarChar(50)主键,日记唯一标识
vectorFloatVector(1024)日记内容的向量表示
contentVarChar(5000)日记原文
dateVarChar(50)日期
moodVarChar(50)心情标签
tagsArray话题标签

设计思考:为什么不只是存向量?

你可能会问:既然向量搜索只需要 vector 字段,为什么要存 contentdatemoodtags 这些标量字段?

因为 RAG 的最后一步是把检索结果拼成 prompt 喂给 LLM。如果只存向量,检索出来后你拿不到原始文本,就没法生成有意义的回答。这些标量字段是「载荷」——跟着向量一起存,搜索时一起返回。

这个设计思路和 Elasticsearch 一样:存储和检索一体化。你不需要像 MySQL + 向量数据库那样做两次查询,Milvus 一次搜索就能返回向量和关联的标量数据。

几个学习要点

1. is_primary 而不是 is_primary_key

SDK 的字段属性名是 is_primary。写成 is_primary_key 不会报语法错误,但主键不会生效,后续插入会出问题。这种「看起来对但实际不对」的坑最磨人。

2. Array 类型需要三个约束

{ name: 'tags', data_type: DataType.Array, element_type: DataType.VarChar, max_capacity: 10, max_length: 50 }
  • element_type:数组元素类型
  • max_capacity:最多几个元素
  • max_length:每个 VarChar 元素的最大字符数

少了任何一个,SDK 都会拒绝建表。

3. 维度必须和 Embedding 模型一致

通义千问的 text-embedding-v3 支持 1024 维输出,所以 dim: 1024。如果你用的是 OpenAI 的 text-embedding-3-small,默认是 1536 维。维度不匹配,插入数据时会报错。

4. Schema 字段名必须和插入数据字段名完全一致

这个坑也很隐蔽。我在 schema 里定义了 name_data,但插入数据时用的字段名是 date。SDK 不会报错,但 date 字段的数据会被静默丢弃。调试了半天才反应过来——Schema 里的 name_data 改成 date 后一切正常。

第三步:建索引——让查询从 O(n) 变成毫秒级

没有索引时,每次查询都要把库里的所有向量和查询向量逐一算相似度(O(n))。数据量一大,根本没法用。

await client.createIndex({
  collection_name: 'ai_dairy',
  field_name: 'vector',
  index_type: IndexType.IVF_FLAT,
  metric_type: MetricType.COSINE
})

类比理解索引: 图书馆找《三体》,没有索引就是在所有书架上一本一本翻;有了索引,先去「文学区」→「小说区」→「科幻区」,几步就找到。

IVF_FLAT 是什么?

IVF_FLAT 是一种「聚簇索引」:把向量空间分成若干个簇(cluster),查询时先找到最近的簇,再在簇内做精确搜索。从 O(n) 降到 O(√n) 级别。

具体过程:

  1. 训练阶段:对所有向量做 K-Means 聚类,得到 N 个聚类中心
  2. 插入阶段:每条向量归入最近的聚类中心所在的簇
  3. 查询阶段:先计算查询向量与所有聚类中心的距离,找到最近的 nprobe 个簇,再在这些簇内做暴力搜索

nprobe 是个可调参数——值越大,搜索越精确但越慢;值越小,速度越快但可能漏掉一些结果。这就是「近似最近邻」(ANN)的「近似」二字所在:用一点点精度换取巨大的速度提升。

其他索引类型

Milvus 支持多种索引类型,各有适用场景:

索引类型特点适用场景
IVF_FLAT聚簇 + 簇内暴力搜索中等数据量(百万级),精度优先
IVF_SQ8聚簇 + 标量量化压缩大数据量,存储敏感
HNSW图结构索引,多层导航查询延迟极低,内存充足
AUTOINDEXZilliz Cloud 自动选择不想调参,托管版推荐

本项目选 IVF_FLAT,因为数据量小(5 条日记),精度比速度更重要。

MetricType 的选择

类型适用场景
COSINE文本语义相似度(忽略向量长度,只看方向)
L2图像相似度(关注绝对距离)
IP推荐系统(内积,关注点积大小)

日记场景选 COSINE——我们关心的是语义方向是否一致,不是向量绝对大小。两段文本即使长短不同,只要说的"意思"接近,COSINE 相似度就会比较高。

重要约束: metric_type 一旦在建索引时确定,后续搜索时必须传入相同的值,否则搜索结果不正确。

第四步:加载集合并插入数据

Milvus 的一个特殊设计:集合创建后不能直接写入,需要先 load(加载到内存)。

await client.loadCollection({ collection_name: 'ai_dairy' })

这一步把集合的数据和索引加载到内存,为后续的搜索做准备。Zilliz Cloud 的 Serverless 实例会自动管理这一步,但 SDK 调用时显式 load 一次更稳妥。

文本向量化的本质

数据插入的核心是:先向量化,再插入

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDING_MODEL_NAME,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  dimensions: 1024
})

const getEmbedding = async (text) => {
  return await embeddings.embedQuery(text)
}

OpenAIEmbeddings 虽然名字里有 "OpenAI",但通过 configuration.baseURL 可以指向任何兼容 OpenAI 接口的服务。这里指向阿里云 DashScope,用通义千问的 embedding 模型。

向量化的本质是:把一段文本映射到一个 1024 维的浮点数向量。语义越接近的文本,向量之间的距离越小。"今天心情很好" 和 "感觉很开心" 的向量会非常接近,虽然它们没有共同的关键词。

批量插入的并发优化

const diaryContents = [
  { id: 'diary_001', content: '今天天气很好,去公园散步了,心情愉快。看到了很多花开了,春天真美好。', date: '2026-01-10', mood: 'happy', tags: ['生活', '散步'] },
  { id: 'diary_002', content: '今天工作很忙,完成了一个重要的项目里程碑。团队合作很愉快,感觉很有成就感。', date: '2026-01-11', mood: 'excited', tags: ['工作', '成就'] },
  // ...更多日记
]

// 并发生成向量
const diaryData = await Promise.all(
  diaryContents.map(async (diary) => ({
    ...diary,
    vector: await getEmbedding(diary.content)
  }))
)

const insertResult = await client.insert({
  collection_name: 'ai_dairy',
  data: diaryData
})

为什么要用 Promise.all

5 条日记,如果串行调用 embedding API,要等 5 次网络往返。用 Promise.all 并发请求,理论上只需要 1 次往返的时间。当然,这里有个隐含的前提:embedding API 支持并发请求。阿里云 DashScope 的并发限制比较宽裕,5 条没问题。

但要注意:如果数据量从 5 条变成 5000 条,直接 Promise.all 会瞬间发起 5000 个 HTTP 请求,大概率触发 API 限流。这时候需要分批并发,比如每次处理 20 条:

const BATCH_SIZE = 20
for (let i = 0; i < diaryContents.length; i += BATCH_SIZE) {
  const batch = diaryContents.slice(i, i + BATCH_SIZE)
  const batchData = await Promise.all(
    batch.map(async (diary) => ({ ...diary, vector: await getEmbedding(diary.content) }))
  )
  await client.insert({ collection_name: 'ai_dairy', data: batchData })
}

embedQuery 还是 embedDocuments

LangChain 的 OpenAIEmbeddings 提供两个方法:

  • embedQuery(text):单条文本,用于查询向量
  • embedDocuments(texts):多条文本,用于批量文档向量化

这里每条日记单独调用 embedQuery,功能上没问题。如果数据量大,应该用 embedDocuments 一次性传所有文本,减少网络请求次数。

踩坑点: 我最初把方法名写成了 embedEmbeddings——这个方法在 LangChain 里根本不存在。可能是 IDE 自动补全或手误导致的。运行时报 embedEmbeddings is not a function,查了文档才发现正确的方法名是 embedQuery

第五步:RAG 检索——从「问问题」到「找日记」

整个项目最核心的部分来了。RAG(Retrieval-Augmented Generation)分三步:检索 → 拼接上下文 → 生成回答

5.1 检索:把问题变成向量去搜索

const retrieveRelevantDiaries = async (question, k = 2) => {
  const queryVector = await getEmbedding(question)
  const searchResult = await client.search({
    collection_name: 'ai_dairy',
    vector: queryVector,
    limit: k,
    metric_type: MetricType.COSINE,
    output_fields: ['id', 'content', 'date', 'mood', 'tags'],
  })
  return searchResult.results
}

注意一个关键点:问题的向量化必须用同一个 Embedding 模型。你用 1024 维的模型建的索引,查询时也得用 1024 维的模型生成查询向量。否则维度不匹配,搜索直接报错。

client.search 的参数:

  • vector:查询向量
  • limit:返回最相似的 k 条(即 Top-K 搜索)
  • metric_type:必须和建索引时一致
  • output_fields:除了相似度分数,还要返回哪些字段

返回结果按相似度从高到低排序,每条包含 score(相似度分数)和你指定的 output_fields

Top-K 里的 K 怎么选?

K 是 RAG 里一个关键超参数。K 太小(比如 1),可能漏掉相关信息;K 太大(比如 20),会把不相关的结果塞进 prompt,干扰 LLM 判断,还会增加 token 消耗。

一般经验值:

  • 问答场景:K = 3~5
  • 总结场景:K = 5~10
  • 日记场景:K = 2(日记信息密度高,2 条足够)

本项目默认 K = 2,正好够回答一个问题。

5.2 拼接上下文:把检索结果组织成 Prompt

const context = retrievedDiaries.map((diary, i) =>
  `[日记]${i+1},日期:${diary.date},标签:${diary.tags?.join(', ')},心情:${diary.mood},内容:${diary.content},`
).join('\n\n----\n\n')

这一步是 RAG 的精髓:你检索到的不是文档,是上下文。把向量搜索的结果重新组织成 LLM 能理解的自然语言格式,让 LLM 基于这些真实数据来回答,而不是凭空编造。

tags?.join(', ') 里的可选链 ?. 是防御性写法——万一某条日记没有 tags 字段,不会报 Cannot read properties of undefined。这种细节在原型阶段容易忽略,但在生产环境里一个 undefined 就能让整个请求挂掉。

5.3 生成回答:让 LLM 基于上下文说话

const prompt = `
  你是一个温暖贴心的ai日记助手。基于用户的日记内容回答问题用情切自然的语言。请根据以下日记内容回答问题:
  ${context}
  回答要求:
  1. 如果日记中有相关信息,请结合日记内容给出详细,温暖的回答。
  2. 可以总结多篇日记的内容,找出共同点或趋势。
  3. 如果日记中没有相关信息,请温和告知用户。
  4. 用第一人称"你"来称呼日记的作者。
  5. 回答要有同理心,让用户感到被理解和关心。
  ai助手回答:
`

const response = await model.invoke(new HumanMessage(prompt))
console.log(response.content)

Prompt 设计的几个要点:

  1. 角色设定:「温暖贴心的 AI 日记助手」——一句话定调,让 LLM 知道自己应该用什么语气说话
  2. 上下文注入${context} 把检索到的日记内容嵌入 prompt,这是 RAG 的核心——让 LLM 基于真实数据回答
  3. 约束条件:5 条规则,防止 LLM 跑偏。特别是第 3 条「如果日记中没有相关信息,请温和告知用户」——这是防幻觉的关键
  4. 兜底方案:没有兜底的 RAG 系统是不完整的。LLM 最擅长的就是「一本正经地胡说八道」,你必须明确告诉它"不知道就说不知道"

Temperature 的选择:

const model = new ChatOpenAI({
  temperature: 0.7,
  model: process.env.MODEL_NAME,
  // ...
})

temperature: 0.7 是一个偏创造性的设定。日记助手需要温暖自然的语言,不能太机械,所以不能设 0;但也不能太发散,否则回答会跑题。0.7 是一个平衡点。

如果是事实型问答(比如法律条文查询),应该设 temperature: 0 甚至更低,确保回答确定性。

踩坑点:new HumanMessage(prompt) 不能省

在 LangChain v1.x 中,ChatOpenAI.invoke() 不再直接接受纯字符串。传入字符串会报:

TypeError: Cannot read properties of undefined (reading 'toChatMessages')

必须用消息对象包装:

import { HumanMessage } from '@langchain/core/messages'
const response = await model.invoke(new HumanMessage(prompt))

这是因为 LangChain v1.x 统一了消息格式——所有输入必须是 BaseMessage 的子类(HumanMessageSystemMessageAIMessage)。纯字符串在内部转换时找不到 toChatMessages 方法,就报错了。

完整数据流

把五个步骤串起来,完整的 RAG 链路是这样的:

用户提问:"我最近做了什么让我感到快乐的事情?"
  │
  ▼
Embedding 模型:问题 → 1024维向量
  │  (通义千问 text-embedding-v3)
  ▼
Milvus 向量搜索:queryVector × 库中所有向量 → Top-2 相似日记
  │  (IVF_FLAT 索引 + COSINE 相似度)
  │
  │  返回:
  │  - diary_001: "心情愉快..." (score: 0.89)
  │  - diary_003: "心情放松..." (score: 0.85)
  │
  ▼
拼接上下文:日记内容 + 日期 + 心情 + 标签 → structured prompt
  │
  ▼
LLM:prompt → "你最近去公园散步,还和朋友去爬山了,看起来你很享受户外活动..."
  │  (qwen-plus, temperature: 0.7)
  ▼
返回给用户

文件分工

文件职责核心函数
demo/src/main.mjs连接测试、基础 CRUD 练习main()
index.mjs建集合、建索引、插入数据main()
rag.mjs检索 + 生成(RAG 核心)retrieveRelevantDiaries() / answerQuestion()

这种拆分不是随意为之。index.mjs 是「写入路径」,rag.mjs 是「读取路径」。写入和读取分离,是数据系统设计的基本原则——写入关心数据完整性,读取关心查询性能,两者的优化方向不同。

踩坑记录

整个过程踩了不少坑,挑几个最典型的分享。每一个都是真实遇到的,附带了报错信息和修复方法。

坑 1:is_primary_key vs is_primary

// ❌ 错误:SDK 不认这个名字
{ name: 'id', is_primary_key: true }

// ✅ 正确
{ name: 'id', is_primary: true }

SDK 字段属性名是 is_primary。不会报语法错误,但主键不生效会导致后续 insert 行为异常——可能重复插入、可能无法按主键查询。这种「静默失败」比直接报错更危险。

坑 2:集合已存在但代码不判断

await client.createCollection({ ... })  // 第二次运行就报错

Milvus 不支持 IF NOT EXISTS 语法。重复创建会报错,需要用 try/catch 包裹,或先 hasCollection 检查:

const exists = await client.hasCollection({ collection_name: 'ai_dairy' })
if (!exists.has_collection) {
  await client.createCollection({ ... })
}

坑 3:注释掉了 createCollection 但还在 insert

// await client.createCollection({ ... })  // 被注释了
await client.loadCollection({ ... })        // 报错:collection not found

调试时注释掉了建表代码,但忘了注释后续的 load 和 insert。报 CollectionNotExists 错误。这种问题看起来低级,但在实际开发中非常常见——尤其是当你反复修改代码、注释/取消注释来调试时。

坑 4:Embedding 方法名写错

// ❌ 不存在的方法
const result = await embeddings.embedEmbeddings(text)

// ✅ 正确
const result = await embeddings.embedQuery(text)

embedEmbeddings 这个方法在 LangChain 里根本不存在。可能是 IDE 自动补全或手误导致的。运行时报 embedEmbeddings is not a function

LangChain 的 Embedding 类只有两个公开方法:

  • embedQuery(text: string): Promise<number[]> — 单条文本
  • embedDocuments(texts: string[]): Promise<number[][]> — 多条文本

记牢这两个,就不会再被自动补全坑了。

坑 5:.env 路径问题

import 'dotenv/config'  // 从 cwd 查找 .env

如果你从项目根目录以外的位置运行脚本,dotenv 找不到 .env,所有环境变量都是 undefined。更糟糕的是,MilvusClient 不会立即报错——它会接受 address: undefined,然后在第一次网络请求时报一个完全无关的错误,让你怀疑是网络问题。

更稳健的写法是用显式路径:

import dotenv from 'dotenv'
import { fileURLToPath } from 'url'
import { dirname, resolve } from 'path'

const __dirname = dirname(fileURLToPath(import.meta.url))
dotenv.config({ path: resolve(__dirname, '../.env') })

这样无论从哪个目录运行脚本,都能准确找到 .env 文件。

坑 6:model.invoke() 传纯字符串报错

// ❌ LangChain v1.x 报错
const response = await model.invoke(prompt)

// ✅ 必须用消息对象包装
const response = await model.invoke(new HumanMessage(prompt))

报错信息:TypeError: Cannot read properties of undefined (reading 'toChatMessages')。这个错误信息完全看不出是 invoke 参数类型的问题,得翻 LangChain 源码才能理解。

LangChain v0.x 是接受纯字符串的,v1.x 统一了消息格式后不再兼容。升级依赖时这类 breaking change 最容易踩到。

架构思考:MySQL 和 Milvus 的分工

回看这个项目,一个重要的架构决策是:Milvus 和 MySQL 不是替代关系,而是互补关系

用户写日记
  │
  ├──→ MySQL(CRUD)
  │      - id, content, date, mood, tags
  │      - 精确查询:按日期、按 mood
  │      - 增删改查
  │
  └──→ Milvus(语义搜索)
         - id, vector, content, date, mood, tags
         - 模糊查询:按语义相似度
         - 只读为主
  • MySQL 负责「精确」:按日期查日记、按 mood 过滤、编辑/删除日记
  • Milvus 负责「模糊」:「我最近开心的事情」「和户外相关的日记」这类语义查询

两个数据库存了部分相同的数据(content、date、mood、tags),但查询方式完全不同。写入时双写,读取时各走各的路。这种「双数据库同步」的模式在 AI 应用中非常常见。

成本和性能考量

Embedding API 成本

每次插入一条日记,调用一次 embedding API。每次查询,也调用一次 embedding API。通义千问的 embedding 价格很低,但数据量大时仍然是成本项。

优化方向:

  • 缓存:相同文本的 embedding 不变,可以缓存
  • 批量:用 embedDocuments 一次处理多条,减少 HTTP 开销
  • 本地模型:数据量大时换本地 embedding 模型(如 BGE),零 API 成本

Milvus 查询性能

5 条日记的搜索是毫秒级的。但当数据量到百万级,以下因素会影响性能:

  • nprobe 参数:IVF 索引的搜索深度
  • limit(Top-K):返回结果数量
  • output_fields:返回字段越多,网络传输越大

Zilliz Cloud Serverless 会自动扩缩容,但在高并发场景下仍需要注意限流。

总结

这个项目虽然代码量不大,但完整走通了 RAG 的全链路:

阶段做了什么对应文件
连接Zilliz Cloud 健康检查demo/src/main.mjs
建表定义集合 schema + 建索引index.mjs
插入文本向量化 + 批量插入index.mjs
检索向量相似度搜索 Top-Krag.mjs
生成上下文拼接 + LLM 回答rag.mjs

四个关键认知

1. 向量数据库不是替代 MySQL

日记的增删改查仍然用 MySQL,Milvus 只负责「语义相似度搜索」这个特定场景。两者是互补关系,不是替代关系。

2. Embedding 模型是桥梁

插入时用它把文本变向量,查询时用它把问题变向量。两次用的必须是同一个模型,否则维度和语义空间不匹配。这就像你用英语编码、用中文解码——信息会丢失。

3. RAG 的本质是「先检索后生成」

LLM 不再凭空回答,而是基于你喂给它的真实数据说话。这大幅降低了幻觉风险。但 RAG 不是万能的——如果检索质量差(检索不到相关内容),生成的回答也好不到哪去。RAG 的上限由检索质量决定。

4. Prompt 工程很重要

同样的检索结果,好的 prompt 能让 LLM 给出温暖的、有同理心的回答;差的 prompt 只会得到干巴巴的信息罗列。角色设定、上下文注入、约束条件、兜底方案——这四要素缺一不可。

下一步扩展

  • 接入真实 CRUD API:让 Milvus 和 MySQL 同步,写日记时自动向量化插入
  • 加入 Rerank 模型:对检索结果做二次排序,提升 Top-K 的精度
  • 支持多轮对话:维护对话历史,让 LLM 能理解上下文语境
  • 用 LangChain 的 RetrievalQAChain:替代手写的 RAG 逻辑,减少样板代码
  • 实现混合检索:结合向量搜索 + 关键词搜索,取两者之长

学习路径建议

如果你也想入门向量数据库和 RAG,建议按这个顺序来:

  1. 先跑通本地 demo(就是本文的内容)
  2. 理解 Embedding 的原理(为什么文本能变向量)
  3. 深入索引类型(IVF / HNSW / DiskANN 的区别)
  4. 学习 Prompt 工程(系统消息、Few-shot、思维链)
  5. 实践完整 RAG 项目(带前端的 AI 知识库)

希望这篇学习笔记能帮到同样在探索向量数据库和 RAG 的你。代码不多,但每一步都踩过坑、查过文档、理解了为什么。这比复制粘贴一百个教程都有用。