Node.js 实战 Milvus 向量数据库:从建库、向量检索到 RAG AI 日记本

Node.js 实战 Milvus 向量数据库:从 Zilliz 建库到 RAG AI 日记本

  • 前言
  • [1. 向量数据库基础:从"是什么"到"像什么"](#1. 向量数据库基础:从“是什么”到“像什么”)
    • [1.1 传统数据库与向量数据库解决的问题不同](#1.1 传统数据库与向量数据库解决的问题不同)
    • [1.2 Embedding 为什么能让计算机理解语义](#1.2 Embedding 为什么能让计算机理解语义)
    • [1.3 Milvus、Collection、字段和索引](#1.3 Milvus、Collection、字段和索引)
    • [1.4 从向量检索到 RAG](#1.4 从向量检索到 RAG)
  • [2. 项目准备:在 Zilliz 创建集群并初始化 Node.js](#2. 项目准备:在 Zilliz 创建集群并初始化 Node.js)
    • [2.1 在 Zilliz Cloud 创建项目与集群](#2.1 在 Zilliz Cloud 创建项目与集群)
    • [2.2 初始化项目并安装依赖](#2.2 初始化项目并安装依赖)
    • [2.3 配置环境变量](#2.3 配置环境变量)
    • [2.4 四个脚本的职责与执行顺序](#2.4 四个脚本的职责与执行顺序)
  • [3. JavaScript 基础:读懂项目所需的核心语法](#3. JavaScript 基础:读懂项目所需的核心语法)
    • [3.1 `import`、对象和环境变量](#3.1 import、对象和环境变量)
    • [3.2 `async`、`await` 与 Promise](#3.2 asyncawait 与 Promise)
    • [3.3 箭头函数、展开语法和可选链](#3.3 箭头函数、展开语法和可选链)
  • [4. `main.mjs`:完成第一次 Milvus 数据闭环](#4. main.mjs:完成第一次 Milvus 数据闭环)
    • [4.1 连接 Zilliz 并检查集群状态](#4.1 连接 Zilliz 并检查集群状态)
    • [4.2 创建 `test` 集合](#4.2 创建 test 集合)
    • [4.3 为向量字段建立索引](#4.3 为向量字段建立索引)
    • [4.4 插入两条测试数据](#4.4 插入两条测试数据)
    • [4.5 使用向量查询数据](#4.5 使用向量查询数据)
    • [4.6 整合完整的 `main.mjs`](#4.6 整合完整的 main.mjs)
    • [4.7 运行 `main.mjs` 后能看到什么](#4.7 运行 main.mjs 后能看到什么)
  • [5. `index.mjs`:创建 AI 日记集合并写入向量数据](#5. index.mjs:创建 AI 日记集合并写入向量数据)
    • [5.1 初始化 Embedding 与 Milvus 客户端](#5.1 初始化 Embedding 与 Milvus 客户端)
    • [5.2 创建 `ai_dairy` 集合](#5.2 创建 ai_dairy 集合)
    • [5.3 创建 `IVF_FLAT` 索引](#5.3 创建 IVF_FLAT 索引)
    • [5.4 加载集合并准备五篇日记](#5.4 加载集合并准备五篇日记)
    • [5.5 批量向量化并插入日记](#5.5 批量向量化并插入日记)
    • [5.6 整合完整的 `index.mjs`](#5.6 整合完整的 index.mjs)
    • [5.7 运行 `index.mjs` 后能看到什么](#5.7 运行 index.mjs 后能看到什么)
  • [6. `query.mjs`:把问题向量化并搜索相关日记](#6. query.mjs:把问题向量化并搜索相关日记)
    • [6.1 初始化查询所需对象](#6.1 初始化查询所需对象)
    • [6.2 检查连接并生成查询向量](#6.2 检查连接并生成查询向量)
    • [6.3 搜索并输出结果](#6.3 搜索并输出结果)
    • [6.4 整合完整的 `query.mjs`](#6.4 整合完整的 query.mjs)
    • [6.5 运行 `query.mjs` 后能看到什么](#6.5 运行 query.mjs 后能看到什么)
  • [7. `rag.mjs`:完善 Prompt,实现检索增强生成](#7. rag.mjs:完善 Prompt,实现检索增强生成)
    • [7.1 同时初始化 Embedding 模型和聊天模型](#7.1 同时初始化 Embedding 模型和聊天模型)
    • [7.2 将 Milvus 搜索封装为检索函数](#7.2 将 Milvus 搜索封装为检索函数)
    • [7.3 将相关日记组织成上下文](#7.3 将相关日记组织成上下文)
    • [7.4 完善 Prompt 并调用大模型](#7.4 完善 Prompt 并调用大模型)
    • [7.5 连接 Milvus 并启动完整问答](#7.5 连接 Milvus 并启动完整问答)
    • [7.6 整合完整的 `rag.mjs`](#7.6 整合完整的 rag.mjs)
    • [7.7 运行 `rag.mjs` 后能看到什么](#7.7 运行 rag.mjs 后能看到什么)
  • [8. 串联全流程:一条问题如何得到最终答案](#8. 串联全流程:一条问题如何得到最终答案)
    • [8.1 数据准备链路](#8.1 数据准备链路)
    • [8.2 用户提问链路](#8.2 用户提问链路)
    • [8.3 正确的阶段性运行方式](#8.3 正确的阶段性运行方式)
  • [9. 项目中的关键原则](#9. 项目中的关键原则)
  • 总结

前言

普通日记应用擅长保存日期、心情、标签和正文,也能按照 ID、日期或关键词查询数据。但当用户问"我最近做过哪些让我感到快乐的事情"时,仅靠关键词匹配就不够了:日记中可能写的是"去公园散步""完成项目里程碑"或"家人喜欢我做的晚餐",并没有直接出现"快乐"这个词。

AI 日记本要解决的正是这种语义查询问题。项目先把日记正文转换成向量并存入 Milvus,再把用户问题转换成同一空间中的查询向量,通过相似度搜索找出相关日记,最后把检索结果放进 Prompt,由大模型生成自然、温暖的回答。

整套项目不是一次性堆出四个文件,而是逐步完成的:

  • 先在 Zilliz Cloud 创建项目和集群,获得数据库连接地址与 Token。
  • 使用 main.mjs 学会连接 Milvus、创建集合、创建索引、插入数据和搜索数据。
  • 使用 index.mjs 创建正式的 ai_dairy 日记集合,将五篇日记向量化后写入数据库。
  • 使用 query.mjs 把自然语言问题向量化,验证 Milvus 能否找回语义相关的日记。
  • 使用 rag.mjs 将检索结果组织成上下文,完善 Prompt,实现完整的 RAG 问答。

1. 向量数据库基础:从"是什么"到"像什么"

1.1 传统数据库与向量数据库解决的问题不同

传统 Web 应用通常把业务数据存入 MySQL、PostgreSQL 或 SQLite,再围绕数据完成增删改查,也就是 CRUD。以日记为例,传统数据库很适合处理下面的问题:

  • 查询 ID 为 diary_001 的日记。
  • 查询日期为 2026-01-12 的日记。
  • 查询心情字段等于 happy 的日记。
  • 查询标签中包含"户外"的日记。

这些查询关注的是字段是否相等、是否包含某个值,或者是否满足某个过滤条件。它们擅长回答"这条数据是什么"。

但"最近心情比较好的日记""哪些事情让我有成就感""找找与户外活动有关的内容"不一定能靠固定字段或关键词准确表达。这类问题关注的是两段文本在含义上是否相近,也就是"它们像不像"。

数据能力 传统数据库 向量数据库
核心查询 精确匹配、范围过滤、关联查询 语义相似度搜索
常见依据 ID、日期、状态、关键词 高维向量之间的距离
日记场景 查某天、某标签、某 ID 查快乐经历、户外活动、情绪趋势
擅长回答 "它是什么" "它像什么"

AI 日记本并不是要用 Milvus 替代所有传统数据库能力,而是在结构化 CRUD 之外增加语义检索能力。

1.2 Embedding 为什么能让计算机理解语义

自然语言不能直接参与数学计算。Embedding 模型会接收一段文本,并输出一个固定长度的浮点数数组:

javascript 复制代码
// 仅用于理解形式,真实项目使用的是 1024 维向量
const vector = [0.12, -0.38, 0.76, 0.21];

这个数组就是文本的向量表示。向量中的单个数字通常没有适合人类直接阅读的含义,但整个向量描述了文本在高维语义空间中的位置。语义越相近的文本,向量在空间中的位置通常也越接近。

例如,"周末和朋友去爬山"和"我想看看关于户外活动的日记"没有完全相同的字面表达,但它们都与户外、活动、放松有关。使用同一个 Embedding 模型向量化后,两者更容易被判定为相似。

这里有三个必须始终保持一致的条件:

  • 模型一致:日记和问题必须使用同一个 Embedding 模型。
  • 维度一致:集合的向量字段是 1024 维,写入和查询的向量也必须是 1024 维。
  • 语义空间一致:不能用模型 A 生成日记向量,再用模型 B 生成查询向量。

1.3 Milvus、Collection、字段和索引

Milvus 是面向海量高维向量数据设计的开源向量数据库。它既能保存向量,也能同时保存正文、日期、心情和标签等标量数据。项目中的几个核心概念如下:

Milvus 概念 类比 项目中的实例
Collection MySQL 中的表 testai_dairy
Field 表中的字段 idvectorcontent
Row 一行记录 一篇日记及其向量
Index 数据检索结构 AUTOINDEXIVF_FLAT
Metric 相似度计算方法 COSINE

如果没有索引,查询向量通常需要与集合中的大量向量逐一比较,数据规模为 n 时,搜索成本会接近 O(n)。可以把它类比为在图书馆里找《三体》:没有分类时需要一本一本翻;有了"文学 → 小说 → 科幻"的分类后,搜索范围会迅速缩小。

IVF_FLAT 是一种聚类索引。它会先把向量空间划分成多个区域,查询时优先在相近区域中寻找候选向量。AUTOINDEX 则把索引策略交给服务端管理。索引解决的是"如何更快找到候选数据",相似度度量解决的是"如何判断两个向量有多像"。

本项目使用 MetricType.COSINE,即余弦相似度。它主要比较两个向量的方向,适合文本语义搜索。搜索结果中的 score 表示相似程度,分数越高通常代表语义越接近,但它不是答案正确率。

1.4 从向量检索到 RAG

向量搜索只能找出相关日记,还不能自动组织回答。RAG 的完整名称是 Retrieval-Augmented Generation,即检索增强生成。它把检索和生成连接起来:

  1. 将用户问题转换成查询向量。
  2. 到 Milvus 中检索最相似的日记。
  3. 把日记正文、日期、心情和标签整理为上下文。
  4. 将上下文、用户问题和回答规则一起放入 Prompt。
  5. 调用聊天模型生成最终回答。

RAG 的关键不是让大模型"记住"日记,而是在回答前把真正相关的日记检索出来,作为本次回答的事实依据。

2. 项目准备:在 Zilliz 创建集群并初始化 Node.js

2.1 在 Zilliz Cloud 创建项目与集群

Zilliz Cloud 是基于 Milvus 提供的全托管向量数据库服务。使用它之后,不需要在本地安装和运维 Milvus 服务,只需要通过 Endpoint 和 Token 连接云端集群。

具体操作顺序如下:

  1. 登录 Zilliz Cloud 控制台,新建一个 Project。
  2. 在 Project 中创建 Serving 集群,学习阶段可以使用 Free 规格。
  3. 等待集群进入"运行中"状态。
  4. 打开集群的连接信息页面,复制 Public Endpoint。
  5. 获取用于身份认证的 Token。
  6. 后续所有 MilvusClient 操作都通过这组 Endpoint 和 Token 访问该集群。

图中的 Free-01 是正在运行的 Serving 集群,ai_dairytest 是该集群中的两个 Collection。test 用于 main.mjs 的基础练习,ai_dairy 用于正式的 AI 日记业务。

2.2 初始化项目并安装依赖

进入项目目录后,先初始化 Node.js 项目:

bash 复制代码
npm init -y

npm init -y 会生成默认的 package.json。其中记录项目名称、版本、脚本和依赖,是 Node.js 项目的基础配置文件。-y 表示接受默认选项,不再逐项询问。

接着安装 Milvus SDK 和环境变量加载工具:

bash 复制代码
pnpm i @zilliz/milvus2-sdk-node dotenv
依赖 作用
@zilliz/milvus2-sdk-node 在 Node.js 中连接 Milvus、创建集合、建索引、插入和搜索
dotenv .env 中加载地址、Token 和模型密钥

后续的日记向量化和 RAG 还使用了 LangChain 的 OpenAI 集成,因此需要继续安装:

bash 复制代码
pnpm i @langchain/openai

@langchain/openai 提供两个关键类:OpenAIEmbeddings 用于文本向量化,ChatOpenAI 用于调用聊天模型生成回答。

项目使用 .mjs 扩展名。.mjs 会让 Node.js 按 ES Module 解析文件,因此可以直接使用 import 语法,即使 package.json 中没有设置 "type": "module" 也能正常识别。

2.3 配置环境变量

在项目根目录创建 .env

dotenv 复制代码
# Zilliz Cloud 连接信息
MILVUS_ADDRESS=你的PublicEndpoint
MILVUS_TOKEN=你的Token

# Embedding 与聊天模型配置
OPENAI_API_KEY=你的模型服务Key
OPENAI_BASE_URL=你的模型服务地址
EMBEDDING_MODEL_NAME=统一使用的Embedding模型
MODEL_NAME=聊天模型名称

项目代码会通过 process.env.变量名 读取这些配置。.env 应加入 .gitignore,避免 Token 和 API Key 被提交到代码仓库。

2.4 四个脚本的职责与执行顺序

顺序 文件 主要任务 操作的 Collection
1 main.mjs 学习建集合、建索引、插入和搜索 test
2 index.mjs 创建日记 Schema,将五篇日记向量化入库 ai_dairy
3 query.mjs 将问题向量化并检索相关日记 ai_dairy
4 rag.mjs 将相关日记放入 Prompt,生成自然语言回答 ai_dairy

这里必须理解源码中"为什么有些代码被注释"。以 main.mjs 为例,在 test 集合尚不存在时,可以把创建集合、创建索引、插入数据和查询全部放在同一个 try 中,第一次运行会按照 await 的先后顺序连续完成。源码后来把前面几块注释掉,只是因为它们已经执行成功,当前再次运行只需要查询;这并不代表这些代码原本分属四个互不相干的程序。

下面会先按功能分块讲解,再给出整合后的完整 main.mjs,让读者既能看懂每个 API,也能明确这些代码在文件中的实际位置。

3. JavaScript 基础:读懂项目所需的核心语法

3.1 import、对象和环境变量

javascript 复制代码
import {
  MilvusClient,
  MetricType
} from '@zilliz/milvus2-sdk-node';
import 'dotenv/config';

const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;

花括号中的 MilvusClientMetricType命名导入 ,表示从一个模块中取出指定成员。import 'dotenv/config' 属于只执行模块副作用的导入:不接收返回值,但会让 dotenv 自动读取 .env

const 声明不会被重新赋值的变量。process.env 是 Node.js 暴露的环境变量对象,点语法 process.env.MILVUS_ADDRESS 用于读取其中的字段。

3.2 asyncawait 与 Promise

连接云端、生成向量、插入数据库和搜索数据都需要等待网络结果,因此这些 API 返回 Promise。async 表示函数内部可以使用 awaitawait 表示暂停当前异步函数,等待 Promise 完成后再继续。

javascript 复制代码
async function main() {
  // 等待云端返回健康检查结果,再执行下一行
  const health = await client.checkHealth();
  console.log(health);
}

main().catch(console.error);

main() 本身也会返回 Promise,所以末尾通过 .catch(console.error) 捕获未处理的异常。源码内部还使用 try...catch,目的是在某个具体业务阶段给出更明确的错误日志。

3.3 箭头函数、展开语法和可选链

javascript 复制代码
const getEmbedding = async (text) => {
  return await embeddings.embedQuery(text);
};

const row = {
  ...diary,
  vector: await getEmbedding(diary.content)
};

const tagText = diary.tags?.join(',');

(text) => {} 是箭头函数,...diary 会把日记对象已有的字段展开到新对象中,再添加 vectortags?.join(',') 使用可选链:如果 tags 不存在,表达式返回 undefined,不会因为访问空值而直接报错。

4. main.mjs:完成第一次 Milvus 数据闭环

4.1 连接 Zilliz 并检查集群状态

第一步先导入 SDK、读取连接配置、创建客户端并执行健康检查:

javascript 复制代码
import {
  MilvusClient,
  IndexType,
  MetricType
} from '@zilliz/milvus2-sdk-node';
import 'dotenv/config';

const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;

async function main() {
  const client = new MilvusClient({
    address: ADDRESS,
    token: TOKEN
  });

  console.log('正在连接 zilliz cloud...');

  // 在真正读写数据前,先确认地址、Token 和集群状态正常
  const checkHealthy = await client.checkHealth();
  if (!checkHealthy.isHealthy) {
    console.error('连接失败', checkHealthy.reasons);
    return;
  }

  console.log('连接成功,集群状态正常');
}

main().catch(console.error);

new MilvusClient() 创建一个客户端实例。配置对象中的 address 指向 Zilliz Endpoint,token 用于认证。checkHealth() 不会创建或查询数据,它只负责验证当前客户端能否正常访问集群。

if (!checkHealthy.isHealthy) 中的 ! 是逻辑取反。如果集群不健康,就打印 reasons 并执行 return,提前结束函数,避免程序在不可用的连接上继续建集合或搜索。

4.2 创建 test 集合

连接成功后,先创建一个简单的 test 集合:

javascript 复制代码
const COLLECTION_NAME = 'test';
const DIMENSION = 4;

await client.createCollection({
  collection_name: COLLECTION_NAME,
  dimension: DIMENSION,
  auto_id: true
});

console.log('集合创建成功');

createCollection() 负责创建 Collection。参数含义如下:

参数 作用 当前值
collection_name 集合名称 test
dimension 向量字段维度 4
auto_id 是否自动生成主键 true

这里使用四维向量,是为了让第一次练习足够直观。后面插入的每个 vector 必须正好包含四个数字,否则会因为维度不一致而无法写入。

第一次运行完整程序时,这段代码位于 try 的最前面。只要 test 尚不存在,它会正常创建集合,然后程序继续向下建索引、插入并查询。以后再次运行时才需要注释这段代码,否则会因为同名集合已经存在而进入 catch

4.3 为向量字段建立索引

集合存在后,为默认的 vector 字段建立索引:

javascript 复制代码
await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: 'vector',
  index_type: IndexType.AUTOINDEX,
  metric_type: MetricType.COSINE
});

console.log('索引创建成功');

field_name: 'vector' 表示索引作用于向量字段;IndexType.AUTOINDEX 让 Zilliz 自动管理索引策略;MetricType.COSINE 表示使用余弦相似度。

索引不保存新的业务内容,它是建立在已有向量字段之上的查询结构。没有索引时,数据量越大,需要比较的向量越多;有了索引后,系统可以先缩小候选范围,再计算相似度。

这段代码紧跟在 createCollection() 后面。由于前面使用了 await,集合创建完成后才会开始建索引,不会发生索引早于集合创建的问题。索引也是一次性结构,完整程序第一次执行成功后,后续只做搜索时可与创建集合代码一起注释。

4.4 插入两条测试数据

javascript 复制代码
const data = [
  {
    vector: [0.1, 0.2, 0.3, 0.4],
    content: '这是第一条数据'
  },
  {
    vector: [0.5, 0.6, 0.7, 0.8],
    content: '这是第二条数据'
  }
];

const insertRes = await client.insert({
  collection_name: COLLECTION_NAME,
  data
});

console.log('插入成功', insertRes.IDs);

data 是一个数组,数组中的每个对象代表一行记录。对象的 vector 字段用于相似度搜索,content 字段保存可读文本。

调用 client.insert() 时使用了对象属性简写:

javascript 复制代码
{
  collection_name: COLLECTION_NAME,
  data
}

最后一个 data 等价于 data: data。JavaScript 允许在属性名与变量名相同时省略冒号后的变量名。Milvus SDK 直接接收 JavaScript 对象,不需要手写 SQL,这也是源码注释中"JSON 不用写 SQL"的含义。

插入成功后,insertRes.IDs 中包含自动生成的主键。第一次运行时程序会在建索引后继续执行这一块,然后马上进入搜索。以后只验证查询时,可以把插入代码与前面的初始化代码一起注释,避免重复增加测试记录。

4.5 使用向量查询数据

集合、索引和测试数据都准备好后,最后执行搜索:

javascript 复制代码
const searchRes = await client.search({
  collection_name: COLLECTION_NAME,
  data: [[0.5, 0.5, 0.6, 0.8]],
  limit: 2,
  output_fields: ['content']
});

console.log(JSON.stringify(searchRes.results, null, 2));

查询参数的含义如下:

参数 作用
collection_name 指定搜索 test 集合
data 查询向量列表,内层数组是一条四维向量
limit 最多返回两条最相似记录
output_fields 除相似度外,还返回 content 字段

查询向量 [0.5, 0.5, 0.6, 0.8] 与第二条数据 [0.5, 0.6, 0.7, 0.8] 更接近,因此第二条记录通常会排在更靠前的位置。

JSON.stringify(searchRes.results, null, 2) 的第二个参数 null 表示不自定义字段转换,第三个参数 2 表示使用两个空格缩进。这样打印出的搜索结果比默认对象日志更容易阅读。

4.6 整合完整的 main.mjs

前面为了讲清 API 将代码拆成了几个小块。把它们放回同一个文件后,第一次运行的完整代码如下:

javascript 复制代码
import {
  MilvusClient,
  IndexType,
  MetricType
} from '@zilliz/milvus2-sdk-node';
import 'dotenv/config';

const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;

async function main() {
  const client = new MilvusClient({
    address: ADDRESS,
    token: TOKEN
  });

  console.log('正在连接 zilliz cloud...');

  const checkHealthy = await client.checkHealth();
  if (!checkHealthy.isHealthy) {
    console.error('连接失败', checkHealthy.reasons);
    return;
  }

  console.log('连接成功,集群状态正常');

  const COLLECTION_NAME = 'test';
  const DIMENSION = 4;

  try {
    // 1. 创建集合
    await client.createCollection({
      collection_name: COLLECTION_NAME,
      dimension: DIMENSION,
      auto_id: true
    });
    console.log('集合创建成功');

    // 2. 集合创建完成后建立向量索引
    await client.createIndex({
      collection_name: COLLECTION_NAME,
      field_name: 'vector',
      index_type: IndexType.AUTOINDEX,
      metric_type: MetricType.COSINE
    });
    console.log('索引创建成功');

    // 3. 索引建立后插入两条测试数据
    const data = [
      {
        vector: [0.1, 0.2, 0.3, 0.4],
        content: '这是第一条数据'
      },
      {
        vector: [0.5, 0.6, 0.7, 0.8],
        content: '这是第二条数据'
      }
    ];

    const insertRes = await client.insert({
      collection_name: COLLECTION_NAME,
      data
    });
    console.log('插入成功', insertRes.IDs);

    // 4. 数据插入完成后立即执行相似度查询
    const searchRes = await client.search({
      collection_name: COLLECTION_NAME,
      data: [[0.5, 0.5, 0.6, 0.8]],
      limit: 2,
      output_fields: ['content']
    });

    console.log(JSON.stringify(searchRes.results, null, 2));
  } catch (err) {
    console.error('集合可能存在或创建出错', err.message);
  }
}

main().catch(console.error);

这段完整代码不是四次执行,而是同一次 main() 调用中的连续流程。每个数据库 API 前都有 await,所以只有前一步成功返回后才会进入下一步。只要 test 集合尚不存在、连接配置正确,程序就会依次完成创建、建索引、插入和查询。

try...catch 包住了所有数据库操作。如果集合已经存在,再次执行 createCollection() 时会抛出异常,程序会直接进入 catch,后面的建索引、插入和查询不会继续执行。因此第一次完整运行成功后,源码才把创建集合、创建索引和插入数据注释,只留下查询代码。这样再次运行时不会被重复创建操作打断。

至此,main.mjs 完成了最小闭环:连接 → 建集合 → 建索引 → 插入 → 查询。分块代码负责解释细节,完整代码负责呈现它们在文件中的真实位置和执行顺序。

4.7 运行 main.mjs 后能看到什么

首次执行:

bash 复制代码
node src/main.mjs

终端会依次出现类似日志:

text 复制代码
正在连接 zilliz cloud...
连接成功,集群状态正常
集合创建成功
索引创建成功
插入成功 [自动生成的ID]
[
  {
    "score": 相似度分数,
    "content": "这是第二条数据"
  },
  {
    "score": 相似度分数,
    "content": "这是第一条数据"
  }
]

最终会得到三个可观察的效果:

  • Zilliz Cloud 集群中出现名为 test 的 Collection。
  • test 中写入两条包含四维向量和 content 的测试记录。
  • 查询向量会返回两条按相似度排序的记录,其中第二条数据通常排在前面。

如果再次运行未注释的完整版本,终端会打印"集合可能存在或创建出错",因为 test 已经存在。这时按项目最终状态注释创建、建索引和插入部分,仅保留搜索即可重复查看查询结果。

5. index.mjs:创建 AI 日记集合并写入向量数据

5.1 初始化 Embedding 与 Milvus 客户端

main.mjs 中的四维向量是手工编写的测试数据。真实日记不能靠人工编写 1024 个浮点数,因此 index.mjs 引入 OpenAIEmbeddings 自动生成向量:

javascript 复制代码
import 'dotenv/config';
import {
  MilvusClient,
  MetricType,
  IndexType,
  DataType
} from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';

const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;
const COLLECTION_NAME = 'ai_dairy';
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDING_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL
  },
  dimensions: VECTOR_DIM
});

const client = new MilvusClient({
  address: ADDRESS,
  token: TOKEN
});

const getEmbedding = async (text) => {
  // embedQuery 接收字符串,返回该文本对应的向量数组
  const result = await embeddings.embedQuery(text);
  return result;
};

VECTOR_DIM 被抽成常量,是因为创建集合、配置模型和检查向量长度都依赖同一个维度。只维护一个常量,可以避免多处手写 1024 造成不一致。

configuration.baseURL 用于指定兼容 OpenAI 接口的模型服务地址。getEmbedding()embeddings.embedQuery() 做了一层简单封装,后面无论是处理日记还是问题,都可以直接传入文本获得向量。

5.2 创建 ai_dairy 集合

正式日记不仅需要向量,还要保存 ID、正文、日期、心情和标签,因此使用 fields 明确定义 Schema:

javascript 复制代码
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
    }
  ]
});

console.log('collection created');

字段设计如下:

字段 数据类型 关键约束 业务作用
id VarChar 主键,最长 50 唯一标识一篇日记
vector FloatVector 1024 维 用于语义相似度搜索
content VarChar 最长 5000 保存日记原文
date VarChar 最长 50 保存日期
mood VarChar 最长 50 保存心情
tags Array<VarChar> 最多 10 个元素 保存多个标签

is_primary_key: true 表示 id 是主键,每篇日记的 ID 必须唯一。DataType.FloatVector 专门保存浮点向量,dim 必须与 VECTOR_DIM 一致。tags 使用数组类型,是因为一篇日记可能同时属于"户外"和"朋友"等多个标签。

这段代码位于日记初始化流程的前部,用于准备正式的业务 Schema。集合已经创建后不应重复执行,因此当前源码中保留了代码但将其注释。

5.3 创建 IVF_FLAT 索引

javascript 复制代码
await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: 'vector',
  index_type: IndexType.IVF_FLAT,
  metric_type: MetricType.COSINE
});

console.log('index created');

这里没有继续使用 AUTOINDEX,而是显式选择 IVF_FLAT。它会先对向量进行聚类,搜索时优先检查与查询向量接近的聚类区域,从而减少无关比较。

索引建立在 ai_dairy.vector 字段上,并使用 COSINE 作为相似度度量。后面的 query.mjsrag.mjs 同样使用 MetricType.COSINE,这样写入、索引和查询阶段的语义判断标准是一致的。

索引同样只需创建一次,所以当前源码中与集合创建代码一起被注释。它们仍属于 index.mjs 的数据准备逻辑,不需要理解成多个独立程序。

5.4 加载集合并准备五篇日记

javascript 复制代码
console.log('loading collection');

await client.loadCollection({
  collection_name: COLLECTION_NAME
});

console.log('collection loaded');

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: ['美食', '家庭']
  }
];

loadCollection() 把集合加载到可搜索状态。虽然这一步出现在插入之前,但它也为后续立即执行查询做好准备。

diaryContents 中的每个对象都符合刚才定义的 Schema,只是暂时没有 vector。原始正文必须保留,因为向量适合数学比较,却不适合直接展示给用户;后续 RAG 需要把 content 放回 Prompt。

5.5 批量向量化并插入日记

javascript 复制代码
console.log('Generating embeddings...');

const diaryData = await Promise.all(
  diaryContents.map(async (diary) => ({
    // 保留 id、content、date、mood 和 tags
    ...diary,
    // 为当前日记新增 1024 维 vector 字段
    vector: await getEmbedding(diary.content)
  }))
);

const insertResult = await client.insert({
  collection_name: COLLECTION_NAME,
  data: diaryData
});

console.log(insertResult.insert_cnt, '条记录成功插入。');

这一块是日记入库的核心,执行过程可以拆成四步:

  1. diaryContents.map() 遍历五篇日记。
  2. 每次调用 getEmbedding(diary.content),把正文转换成向量。
  3. ...diary 保留原字段,再添加 vector,形成符合 Collection Schema 的新对象。
  4. Promise.all() 等待五次异步向量化全部完成,得到 diaryData

为什么不能直接写成普通 map() 后立即插入?因为异步回调返回的是 Promise,如果不使用 Promise.all(),数组中保存的会是尚未完成的 Promise,而不是最终日记对象。

insertResult.insert_cnt 表示成功插入的记录数量。整个 index.mjs 的连续逻辑是:连接并检查集群、准备集合和索引、加载集合、定义日记、批量向量化、一次性插入。五篇固定日记写入成功后,不需要反复运行插入逻辑。至此,ai_dairy 已经具备可供语义搜索的数据。

5.6 整合完整的 index.mjs

把前面的配置、建集合、建索引、加载、向量化和插入重新组合后,首次准备日记数据库的完整代码如下:

javascript 复制代码
import 'dotenv/config';
import {
  MilvusClient,
  MetricType,
  IndexType,
  DataType
} from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';

const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;
const COLLECTION_NAME = 'ai_dairy';
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDING_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL
  },
  dimensions: VECTOR_DIM
});

const client = new MilvusClient({
  address: ADDRESS,
  token: TOKEN
});

const getEmbedding = async (text) => {
  const result = await embeddings.embedQuery(text);
  return result;
};

async function main() {
  console.log('正在连接 zilliz cloud....');

  const checkHealth = await client.checkHealth();
  if (!checkHealth.isHealthy) {
    console.error('连接失败', checkHealth.reasons);
    return;
  }
  console.log('连接成功,集群状态正常。');

  // 1. 创建能够同时保存向量和日记元数据的集合
  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
      }
    ]
  });
  console.log('collection created');

  // 2. 为 vector 字段建立余弦相似度索引
  await client.createIndex({
    collection_name: COLLECTION_NAME,
    field_name: 'vector',
    index_type: IndexType.IVF_FLAT,
    metric_type: MetricType.COSINE
  });
  console.log('index created');

  // 3. 将集合加载到可用状态
  console.log('loading collection');
  await client.loadCollection({
    collection_name: COLLECTION_NAME
  });
  console.log('collection loaded');

  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: ['美食', '家庭']
    }
  ];

  // 4. 并行生成五个向量,并将向量补充到日记对象中
  console.log('Generating embeddings...');
  const diaryData = await Promise.all(
    diaryContents.map(async (diary) => ({
      ...diary,
      vector: await getEmbedding(diary.content)
    }))
  );

  // 5. 将完整日记记录批量写入 ai_dairy
  const insertResult = await client.insert({
    collection_name: COLLECTION_NAME,
    data: diaryData
  });

  console.log(insertResult.insert_cnt, '条记录成功插入。');
}

main().catch(console.error);

这段代码中的所有 await 也构成连续执行链:健康检查通过后创建集合,集合创建完成后建索引,随后加载集合、生成五个向量并完成批量插入。源码中创建集合和索引被注释,是因为实际数据库已经完成这两项初始化;分块讲解与完整代码展示的是它们在整个日记入库流程中的原始位置。

5.7 运行 index.mjs 后能看到什么

首次运行:

bash 复制代码
node src/index.mjs

终端会出现类似输出:

text 复制代码
正在连接 zilliz cloud....
连接成功,集群状态正常。
collection created
index created
loading collection
collection loaded
Generating embeddings...
5 条记录成功插入。

运行完成后,Zilliz Cloud 中会出现 ai_dairy Collection,并包含 idvectorcontentdatemoodtags 六个字段。五篇日记各自拥有一个 1024 维向量,因此后续可以使用自然语言问题进行语义搜索。

如果 ai_dairy 已经在前一次运行中创建,实际运行当前项目源码时应保持创建集合和索引代码为注释状态,直接加载已有集合并处理需要写入的新日记。固定的五篇示例日记也不应使用相同主键重复插入。

6. query.mjs:把问题向量化并搜索相关日记

6.1 初始化查询所需对象

javascript 复制代码
import 'dotenv/config';
import {
  MilvusClient,
  MetricType,
  IndexType,
  DataType
} from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';

const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;
const COLLECTION_NAME = 'ai_dairy';
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDING_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL
  },
  dimensions: VECTOR_DIM
});

const client = new MilvusClient({
  address: ADDRESS,
  token: TOKEN
});

const getEmbedding = async (text) => {
  const result = await embeddings.embedQuery(text);
  return result;
};

这一部分与 index.mjs 基本一致,因为入库和查询必须使用相同的 Embedding 逻辑。IndexTypeDataType 在当前查询阶段没有直接调用,但保留了原项目的导入结构。

6.2 检查连接并生成查询向量

javascript 复制代码
async function main() {
  try {
    console.log('Connection to Milvus...');

    const health = await client.checkHealth();
    if (!health.isHealthy) {
      console.error('连接失败', health.reasons);
      return;
    }

    console.log('Connected');

    const query = '我想看看关于户外活动的日记';
    console.log(`QUERY:${query}`);

    // 用户输入不能直接交给 Milvus,需要先变成 1024 维查询向量
    const queryVector = await getEmbedding(query);
  } catch (err) {
    console.error(err);
  }
}

模板字符串 QUERY:${query} 使用反引号包裹,并通过 ${query} 插入变量。queryVector 与日记入库时的 vector 位于同一语义空间,因此 Milvus 才能比较它们的相似程度。

6.3 搜索并输出结果

将搜索逻辑接在生成 queryVector 之后:

javascript 复制代码
const searchResult = await client.search({
  collection_name: COLLECTION_NAME,
  vector: queryVector,
  limit: 2,
  metric_type: MetricType.COSINE,
  output_fields: [
    'id',
    'content',
    'date',
    'mood',
    'tags'
  ]
});

console.log(`Found ${searchResult.results.length} results.`);

searchResult.results.forEach((item, index) => {
  console.log(`${index + 1}.[Score:${item.score.toFixed(4)}]`);

  console.log(`
    ID:${item.id};
    Date:${item.date};
    Mood:${item.mood};
    Tags:${item.tags?.join(',')}
    Content:${item.content}
  `);
});

vector 是刚生成的查询向量,limit: 2 表示只取两个最相似结果,metric_type 与建索引阶段保持一致。output_fields 明确列出要带回的业务字段,否则搜索结果只有主键和分数,无法展示完整日记。

对于"户外活动"这个问题,diary_003 中的"和朋友去爬山""享受大自然"在语义上最接近,因此通常会成为最相关结果。

forEach() 用于遍历搜索结果,index + 1 将从 0 开始的数组索引转换成从 1 开始的展示序号。item.score.toFixed(4) 把相似度分数保留四位小数,item.tags?.join(',') 则把标签数组连接成字符串。

这一阶段证明了 Milvus 可以从"户外活动"这样的自然语言问题中召回相关日记,但程序目前仍然只是把数据库记录打印出来。下一步要让大模型阅读这些记录并生成更自然的回答。

6.4 整合完整的 query.mjs

将初始化、健康检查、问题向量化、Milvus 搜索和结果输出整合后,完整代码如下:

javascript 复制代码
import 'dotenv/config';
import {
  MilvusClient,
  MetricType,
  IndexType,
  DataType
} from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';

const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;
const COLLECTION_NAME = 'ai_dairy';
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDING_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL
  },
  dimensions: VECTOR_DIM
});

const client = new MilvusClient({
  address: ADDRESS,
  token: TOKEN
});

const getEmbedding = async (text) => {
  const result = await embeddings.embedQuery(text);
  return result;
};

async function main() {
  try {
    console.log('Connection to Milvus...');

    const health = await client.checkHealth();
    if (!health.isHealthy) {
      console.error('连接失败', health.reasons);
      return;
    }

    console.log('Connected');

    // 1. 准备自然语言查询
    const query = '我想看看关于户外活动的日记';
    console.log(`QUERY:${query}`);

    // 2. 使用与入库相同的模型生成查询向量
    const queryVector = await getEmbedding(query);

    // 3. 在 ai_dairy 中搜索两个最相似结果
    const searchResult = await client.search({
      collection_name: COLLECTION_NAME,
      vector: queryVector,
      limit: 2,
      metric_type: MetricType.COSINE,
      output_fields: [
        'id',
        'content',
        'date',
        'mood',
        'tags'
      ]
    });

    console.log(`Found ${searchResult.results.length} results.`);

    // 4. 输出相似度以及每篇日记的业务字段
    searchResult.results.forEach((item, index) => {
      console.log(`${index + 1}.[Score:${item.score.toFixed(4)}]`);

      console.log(`
        ID:${item.id};
        Date:${item.date};
        Mood:${item.mood};
        Tags:${item.tags?.join(',')}
        Content:${item.content}
      `);
    });
  } catch (err) {
    console.error(err);
  }
}

main();

完整程序只有一条清晰的数据流:字符串问题 → getEmbedding() → 查询向量 → client.search() → 相关日记数组 → forEach() 输出 。分块讲解中的变量都位于同一个 main() 作用域中,因此 queryVector 可以直接传给后面的搜索请求。

6.5 运行 query.mjs 后能看到什么

执行:

bash 复制代码
node src/query.mjs

终端会输出类似内容:

text 复制代码
Connection to Milvus...
Connected
QUERY:我想看看关于户外活动的日记
Found 2 results.
1.[Score:0.xxxx]
ID:diary_003;
Date:2026-01-12;
Mood:relaxed;
Tags:户外,朋友
Content:周末和朋友去爬山,天气很好,心情也很放松......

实际分数由 Embedding 模型决定,因此不应该写死。最直观的效果是:问题中没有直接写"爬山",但 Milvus 仍能把包含"爬山、朋友、大自然"的 diary_003 召回,这说明查询已经从关键词匹配升级为语义匹配。第二条结果也会按照相似度返回,但具体是哪篇日记取决于模型生成的向量。

7. rag.mjs:完善 Prompt,实现检索增强生成

7.1 同时初始化 Embedding 模型和聊天模型

javascript 复制代码
import 'dotenv/config';
import {
  MilvusClient,
  MetricType
} from '@zilliz/milvus2-sdk-node';
import {
  ChatOpenAI,
  OpenAIEmbeddings
} from '@langchain/openai';

const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;
const COLLECTION_NAME = 'ai_dairy';
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDING_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL
  },
  dimensions: VECTOR_DIM
});

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
  }
});

const client = new MilvusClient({
  address: ADDRESS,
  token: TOKEN
});

const getEmbedding = async (text) => {
  const result = await embeddings.embedQuery(text);
  return result;
};

这里存在两类模型对象:

对象 职责 输入与输出
embeddings 把问题转换成向量 文本 → 浮点数组
model 根据 Prompt 生成回答 Prompt → 自然语言

temperature: 0.1 把生成随机性设置得较低。AI 日记问答更重视忠于已有日记,而不是追求天马行空的表达,因此低温度更合适。

7.2 将 Milvus 搜索封装为检索函数

javascript 复制代码
async function retrieveDiaries(question, k = 2) {
  try {
    // Retrieval:先把问题向量化,再检索最相关的 k 篇日记
    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.error('检索日记时出错', err.message);
    return [];
  }
}

question 是用户问题,k = 2 是默认参数,表示调用者没有传入第二个参数时默认检索两篇日记。limit: k 让调用者可以控制召回数量。

把检索逻辑封装成 retrieveDiaries(),是为了让 RAG 的 Retrieval 与 Generation 分离。函数只负责"找到哪些日记相关",不负责组织最终回答。发生错误时返回空数组,调用者可以统一处理"没有检索结果"的情况。

7.3 将相关日记组织成上下文

javascript 复制代码
async function answerDiaryQuestion(question, k = 2) {
  try {
    console.log('='.repeat(50));
    console.log(`问题: ${question}`);
    console.log('='.repeat(50));

    console.log('检索相关日记');
    const retrievedDiaries = await retrieveDiaries(question, k);

    if (retrievedDiaries.length === 0) {
      return '没有找到相关日记';
    }

    retrievedDiaries.forEach((diary, i) => {
      console.log(
        `日记${i + 1} 相似度:${diary.score.toFixed(4)}
内容: ${diary.content}`
      );
    });

    const context = retrievedDiaries
      .map((diary, i) => `
        [日记${i + 1}]
        日期: ${diary.date}
        心情: ${diary.mood}
        标签: ${diary.tags?.join(', ')}
        内容: ${diary.content}
      `)
      .join('\n\n----\n\n');
  } catch (err) {
    console.error(err);
  }
}

'='.repeat(50) 会重复等号 50 次,用于在终端中划分问题和结果。检索结束后先检查数组长度,如果没有日记就提前返回。

map() 把每篇日记转换成带编号、日期、心情、标签和正文的文本片段,join('\n\n----\n\n') 再用分隔线连接所有片段。这样得到的 context 不是给用户直接看的,而是为聊天模型准备的事实材料。

7.4 完善 Prompt 并调用大模型

javascript 复制代码
const prompt = `你是一个温暖贴心的AI 日记助手。基于用户的日记内容回答问题,
用亲切自然的语言。请根据以下日记内容回答问题:
${context}

用户问题: ${question}

回答要求:
1. 如果日记中有相关信息,请结合日记内容给出详细温暖的回答。
2. 可以总结多篇日记的内容,找出共同点或趋势。
3. 如果日记中没有相关信息,请温和告知用户。
4. 用第一人称"你"来称呼日记的作者。
5. 回答要有同理心,让用户感到被理解和关心。

AI助手的回答:
`;

console.log('[AI回答]');

// Generation:把检索上下文交给聊天模型生成最终回答
const response = await model.invoke(prompt);
console.log(response.content);

Prompt 分成四个部分:

Prompt 部分 作用
角色 规定模型是温暖贴心的日记助手
context 提供 Milvus 检索到的真实日记
question 明确用户本次想问什么
回答要求 约束事实边界、语气、称呼和表达方式

如果只把日记内容丢给模型,它可能不知道应该以什么身份回答,也可能忽略"没有信息时不要编造"的边界。Prompt 中的五条规则分别控制信息来源、跨日记总结、无结果处理、称呼方式和情感表达。

model.invoke(prompt) 将完整 Prompt 发送给聊天模型,返回的 response 是消息对象,真正要打印的文本位于 response.content

7.5 连接 Milvus 并启动完整问答

javascript 复制代码
async function main() {
  try {
    console.log('连接到Milvus...');

    // 等待客户端完成与云端服务的握手
    await client.connectPromise;
    console.log('已连接');

    await answerDiaryQuestion(
      '我最近做了什么让我感到快乐的事情?',
      2
    );
  } catch (err) {
    console.error('连接失败', err);
  }
}

main().catch(console.error);

await client.connectPromise 等待客户端完成连接握手。随后调用 answerDiaryQuestion(),传入问题和召回数量 2。函数内部会自动完成向量化、检索、上下文拼接、Prompt 构造和模型调用。

7.6 整合完整的 rag.mjs

将模型初始化、检索函数、上下文处理、Prompt 和主函数放回同一个文件后,完整代码如下:

javascript 复制代码
import 'dotenv/config';
import {
  MilvusClient,
  MetricType
} from '@zilliz/milvus2-sdk-node';
import {
  ChatOpenAI,
  OpenAIEmbeddings
} from '@langchain/openai';

const ADDRESS = process.env.MILVUS_ADDRESS;
const TOKEN = process.env.MILVUS_TOKEN;
const COLLECTION_NAME = 'ai_dairy';
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDING_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL
  },
  dimensions: VECTOR_DIM
});

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
  }
});

const client = new MilvusClient({
  address: ADDRESS,
  token: TOKEN
});

const getEmbedding = async (text) => {
  const result = await embeddings.embedQuery(text);
  return result;
};

// Retrieval:负责从 Milvus 中找到相关日记
async function retrieveDiaries(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.error('检索日记时出错', err.message);
    return [];
  }
}

// Augment + Generation:构造上下文并调用聊天模型
async function answerDiaryQuestion(question, k = 2) {
  try {
    console.log('='.repeat(50));
    console.log(`问题: ${question}`);
    console.log('='.repeat(50));

    console.log('检索相关日记');
    const retrievedDiaries = await retrieveDiaries(question, k);

    if (retrievedDiaries.length === 0) {
      return '没有找到相关日记';
    }

    retrievedDiaries.forEach((diary, i) => {
      console.log(`日记${i + 1} 相似度:${diary.score.toFixed(4)}
内容: ${diary.content}`);
    });

    const context = retrievedDiaries
      .map((diary, i) => `
        [日记${i + 1}]
        日期: ${diary.date}
        心情: ${diary.mood}
        标签: ${diary.tags?.join(', ')}
        内容: ${diary.content}
      `)
      .join('\n\n----\n\n');

    const prompt = `你是一个温暖贴心的AI 日记助手。基于用户的日记内容回答问题,
用亲切自然的语言。请根据以下日记内容回答问题:
${context}

用户问题: ${question}

回答要求:
1. 如果日记中有相关信息,请结合日记内容给出详细温暖的回答。
2. 可以总结多篇日记的内容,找出共同点或趋势。
3. 如果日记中没有相关信息,请温和告知用户。
4. 用第一人称"你"来称呼日记的作者。
5. 回答要有同理心,让用户感到被理解和关心。

AI助手的回答:
`;

    console.log('[AI回答]');
    const response = await model.invoke(prompt);
    console.log(response.content);
  } catch (err) {
    console.error(err);
  }
}

async function main() {
  try {
    console.log('连接到Milvus...');
    await client.connectPromise;
    console.log('已连接');

    await answerDiaryQuestion(
      '我最近做了什么让我感到快乐的事情?',
      2
    );
  } catch (err) {
    console.error('连接失败', err);
  }
}

main().catch(console.error);

完整程序体现了 RAG 的三段职责:retrieveDiaries() 负责 Retrieval,contextprompt 负责 Augment,model.invoke() 负责 Generation。answerDiaryQuestion() 是中间编排层,它把三个阶段串成一次问答,而 main() 只负责连接和传入用户问题。

7.7 运行 rag.mjs 后能看到什么

执行:

bash 复制代码
node src/rag.mjs

终端会先打印问题和两篇召回日记,再输出模型回答:

text 复制代码
连接到Milvus...
已连接
==================================================
问题: 我最近做了什么让我感到快乐的事情?
==================================================
检索相关日记
日记1 相似度:0.xxxx
内容: 今天天气很好,去公园散步了......
日记2 相似度:0.xxxx
内容: 晚上做了一顿丰盛的晚餐......
[AI回答]
最近有几件事给你带来了快乐:你去公园散步、欣赏春天盛开的花,
也尝试了新的晚餐菜谱,并得到了家人的认可......

日记排序、相似度和最终措辞会随模型而变化,但业务效果是稳定的:终端不再只展示数据库记录,而是得到一段结合多篇日记、带有情绪理解的自然语言回答。回答中的事实来自 Milvus 召回的 context,表达方式则由 Prompt 和聊天模型共同决定。

8. 串联全流程:一条问题如何得到最终答案

8.1 数据准备链路

AI 日记本的数据不是在提问时才临时生成,而是提前完成下面的准备:

  1. 在 Zilliz Cloud 创建 Project 和 Serving 集群。
  2. 通过 Endpoint 与 Token 建立 Node.js 客户端连接。
  3. main.mjs 使用 test 集合验证创建、索引、插入和查询 API。
  4. index.mjs 创建 ai_dairy Schema 与 IVF_FLAT 索引。
  5. 对五篇日记分别调用 embedQuery()
  6. 把向量与 idcontentdatemoodtags 一起写入 Milvus。

此时数据库中保存的不是孤立向量,而是"向量 + 原文 + 元数据"的完整记录。向量负责找到内容,原文和元数据负责解释内容。

8.2 用户提问链路

当用户输入"我最近做了什么让我感到快乐的事情"时,系统依次执行:

  1. getEmbedding(question) 把问题变成 1024 维查询向量。
  2. retrieveDiaries()ai_dairy 中执行余弦相似度搜索。
  3. Milvus 返回最相似的两篇日记及分数。
  4. map()join() 把日记整理成 context
  5. Prompt 将角色、日记上下文、用户问题和回答规则组合起来。
  6. ChatOpenAI.invoke() 根据上下文生成回答。
阶段 输入 处理 输出
向量化 用户问题 embedQuery() 查询向量
检索 查询向量 client.search() 两篇相关日记
增强 搜索结果 拼接 context 有事实依据的 Prompt
生成 Prompt model.invoke() 自然语言回答

这就是完整的 RAG 闭环:Retrieve 找资料,Augment 补上下文,Generate 生成回答

8.3 正确的阶段性运行方式

项目中的创建和插入操作都不是每次运行都要重复执行。推荐按照以下阶段操作:

bash 复制代码
# 1. 初始化项目与依赖
npm init -y
pnpm i @zilliz/milvus2-sdk-node dotenv
pnpm i @langchain/openai

# 2. 首次运行 main.mjs:依次创建 test、建索引、插入并查询
node src/main.mjs

# 3. 运行 index.mjs:准备 ai_dairy 并插入五篇日记
node src/index.mjs

# 4. 验证语义检索
node src/query.mjs

# 5. 执行完整 RAG 问答
node src/rag.mjs

main.mjs 第一次运行时可以保留全部代码,一次完成创建、索引、插入和查询。第一次成功后,再把创建、建索引和插入这些一次性操作注释,只保留查询。index.mjsquery.mjsrag.mjs 都应按各自文件中的逻辑连续理解,不需要拆成多次独立执行。

9. 项目中的关键原则

  • 向量维度必须一致:集合字段、日记向量和查询向量都必须是 1024 维。
  • Embedding 模型必须一致:否则日记和问题不在同一语义空间。
  • 索引和搜索度量一致 :项目统一使用 MetricType.COSINE
  • 原文不能丢失 :向量负责搜索,content 才是 RAG 的事实上下文。
  • 一次性操作不要重复执行:创建集合、创建索引和插入固定测试数据完成后应注释。
  • Token 不写入源码 :连接凭证和模型密钥统一放进 .env
  • RAG 先检索再生成:大模型的回答应尽量建立在检索到的真实日记上。

总结

这个项目从最小化的四维向量练习开始,先通过 main.mjs 掌握 Milvus 的连接、建集合、建索引、插入和搜索,再在 index.mjs 中建立正式的日记 Schema,将五篇日记转换成 1024 维向量写入 ai_dairyquery.mjs 验证了自然语言问题可以召回语义相关的日记,rag.mjs 则进一步把搜索结果整理成 Prompt 上下文,由聊天模型生成有事实依据、语气自然的回答。整个过程体现了 AI 应用中"结构化数据管理 + 向量语义检索 + 大模型生成"的完整协作方式。

相关推荐
DFT计算杂谈1 小时前
交错磁研究进展材料物性与交叉应用
数据库·人工智能·python·opencv·算法
ToolReel1 小时前
Node.js 查询 Google Analytics 4 数据:GA Lite API 接入实战
node.js
IvorySQL2 小时前
PG 日报|查询优化器重大修复,支持更多复杂 SQL 优化
数据库·人工智能·postgresql·开源
寒水馨2 小时前
Linux下载、安装 Bun v1.3.14(附安装包bun-linux-x64.zip)
linux·javascript·typescript·node.js·bun·运行时·包管理器
写代码的强哥2 小时前
TiDB 和 OceanBase 对比:架构师视角下的企业选型实战指南
数据库·云原生·架构
兮动人3 小时前
Linux 安装 Claude Code 实战:Node.js、npm、GLM 配置一次跑通
linux·npm·node.js·cc·claude code
金融小白数据分析之路4 小时前
绍兴市镇街echarts 制作
前端·数据库·echarts
桐薇全肯定4 小时前
MySQL学生成绩管理系统实战操作
java·数据库·sql
hellojike4 小时前
free-stockdb本地金融数据库核心设计
数据库·金融
井川廊咏5 小时前
初探性能优化——2个月到4小时的性能提升
jvm·数据库·性能优化