用 Milvus + Qwen 做 AI 日记本:从 Embedding 入库到 RAG 问答

2 阅读8分钟

普通日记应用可以通过日期、标签和心情字段完成增删改查。例如筛选 mood = happy,或者查询某一天写过什么。

但用户更自然的提问往往不是标准字段:

我最近做了什么让我感到快乐的事情?

答案可能分散在“公园散步”“完成项目里程碑”“和朋友爬山”“给家人做晚餐”等多篇日记里。仅靠关键词或固定字段,很难覆盖这些不同表达。

本文使用 Embedding、Milvus 和 Qwen 完成一个 AI 日记本:先把日记内容转换成向量存入 Milvus,再根据自然语言问题检索相关日记,最后把检索结果交给 Qwen 组织回答。

AI 日记本为什么仍然需要结构化字段

向量可以表达日记内容的语义,但它不能替代所有业务字段。

一篇日记仍然需要明确的结构:

{
  id: 'diary_003',
  content: '周末和朋友去爬山,天气很好,心情也很放松。',
  date: '2026-01-12',
  mood: 'relaxed',
  tags: ['户外', '朋友']
}

这些字段承担不同职责:

字段作用
id唯一标识日记,便于更新和删除
content保存完整日记,也是 Embedding 的主要输入
date支持时间筛选和结果展示
mood保存明确的情绪分类
tags保存业务标签
vector参与语义相似度检索

因此,一个完整的 AI 日记本通常有两类能力:

结构化字段 → 精确 CRUD、筛选、排序
向量字段   → 语义搜索、相似回忆、RAG 问答

二者不是竞争关系,而是共同描述同一篇日记。

为日记设计 Milvus Schema

示例创建名为 ai_dairy 的 Collection,向量维度为 1024:

const COLLECTION_NAME = 'ai_dairy';
const VECTOR_DIM = 1024;

Collection 的字段定义如下:

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

id 被设为主键,因此同一篇日记可以被稳定定位。vector 必须明确维度,其余字段在检索命中后作为 metadata 一起返回。

向量字段使用 IVF_FLAT 索引和余弦相似度:

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

IVF_FLAT 会先对向量进行聚类,查询时缩小需要比较的范围,再在候选范围内计算距离。Collection 和索引只需要首次创建,后续运行直接加载已经存在的 Collection 即可,因此示例完成初始化后将创建代码注释了起来。

Embedding Model 负责把日记变成向量

Milvus 不会自动理解日记文字。写入数据库前,需要先调用 Embedding Model:

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

这里使用 LangChain 的 OpenAI 兼容封装,实际模型由项目配置决定。dimensions 与 Collection 中的 VECTOR_DIM 保持一致。

项目使用 getEmbedding 封装单条文本的向量生成:

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

随后通过 Promise.all 并发处理多篇日记:

const diaryData = await Promise.all(
  diaryContents.map(async (diary) => ({
    ...diary,
    vector: await getEmbedding(diary.content),
  }))
);

map 为每篇日记创建一个异步任务,Promise.all 等待所有向量生成完成,并按照原数组顺序返回结果。这样 vector 会直接和对应日记的 id、正文、日期、心情及标签组合在一起。

处理完成后,每条数据同时包含原始字段和向量:

id + content + date + mood + tags + vector

原始字段让结果可读、可筛选,向量让内容可以按语义被找到。

把日记和向量一起写入 Milvus

所有 Embedding 生成完成后,再统一执行 insert:

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

这一步写入的不是孤立向量,而是一组完整记录:每条记录既包含参与相似度计算的 vector,也包含后续展示和组装上下文需要的原始字段。

用户问题也要转换成向量

日记入库后,查询端接收一个自然语言问题:

const query = '我想看看关于户外活动的日记';
const queryVector = await getEmbedding(query);

日记与问题必须由兼容的 Embedding Model 转换,才能在同一个向量空间中比较。

随后使用问题向量搜索:

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

limit: 2 表示返回相似度最高的两篇日记,也就是 Top-2。

output_fields 很重要。如果只取回向量 ID 和分数,后续大模型仍然不知道日记写了什么。这里把正文、日期、心情和标签一起返回,才能组成可读上下文。

当前 Node SDK 的高层搜索结果位于:

searchResult.results

每一项会包含相似度分数与指定的输出字段:

