一个看似简单的功能背后,涉及内容建模、相似度算法、UI 取舍三个层面的决策。记录从方案选型到落地的完整过程。
「相关文章」是一个看起来简单的功能。大多数博客框架内置了这个能力,通常的实现是:找到和当前文章共享最多标签的几篇,列在底部。
但实现之前有几个问题值得先想清楚:
这篇文章记录设计决策的完整过程。
series 字段本站文章的 frontmatter 原本只有 tags 和 category。标签可以表达主题,但无法表达序列关系——「高频交易系统(一)」和「高频交易系统(二)」共享相同的标签,但它们之间的关联远比「同一个 tag 的两篇随机文章」紧密得多:它们是同一个叙事的延续,读者读完第一篇的下一步几乎必然是第二篇。
于是引入了 series 字段:
---
title: "高频交易系统核心构建技术的探索与实践(一)"
tags: ["高频交易", "FAST 协议", "金融科技"]
category: "tech"
series: "building-high-frequency-trading-system"
---series 是一个语义 key,不直接展示给读者,只用于内部匹配。设计成字符串而非布尔值,是因为一个站点可以有多个独立的系列,key 必须唯一。
这里有一个判断:是否通过 slug 后缀自动推断系列关系(-01、-02 这样的命名)?结论是不做——自动推断能处理命名规律的情况,但本站有几个系列的 slug 并不带序号(比如 dom-export-pdf 和 dom-export-image),显式标注比隐式推断更可靠,维护成本也不高。
getRelatedNotes 接收当前文章的 slug,返回得分最高的前 N 篇:
export function getRelatedNotes(slug: string, count = 3): NoteMetadata[] {
const all = getAllNotes();
const current = all.find((n) => n.slug === slug);
if (!current) return [];
const scored = all
分数由三部分构成:
| 条件 | 分数 |
|---|---|
| 同系列 | 10 |
| 每个共同 tag | 1 |
| 同 category | 0.5 |
系列匹配的分数设为 10,远超标签匹配(最多几分),确保同系列文章永远排在最前,剩余名额才由标签相似度竞争。category 分数最低(0.5),只在打平时起决定性作用。
score > 0 过滤掉没有任何关联的文章——如果一篇文章和当前文章完全没有交集,返回空比强行凑数更诚实。
最精确的方案是 TF-IDF(Chen 注:TF-IDF(词频-逆文档频率):衡量一个词对某篇文档的重要程度。词在文档中出现越频繁、在其他文档中越少见,分数越高。两篇文章的 TF-IDF 向量越相似,内容越接近。) 或向量嵌入(Chen 注:把文本映射为高维空间中的一个实数向量,语义相近的文本在向量空间中距离更近。常见实现是用预训练语言模型(如 text-embedding-3-small)对文章正文生成 embedding,再用余弦相似度排名。),对正文内容做语义匹配。但这需要在构建时处理所有文章的全文,计算量更大,实现更复杂,而且更重要的是,本站的文章本来就用标签手动标注了主题,标签是人工的语义归纳,不比机器的统计推断差。标签和系列字段已经足够。
getRelatedNotes 在 Next.js 的 Server Component 里调用:
// src/app/notes/[slug]/page.tsx
export default async function NotePage({ params }: Props) {
const { slug } = await params;
// ...
const relatedNotes = getRelatedNotes(slug);
// ...
}这段代码运行在构建期(next build 时静态生成)。所有文章数据来自本地文件系统,getAllNotes() 读取并解析 frontmatter,不涉及网络请求。构建产物是静态 HTML,浏览器请求文章页时直接拿到已计算好的相关推荐,零运行时开销。
唯一的限制:相关推荐在构建时固定,不会因为后来新增的文章而自动更新。但这是个人博客,发布新文章时本来就要重新构建,这个限制在实践中不存在。
相关文章放在「上一篇 / 下一篇」导航之后、ReactionBar 之前。「上一篇 / 下一篇」是按时间顺序的导航,相关文章是按主题的导航,两者不冲突,依序排列。
展示内容只保留标题和日期,去掉了摘要。读者读完一篇文章后,处于「浏览模式」而非「选择模式」——不需要通过摘要来判断值不值得读,标题已经足够做决策。摘要反而增加了扫描时的认知负担。
整条链路没有客户端状态,没有 useEffect,没有数据请求。RelatedNotes 是纯展示组件,接收 props,输出 HTML。
这套方案最大的优点是低维护成本:
tags 和 series,相关推荐自动更新,不需要手动维护任何引用关系。series key,下次构建后所有文章互相推荐。getRelatedNotes 一个函数,不影响任何展示层代码。唯一需要人工介入的场景:新文章属于已有系列,但 slug 不带序号,这时需要手动加 series 字段。这是显式优于隐式的代价,可以接受。