导语 :在这个万物皆可 Agent 的时代,大模型虽然聪明,但往往是个"金鱼记忆"。怎么让 AI 记住你的喜好?怎么让它基于你的私有数据回答问题?答案就是:向量数据库 + RAG。今天,我们将用 Node.js、Milvus 和 LangChain,从零搭建一个能听懂你"弦外之音"的 AI 日记本!代码全量开源,逐行深度拆解,建议收藏防走丢!
🤔 灵魂拷问:为什么我们需要向量数据库?
想象一下,你在图书馆找一本《三体》:
- 传统数据库(MySQL/SQLite) 就像是一个死板的图书管理员,你问"有没有讲宇宙社会学的书?",他只会去书名和简介里搜这几个字,搜不到就告诉你"没有"。
- 向量数据库(Milvus) 则像是一个博学的大师。他不仅认识字,还懂"语义"。当你问"讲宇宙社会学的书"时,他能通过理解内容,直接把《三体》递给你,哪怕书里根本没出现过这几个字。
在 AI 应用中,传统数据库负责处理"是什么"(比如:日记的 ID、创建时间),而 Milvus 这样的向量数据库则负责处理"像什么"(比如:最近心情好的日记、关于户外的记忆)。它是 AI Agent 的"长期记忆"和"知识大脑"。
🛠️ 准备工作:认识我们的技术栈
- Milvus / Zilliz Cloud:专为海量高维向量设计的开源数据库(本文使用 Zilliz Cloud 全托管服务,免去运维烦恼)。
- LangChain:AI 应用开发框架,帮我们轻松调用大模型和 Embedding 模型。
- Node.js:用我们熟悉的 JavaScript 搞定全栈 AI!
第一步:安装依赖
bash
npm install @zilliz/milvus2-sdk-node @langchain/openai dotenv
第二步:配置环境变量(.env 文件)
ini
MILVUS_ADDRESS=你的ZillizCloud地址
MILVUS_TOKEN=你的API密钥
OPENAI_API_KEY=你的大模型API密钥
EMBEDDINGS_MODEL_NAME=text-embedding-3-small
MODEL_NAME=gpt-4o-mini
OPENAI_BASE_URL=你的代理地址(可选)
🚀 模块一:安全连接你的"AI海马体"
操作数据库前,健康检查是极其重要的好习惯。我们先建立连接,确保集群状态正常。
javascript
import 'dotenv/config';
import { MilvusClient } from '@zilliz/milvus2-sdk-node';
const client = new MilvusClient({
address: process.env.MILVUS_ADDRESS,
token: process.env.MILVUS_TOKEN
});
async function main() {
console.log('正在连接 Zilliz Cloud....');
// 核心操作:连接前先进行健康检查,确保集群状态正常
const checkHealth = await client.checkHealth();
if (!checkHealth.isHealthy) {
console.error('连接失败', checkHealth.reasons);
return; // 阻断后续操作
}
console.log('连接成功,集群状态正常。');
}
💡 深度拆解与底层原理:
- 为什么要先做
checkHealth? 在分布式系统或云服务中,网络波动是常态。如果不做健康检查直接执行后续操作,一旦连接断开,程序会抛出难以定位的底层网络异常。通过checkHealth我们可以做到"防御性编程",在操作前就优雅地拦截问题。dotenv/config的作用 :Node.js 原生不支持.env文件,引入这个包可以在内存中自动注入环境变量。注意 :它必须在所有读取process.env的代码之前被引入。
📚 模块二:设计日记本的"Schema"与索引(最易踩坑)
在 MySQL 里我们要建表,在 Milvus 里我们要建 Collection(集合) 。对于 AI 日记本,我们不仅需要存文本,还要存它的"灵魂"(向量)。
php
import { DataType, IndexType, MetricType } from '@zilliz/milvus2-sdk-node';
const COLLECTION_NAME = 'ai_dairy';
const VECTOR_DIM = 1024; // 向量维度,需与 Embedding 模型严格一致
// 1. 创建集合 (Schema设计)
await client.createCollection({
collection_name: COLLECTION_NAME,
fields: [
{ name: 'id', data_type: DataType.VarChar, max_length: 50, is_primary_key: true },
{ name: 'vector', data_type: DataType.FloatVector, dim: VECTOR_DIM }, // 核心:向量字段
{ 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 // 支持数组类型,非常适合存标签
},
]
});
// 2. 创建索引 (让查询飞起来)
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: 'vector',
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE
});
// 3. 加载集合到内存 (Milvus是C/S架构,查询前必须Load)
await client.loadCollection({ collection_name: COLLECTION_NAME });
console.log('Collection 加载成功!');
💡 深度拆解与底层原理:
DataType强类型约束 :Milvus 是强类型的。FloatVector存向量,Array存标签。致命避坑 :dim: VECTOR_DIM必须和你后续调用的 Embedding 模型输出的维度(如 1024)完全一致,否则插入数据时会直接报维度不匹配的错误。IndexType与MetricType的底层逻辑 :没有索引的向量搜索就像在字典里一页一页翻找(时间复杂度 O(n)),数据量大了会直接卡死。这里我们使用了IVF_FLAT(倒排文件索引),它就像给字典加了拼音偏旁索引,能把查询范围迅速缩小;COSINE(余弦相似度)则是衡量两段文本语义相似度的最佳数学公式,它计算的是两个向量夹角的余弦值,不受向量绝对长度的影响。loadCollection的内存机制 :这是新手最容易踩坑的地方!Milvus 为了追求极致的查询性能,将数据和索引放在内存中。查询前必须手动加载到内存,否则搜索时会直接报错"Collection not loaded"。
🧠 模块三:注入灵魂(文档向量化与并发入库)
怎么把一段话变成向量?这就需要 Embedding(嵌入)模型 了。我们用 LangChain 配合大模型,把日记"翻译"成机器能懂的浮点数数组。
javascript
import { OpenAIEmbeddings } from '@langchain/openai';
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
dimensions: VECTOR_DIM
});
// 封装获取向量的方法
const getEmbedding = async (text) => {
const result = await embeddings.embedQuery(text);
return result;
};
// 准备测试数据
const diaryContents = [
{ id: 'diary_001', content: '今天天气很好,去公园散步了,心情愉快。', date: '2026-01-10', mood: 'happy', tags: ['生活', '散步'] },
{ id: 'diary_004', content: '今天学习了 Milvus 向量数据库,感觉很有意思。', date: '2026-01-12', mood: 'curious', tags: ['学习', '技术'] }
];
// 批量生成向量并插入(核心并发逻辑)
console.log('正在生成 Embeddings...');
const diaryData = await Promise.all(
diaryContents.map(async(diary) => ({
...diary,
vector: await getEmbedding(diary.content) // 文本转向量
}))
);
const insertResult = await client.insert({ collection_name: COLLECTION_NAME, data: diaryData });
console.log(insertResult.insert_cnt, "条记录成功插入!");
💡 深度拆解与底层原理:
- 为什么用
Promise.all? 生成 Embedding 需要调用外部大模型 API,这是一个非常耗时的 I/O 操作。如果我们用传统的for循环await,5条日记就要串行等待5次网络请求。使用Promise.all可以将它们并发执行,极大缩短数据入库的时间。这也是实际工程中处理批量数据的标配写法。embedQuery的本质:这个方法底层会调用大模型的 API,将自然语言映射到高维空间(比如 1024 维),返回一个包含 1024 个浮点数的数组。这个数组就是这段文本在多维语义空间中的"坐标"。
🔍 模块四:RAG终极形态------让AI听懂你的"弦外之音"
这是最激动人心的时刻!我们将实现 RAG(检索增强生成) 的核心闭环。为了让代码更优雅,我们把检索逻辑封装成独立函数。
javascript
import { ChatOpenAI } from '@langchain/openai';
// 初始化大模型
const model = new ChatOpenAI({
temperature: 0.1, // 降低随机性,让回答更严谨
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_BASE_URL }
});
// 1. 封装检索函数
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,
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'date', 'mood', 'tags'] // 关键:必须指定输出字段
});
return searchResult.results;
} catch (err) {
console.log('检索日记时出错', err.message);
return [];
}
}
// 2. RAG 核心问答函数
async function answerDiaryQuestion(question, k = 2) {
console.log(`问题:${question}`);
// 检索相关日记
const retrievedDiaries = await retrieveRelevantDiaries(question, k);
if (retrievedDiaries.length === 0) {
console.log('未找到相关日记');
return;
}
// 3. 增强阶段:把检索到的日记拼装成上下文
const content = retrievedDiaries.map((diary, i) => `
[日记${i + 1}]
日期:${diary.date} | 心情:${diary.mood} | 标签:${diary.tags.join(', ')}
内容:${diary.content}
`).join('\n\n----\n\n');
// 4. 生成阶段:给大模型下达"暖心指令"(精心设计的Prompt)
const prompt = `你是一个温暖贴心的AI日记助手。基于用户的日记内容回答问题,用亲切自然的语言。请根据以下日记内容回答问题:
${content}
用户问题:${question}
回答要求:
1. 结合日记内容给出详细、温暖的回答。
2. 用第一人称"你"来称呼日记的作者。
3. 回答要有同理心,让用户感到被理解和关心。
AI 助手的回答:`;
const response = await model.invoke(prompt);
console.log('[AI 暖心回答]', response.content);
}
// 运行测试
await answerDiaryQuestion('我最近做了什么让我感到快乐的事情?', 2);
💡 深度拆解与底层原理:
output_fields的作用 :在search时,Milvus 默认只返回 ID 和相似度分数。我们必须通过output_fields明确告诉它把content、date等原文字段带回来,否则大模型就没有素材可以回答了。temperature: 0.1的底层逻辑:在 RAG 场景中,我们希望大模型是一个"严谨的总结者"而不是"天马行空的创作者"。将温度调低(接近0),可以最大程度减少大模型的"幻觉",让它老老实实基于检索到的日记回答问题。- Prompt 工程的精髓:注意看 Prompt 中的约束条件。明确告诉 AI "用第一人称"、"要有同理心",这就是通过提示词赋予 AI 人格。RAG 的上限不仅取决于数据库,更取决于你如何引导大模型。
📝 总结与思考
通过这几步,我们成功把传统的 CRUD 业务与 AI 语义检索结合了起来。
- MySQL/SQLite 负责日记的增删改查、权限校验等结构化逻辑。
- Milvus 负责记住日记的"情绪"和"语义",为 AI 提供精准的记忆检索。