searchResult.results.forEach((item) => {
  console.log(item.score);
  console.log(item.date);
  console.log(item.tags);
  console.log(item.content);
});

例如查询“户外活动”时,即使日记中没有这个完整词组,“爬山”和“公园散步”仍可能因为语义接近排在前面。

语义检索还不是 RAG

到这里,程序完成的是语义搜索:输入问题,返回相关日记。

要让系统直接回答“我最近做了什么让我感到快乐”,还需要把检索结果整理成上下文,再交给生成模型。

先封装检索函数:

async function retrieveDiaries(question, k = 2) {
  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;
}

这个函数只负责 Retrieve,不负责生成答案。把检索和回答分开后,更容易单独观察召回结果,判断问题出在向量检索还是大模型生成。

把日记整理成上下文

Milvus 返回的是 JavaScript 对象数组。大模型需要的是结构清楚的文本,因此要把每篇日记的 metadata 和正文拼接起来:

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

保留日期、心情和标签有两个好处:

  1. 模型能够按时间和情绪组织答案;
  2. 回答可以引用明确事实,而不只依赖一段脱离背景的正文。

这也是为什么向量数据库不应该只保存向量。

使用 Qwen 根据日记生成回答

回答模型通过 ChatOpenAI 的 OpenAI 兼容接口调用,具体 Qwen 模型由 MODEL_NAME 指定:

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

日记问答更重视事实一致性,因此使用较低 Temperature,减少不必要的自由发挥。

Prompt 同时包含检索上下文、用户问题和回答边界:

const prompt = `
你是一个温暖、客观的 AI 日记助手。请只根据提供的日记回答问题,不要编造日记中没有的信息。

相关日记:
${context}

用户问题:${question}

如果日记不足以回答,请明确说明。回答时可以归纳多篇日记,但要保留关键事实。
`;

const response = await model.invoke(prompt);

这里的关键不是把 Prompt 写得很长,而是明确三件事:

  • 只能依据检索到的日记;
  • 可以归纳多篇日记;
  • 证据不足时必须说明,不能自行补全经历。

完整执行链路为:

用户问题
→ 问题 Embedding
→ Milvus COSINE Top-K
→ 返回 content、date、mood、tags
→ 拼接 Context
→ Qwen 生成回答

这就是一个最小但完整的 RAG 问答流程。

AI 功能和普通 CRUD 的边界

完成 RAG 后,仍然不应该把所有查询都改成向量搜索。

下面这些请求适合结构化查询:

打开 2026-01-12 的日记
删除 diary_003
列出 mood 为 happy 的全部日记
把 diary_005 的标签改成“家庭聚餐”

下面这些请求更适合向量检索或 RAG:

最近有哪些事情让我有成就感?
我和朋友一起做过什么?
找出和学习新技术有关的记录
最近的情绪有什么变化?

判断标准不是“AI 更高级”,而是问题是否能够被明确字段和条件表达。

结构清楚时直接查字段更准确、便宜;语义模糊、表达多样或需要跨多篇内容归纳时,再使用 Embedding、Milvus 和大模型。

继续完善时要考虑什么

这个 Demo 已经跑通核心流程,进入真实产品后还需要继续处理:

  • 日记新增、修改和删除时同步更新向量;
  • 根据用户 ID 隔离不同用户的数据;
  • 在向量搜索前增加日期、标签等标量过滤;
  • 设置相似度阈值,避免把低相关日记交给模型;
  • 对较长日记进行合理切分,并保留原始日记 ID;
  • 在发送给外部模型前明确隐私范围和数据处理规则;
  • 记录检索结果,区分召回失败和生成失败。

尤其是日记数据通常包含隐私。向量数据库解决的是检索问题,并不会自动解决权限、隔离、加密和外部模型数据边界。

总结

AI 日记本并不是简单地给普通 CRUD 接口加一个聊天框,而是增加了一条语义处理链路:

日记正文 → Embedding → Milvus
用户问题 → Embedding → Top-K 日记
相关日记 → Context → Qwen → 回答

其中:

  • Embedding Model 把文本映射到向量空间;
  • Milvus 保存向量、原文和 metadata,并完成相似度检索;
  • Qwen 根据检索到的日记组织自然语言回答;
  • id、日期、心情和标签继续承担结构化业务职责。

把结构化数据与向量语义能力结合起来,日记应用才既能准确管理记录,也能理解“我最近经历了什么”这类自然语言问题。