用余弦相似度找出 Obsidian 笔记中的相近内容

📅
1 分钟阅读
·

我的 Obsidian 仓库里有不少零散笔记。写一篇新笔记时,经常会怀疑:这个主题以前是否已经写过?两篇笔记是否只是在用不同的词描述同一件事?

这类需求不需要先搭建 RAG,也不需要为了几百或几千篇笔记引入向量数据库。向量数据库主要解决大量向量的持久化、索引和近似最近邻检索。个人笔记库的规模通常可以把向量存成一个本地 JSON 文件,查询时遍历全部向量并排序。这样实现简单,索引也容易重建。

本文实现一个最小版本:读取 Obsidian 的 Markdown 文件,生成 embedding,计算余弦相似度,输出每篇笔记最相近的几篇笔记。

目标和边界

这个脚本解决的是“内容相近”的问题,而不是全文搜索。

全文搜索适合查找明确出现过的词,例如“余弦相似度”或“向量数据库”。相似度检索适合表达方式不同、主题相近的内容,例如“如何避免重复记录”和“笔记去重”。它依赖 embedding 模型将文本映射到向量空间,因此结果受模型、笔记长度和清洗规则影响。

本文的方案有以下边界:

  • 不修改 Obsidian 的 Markdown 原文;索引单独保存在 vault 外部或被忽略的目录;
  • 使用第三方 embedding API 时会发送清洗后的笔记正文;如果笔记不能发送到第三方服务,应改用本地 embedding 模型;
  • 不构建倒排索引或近似最近邻索引;
  • 适合数百到数千篇笔记。笔记量继续增长后,再考虑 SQLite、HNSW 或专门的向量数据库。

余弦相似度是什么

embedding 模型会把一段文本转换成由浮点数组成的向量。假设两篇笔记对应向量为 (A) 和 (B),余弦相似度定义为:

分子是两个向量的点积,分母是它们的长度。结果描述两个向量的夹角:越接近 (1),方向越接近;接近 (0) 表示关联较弱;理论上接近 (-1) 表示方向相反。实际 embedding 的分布取决于具体模型,负值在具体语料中也不应直接解释为语义反义,因此不要把某个固定分数当作所有语料通用的“相似”阈值。

如果在写入索引时先将每个向量归一化为单位向量,长度都为 (1),公式可以简化为点积:

归一化后,查询时不必反复计算向量长度。

数据如何保存

不使用向量数据库不等于每次查询都重新生成向量。embedding 调用有时间和费用成本,因此需要把结果缓存到本地。索引文件可以使用下面的结构:

{
  "model": "text-embedding-3-small",
  "updatedAt": "2023-04-09T14:00:00.000Z",
  "notes": [
    {
      "path": "技术/余弦相似度.md",
      "hash": "...",
      "embedding": [0.012, -0.034]
    }
  ]
}

path 用于回到原始笔记,hash 用于判断内容是否变化,embedding 是模型返回的向量。脚本只为新增或内容变化的笔记重新生成向量。模型名称也必须保存在索引中:更换 embedding 模型时,旧向量和新向量不能混用,应全量重建。

读取 Obsidian 笔记

Obsidian 的笔记就是 Markdown 文件。生成向量前,我会去掉对语义帮助不大的部分:YAML frontmatter、代码块、图片和普通链接的 URL;保留标题、正文、标签和链接文本。下面的函数只做基础清洗,足以作为起点:

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";

