🔥 从 MySQL 到 Milvus:用 AI 日记本项目搞懂向量数据库和 RAG
摘要 :你还在用
WHERE content LIKE '%关键词%'搜索文本?太落后了!本文用一个「AI 日记本」项目,带你从零搞懂向量数据库 Milvus 和 RAG,让你的 AI 应用真正拥有"记忆"。
📌 前言
上周有个需求:用户输入一句话,系统要从几千条日记里找到语义最相关的几条。
我第一反应是 MySQL 全文搜索,但很快发现不对:
| 用户搜索 | 日记原文 | MySQL 结果 |
|---|---|---|
| "心情好的日子" | "今天阳光明媚,去公园散步了" | ❌ 搜不到 |
| "和朋友出去玩" | "周末和哥们儿去爬山了" | ❌ 搜不到 |
| "工作压力大" | "今天deadline,疯狂加班" | ❌ 搜不到 |
关键词完全对不上,但语义上高度相关!
这就是传统数据库的痛点 ------ 它只能做字面匹配 ,理解不了语义。
直到我接触了 Milvus 向量数据库 + RAG,才发现:原来 AI 应用的"记忆"可以这样做。
🎯 本文适合谁
- 有 Node.js 基础,想了解向量数据库的前端/全栈开发者
- 听过 RAG 但不知道怎么落地的同学
- 想给自己的 AI 应用加"长期记忆"的开发者
📚 先搞懂:什么是向量数据库?
用图书馆类比
想象你去图书馆找书:
| 方式 | MySQL | Milvus |
|---|---|---|
| 怎么找 | 按书名、ISBN 查(精确匹配) | 告诉管理员"我想看一本关于孤独的科幻小说"(语义匹配) |
| 底层逻辑 | WHERE title = '三体' |
把你的描述和每本书的"语义指纹"做相似度计算 |
| 擅长 | 结构化数据的精确查询 | 非结构化数据的模糊语义检索 |
核心概念:Embedding(向量化)
向量数据库的灵魂是 Embedding ------ 把任意文本变成一组数字(向量):
arduino
"今天心情很好" → [0.12, -0.34, 0.56, ..., 0.78] (1024 个浮点数)
"开心的一天" → [0.11, -0.33, 0.55, ..., 0.77] (语义相近,数字也接近)
"项目上线了" → [0.89, 0.12, -0.45, ..., 0.23] (语义不同,数字差距大)
语义相近的文本,向量在空间中的距离也近 ------ 这就是向量搜索的原理。
💡 为什么需要 Embedding? 因为计算机不懂中文,但它懂数字。Embedding 就是把人类语言翻译成计算机能理解的"语义坐标"。
🛠️ 项目实战:AI 日记本
学习路线图:4 个文件的递进逻辑
我们用 4 个文件,循序渐进地学习:
erlang
┌─────────────────────────────────────────────────────────────────────┐
│ 第 1 步:main.mjs --- 先用"假数据"跑通最小闭环 │
│ 目的:理解 Milvus 的核心操作:建表 → 插数据 → 搜索 │
│ 特点:手写 4 维向量,快速验证流程 │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 第 2 步:index.mjs --- 用真实数据 + Embedding 模型写入 │
│ 目的:学会用 Embedding 模型自动生成向量 │
│ 特点:从"假数据"升级到"真实日记数据" │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 第 3 步:query.mjs --- 用自然语言搜索日记 │
│ 目的:学会用自然语言检索向量数据 │
│ 特点:搜索"户外活动"能找到"爬山"和"公园散步" │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 第 4 步:rag.mjs --- 检索 + LLM = AI 日记助手 │
│ 目的:把检索结果喂给 LLM,生成智能回答 │
│ 特点:完整 RAG 流程,AI 真正"读懂"了你的日记 │
└─────────────────────────────────────────────────────────────────────┘
端到端数据流:从文本到回答
在开始写代码之前,先看懂整个数据流:
arduino
┌──────────────────────────────────────────────────────────────────────┐
│ 数据写入阶段(index.mjs) │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ 日记文本:"今天天气很好,去公园散步了" │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Embedding 模型 │ 文本 → 向量 │
│ │ text-embedding-3-small │ │
│ └──────────────┬──────────────┘ │
│ ▼ │
│ 向量:[0.12, -0.34, 0.56, ..., 0.78](1536 维) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Milvus 向量数据库 │ 存储向量 + 元数据 │
│ │ ai_dairy 集合 │ │
│ └─────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ 检索 + 生成阶段(rag.mjs) │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ 用户提问:"我最近做了什么让我感到快乐的事情?" │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Embedding 模型 │ 把问题也转成向量 │
│ └──────────────┬──────────────┘ │
│ ▼ │
│ 问题向量:[0.15, -0.32, 0.58, ..., 0.76] │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Milvus 检索 │ 找到最相似的 2 条日记 │
│ │ COSINE 相似度搜索 │ │
│ └──────────────┬──────────────┘ │
│ ▼ │
│ 检索结果: │
│ - 日记1:"今天天气很好,去公园散步了"(相似度 0.89) │
│ - 日记2:"周末和朋友去爬山"(相似度 0.85) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ 拼接 Prompt │ 日记内容 + 问题 → 完整提示词 │
│ └──────────────┬──────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ LLM 生成 │ AI 基于日记内容生成回答 │
│ └──────────────┬──────────────┘ │
│ ▼ │
│ "从你的日记来看,最近让你快乐的事情有: │
│ 1. 去公园散步,感受春天的美好 │
│ 2. 和朋友爬山,享受户外时光" │
│ │
└──────────────────────────────────────────────────────────────────────┘
💡 为什么需要这个流程? 因为 LLM 自己没有你的日记数据,它会"幻觉"。RAG 让 AI 先查资料再回答,保证回答有据可依。
1️⃣ main.mjs --- Milvus 入门:先跑通最小闭环
为什么要从这里开始? 因为在用真实数据之前,先用"假数据"验证流程是最安全的。就像学做菜先用便宜食材练手,别一上来就用和牛。
连接 Zilliz Cloud
javascript
import { MilvusClient, IndexType, MetricType } from "@zilliz/milvus2-sdk-node";
const client = new MilvusClient({
address: process.env.MILVUS_ADDRESS, // Zilliz Cloud 地址
token: process.env.MILVUS_TOKEN // API Key
});
// 检查连接
const checkHealth = await client.checkHealth();
console.log(checkHealth.isHealthy ? '✅ 连接成功' : '❌ 连接失败');
💡 Zilliz Cloud 是 Milvus 的全托管云服务,不用自己部署,注册就能用。
创建集合(类似 MySQL 建表)
javascript
await client.createCollection({
collection_name: 'test',
dimension: 4, // 向量维度,这里用 4 维方便演示
auto_id: true // 自动生成 ID
});
创建索引 ------ 为什么需要?
没有索引时,搜索要逐一计算所有向量的相似度,时间复杂度 O(n)。数据量大了就慢得离谱。
IVF_FLAT 索引的原理类似图书馆分区:
scss
没有索引:在整栋楼里一本本翻 → O(n)
有索引: 先去"文学区",再找"科幻" → 近似 O(1)
javascript
await client.createIndex({
collection_name: 'test',
field_name: 'vector',
index_type: IndexType.AUTOINDEX,
metric_type: MetricType.COSINE // 用余弦相似度
});
插入和搜索
javascript
// 插入数据(手动指定的 4 维向量)
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 });
// 搜索:找和 [0.5, 0.5, 0.6, 0.8] 最相似的 2 条
const searchRes = await client.search({
collection_name: 'test',
data: [[0.5, 0.5, 0.6, 0.8]],
limit: 2,
output_fields: ['content']
});
console.log('搜索结果:', searchRes.results);
到这里,你已经理解了 Milvus 的核心三板斧:建集合 → 插数据 → 搜索。
2️⃣ index.mjs --- 用 Embedding 模型写入真实数据
为什么要升级到这一步? 因为手写向量不现实。实际项目中,你需要用 Embedding 模型自动把文本转成向量。
定义集合结构
javascript
import { DataType } from "@zilliz/milvus2-sdk-node";
await client.createCollection({
collection_name: 'ai_dairy',
fields: [
{ name: 'id', data_type: DataType.VarChar, max_length: 50, is_primary_key: true },
{ name: 'vector', data_type: DataType.FloatVector, dim: 1024 }, // 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 }
]
});
跟 MySQL 建表几乎一样,只是多了一个
FloatVector类型的vector字段。
Embedding:文本 → 向量
javascript
import { OpenAIEmbeddings } from '@langchain/openai';
const embedding = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME // 比如 text-embedding-3-small
});
const getEmbedding = async (text) => {
return await embedding.embedQuery(text); // 一段文本 → 1024 维向量
};
💡 为什么要用 Embedding 模型? 因为它能把"语义"编码成数字。手动写向量无法捕捉语义信息。
准备日记数据 + 写入
javascript
const diaryContents = [
{ id: 'diary_001', content: '今天天气很好,去公园散步了,心情愉快。', date: '2026-01-10', mood: 'happy', tags: ['生活', '散步'] },
{ id: 'diary_002', content: '今天工作很忙,完成了一个重要的项目里程碑。', date: '2026-01-11', mood: 'excited', tags: ['工作', '成就'] },
{ id: 'diary_003', content: '周末和朋友去爬山,天气很好,心情也很放松。', date: '2026-01-12', mood: 'relaxed', tags: ['户外', '朋友'] },
{ id: 'diary_004', content: '今天学习了 Milvus 向量数据库,感觉很有意思。', date: '2026-01-12', mood: 'curious', tags: ['学习', '技术'] },
{ id: 'diary_005', content: '晚上做了一顿丰盛的晚餐,尝试了新菜谱。', date: '2026-01-13', mood: 'proud', 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
});
console.log('✅', insertResult.insert_cnt, '条记录成功插入。');
// 重要:插入后重新加载集合!
await client.loadCollection({ collection_name: 'ai_dairy' });
关键流程:
yaml
文本日记 → Embedding 模型 → 1024 维向量 → 存入 Milvus
3️⃣ query.mjs --- 用自然语言搜索日记
为什么要单独讲搜索? 因为写入和搜索是两个独立的操作。写入是一次性的,搜索是实时的。而且搜索涉及"把用户问题也转成向量"这个关键步骤。
javascript
// 用户说了一句话
const query = '我想看看关于户外活动的日记';
// 1. 把查询文本也转成向量(这一步很关键!)
const queryVector = await getEmbedding(query);
// 2. 去 Milvus 里找最相似的 2 条
const searchResult = await client.search({
collection_name: 'ai_dairy',
vector: queryVector,
limit: 2,
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'date', 'mood', 'tags']
});
// 3. 打印结果
console.log('🔍 搜索结果:\n');
searchResult.results.forEach((item, index) => {
console.log(`${index + 1}. [Score: ${item.score.toFixed(4)}]`);
console.log(` 内容:${item.content}`);
console.log(` 心情:${item.mood}`);
console.log(` 日期:${item.date}\n`);
});
输出大概是:
css
🔍 搜索结果:
1. [Score: 0.8923]
内容:周末和朋友去爬山,天气很好,心情也很放松。
心情:relaxed
日期:2026-01-12
2. [Score: 0.8156]
内容:今天天气很好,去公园散步了,心情愉快。
心情:happy
日期:2026-01-10
用户说"户外活动",系统找到了"爬山"和"公园散步" ------ 没有任何关键词匹配,纯靠语义理解!
4️⃣ rag.mjs --- 检索 + LLM = AI 日记助手
为什么要加 RAG? 因为光检索还不够。用户问"我最近快乐吗?",你不能只返回几条日记,而是要让 AI 分析这些日记,给出一个有温度的回答。
什么是 RAG?
RAG = R etrieval(检索)+ A ugmented(增强)+ Generation(生成)
用图书馆类比:
markdown
1. 检索(Retrieval) :去图书馆找几本相关的书
2. 增强(Augmented) :把书翻到相关页面,放在桌上
3. 生成(Generation) :让 AI 看着这些书来回答问题
没有 RAG :AI 只能靠自己的"记忆"回答,可能胡说八道(幻觉)。 有了 RAG:AI 先查资料再回答,有理有据。
模块一:检索(Retrieval)
javascript
async function retrieveRelevantDiaries(question, k = 2) {
const queryVector = await getEmbedding(question);
const searchResult = await client.search({
collection_name: 'ai_dairy',
limit: k,
metric_type: MetricType.COSINE,
vector: queryVector,
output_fields: ['id', 'content', 'date', 'mood', 'tags']
});
return searchResult.results;
}
这一步就是前面 query.mjs 做的事 ------ 找到最相关的 k 条日记。
模块二:增强(Augmented)
javascript
// 把检索到的日记拼成一段"上下文"
function buildContext(retrievedDiaries) {
return retrievedDiaries.map((diary, i) => `
[日记 ${i + 1}]
日期:${diary.date}
心情:${diary.mood}
标签:${diary.tags?.join(', ')}
内容:${diary.content}
`).join('\n----\n');
}
为什么要拼接上下文? 因为 LLM 需要"参考资料"才能准确回答。就像你回答问题时,老师会给你一些参考材料。
模块三:生成(Generation)
javascript
async function generateAnswer(question, context) {
const prompt = `你是一个温暖贴心的AI日记助手。
基于用户的日记内容回答问题,用亲切自然的语言。
请根据以下日记内容回答问题:
${context}
用户问题:${question}
回答要求:
1. 如果日记中有相关信息,请结合日记内容给出详细、温暖的回答。
2. 可以总结多篇日记的内容,找出共同点或趋势。
3. 如果日记中没有相关信息,请温和告知用户。
4. 用第二人称"你"来称呼日记的作者。
5. 回答要有同理心,让用户感到被理解和关心。
AI 助手回答:`;
const response = await model.invoke(prompt);
return response.content;
}
完整调用流程
javascript
// RAG 完整流程
async function aiDiaryAssistant(question) {
console.log(`\n📝 用户提问:${question}\n`);
// 1. 检索
console.log('🔍 正在检索相关日记...');
const retrievedDiaries = await retrieveRelevantDiaries(question, k = 3);
// 2. 构建上下文
const context = buildContext(retrievedDiaries);
// 3. 生成回答
console.log('🤖 AI 正在生成回答...\n');
const answer = await generateAnswer(question, context);
console.log('💡 AI 回答:');
console.log(answer);
return answer;
}
// 测试
await aiDiaryAssistant('我最近做了什么让我感到快乐的事情?');
输出示例:
markdown
📝 用户提问:我最近做了什么让我感到快乐的事情?
🔍 正在检索相关日记...
🤖 AI 正在生成回答...
💡 AI 回答:
从你的日记来看,最近让你感到快乐的事情有:
1. **去公园散步**(1月10日)------ 你提到"天气很好,心情愉快",看得出来你很享受亲近自然的时光。
2. **和朋友爬山**(1月12日)------ 周末和朋友一起户外活动,你说"心情也很放松",友情和运动都让你开心。
3. **尝试新菜谱**(1月13日)------ 你为自己做了一顿丰盛的晚餐,这种自我照顾的小确幸也很珍贵呢 😊
看得出来你是一个热爱生活、懂得享受当下的人。继续保持这份对生活的热情吧!
💡 关键技术点总结
| 概念 | 一句话解释 | 在项目中的作用 |
|---|---|---|
| Milvus | 专门存和查向量的数据库 | 存储日记的向量表示 |
| Embedding | 文本 → 数字数组 | 把日记和问题转成向量 |
| COSINE 相似度 | 衡量两个向量方向的相似程度 | 找到语义最相关的日记 |
| IVF_FLAT 索引 | 聚簇索引,加速搜索 | 让搜索更快 |
| RAG | 检索 + LLM 生成 | 让 AI 基于日记内容回答 |
| Zilliz Cloud | Milvus 的全托管云服务 | 免去部署烦恼 |
🐛 踩坑记录
坑 1:查到 0 条数据
现象:insert 明明成功了,search 返回空数组。
原因 :loadCollection 在 insert 之前调用了,新插入的数据没有被加载到内存。
解决 :插入数据后,重新调用 loadCollection:
javascript
await client.insert({ collection_name: 'ai_dairy', data: diaryData });
await client.loadCollection({ collection_name: 'ai_dairy' }); // 重新加载!
预防:养成习惯,每次 insert 后都 load 一次。
坑 2:output_fields 字段名拼错
现象 :返回的字段值全是 undefined。
原因 :output_fields 里写的是 'data',实际字段名是 'date'。
解决:仔细检查集合的字段定义,别拼错。
预防:复制字段名,不要手打。
坑 3:向量维度不匹配
现象 :创建集合时 dim: 1024,但 Embedding 模型生成的向量维度不是 1024。
解决 :确保 dim 参数和 Embedding 模型输出的维度一致。常用模型的维度:
| 模型 | 维度 |
|---|---|
| text-embedding-3-small | 1536 |
| text-embedding-3-large | 3072 |
| text-embedding-ada-002 | 1536 |
预防:在创建集合前,先调用一次 Embedding API 确认输出维度。
🔗 参考资料
💬 交流讨论
你在项目中用过向量数据库吗?有没有遇到什么坑?欢迎评论区交流!
觉得有用?点个赞 👍 收藏 ⭐ 关注 👆,后续会更新更多 AI 实战内容!