从零搭建 AI 日记助手:用 Milvus 向量数据库 + RAG 让机器读懂你的每一天
写在前面
如果你正在构建一个 AI Agent,你大概率绕不开一个基础设施------向量数据库 。它不是替代 MySQL 的存在,而是补上了传统数据库最不擅长的一环:语义理解。
本文将以一个完整的实战项目------AI 日记助手------为主线,带你从零搭建一套基于 Milvus 向量数据库的 RAG(检索增强生成)系统。我们会写四个 JS 文件,逐步完成:连接向量数据库 → 创建集合 → 向量化并存储日记 → 语义搜索 → 大模型生成回答。
项目使用 Zilliz Cloud(基于 Milvus 的全托管服务)+ 阿里云 DashScope(兼容 OpenAI 接口)作为 Embedding 和 LLM 后端。
一、为什么要用向量数据库?
1.1 传统数据库的边界
日常的 Web 开发中,我们把数据存在 MySQL、PostgreSQL 或者 SQLite 里,做的事情无非是 增删改查(CRUD) ------根据 ID 精确查找、根据关键词 LIKE 模糊匹配、关联查询出列表数据。这套模式处理结构化数据非常成熟。
但它有一个根本性的局限:它不懂语义。
举个具体的例子。你的日记本里有这么一条:
"周末和朋友去爬山,天气很好,心情也很放松。享受大自然的感觉真好。"
如果你想查"关于户外活动的日记",SQL 能帮你做什么?用 LIKE '%户外%' 去匹配?这条日记里根本没出现"户外"这个词,SQL 会直接返回空结果。
这就是关键词匹配的天花板------它只能匹配字面,无法理解含义。
1.2 向量数据库解决了什么
向量数据库的核心思路完全不同:
css
文本 → Embedding模型 → 高维向量(一串浮点数)→ 存入向量数据库
查询时:
查询文本 → Embedding模型 → 查询向量 → 在数据库中做相似度计算 → 返回最相似的Top-K结果
"爬山"和"户外活动"在向量空间里的距离很近 ------虽然字面上完全不同,但语义上高度相关。这就是向量数据库最核心的能力:语义检索。
css
flowchart LR
A["📝 原始文本<br/>日记内容"] --> B["🧠 Embedding 模型<br/>文本 → 向量"]
B --> C["🗄️ Milvus 向量数据库<br/>存储 + 索引"]
D["❓ 用户查询<br/>'户外活动'"] --> E["🧠 同一个 Embedding 模型"]
E --> F["🔍 相似度搜索<br/>COSINE / IP / L2"]
F --> G["📋 返回 Top-K 结果"]
1.3 代码中的体现
在项目里,我们封装了一个 getEmbedding 函数,它就叫一次 API,把任意文本变成一串 1024 维的浮点数向量:
javascript
// index.mjs / query.mjs / rag.mjs 中均有此函数
const getEmbedding = async (text) => {
const result = await embeddings.embedQuery(text);
return result; // 返回 number[],长度 = VECTOR_DIM (1024)
}
这一行代码的背后是一次网络请求到 Embedding 服务(我们用的是阿里云 DashScope 的 text-embedding-v3 模型),把自然语言文本映射为一个高维空间中的点。
二、Milvus 是什么
2.1 定位
readme 中有一段很精炼的定义:
Milvus 是一款开源的向量数据库,专为处理海量高维向量数据而设计。AI Agent 产品都会使用 Milvus 这样的 Vector Store。
这里有三个关键词值得展开:
| 关键词 | 含义 |
|---|---|
| 向量数据库 | 专门为向量数据的存储和检索而优化的数据库系统,不同于传统的关系型数据库 |
| 海量高维 | 单条向量可达数百到数千维,数据量可达数十亿级别 |
| Vector Store | Agent 架构中的记忆和知识存储层,Agent 把"记忆"放在 Milvus 中做语义检索 |
2.2 C/S 架构
Milvus 采用经典的 客户端/服务端(C/S)架构,在代码注释中有所标注:
javascript
// index.mjs
import {
MilvusClient, // C/S 架构 B/S 架构
MetricType, // 相似度求法
IndexType,
DataType // 字段数据类型约束
} from '@zilliz/milvus2-sdk-node';
- 服务端(Server) :Milvus 数据库实例,负责存储数据、构建索引、执行查询
- 客户端(Client) :通过 SDK 连接服务端,发指令操作数据
这和 MySQL 的 mysql2 驱动、Redis 的 ioredis 是同一个模式。MilvusClient 就是你操作向量数据库的入口对象。
2.3 Zilliz Cloud
基于 Milvus 的全托管向量数据库服务。
如果不想自己搭 Milvus 服务端,Zilliz Cloud 提供了开箱即用的云服务(就像 RDS 之于 MySQL)。我们项目中使用的就是 Zilliz Cloud 的 Serverless 实例,地址配置在 .env 中:
ini
MILVUS_ADDRESS=https://in03-5d01b4cb0f9332d.serverless.ali-cn-hangzhou.cloud.zilliz.com.cn
MILVUS_TOKEN=your-auth-token
三、环境准备与连接
3.1 依赖
项目使用三个核心依赖:
| 包 | 用途 |
|---|---|
@zilliz/milvus2-sdk-node |
Milvus Node.js SDK(v3.0.3),用于操作向量数据库 |
@langchain/openai |
LangChain 的 OpenAI 兼容封装,提供 OpenAIEmbeddings 和 ChatOpenAI |
dotenv |
加载 .env 环境变量 |
3.2 Embedding 模型初始化
arduino
// index.mjs / query.mjs / rag.mjs
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY, // API 密钥
model: process.env.EMBEDDINGS_MODEL_NAME, // 模型名称:text-embedding-v3
configuration: {
baseURL: process.env.OPENAI_BASE_URL // 自定义 API 端点
},
dimensions: VECTOR_DIM // 输出向量维度:1024
});
configuration.baseURL 是一个值得留意的配置点。OpenAI 官方的 API 地址是 https://api.openai.com/v1,但当你使用国内的大模型服务(如 DeepSeek、通义千问、智谱等)时,它们的接口是 OpenAI 兼容的------协议一样,地址不同 。你只需要把 baseURL 改成对应的地址就能无缝切换,不需要修改任何业务代码。这是一种协议层面的抽象:OpenAI 定义了 HTTP API 的事实标准,各家厂商通过实现同一套接口来降低开发者的迁移成本。
这里我们用阿里云 DashScope:
ini
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
EMBEDDINGS_MODEL_NAME=text-embedding-v3
3.3 MilvusClient 初始化与连接
arduino
// index.mjs / query.mjs / rag.mjs
const client = new MilvusClient({
address: ADDRESS, // Milvus 服务地址
token: TOKEN // 认证 token(Zilliz Cloud 需要)
})
我们的代码注释中写了 // SDK v3 自动连接,不需要 connectPromise()。这是因为 Milvus Node.js SDK v3 在构造函数中会自动调用 connect() 方法建立 gRPC 连接,所有后续的 API 调用(checkHealth、createCollection、insert、search 等)内部都会 await this.connectPromise 确保连接就绪后再执行。
javascript
// main.mjs / index.mjs
const checkHealth = await client.checkHealth();
if (!checkHealth.isHealthy) {
console.error('连接失败', checkHealth.reasons);
return;
}
console.log('连接成功,集群状态正常');
checkHealth() 不仅验证网络是否通,还会检查 Milvus 集群各组件的运行状态。在生产环境中,这是一个很好的启动检查(startup probe)实践。
四、索引:为什么能让向量搜索做到毫秒级
4.1 问题:暴力搜索的 O(n) 困境
main.mjs 中有一段很长的注释,用了一个非常好的类比来解释索引的必要性:
arduino
// Milvus 存的是高维向量,
// 没有索引时,每次查询都要把库里的向量和查询的向量逐一算相似度(O(n))
// 数据量大了慢的没法用
// 字典 拼音,偏旁的索引, 迅速减少查询范围
// 图书馆,找一本《三体》 没有索引,每本都要翻一下。
// 文学馆/小说/科幻
// IVF_FLAT 聚簇索引 毫秒级
IndexType,
这段注释的信息量很大,逐层拆解:
第一层:为什么需要索引。 假设你存了 1000 万条向量,每次查询都要和这 1000 万条逐一计算相似度------时间复杂度 O(n),这在生产环境下是不可接受的。索引的作用就是避免全量扫描。
第二层:查字典的类比。 你在新华字典里查一个字,不会从第一页翻到最后一页。你会根据拼音索引先定位到声母区,再定位到韵母区,最后精准翻到目标页------三次跳转就把范围从几万缩小到 1。向量索引的底层思想和这个完全一致:通过空间划分,把搜索范围从全库缩小到少数几个区域。
第三层:图书馆的类比。 没有索引的图书馆 → 你找《三体》要翻遍每一本书。有索引的图书馆 → 文学馆/小说/科幻 → 三秒定位。这就是 IVF_FLAT 要做的事------把向量空间划分为多个聚类(cluster) ,查询时先找到最近的几个聚类,再只在那些聚类内部做精确比较。
4.2 代码中的索引创建
csharp
// index.mjs
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: 'vector', // 对 vector 字段建索引
index_type: IndexType.IVF_FLAT, // 倒排文件索引
metric_type: MetricType.COSINE // 余弦相似度
})
IVF_FLAT(Inverted File with Flat compression)是最常用的向量索引类型之一:
scss
IVF_FLAT 工作原理:
训练阶段:用 K-Means 将所有向量聚成 N 个簇
[簇1] [簇2] ... [簇N]
查询阶段:
1. 计算查询向量到 N 个簇中心距离 → 选最近的 M 个簇
2. 只在这 M 个簇内部做精确比对
3. 返回 Top-K
复杂度:O(N + M),其中 M << 总向量数
4.3 相似度算法
代码中用了 MetricType.COSINE(余弦相似度),注释中也标注了 // 相似度求法:
arduino
MetricType, // 相似度求方法
常见的三种向量相似度计算方法:
| 度量 | 公式直觉 | 适用场景 |
|---|---|---|
| COSINE(余弦) | 看两个向量的"夹角",夹角越小越相似 | 文本语义相似度(推荐) |
| IP(内积) | 看两个向量的"投影长度",值越大越相似 | 需要向量已归一化的场景 |
| L2(欧氏距离) | 看两个点在空间中的"直线距离",越近越相似 | 图像特征匹配 |
对于文本 Embedding,余弦相似度是最常用的选择,因为它只关心方向,不关心向量的绝对长度------"爬山"和"登山"的向量方向应该很接近,哪怕它们各自的向量长度不同。
五、Collection:向量数据库中的"表"
5.1 Schema 设计
传统关系型数据库中,你要先 CREATE TABLE 定义好每一列的类型和约束,然后才能插入数据。Milvus 中的 Collection 就是类比 MySQL Table 的存在。在 index.mjs 中,我们用显式的 Schema 定义了日记 Collection 的字段结构:
ini
const COLLECTION_NAME = 'ai_diary';
const VECTOR_DIM = 1024; // 向量维度,由 Embedding 模型的输出决定
php
await client.createCollection({
collection_name: COLLECTION_NAME,
fields: [
// diary_01
{
name: 'id',
data_type: DataType.VarChar, // 字符串类型
max_length: 50, // 最大长度限制
is_primary_key: true, // 设为主键
description: '日记唯一标识'
},
{
name: 'vector',
data_type: DataType.FloatVector, // 浮点向量类型
dim: VECTOR_DIM, // 向量维度 1024
description: '日记内容的向量表示'
},
{
name: 'content',
data_type: DataType.VarChar,
max_length: 5000, // 日记正文,允许较长的字符串
description: '日记正文内容'
},
{
name: 'date',
data_type: DataType.VarChar,
max_length: 50,
description: '日记日期'
},
{
name: 'mood',
data_type: DataType.VarChar,
max_length: 50,
description: '心情标签'
},
{
name: 'tags',
data_type: DataType.Array, // 数组类型!
element_type: DataType.VarChar, // 数组中每个元素的类型
max_capacity: 10, // 数组最大容量
max_length: 50, // 每个元素的最大长度
description: '日记标签列表'
},
]
});
5.2 字段类型解读
注意到这里出现了 // 字段数据类型约束 注释对应的 DataType 导入。Schema 中定义了 6 个字段,各自承载不同的职责:
| 字段 | 类型 | 作用 | 类比 MySQL |
|---|---|---|---|
id |
VarChar(50),主键 | 日记唯一标识,如 diary_001 |
VARCHAR(50) PRIMARY KEY |
vector |
FloatVector(1024) | 日记内容的 Embedding 向量 | MySQL 没有等价类型 |
content |
VarChar(5000) | 日记正文内容 | TEXT |
date |
VarChar(50) | 日记日期 | VARCHAR(50) |
mood |
VarChar(50) | 心情标签 | VARCHAR(50) |
tags |
Array | 标签数组 | MySQL 8+ 的 JSON 数组 |
最有趣的设计是 tags 字段。传统 SQL 中,一对多的标签关系通常需要建一张关联表。在 Milvus 中,DataType.Array 允许直接在一条记录里存储标签列表(max_capacity: 10 限制了最多 10 个标签),这使得查询时可以直接拿到完整数据,不需要 JOIN。当然,这也意味着标签的过滤(如"找出所有包含'工作'标签的日记")需要通过标量过滤(Scalar Filtering)来完成,而非传统 SQL 的 WHERE 子查询。
5.3 幂等创建
csharp
// 检查并创建 Collection
const hasCollection = await client.hasCollection({ collection_name: COLLECTION_NAME });
if (!hasCollection.value) {
// 创建 Collection 和 Index ...
} // end if !hasCollection
hasCollection 在 SDK v3 中返回一个包含 .value 属性的对象,而不是直接返回布尔值------这是 v3 API 的一个细节变更。用这个判断可以实现幂等执行:第一次运行时创建 Collection,后续重复运行跳过创建直接插入数据。
5.4 加载到内存
javascript
console.log('loading collection');
await client.loadCollection({
collection_name: COLLECTION_NAME
});
console.log('collection loaded')
Collection 创建后处于 未加载(unloaded) 状态------数据在磁盘上,但搜索需要数据在内存中。loadCollection 把 Collection 的索引和数据加载到内存,之后才能执行搜索操作。这是一个关键的性能设计:你可以有几百个 Collection,但只加载需要查询的那几个,节省内存开销。
六、日记数据向量化与插入
6.1 示例数据
less
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: ['美食', '家庭']
}
];
5 条模拟日记,覆盖了生活、工作、户外、学习、美食五个主题,心情从 happy 到 proud,为后面的语义搜索提供了足够的多样性。
6.2 批量向量化
javascript
console.log('Generating embeddings...');
const diaryData = await Promise.all(
diaryContents.map(async (diary) => ({
...diary,
vector: await getEmbedding(diary.content)
}))
);
Promise.all + async map 并发调用 Embedding API,5 条日记并行生成向量,比串行快数倍。生成后的 diaryData 中每条记录多了 vector 字段------一个 1024 维的 number[]。
思考一个问题:为什么这里必须用 Promise.all 而不是 forEach?
forEach 的回调是同步执行的,它不会等待内部的 await。如果你的 Embedding 接口限流是 10 QPS,5 条并发请求可能在几十毫秒内全部发出,瞬间触发限流。实际项目中可能要改用 p-limit 或其他并发控制方案来控制并发数。
6.3 插入数据
javascript
const insertResult = await client.insert({
collection_name: COLLECTION_NAME,
data: diaryData // 太简单了 json 不用写 sql
})
console.log(insertResult.insert_cnt, "条记录成功插入。");
数据以 JSON 对象数组 的形式直接传入------无需像 SQL 那样拼 INSERT INTO ... VALUES ...。Milvus 会自动校验每条记录的字段是否与 Schema 匹配,字段名不一致会直接报错。
七、语义搜索
7.1 query.mjs 中的搜索实现
javascript
const query = '我想看看关于户外活动的日记';
console.log(`Query: ${query}`);
const queryVector = await getEmbedding(query);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector, // 查询向量
limit: 2, // 返回 Top-2
metric_type: MetricType.COSINE, // 余弦相似度
output_fields: ['id', 'content', 'date', 'mood', 'tags'] // 返回的标量字段
});
这就是完整的语义搜索流程:
scss
"户外活动"
→ Embedding 模型 → [0.023, -0.451, ..., 0.337] (1024维向量)
→ Milvus 搜索 (COSINE)
→ 返回相似度最高的 2 条日记
7.2 main.mjs 中的另一种搜索方式
main.mjs 里演示了一种不同的搜索 API 调用方式------使用 data 参数直接传入多个向量:
javascript
const searchRes = await client.search({
collection_name: COLLECTION_NAME,
data: [[0.5, 0.5, 0.6, 0.8]], // 直接传向量数组
limit: 2,
output_fields: ['content'] // 只返回 content 字段
})
console.log(JSON.stringify(searchRes.results, null, 2));
注意这里是 data(复数向量)而不是 vector(单数向量)。这是 SDK 提供的批量搜索能力------一次请求可以对多个查询向量同时做搜索,减少网络往返次数。
7.3 搜索结果解析
javascript
console.log(`Found ${searchResult.results.length} results.`);
searchResult.results.forEach((item, index) => {
console.log(`Result ${index + 1}.[Score: ${item.score.toFixed(4)}]`);
console.log(`
ID: ${item.id}
Content: ${item.content}
Date: ${item.date}
Mood: ${item.mood}
Tags: ${item.tags?.join(",")}
`)
});
搜索返回 query: '我想看看关于户外活动的日记' 的结果是------diary_003(爬山日记),score 约 0.66:
yaml
Result 1.[Score: 0.6575]
ID: diary_003
Content: 周末和朋友去爬山,天气很好...
Mood: relaxed
Tags: 户外,朋友
COSINE 相似度范围是 -1, 1,0.66 表示两个向量在方向上高度一致。核心洞察:查询词"户外活动"和这篇日记的内容"爬山"是语义相关的,尽管它们没有任何共同词汇。 这就是向量搜索区别于关键词搜索的根本之处------它不匹配字面,它匹配含义。
八、RAG:串联检索与生成
8.1 什么是 RAG
readme 中有一段对 RAG 流程的精炼总结:
文档向量化放到向量数据库,每次查询根据向量化的 query 去数据库做相似度匹配,查出相关文档放到 prompt 里给大模型,大模型来生成回答。
这四句话精准概括了 RAG 的四个环节:
css
flowchart TD
Q["❓ 用户问题<br/>'我最近做了什么让我很骄傲的事'"] --> E["🧠 Embedding<br/>问题 → 查询向量"]
E --> S["🔍 Milvus 语义检索<br/>返回 Top-K 相关日记"]
S --> P["📋 拼接 Prompt<br/>Context + 问题 → 完整提示词"]
P --> L["🤖 ChatOpenAI / LLM<br/>基于日记内容生成回答"]
L --> A["✅ 返回温暖、有同理心的回答"]
rag.mjs 中将其实现为了两个模块化函数。
8.2 检索模块
javascript
// r a g 模块化
console.log('检索相关日记');
async function retrieveRelevantDiaries(question, k = 2) {
try {
const queryVector = await getEmbedding(question);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k, // 返回最相关的 k 条日记
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'date', 'mood', 'tags']
});
return searchResult.results;
} catch (err) {
console.error('检索日记时出错:', err.message);
return []; // 降级处理:检索失败返回空数组,不影响主流程
}
}
注意 retrieveRelevantDiaries 的命名------不是简单的 search,而是 retrieve(检索)。在 RAG 的语境中,"检索"的含义比"搜索"更精确:给定一个问题,找到与之相关的文档片段。 这里的 k = 2 是一个重要参数------它决定了注入到 Prompt 中的上下文量。k 过小可能导致信息不足,k 过大会稀释有效信息并增加 token 消耗。
8.3 Context 组装
javascript
const context = retrievedDiaries
.map((diary, i) => `
[日记 ${i + 1}]
日期:${diary.date}
心情:${diary.mood}
标签:${diary.tags?.join(",")}
内容:${diary.content}
`).join('\n')
检索到的日记被格式化为结构化的文本块,每条日记包含日期、心情、标签和正文。这种格式化的目的是帮助 LLM 快速解析信息结构 ------明确的分隔符([日记 N])和字段名(日期:、心情:)让模型能准确区分不同日记和不同维度的信息。
8.4 Prompt 工程
markdown
const prompt = `你是一个温暖贴心的AI 日记助手。基于用户的日记内容回答问题,
用亲切自然的语言,请根据以下日记内容回答问题:
${context}
用户问题:${question}
回答要求:
1. 如果日记中有相关信息,请结合日记内容给出详细、温暖的回答。
2. 可以总结多篇日记的内容,找出共同点或趋势。
3. 如果日记中没有相关信息,请温和告知用户。
4. 用第一人称"你"来称呼日记的作者。
5. 回答要有同理心,让用户感到被理解和关心。
AI 助手的回答:
`
这个 Prompt 的设计有几个值得注意的细节:
- 角色设定("温暖贴心的 AI 日记助手"):限定了模型的语气和行为边界
- Chain-of-Thought 引导("找出共同点或趋势"):引导模型做推理而非简单复述
- 兜底策略("如果没有相关信息,请温和告知"):防止模型在信息缺失时产生幻觉(hallucination)
- 人称设定("用'你'来称呼作者"):让回答有对象感和温度
- 标记结束 ("AI 助手的回答:"):这是关键------给 LLM 一个明确的"轮到你了"的信号,有助于生成更可控的输出
8.5 LLM 调用
arduino
const model = new ChatOpenAI({
temperature: 0.1, // 低温度 → 输出更确定、更稳定
model: process.env.MODEL_NAME, // qwen-plus
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL // DashScope 兼容端点
}
})
const response = await model.invoke(prompt);
console.log(response.content);
temperature: 0.1 接近于 0,意味着模型会倾向于选择概率最高的 token,输出更加稳定可预测。在 RAG 场景中,我们希望模型忠实于检索到的文档内容,而不是自由发挥------所以低温度是正确的选择。
8.6 完整 RAG 的主入口
javascript
async function main() {
try {
console.log('连接到Milvus ...');
// SDK v3 自动连接
console.log('已连接');
await answerQuestion('我最近做了什么让我很骄傲的事');
} catch (err) {
console.error('查询失败:', err.message);
}
}
main().catch(console.error);
这里提的问题是"我最近做了什么让我很骄傲的事"。 "骄傲"这个词没有出现在任何一篇日记里,但向量搜索能命中内容中带有"成就感"的日记------因为"骄傲"和"成就感"在语义空间中位置接近。这正是 embedding + 向量搜索相对于关键词搜索的核心优势。
实际运行结果命中了 diary_002(项目里程碑、成就感)和 diary_005(家人夸赞、成就感),LLM 据此生成了一段温暖的、总结性的回答。
九、main.mjs:最快的入门路径
main.mjs 是项目中最精简的示例,它用最少的代码演示了 Milvus 的核心操作闭环。让我们完整走一遍:
javascript
import {
MilvusClient, // client 连接 zilliz server
IndexType, // 索引类型
MetricType // 相似度的计算类型
} from '@zilliz/milvus2-sdk-node'
import 'dotenv/config'
连接:
ini
const client = new MilvusClient({
address: ADDRESS,
token: TOKEN
});
const checkHealth = await client.checkHealth();
搜索 (使用一个已经创建好的 test Collection):
lua
const searchRes = await client.search({
collection_name: COLLECTION_NAME, // mysql table 集合
data: [[0.5, 0.5, 0.6, 0.8]], // 4维向量 --- 因为创建时 DIMENSION = 4
limit: 2,
output_fields: ['content']
})
关于维度(DIMENSION)设置的一个底层细节:main.mjs 中 DIMENSION = 4 是为了演示方便。实际应用中,维度过低会导致向量"表达能力"不足(很多语义信息被压缩丢失),维度过高则占用更多存储和计算资源。Embedding 模型的输出维度(如 1024)是经过大量实验验证的工程最佳平衡点,在语义丰富度和计算开销之间做了折中。
十、.env 配置全景
ini
MODEL_NAME=qwen-plus # LLM 模型:通义千问增强版
EMBEDDINGS_MODEL_NAME=text-embedding-v3 # Embedding 模型
OPENAI_API_KEY=sk-xxx # API 密钥(DashScope)
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # 兼容端点
MILVUS_ADDRESS=https://xxx.zilliz.com.cn # Zilliz Cloud 地址
MILVUS_TOKEN=xxx # Zilliz 认证 token
这里涉及两个 AI 服务:
| 服务 | 用途 | 关键配置 |
|---|---|---|
| 阿里云 DashScope | Embedding + LLM | OPENAI_BASE_URL 指向兼容端点 |
| Zilliz Cloud | 向量存储与检索 | Milvus 的全托管云服务 |
使用自定义 baseURL 的架构含义是:你不需要同时接入多个 SDK 。阿里云、DeepSeek、智谱等厂商都提供了 OpenAI 兼容接口,代码中只引入 @langchain/openai 一个包,通过切换 baseURL 就能使用不同厂商的模型------这是一种基于接口统一的供应商解耦方案。
十一、项目文件结构总览
bash
demo/
├── .env # 环境变量配置
├── package.json # 依赖管理
└── src/
├── main.mjs # 最简连接 + 搜索示例
├── index.mjs # 完整数据流:连接 → 建表 → 向量化 → 插入
├── query.mjs # 语义搜索查询
└── rag.mjs # RAG 完整流程:检索 + 生成
按照 readme.md 的笔记顺序,学习和开发的路径是:
css
main.mjs(理解 Milvus 基础连接和搜索)
→ index.mjs(理解 Schema 设计、向量化、数据插入)
→ query.mjs(理解语义搜索的完整链路)
→ rag.mjs(串联检索和 LLM 生成,完成 RAG 闭环)
十二、关键技术决策回顾
| 决策 | 选择 | 理由 |
|---|---|---|
| 向量数据库 | Milvus (Zilliz Cloud) | 开源、社区活跃、云原生架构 |
| Embedding 模型 | text-embedding-v3 (DashScope) | 1024 维,中文效果好 |
| LLM | qwen-plus (DashScope) | 中文能力强,兼容 OpenAI 协议 |
| 相似度算法 | COSINE | 文本语义场景的最优选择 |
| 索引类型 | IVF_FLAT | 适合中型数据集,毫秒级响应 |
| SDK 版本 | @zilliz/milvus2-sdk-node v3 | 最新 API 设计,支持 ESM |
| LLM 封装 | LangChain ChatOpenAI | 统一接口,切换厂商零成本 |
| Temperature | 0.1 | RAG 场景需要稳定、忠实于文档的输出 |
写在最后
这篇文章从零开始,带你走完了一个完整的技术栈:
- 理解问题------为什么传统数据库做不了语义搜索
- 选择工具------Milvus 是什么、Zilliz Cloud 怎么用
- 设计 Schema------Collection 的字段定义怎么设计
- 数据向量化------Embedding 模型如何把文本变成向量
- 建立索引------IVF_FLAT 为什么能在海量数据中做到毫秒级搜索
- 语义搜索------用一句自然语言找到语义相关的文档
- RAG 闭环------检索结果喂给 LLM,生成有同理心的回答
这个 AI 日记助手的代码量不到 200 行,但它背后的架构模式------向量数据库 + Embedding + LLM------是当今几乎所有 AI Agent 产品的基础设施。
当你的 Agent 需要"记住"东西、"理解"语义、"检索"记忆时,这套三件套就是标准答案。