2386 字
12 分钟
给相关文章列表定一套打分标准:31 篇文章上的六种算法对比

翻自己博客的时候发现两件事。React2Shell 那篇拉到底,相关文章一栏是空的;Tomcat AJP 那篇下面挂着两篇 PostgreSQL 堆溢出,除了都带 CVE 标签,它们没什么共同点。

上一版是按标签重合数排序的。CVE 这个词出现在 20 篇文章里,RCE 出现在 14 篇里,命中这两个词,任何两篇漏洞复现都能凑成一对。

后来改成正文分词加 TF-IDF,肉眼看着好了一些,但好多少说不清。加一篇新文章会不会又乱,也没有答案。于是标了一批数据,把几种算法放在同一个标准下比了一遍。

标准:四级关联#

评估的第一件事是定义什么叫相关。博客没有点击日志,也没有用户行为数据,只能人工标注。

四级评分:

分值含义例子
3同一产品,或同一技术栈加同一漏洞类型Spring4Shell 与 Spring Cloud Gateway SpEL 注入
2同一大类,技术栈或漏洞类型有一项不同Log4j2 与 React2Shell,都是 RCE,一个 Java 一个 Node
1只有方向或工具层面的交集Tomcat 文件包含与 OverlayFS 提权,都是漏洞复现
0领域不同LDA 主题模型与 arp-scan 使用

标注方法是先确定每篇文章的三个属性:研究对象(Tomcat、Log4j2、LDA 这类)、技术栈(Java、Node、PHP、系统层)、漏洞类型(RCE、提权、信息泄露、DoS)。两个属性相同给 3 分,相同一个给 2 分,只同属安全方向给 1 分,跨领域给 0 分。

这个标准有个明显缺陷:三个属性恰好是标签编码的东西,所以它天然有利于标签算法。这点后面会再提。

候选池#

31 篇文章两两组合有 465 对,全部标注不现实。做法是取五种算法各自 top-4 的并集,得到 169 对,覆盖了所有算法可能展示给读者的组合。剩下 296 对不会出现在任何算法结果里,也就不影响对比。

标注分布:0 分 40 对,1 分 70 对,2 分 33 对,3 分 26 对。

预处理#

中文不像英文有空格,先要分词。没有装 jieba 这类库,用的是 Node 内置的 Intl.Segmenter,它背后是 ICU 的词典分词。

> new Intl.Segmenter("zh-CN", { granularity: "word" }).segment("Log4j2 远程代码执行漏洞(CVE-2021-44228)复现")
Log4j2 | 远程 | 代码 | 执行 | 漏洞 | CVE | 2021 | 44228 | 复现

问题一眼就能看到:CVE 编号被拆成三段,Log4j2 之所以完整只是因为词典里有这个词。Spring4Shell、Next.js 这类词基本都会被拆碎。

所以在分词前先用正则把技术标识符整词取出来,再从待分词文本里删掉,避免同一批字符被计两次。规则是 CVE 编号加 ASCII 标识符(长度不小于 2,允许点、下划线、连字符)。

另一项清洗是去掉代码块、行内代码、图片、链接地址、HTML 标签和表格线。安全文章里代码占比很高,变量名和命令参数会把两篇不相干的文章拉近。

最后过一遍停用词表,中文虚词(的、是、进行、使用、通过)和单字词全部丢弃。

六种算法#

算法做法
tag标签集合的 Jaccard 系数,即交集除以并集
tfidf分词后用 TF-IDF 加权,算余弦相似度
tfidf_gram在 tfidf 基础上加入字符二元组(相邻两个字),权重各半
bm25BM25,把词频做饱和处理并按文档长度归一化
lsa对 TF-IDF 矩阵做奇异值分解,取前 12 维做隐含语义
hybrid0.65 乘 LSA 相似度加 0.35 乘标签 Jaccard

TF-IDF 的思路是:一个词在本文出现得越多、在其它文章出现得越少,它越能代表本文。BM25 是它的改进版,避免长文章因为词多而占便宜。LSA 把词袋压到低维空间,两个没共用任何词但语义相邻的文章也能算近。hybrid 是把内容相似度和标签重合度按权重加起来。

评估指标选 nDCG@4。它衡量排序质量:把分高的排在前面得分高,把 3 分文章排在 4 分的位置会扣分。另外看三项:P@4 是前四名里关联度不低于 2 分的比例,无关率是前四名里 0 分的比例,空结果是没有可展示内容时的文章数。

结果#

PS> node --experimental-strip-types scripts/seo-eval/eval2.mjs
[固定候选池 169 对上的排序质量]
算法 nDCG@4 P@4 无关率 空结果 组合数
tag 0.962 0.573 0.081 6/31 29/31
tfidf 0.945 0.532 0.113 4/31 31/31
tfidf_gram 0.937 0.565 0.113 6/31 31/31
bm25 0.934 0.589 0.129 2/31 31/31
lsa 0.907 0.548 0.153 0/31 31/31
hybrid 0.915 0.581 0.129 2/31 31/31

组合数是 31 篇文章中出现过多少种不同的 top-4 组合,越接近 31 说明推荐越分散。

