从 MySQL CRUD 到向量语义搜索,手把手带你用 Milvus + OpenAI Embedding + LangChain 打造一个懂你心情的 AI 日记助手。
一、为什么需要向量数据库?
1.1 传统数据库能做什么
做 Web 开发的同学习惯了这样的模式:
-- 精确查找
SELECT * FROM diary WHERE id = 1;
-- 关键词模糊匹配
SELECT * FROM diary WHERE content LIKE '%开心%';
-- 结构化过滤
SELECT * FROM diary WHERE mood = 'happy' AND date > '2026-01-01';
MySQL / PostgreSQL / SQLite 的本质能力:基于 id 或关键词的精确匹配。数据以行和列的形式组织,增删改查(CRUD)都围绕结构化数据展开。
1.2 AI 应用需要什么
但 AI Agent 产品面临的是另一种需求:
- "帮我找找最近心情比较好的日记"
- "上周有什么让我印象深刻的事?"
- "那些关于户外活动的记录"
这些查询的共同特点:没有精确的 id,没有确定的关键词,搜索的是「语义」而非字符串。
你无法用 WHERE content LIKE '%心情好%' 找到"今天阳光明媚,整个人都很舒畅"——它们字面上完全不同,但表达的情绪一致。
1.3 向量数据库登场
这就是 Milvus 存在的意义。
Milvus 是一款开源的向量数据库,专为处理海量高维向量数据而设计。几乎所有 AI Agent 产品背后都有类似的 Vector Store。
它的核心能力:语义相似度搜索。把文本变成数学向量,然后在高维空间里找"意思最接近的"。
二、核心概念图谱
2.1 关系型 vs 向量型对比
| 概念 | MySQL | Milvus |
|---|---|---|
| 表 | Table | Collection(集合) |
| 列定义 | Column(INT, VARCHAR...) | Field(字段) |
| 一行数据 | Row | Entity(实体) |
| 主键 | PRIMARY KEY | is_primary_key: true |
| 索引 | B+ Tree 索引 | IVF_FLAT 聚簇索引 |
| 查询方式 | WHERE id = 1 | 语义相似度搜索 |
| 相似度计算 | 无 | Metric(COSINE / L2 / IP) |
关键区别:Milvus 的 Field 类型中多了一个 FloatVector——浮点数向量数组,这是向量数据库的灵魂。
2.2 什么是 Field?
Field 就是 Collection 中的字段定义,相当于建表时写的列:
fields: [
{ name: 'id', data_type: 'Int64', is_primary_key: true, autoID: true },
{ name: 'vector', data_type: 'FloatVector', dim: 1024 },
{ name: 'content', data_type: 'VarChar', max_length: 65535 },
{ name: 'date', data_type: 'VarChar', max_length: 50 },
{ name: 'mood', data_type: 'VarChar', max_length: 50 },
{ name: 'tags', data_type: 'Array', element_type: 'VarChar', max_capacity: 10 },
]
每个 Field 对应日记的一个属性。其中只有 vector 字段被索引加速,其他标量字段用于存储和过滤。
2.3 维度(Dimension)是什么?
维度 = 向量的长度,即用多少个数字来表达一条文字的含义。
const VECTOR_DIM = 1024; // OpenAI text-embedding-3-large 输出的向量维度
// 一条日记被表达为:
"今天心情很好"
↓ OpenAIEmbedding
[0.023, -0.451, 0.892, 0.114, ..., -0.337] // 1024 个浮点数
不同的 Embedding 模型输出不同维度:
| 模型 | 维度 | 是否支持自定义维度 |
|---|---|---|
| OpenAI text-embedding-3-large | 256/512/1024/3072 | ✅ 支持 dimensions 参数 |
| OpenAI text-embedding-3-small | 512/1536 | ✅ |
| DashScope text-embedding-v2 | 1536(固定) | ❌ 不支持 |
维度在创建 Collection 时写死,之后无法修改,所以开始前要确认好 Embedding 模型的输出维度。
2.4 Metric(相似度度量)
Metric 决定了如何计算两个向量的距离,即"怎么判断像不像":
| Metric 类型 | 含义 | 判断标准 |
|---|---|---|
COSINE | 余弦相似度 | 越接近 1 越像 |
L2 | 欧氏距离 | 越接近 0 越像 |
IP | 内积 | 越大越像 |
通俗理解:Metric 就是一把"比大小的尺子",你选了哪把尺子,决定了 Milvus 怎么给搜索结果打分。
2.5 聚簇索引(IVF_FLAT)
没有索引时,每次查询都要把库里的向量和查询向量逐一算相似度——这就是 O(n) 的暴力检索,数据量大了慢得没法用。
比喻:去图书馆找《三体》
- 没索引:100 万本书一本一本翻
- 有索引:先走到"文学馆/小说/科幻",只在那个分区找,毫秒级定位
IVF_FLAT 的原理:
- 先把所有向量通过 K-means 聚类分成 N 个簇
- 查询时先定位到最近的几个簇
- 只在那些簇内逐一比对
索引只对 vector 字段生效,其他标量字段不走聚簇索引。
三、AI 日记本架构设计
3.1 核心理念:MySQL + Milvus 双存储
┌──────────────────────────────────────┐
│ AI 日记本 │
├─────────────────┬────────────────────┤
│ MySQL 层 │ Milvus 层 │
│ (非 AI 功能) │ (AI 功能) │
├─────────────────┼────────────────────┤
│ · 日记 CRUD │ · 语义向量存储 │
│ · 用户管理 │ · 语义相似度搜索 │
│ · 结构化字段 │ · RAG 上下文检索 │
│ (id, 日期, 心情) │ (自然语言→相关日记) │
└─────────────────┴────────────────────┘
- 结构化数据走 MySQL:精确查询、事务、用户管理
- 语义数据走 Milvus:自然语言搜索、相似度匹配、AI 记忆
3.2 数据流转
写入: 查询:
文字日记 "最近心情好的日记"
│ │
│ OpenAI Embedding │ OpenAI Embedding
▼ ▼
1024 维向量 1024 维向量
│ │
│ client.insert() │ client.search()
▼ ▼
┌──── Milvus ────┐ ┌──── Milvus ────┐
│ id │ │ COSINE 打分 │
│ content │ │ 返回 top-K │
│ vector ← 索引加速 │ │ + content/mood │
│ mood │ └────────────────┘
│ tags │ │
└─────────────────┘ ▼
拼入 Prompt → LLM 生成回答
四、实战:从测试到 RAG 的演进
4.1 第一阶段:Hello Milvus(main.mjs)
先连通 Zilliz Cloud,验证基本操作:
import { MilvusClient, IndexType, MetricType } from '@zilliz/milvus2-sdk-node'
const client = new MilvusClient({
address: process.env.MILVUS_ADDRESS,
token: process.env.MILVUS_TOKEN
})
// ⚠️ 企业级实践:实例化后必须握手验证
const health = await client.checkHealth()
if (!health.isHealthy) {
console.error('连接失败', health.reasons)
return
}
关键认知:
new MilvusClient()只是创建了一个 JS 对象,真正的 TCP/gRPC 连接(握手)发生在第一次 API 调用时。企业级代码必须显式做健康检查,不能console.log('已连接')自欺欺人。
用测试数据快速验证:
// 插入
const data = [
{ vector: [0.1, 0.2, 0.3, 0.4], content: '这是第一条数据' },
{ vector: [0.5, 0.6, 0.7, 0.8], content: '这是第二条数据' },
]
await client.insert({ collection_name: 'test', data })
// 搜索
const result = await client.search({
collection_name: 'test',
data: [[0.1, 0.1, 0.3, 0.4]], // 查询向量
limit: 2,
output_fields: ['content']
})
// 返回的每条结果自带 score,Milvus 自动完成相似度计算
4.2 第二阶段:真正的 Embedding(index.mjs)
手写 4 维测试向量没有意义——我们需要让 OpenAI 模型生成真正带语义的 1024 维向量:
import { OpenAIEmbeddings } from '@langchain/openai'
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: 'text-embedding-3-large', // ← 支持自定义维度
configuration: { baseURL: process.env.OPENAI_BASE_URL },
dimensions: 1024 // ← 指定输出 1024 维
})
const getEmbedding = async (text) => {
return await embeddings.embedQuery(text)
}
然后用真实日记做批量插入:
const diaryContents = [
{
id: 'diary_001',
content: '今天天气很好,去公园散步了,心情愉快。看到了很多花开了,春天真美好。',
date: '2026-01-10', mood: 'happy', tags: ['生活', '散步']
},
// ... 更多日记
]
// 批量生成 Embedding 并插入
const diaryData = await Promise.all(
diaryContents.map(async (diary) => ({
...diary,
vector: await getEmbedding(diary.content) // 每条日记转成 1024 维向量
}))
)
await client.insert({ collection_name: 'ai_diary', data: diaryData })
写入和查询是对称的:
写入:文字 → Embedding → 存向量 → Milvus
查询:问题 → Embedding → 搜向量 → 返回原文
4.3 第三阶段:语义搜索(query.mjs)
有了真实的向量数据,就可以做语义搜索了:
const query = '我想看看关于户外活动的日记'
const queryVector = await getEmbedding(query) // 问题 → 1024维向量
const searchResult = await client.search({
collection_name: 'ai_diary',
vector: queryVector,
limit: 2,
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'date', 'mood', 'tags']
})
// 结果自带 score,Milvus 自动按相似度排序
searchResult.results.forEach((item, i) => {
console.log(`日记${i + 1} 相似度: ${item.score.toFixed(4)}`)
console.log(`内容: ${item.content}`)
})
query.mjs 做的是 RA(检索):给你最相关的原始日记,你自己看、自己总结。
4.4 第四阶段:完整 RAG(rag.mjs)
真正的 AI 助手不能只是扔给你一堆原始数据——它应该理解、总结、共情:
async function answerQuestion(question) {
// R: Retrieve — 检索相关日记
const retrievedDiaries = await retrieveRelevantDiaries(question)
// A: Augment — 将检索结果拼入 Prompt
const context = retrievedDiaries.map((d, i) => `
[日记 ${i + 1}]
日期:${d.date} | 心情:${d.mood} | 标签:${d.tags}
内容:${d.content}
`).join('\n')
const prompt = `你是温暖的 AI 日记助手。参考以下日记回答问题:
${context}
用户问题:${question}
要求:结合日记给出有同理心的回答,用"你"称呼作者。`
// G: Generate — LLM 生成自然语言回答
const response = await model.invoke(prompt)
return response.content
}
query.mjs vs rag.mjs 的本质区别:
query.mjs → 给你一堆原材料(日记片段 + 分数),自己消化
rag.mjs → 给你一盘做好的菜(LLM 理解、总结、共情后的回答)
同一个问题的输出对比:
query.mjs: Result 1.[Score: 0.92] 日记_005: 晚上做了一顿丰盛的晚餐... Result 2.[Score: 0.87] 日记_002: 今天工作很忙,完成了重要项目...
rag.mjs: "你最近有两件特别值得骄傲的事!1月13日你在厨房大显身手,做了一顿丰盛的晚餐,家人都说很好吃。工作上也很出色,1月11日完成了一个重要的项目里程碑。无论家庭还是工作,你都在用心经营,真的很厉害!"
五、完整的 RAG 文件结构
rag.mjs
═══════════════════════════════════════
第1步:导入依赖 第 1-11 行
MilvusClient, MetricType, IndexType, OpenAIEmbeddings, ChatOpenAI
第2步:配置三大组件 第12-44 行
├── 环境变量 第13-17 行
├── OpenAIEmbeddings 初始化 第19-26 行
├── ChatOpenAI(LLM)初始化 第28-35 行
├── MilvusClient 初始化 第37-40 行
└── getEmbedding() 封装 第41-44 行
第3步:检索模块(R) 第46-61 行
retrieveRelevantDiaries(question)
├── 问题 → Embedding → 查询向量 第48 行
├── client.search() 语义搜索 第49-55 行
└── 返回最相关日记 第56 行
第4步:生成模块(A + G) 第63-111 行
answerQuestion(question)
├── 打印分隔线('='.repeat(80)) 第65-67 行
├── 调用检索模块 第70 行
├── 打印检索结果 + 相似度 第75-79 行
├── 拼 context(日记→结构化文本) 第81-88 行
├── 构造 System Prompt + User Prompt 第90-103 行
└── model.invoke(prompt) → 生成回答 第105-106 行
第5步:入口 main() 第112-123 行
└── answerQuestion('我最近做了什么让我很骄傲的事')
调用链:main() → answerQuestion() → retrieveRelevantDiaries() → getEmbedding()
三个函数各司其职,层层调用,是典型的模块化 RAG 编排。
六、避坑指南(实战踩坑记录)
6.1 Collection 的 fields 必须显式定义
// ❌ 简写方式,只传 dimension,容易出问题
await client.createCollection({ collection_name: 'test', dimension: 768, autoID: true })
// ✅ 显式定义每个 field
await client.createCollection({
collection_name: 'test',
fields: [
{ name: 'id', data_type: 'Int64', is_primary_key: true, autoID: true },
{ name: 'vector', data_type: 'FloatVector', dim: 768 },
{ name: 'content', data_type: 'VarChar', max_length: 65535 },
]
})
autoID: true 只在 Int64 类型的字段上生效,VarChar 主键必须手动提供 id。
6.2 先建索引再加载 Collection
// ✅ 正确顺序
await client.createCollection({...}) // 1. 建表
await client.createIndex({...}) // 2. 建索引
await client.loadCollection({...}) // 3. 加载到内存
// ❌ 先加载再建索引会报 IndexNotExist 错误
6.3 维度必须匹配
Collection 创建时定了维度,插入的向量必须长度一致。换 Embedding 模型 → 维度变了 → 必须删旧 Collection 重建。
// 安全做法:重建前先删
const has = await client.hasCollection({ collection_name: 'ai_diary' })
if (has.value) {
await client.dropCollection({ collection_name: 'ai_diary' })
}
6.4 pnpm 构建脚本权限
Milvus SDK 依赖 protobufjs,pnpm 默认拦截其构建脚本。在 pnpm-workspace.yaml 中放开:
allowBuilds:
protobufjs: true
6.5 包名的坑
// ❌ 容易写错
import { MilvusClient } from '@zilliz/milvus-sdk-node' // 旧版
import { MilvusClient } from '@zilliz/milvus2-node-sdk' // 拼写错误
// ✅ 正确
import { MilvusClient } from '@zilliz/milvus2-sdk-node' // v2 新版
6.6 Embedding 模型的 dimensions 参数
不是所有模型都支持自定义维度。OpenAI text-embedding-3-large 支持,DashScope text-embedding-v2 不支持——传了 dimensions 参数会导致 404 MODEL_NOT_FOUND。
6.7 URL 拼写
dashscopel.aliyuncs.com(多了个 l)→ DNS 解析失败,请求卡死超时。
七、总结
核心认知
-
向量数据库不是替代 MySQL,是补充。结构化查询走关系型,语义搜索走向量型,各司其职。
-
RAG 的本质是「检索 + 增强 + 生成」:先去向量库找到相关文档,拼进 Prompt 给 LLM,让 LLM 基于真实数据回答——而不是凭空编造。
-
企业级实践:
new完客户端必须握手验证(checkHealth),不能假装连上了;先建索引再加载 Collection;维度在创建时锁定;显式定义 fields 比简写更可靠。
四个文件的演进
main.mjs → Hello World,手写测试向量验证连通性
index.mjs → 真实 Embedding,批量插入日记数据
query.mjs → 语义搜索,返回原始结果
rag.mjs → 完整 RAG,LLM 生成自然语言回答 ✅ 最终形态
从「连接数据库 → 存向量 → 搜向量 → 拼 Prompt → LLM 回答」,每一步都是 AI 应用的基础能力。掌握了这个流水线,做知识库问答、智能客服、AI 记忆系统都能复用这套模式。
代码仓库:文中的全部代码都可以直接运行,只需要配好
.env中的 Milvus 和 OpenAI API Key 即可。祝你从 CRUD 走向 AI 开发的第一步顺利!