译自 Dries Buytaert 博客原文:Finding related posts with embeddings(2026 年 8 月 26 日)。
我给博客加了个新功能:每篇文章底部一个相关文章列表。它是用向量嵌入(embeddings)实现的,这篇笔记记录具体做法。
我看过其他内容管理系统怎么识别相关文章:多数用共享标签、反向链接、人工挑选,或者向量嵌入。我选了向量嵌入,因为它比较的是每篇文章的语义,能在没有共享标签、没有既有链接、也没有人工挑选的情况下发现关联。
嵌入把语义变成数字
嵌入模型读入文本,返回一个向量:一长串数字。我用的模型是北京智源人工智能研究院(BAAI)的 bge-base-en-v1.5,它为每篇文章返回 768 个数字。
对我其中一篇文章来说,这些坐标的头几个大致长这样:
[ 0.021, -0.045, 0.038, -0.012, 0.007, ..., 0.019 ]你可以把这 768 个数字想象成一个高维"语义空间"里的坐标,每个维度捕捉模型从文本中学到的某种模式。
概念上,这有点像给每篇博客打上好几百个自动生成的标签,只不过这些标签没有名字(它们只是数字),而且是分布式的(语义分散在全部标签上)。768 个数字合在一起,把文章放到语义相近的其他文章旁边。
这就是为什么两篇文章即便用词完全不同也能匹配上。训练时,模型学到某些词和短语出现在相似的上下文里、或扮演相似的角色,于是把它们放在空间里彼此靠近的位置。它不需要 "car" 和 "automobile" 共享任何字母,就能学到这两个词的用法相关。
直接算余弦相似度,什么都看起来相关
每篇文章都有嵌入向量之后,下一个问题是怎样比较它们。这里我不得不翻出一点数学——幸好结果基本是高中数学:平均数、角度、乘法。
比较两个向量的标准做法是余弦相似度。把每个向量想象成一支从原点出发的箭头,余弦相似度度量的是两支箭头之间的夹角,再取这个角的余弦值,名字由此而来。
两支几乎同向的箭头夹角很小,小角的余弦接近 1,说明文章相关。箭头越散开,余弦越小:直角处是 0,方向相反的箭头会掉到 -1,所以不相关的文章得分接近 0,甚至为负。
但在实践中,这些原始余弦值会误导人,因为嵌入模型很少把向量在方向上均匀铺开。它们倾向于把大部分向量挤进一个狭窄的锥体里,这个性质叫各向异性(anisotropy)。这意味着余弦相似度往往聚集在一个很窄的区间内。
在我博客上,随机挑两篇文章,原始余弦相似度几乎总落在 0.5 到 0.75 之间,中位数是 0.64。实际效果是:几乎任意两篇文章看起来都有点像,哪怕它们并不相关。
均值中心化,才看出每篇文章的独特之处
各向异性有几种已知的解法。最简单的是均值中心化,我用的就是这个,本节剩下的部分都在讲它。
其他方案包括 All-but-the-top,它去掉平均值以及最强的几个方向;还有白化(whitening),它把空间拉伸成每个方向权重相同(名字来自白噪声)。
均值中心化很直接:算出所有文章的向量平均值,然后从每篇文章的向量里减掉它。减掉平均向量,等于去掉所有文章共有的部分,剩下的就是这篇文章与众不同的地方。
像 bge-base-en-v1.5 这样的现代模型,受各向异性的影响本来就比老旧或简单的编码器小:它是用对比学习训练的,会把不相关的文本推开,1.5 版还专门针对"让相似度分数铺得更开"做过调优。即便如此,在我的语料上做中心化,仍然让分数有用得多。
举个例子可能更好懂。设想三篇文章,每篇只有两个数字而不是 768 个:
A = (0.90, 0.10)
B = (0.85, 0.80)
C = (0.80, 0.75)乍看之下,三篇都多少有点像。每篇的第一个数字都很高、而且彼此接近(0.90、0.85、0.80)。一个在文章之间几乎不变的数字,告诉不了你它们如何不同,所以第一个数字没什么用。
三个向量的平均值(均值)是:
mean = (0.85, 0.55)现在从每篇文章里减掉这个平均值:
A = ( 0.05, -0.45)
B = ( 0.00, 0.25)
C = (-0.05, 0.20)画面立刻清楚了。B 和 C 的第二个数字都是正的,方向大致相同;A 的第二个数字是负的,指向别处。
中心化之前,一切看起来都相似。中心化之后,比较聚焦在"与平均值不同的部分"上。
归一化把比较简化为点积
中心化之后,每个向量既有方向也有长度。长度说明这篇文章离平均值有多远,方向说明它在哪里不同。
我想按"讲的是什么"来排序,而不是按"有多不寻常"来排序,所以只有方向重要。于是我们把每个向量除以自身的长度做归一化,把它缩到长度 1,落到单位圆(在 768 维里是单位球面)上,只留下方向。
这还让比较更便宜。余弦相似度通常是点积除以两个向量长度的乘积。如果两个向量长度都是 1,分母就是 1 × 1 = 1,整个表达式就退化成点积:两个数组逐位相乘,再把结果加起来。
用同一个例子,B 和 C 中心化后的向量是:
B = ( 0.00, 0.25)
C = (-0.05, 0.20)先把每个向量归一化到长度 1。一个向量的长度是各分量平方和的平方根(还是老毕达哥拉斯,只不过数字多了点)。B 的长度是 √(0.00² + 0.25²) = 0.25,C 的长度是 √((-0.05)² + 0.20²) ≈ 0.206,各自除以自身长度得到:
B ≈ ( 0.00, 1.00)
C ≈ (-0.24, 0.97)然后取点积:
(0.00 × -0.24) + (1.00 × 0.97) = 0.97这是很强的匹配:分数越接近 1,两篇文章的方向越一致。B 和 C 几乎对齐。
而 A 归一化之后主要指向下方。和 B 相比:
A ≈ ( 0.11, -0.99)
B ≈ ( 0.00, 1.00)同样相乘:
(0.11 × 0.00) + (-0.99 × 1.00) = -0.99完全不是匹配。
PHP 代码比解释还短
生产代码做的就是同样的算术,只不过每篇文章是 768 个数字而不是两个:
public static function center(array $raw): array {
if ($raw === []) {
return [];
}
$mean = array_fill(0, count(reset($raw)), 0.0);
foreach ($raw as $vector) {
foreach ($vector as $i => $value) {
$mean[$i] += $value;
}
}
$count = count($raw);
foreach ($mean as $i => $sum) {
$mean[$i] = $sum / $count;
}
$centered = [];
foreach ($raw as $nid => $vector) {
$norm = 0.0;
foreach ($vector as $i => $value) {
$vector[$i] = $value - $mean[$i];
$norm += $vector[$i] * $vector[$i];
}
// 恰好落在均值上的向量中心化后为零;退回 1.0,
// 这样下面的除法永远不会除以零范数。
$norm = sqrt($norm) ?: 1.0;
foreach ($vector as $i => $value) {
$vector[$i] = $value / $norm;
}
$centered[$nid] = $vector;
}
return $centered;
}
public static function topMatches(array $source, array $pool, int $self): array {
$scores = [];
foreach ($pool as $nid => $vector) {
if ($nid === $self) {
continue;
}
$similarity = 0.0;
foreach ($source as $i => $value) {
$similarity += $value * $vector[$i];
}
$scores[$nid] = $similarity;
}
arsort($scores);
return array_keys(array_slice($scores, 0, 3, TRUE));
}尽管我的解释很长,两个 PHP 方法都相当短。在 center() 里,每个向量减掉语料均值,再除以自身长度。在 topMatches() 里,我算出一篇文章与所有其他文章的余弦相似度,然后留下最高的三个。
你可能会以为向量数据库能把这套全替代掉。它会替代一部分:存一个向量、问最近的三个,可以省掉 topMatches(),但省不掉 center()。中心化是可选的,但它实实在在地改善了我的结果。
而向量数据库很可能让中心化更难做。今天我是存原始向量、在比较时再减平均值,所以新增一篇文章不影响任何已存的东西。向量数据库搜索的是我存进去的东西,那就得在存入之前减好。每新增或编辑一篇文章,我都得更新所有已存的向量,这感觉更复杂。也许向量数据库对此有好的答案;我没去研究。
嵌入算一次,排序偶尔算
你可能会想,生成这些嵌入、比较所有向量得花多少成本。结果是又快又便宜。
这里有两种工作,发生在不同的时间。生成嵌入要调用 AI 模型,但只在一篇文章创建或编辑之后做一次。排序用的是普通 PHP 算术,偶尔发生——当 Drupal 重建某个页面的相关文章缓存列表时。
我在 Cloudflare Workers AI 上跑这个模型。要生成一个嵌入,服务器发一个 HTTPS 调用,把文章文本传给 Cloudflare,由它跑模型并返回 768 个数字的向量。这一趟往返大约 250 毫秒。它发生在文章创建或编辑后的第一次浏览时,然后向量被缓存。模型是确定性的,同样的文本永远产出同样的 768 个数字。
Cloudflare 用"Neurons"为单位计费 Workers AI 用量,每天含 10,000 个免费额度。把我全部约 1,500 篇文章嵌入一遍大约用了 4,000 Neurons,而一篇新文章大约花三个。给我的博客做嵌入基本是免费的。
计算相关文章则完全不调用 AI 模型。全部发生在 Drupal 里,也就是我的内容管理系统。当 Drupal 需要构建某个相关文章列表时,它加载所有已存的向量,做中心化,然后把当前文章与其他所有文章打分:大约 1,500 次点积,每次跨 768 个数字。在我的站点上大约耗时 250 毫秒。列表构建好之后会被缓存。
换句话说,我的网站从不加载模型权重,它只存模型返回的那 768 个数字。机器学习的算力在 Cloudflare 的边缘,我的服务器仍然是一个朴素的 PHP 应用。这一切不需要向量数据库,也不需要机器学习框架:一次 HTTP 调用生成嵌入,一个键值存储做缓存,几十行算术选出相关文章。
标签太粗糙,反向链接只捕捉到我记得去加的那些链接,人工挑选又无法规模化。这三者都需要我先注意到那个关联。用向量嵌入听起来有点吓人,但结果它实现起来很容易、全自动,而且能翻出一些我根本想不到要去链接的文章。