export function cleanMarkdown(markdown: string): string {
  return markdown
    .replace(/^---\n[\s\S]*?\n---\n?/, "")
    .replace(/```[\s\S]*?```/g, "")
    .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
    .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
    .replace(/[>#*_`]/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

export async function readNote(path: string) {
  const markdown = await readFile(path, "utf8");
  return {
    content: cleanMarkdown(markdown),
    hash: createHash("sha256").update(markdown).digest("hex"),
  };
}

代码块是否应该删除取决于笔记类型。技术笔记中的代码经常是主题的一部分,可以保留函数名、语言名或代码块摘要。重要的是让同一类笔记遵循同一套规则;否则相似度会被格式差异影响。

生成 embedding 并增量更新索引

下面以 OpenAI 的 text-embedding-3-small 为例。它只是一个 embedding 提供方,检索部分仍在本地完成。先安装 SDK 并配置环境变量:

pnpm add openai
export OPENAI_API_KEY="你的密钥"

生成向量时应批量请求,并跳过内容未变化的笔记。为便于说明,以下代码只展示单条调用:

import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function createEmbedding(content: string): Promise<number[]> {
  const response = await client.embeddings.create({
    model: "text-embedding-3-small",
    input: content,
    encoding_format: "float",
  });

  return normalize(response.data[0].embedding);
}

function normalize(vector: number[]): number[] {
  const length = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
  if (length === 0) throw new Error("无法归一化零向量");
  return vector.map((value) => value / length);
}

真实脚本还需要递归遍历 vault、忽略 .obsidian 与附件目录、读取已有索引,并将更新后的索引写回文件。对于很长的笔记,不能直接将整篇内容作为输入:应按标题或固定 token 数切成片段,分别生成向量,再在输出时回溯到笔记和对应段落。

如果笔记包含私人内容,可以把 createEmbedding 换成本地模型,例如通过 Ollama 或 @xenova/transformers 调用 embedding 模型。后面的索引格式和余弦计算不变;需要删除旧索引后重新生成所有向量。

计算相似度并取 Top K

向量已归一化后,余弦相似度就是对应元素相乘后求和:

type IndexedNote = {
  path: string;
  embedding: number[];
};

function cosineSimilarity(a: number[], b: number[]): number {
  if (a.length !== b.length) throw new Error("向量维度不一致");
  return a.reduce((sum, value, index) => sum + value * b[index], 0);
}

export function findSimilarNotes(
  query: number[],
  notes: IndexedNote[],
  topK = 5,
) {
  return notes
    .map((note) => ({
      path: note.path,
      score: cosineSimilarity(query, note.embedding),
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, topK);
}

为某篇笔记查找相近笔记时,要从候选集里排除它自身。输出结果可以直接生成 Markdown,复制到 Obsidian 笔记中:

## 相近笔记

- [[RAG 的收益与天花板]]:0.842
- [[向量检索的基本流程]]:0.801
- [[知识库的组织方式]]:0.779

分数只适合做排序和人工复核。我的做法是先输出 Top 5,再观察结果:如果第一名已经是明显不同的主题,说明查询或模型不适合;如果 Top 5 中有大量正确结果,再根据实际语料选择一个展示阈值。不要因为两个笔记分数较高就自动合并或删除其中之一。

为什么这个方案足够

假设有 (N) 篇笔记,每次查询都要和全部向量计算一次相似度,时间复杂度是 (O(Nd)),其中 (d) 是向量维度。对于 1,000 篇笔记和 1,536 维向量,这只是约 150 万次乘加运算,普通电脑可以很快完成。索引文件的体积更值得关注:使用 JSON 保存浮点数字会比二进制格式大,但在个人笔记规模下通常仍可接受。

向量数据库在以下情况会开始有实际价值:

  • 笔记或切片数量达到数万级,遍历全部向量的延迟无法接受;
  • 需要多用户、权限过滤、在线更新或服务端查询;
  • 需要结合 metadata 做复杂过滤,例如只在某个项目、日期范围或标签下检索;
  • 索引文件过大,启动、加载和写入成本变得明显。

在此之前,本地 JSON 索引的优势是数据结构透明、可随时删除重建,而且没有额外服务需要维护。

还能继续改进什么

基础版本运行后,结果通常会暴露几个问题。

长笔记常常覆盖多个主题,整篇只用一个向量会把主题平均化。可以按二级标题切片,检索片段后再聚合回笔记。标题、标签和正文的重要性也不同;可以把标题和标签重复一次放入输入,或分别生成向量后加权计算。对于明确的专有名词,关键词搜索比 embedding 更可靠,因此也可以将全文搜索结果与向量结果合并。

相似度检索最适合用作回顾和发现的入口:写完一篇笔记后查看关联内容,或定期找出重复主题。它给出的是候选关系,最终是否建立 Obsidian 链接、合并笔记或补充索引,仍应由人判断。


308 字 · 45 段落
ximing

Follow onGitHub

相关文章