Loading...
Loading...
- 最后编辑:2025/11/01 12:26:39 -
Loading...
自上次自己琢磨出了全文搜索算法后,我就一直在研究关于相关文章的推荐算法,因为我觉得这两个东西挺像的,都是通过比对关键词的相似度来查询和排序。
我的设想是通过结合一篇文章最核心的几大关键词——标签、标题、正文,与数据库中的文章进行比较,并综合计算出一个加权平均数作为比较标准对他们进行筛选。进一步思考后觉得思路可行,于是我就开始着手实现这个想法。
刚好,博客的标签系统还不完善,所以我就可以顺手给这个功能也一起做完了(笑
首先来讲一下我构想的算法原理。
之前提到过,我们已知一篇文章可以用来比较的因素有标签、标题和正文,那么我们就可以围绕这三个层次来对文章进行筛选和打分:
我最开始的思路是通过一个多因素的加权平均数来实现一个较为客观和可信的评分系统,围绕标签、标题和正文三个因素来打分,先计算出每个文章与当前文章的相似度(或称”命中率“),再乘以他们各自的权重,最后综合计算出一个总分。
当然,除了这三个比较具有可信度的主要因素之外,还可以加入一些别的辅助因子来进一步扩大区分度,比如我后面加入了发布时间作为辅助因子来帮助区分那些分数相同的文章,即越新的文章优先级越高,所以,最终的计算公式应该是类似于:

其中:
| 符号 | 含义 |
|---|---|
| A | 当前文章(查询文章) |
| B | 候选推荐文章 |
| TA ,TB | 标签集合 |
| KA , KB | 标题关键词集合(从标题中提取的技术词) |
| WA | 从文章A正文中提取的关键词集合 |
| Text(B) | 文章B的原始正文(用于子串匹配) |
| ageB | 文章B距当前时间的天数 |
| wt+wk+wv=1 | 推荐权重,如wt=0.5,wk=0.3,wv=0.2 |
乍一看,这个公式可唬人了,就算让我来看,我一时间都不太好理解,不过没关系,我们一点点来拆解它。
标签是三个因素中权重占比最大的一个,因为它是我在写作时对它的核心主题认为打上的分类,最能够概括它的一个关键词,所以要着重处理这一部分。
首先获取当前文章(即读者正在阅读的这篇文章)的标签,并通过标签去反向获取含有这个标签的其他文章:
技术、Nextjs、AST技术、CSS、Tailwind技术、Nextjs、MDX、AST接着,对两个标签数组取交集,得到它们共有的标签:
技术技术、Nextjs最后,根据以上两个数据,计算出相关文章标签相对于原文标签的”命中率“,即取他们的交集数量后再除以原文标签数量:
那么这时B文章就通过计算得出了它比A文章相对于原文更相关的结果,而事实也正是如此,光从标签的角度出发,B文章的相关性确实更大一点。
这一套算法,至少在我自己看来,会比其他部分算法要准确一些,比如我刚开始使用的Jaccard 相似度——它是一种于衡量两个有限样本集之间相似性和差异性的指标,实质就是两个集合的交集除以它们的并集,获取它们的相关度。
这一算法看似有理有据,实则实施起来时却缺乏可信度。我并不是指算法本身缺乏可信度,而是在这种应用场景下,它暴露出了明显的缺点——当需要计算多个样本集之间的相关度时,由于每次取的样本集不一样,代入计算的数据就不一样,那么就会导致衡量的标准不同。
举个简单的例子:
技术、Nextjs、React技术、前端、MDX技术、前端、MDX、AST在这个案例中,A文章和B文章与原文的标签的交集数量是一样的,而且A文章除了”AST“标签,其他标签都与B文章一模一样,并且从我们主观上来判断,这两个文章与原文其实都不相关,它们只是刚好都有”技术“这个标签,然后在第一个环节中被筛出来了而已,但这不代表它们真的相关,那么按理来说,它们的相关性应该都是一样的才对,但事实真是如此吗?我们使用Jaccard相似度来计算一下就知道了:
很显然,最终的计算得到了两个不一样的结果——A文章的相关性比B文章更大!
按理来说,这两个文章与原文的相关性应该都是一样的,因为A和B的相关性显然非常高,但是与原文却几乎没有很重要的共同点,但计算结果却有非常大的差异,这是怎么回事呢?
原因就出在算法的计算方式上:我们每次计算时,讨论的都是这两个文章标签数量的交集除以并集,那么当标签数量越来越多时,并集就会越来越大,即使它们本来就与计算无关,但还是干扰了计算结果,换句话说,Jaccard相似度在标签数量不平衡时会产生偏差——标签少的文章更容易获得高分,但这并不一定代表更相关,因为它们计算时的数据完全不一样,根本不能相互比较,所以这种算法显然是不对的,不应该直接粗暴的计算两个样本集的相似性,而应该计算它们的“命中率”,这样一来,计算式中的分母始终都是同一个,只有分子在变化,就能更加清楚的刻画不同文章之间与原文的相关性差异了:
const preliminaryResults; // 初筛时根据标签获取到的相关文章列表
const tagHitRate = [];
for (let i = 0; i < preliminaryResults.length; i++) {
const intersectTags = getIntersectArray(referenceTag,preliminaryResults[i].tag); // 取交集
const similarScore = (intersectTags.length / referenceTag.length).toFixed(4); // 计算命中率
tagHitRate[i] = {"logo": preliminaryResults[i].logo, "score": similarScore};
}标题作为作者为文章起的一个具有概括性的简短句,其对于相关程度的指导意义仅次于标签,因此,我将它的优先级排在了第二位。
在打分前,我先会获取到原文标题和相关文章的标题,并将它们使用分词器切分成单个词元(关于”分词器“相关的内容,可以阅读我的上一篇博客:基于Postgresql的倒排索引实现的全文搜索解决方案),然后使用类似我在比对标签时采用的“命中率”算法来实现相关性的比较:
const preliminaryResults; // 初筛时根据标签获取到的相关文章列表
const titleHitRates = [];
for (let i = 0; i < preliminaryResults.length; i++) {
const titleSgRes = segment.doSegment(...) // 分词(参数省略)
const intersectTitleWords = getIntersectArray(titleSgRes,referenceTitleSgRes); // 取交集
const hitRate = (intersectTitleWords.length / referenceTitleSgRes.length).toFixed(4); // 计算命中率
titleHitRates[i] = {
"logo": preliminaryResults[i].logo,
"rate": hitRate
};
}正文是三个主要因素中权重最低的,因为正文的干扰性太强了,在不用大模型进行语义分析的前提下,很难通过机械手段去提炼关键词,所以我使用的办法非常简单且暴力——统计词频。
是的,原理就是直接将正文使用分词器切分成词块,然后统计每个词在文中出现的次数,并选出次数最高的前五项作为关键词,最后依然是计算命中率来判断相关性:
const preliminaryResults; // 初筛时根据标签获取到的相关文章列表
// 根据词频从高到低排序,并选出出现次数最多的前五个词
const top5RefKeyWords = Object.entries(referenceWordTimes)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([element]) => element);
const contentHitRates = [];
for (let i = 0; i < preliminaryResults.length; i++) {
const contentSegRes = segment.doSegment(...) // 分词(参数省略)
const intersectWords = getIntersectArray
最后,将得出的三个命中率进行加权求平均,得到这篇文章最终的得分:
let finalScores = []; // 推荐文章数组
const lowScoreBlogs = []; // 较低分的备选文章数组
for (let i = 0; i < preliminaryResults.length; i++) {
// 时间筛选(让新文章优先靠上,避免老文章长期霸榜)
const updateTime = new Date(preliminaryResults[i].updatedAt)
const now = new Date()
const timeDiff = (now.valueOf() - updateTime.valueOf()) / 1000;
const dayDiff = (timeDiff / (60 * 60 * 24)).toFixed(0)
const timeLimitEdge = 30;
其中需要说明的就是关于时间的计算,既要使新文章优先往上走,又要使经典文章不被排除在外,因此我将文章按最后编辑时间(下文中用“d”表示)分为三种情况:
d < 30 :这些是发布时间较近的文章,算法对它们有额外的分数加持,满分为0.1,随着时间流失,分数呈线性衰减,形成一种按时间由近到远的降序结构。
30 < d < 365:这些是发布后已经有一段时间,但不算太远的文章,其内容仍具有可信度和时效性,所以不会有特殊处理,时间加分为0,具体分数完全由其他三个主要因素决定。
d > 365:这些是发布时间极早的文章,已经失去时效性了,有些代码放到现在可能已经跑不起来了,那它的可信度就会下降,算法对它们会额外的扣分,但也不能扣太多,因为文章内容依旧具有学习价值,并且有一些强相关的文章也不能因为时间关系而被排除在外,所以会额外扣0.1分。
最后,经过多个维度的综合计算,我们得出了一个最终分数,也就是这篇文章与本文的相关性,我们将它们进行排序后就可以得到一个推荐文章列表了:

这套算法前前后后花了我大概二十多个小时的时间,虽然它相比其他优秀的算法来说并不完善,一定程度上来说也并不那么客观,在某些极端情况下可能也会出现误判或不够精准的现象,但至少我觉得用在我的博客上是足够了。
这个算法会直接应用在博客的下次更新中,但是上线初期会暴露一些可预见的弊端——我现在的搏客数量较少,内容相似的文章更是几乎没有,所以推荐算法算出来的结果其实都不是特别匹配,甚至于说很多博客算出来的的推荐文章都是类似的,不过这个问题肯定是会随着博客的持续运营而不断改进的,所以它还是比较“未来可期”的(笑