tag 的 nDCG 最高,无关率最低。这个结果不奇怪:标注用的三个属性和标签是同一套东西,标签法等于提前看过答案。但它在另外两项上暴露了问题——6 篇文章一个结果都没有,正好是 React2Shell 那种标签自成一派的文章。

tfidf 把空结果压到 4 篇,代价是无关率从 8.1% 升到 11.3%。BM25 空结果最少,只有 2 篇,但无关率最高,达到 12.9%,它太容易把“长得像”当成“讲的是同一件事”。

hybrid 是 LSA 加标签的折中,nDCG 0.915,P@4 0.581,无关率 12.9%,空结果 2 篇。它比单用 LSA 好,但没超过纯标签,说明这里的瓶颈是内容项本身不够准,不是组合方式。

LSA 的表现最差,无关率 15.3%。原因大概是语料太小,31 篇文档做 12 维分解,自由度比数据还多,压出来的向量并不稳。

权重扫描#

既然标签和内容各有短板,权重就成了唯一的调节旋钮。把打分写成:

score = w × 内容相似度 + (1 - w) × 标签 Jaccard

w 从 0 扫到 1,每次步进 0.1:

内容项/权重 nDCG@4 P@4 无关率 空结果 组合数
tfidf 0.0 0.962 0.573 0.081 6/31 29/31
tfidf 0.3 0.946 0.573 0.097 8/31 30/31
tfidf 0.6 0.954 0.581 0.097 7/31 30/31
tfidf 0.7 0.957 0.589 0.097 7/31 31/31
tfidf 0.9 0.952 0.556 0.097 5/31 31/31
tfidf 1.0 0.945 0.532 0.113 4/31 31/31

(中间省略四行,趋势一致)

w 在 0.6 到 0.7 之间时,nDCG 只比纯标签低 0.005,无关率只高 1.6 个百分点,但 31 篇文章全部有结果,组合数拉满。这就是站点最后选的区间。

阈值#

权重定了之后还有一个阈值:分数低于多少就不展示。阈值太高会出现空列表,太低会混进无关文章。

PS> node --experimental-strip-types scripts/seo-eval/tune.mjs
阈值 平均条数 空列表 无关率 均分 nDCG@4
0.00 4.00 0/31 0.089 1.85 0.957
0.10 3.87 0/31 0.073 1.89 0.957
0.12 3.74 0/31 0.056 1.91 0.957
0.15 3.45 2/31 0.000 2.13 0.988
0.20 3.26 4/31 0.000 2.29 0.989
0.25 2.77 5/31 0.000 2.48 0.986

阈值 0.15 是拐点:无关项掉到 0,平均分从 1.91 升到 2.13,代价是 2 篇文章没有相关文章。这两篇是 arp-scan 和 FastAPI 微信小程序,跟站内其它文章确实没有交集,空着比硬凑好。

如果再往后推到 0.20,空列表变成 4 篇,平均分只多 0.16,不划算。

现在的实现#

最终的打分是:

score = cosine(TF-IDF 向量) + 0.35 × Jaccard(标签)
阈值 0.15,最多 4 条

0.35 的权重换算成比例就是 0.74 比 0.26,落在扫描出来的 0.6 到 0.7 区间附近。这个公式写在 src/utils/related-posts.ts 里,构建时对当时的 31 篇文章建一次索引,耗时 36 毫秒,之后每篇文章查一次 11 毫秒,对构建时间没有可见影响。

索引结构是文档数乘词表大小,这里只有 31 篇,全部放在内存里。文章数涨到几百篇时需要换成稀疏矩阵加倒排表,否则建索引的时间会开始显眼。

这套评估的问题#

第一个是标注偏差。标注标准和标签编码的属性重合,标签法因此占了便宜。要消除这个偏差,需要另一个人在不看标签的情况下独立标注,或者引入点击数据。两者现在都没有。

第二个是样本量。169 对能区分出 8% 和 15% 的无关率差异,但区分不了 0.95 和 0.96 的 nDCG。表里几个算法在小数点后第三位上的差距,不值得当成结论。

第三个是语义。这套方法算的是词的重合,不是意思。Tomcat AJP 下面出现 OverlayFS,是因为两篇文章都在反复讲 root、shell、命令、文件这些词,向量确实接近。换成句向量模型能解决,但要在构建时加载模型或者调接口,对 31 篇文章的博客不值得。

第四个是时效。相关文章列表跟着文章内容走,文章改了列表就变。这套逻辑没有稳定输出,同一分对可能因为别处新增一篇文章而换位。对博客来说这是可接受的,对需要长期稳定的场景不是。

复现#

评估脚本在 scripts/seo-eval/ 下,标注数据是 labels.json 里的 169 个整数,与 labelled-pairs.json 的 169 个文章对一一对应。

PS> node --experimental-strip-types scripts/seo-eval/eval2.mjs # 六种算法对比与权重扫描
PS> node --experimental-strip-types scripts/seo-eval/tune.mjs # 阈值扫描

换一批文章也能直接跑,路径写死在文件里,改一下 POSTS_DIR 常量即可。

给相关文章列表定一套打分标准:31 篇文章上的六种算法对比
https://blog.mal-suen.cn/posts/2026/11/相关文章相关性打分标准与六种算法对比/
作者
Malcolm
发布于
2026-11-07
许可协议
CC BY-NC-SA 4.0