从零搭建 AI 日记本:用 Milvus 向量数据库实现 RAG 检索增强生成
本文从一个学习者的视角,记录了从「连接 Milvus」到「跑通完整 RAG 链路」的全过程。不是教程搬运,而是真实踩坑后的代码深挖。读完本文,你将理解向量数据库的核心概念、RAG 的完整实现链路,以及在实际开发中会遇到的 5 个典型陷阱。
为什么需要向量数据库?
先说一个最朴素的类比。
Web 应用把数据存在 MySQL 里,通过 id 或 LIKE 关键词查询。这够用了吗?对于传统 CRUD 业务,够了。但 AI 场景下,你面对的是这样的问题:
「我最近做了什么让我感到快乐的事情?」
这不是关键词匹配能解决的。"快乐" 这个词可能根本没出现在任何一篇日记里——日记里写的是"心情愉快"、"感觉很有成就感"、"享受大自然"。语义检索,关键词匹配做不到。
这就需要向量数据库:把文本转成高维向量,通过相似度计算找到语义最接近的内容。而 Milvus 就是一款专为海量高维向量数据设计的开源向量数据库。
从传统数据库到向量数据库
理解向量数据库最好的方式,是和传统数据库做对比:
| 维度 | MySQL | Milvus |
|---|---|---|
| 存储内容 | 结构化数据(数字、字符串、日期) | 高维向量 + 标量字段 |
| 查询方式 | 精确匹配(WHERE id = 1) | 近似最近邻搜索(ANN) |
| 相似度计算 | 不支持 | COSINE / L2 / IP |
| 索引类型 | B+ Tree / Hash | IVF_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-v3 | 1024 维,中文效果好 |
| LLM | qwen-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 封装了 OpenAIEmbeddings 和 ChatOpenAI,屏蔽了不同模型供应商的 API 差异。今天用通义千问,明天想换成智谱 GLM,只需要改 baseURL 和 model,业务代码不动。这种抽象层在原型阶段非常有价值。
第一步:连上 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 },
]
})
逐字段拆解:
| 字段 | 类型 | 作用 |
|---|---|---|
id | VarChar(50) | 主键,日记唯一标识 |
vector | FloatVector(1024) | 日记内容的向量表示 |
content | VarChar(5000) | 日记原文 |
date | VarChar(50) | 日期 |
mood | VarChar(50) | 心情标签 |
tags | Array | 话题标签 |
设计思考:为什么不只是存向量?
你可能会问:既然向量搜索只需要 vector 字段,为什么要存 content、date、mood、tags 这些标量字段?
因为 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) 级别。
具体过程:
- 训练阶段:对所有向量做 K-Means 聚类,得到 N 个聚类中心
- 插入阶段:每条向量归入最近的聚类中心所在的簇
- 查询阶段:先计算查询向量与所有聚类中心的距离,找到最近的
nprobe个簇,再在这些簇内做暴力搜索
nprobe 是个可调参数——值越大,搜索越精确但越慢;值越小,速度越快但可能漏掉一些结果。这就是「近似最近邻」(ANN)的「近似」二字所在:用一点点精度换取巨大的速度提升。
其他索引类型
Milvus 支持多种索引类型,各有适用场景:
| 索引类型 | 特点 | 适用场景 |
|---|---|---|
IVF_FLAT | 聚簇 + 簇内暴力搜索 | 中等数据量(百万级),精度优先 |
IVF_SQ8 | 聚簇 + 标量量化压缩 | 大数据量,存储敏感 |
HNSW | 图结构索引,多层导航 | 查询延迟极低,内存充足 |
AUTOINDEX | Zilliz 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 设计的几个要点:
- 角色设定:「温暖贴心的 AI 日记助手」——一句话定调,让 LLM 知道自己应该用什么语气说话
- 上下文注入:
${context}把检索到的日记内容嵌入 prompt,这是 RAG 的核心——让 LLM 基于真实数据回答 - 约束条件:5 条规则,防止 LLM 跑偏。特别是第 3 条「如果日记中没有相关信息,请温和告知用户」——这是防幻觉的关键
- 兜底方案:没有兜底的 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 的子类(HumanMessage、SystemMessage、AIMessage)。纯字符串在内部转换时找不到 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-K | rag.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,建议按这个顺序来:
- 先跑通本地 demo(就是本文的内容)
- 理解 Embedding 的原理(为什么文本能变向量)
- 深入索引类型(IVF / HNSW / DiskANN 的区别)
- 学习 Prompt 工程(系统消息、Few-shot、思维链)
- 实践完整 RAG 项目(带前端的 AI 知识库)
希望这篇学习笔记能帮到同样在探索向量数据库和 RAG 的你。代码不多,但每一步都踩过坑、查过文档、理解了为什么。这比复制粘贴一百个教程都有用。