面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记
「相关文章」功能的设计与实现

「相关文章」功能的设计与实现

2026年5月11日1,3314分钟

一个看似简单的功能背后,涉及内容建模、相似度算法、UI 取舍三个层面的决策。记录从方案选型到落地的完整过程。


目录
  • 1. 内容建模:series 字段
  • 2. 相似度算法
  • 为什么不用全文相似度?
  • 3. 计算时机:构建期 vs 运行期
  • 4. UI 决策
  • 5. 完整数据流
  • 6. 维护成本
目录
  • 1. 内容建模:series 字段
  • 2. 相似度算法
  • 为什么不用全文相似度?
  • 3. 计算时机:构建期 vs 运行期
  • 4. UI 决策
  • 5. 完整数据流
  • 6. 维护成本
目录
  1. 1. 内容建模:series 字段
  2. 2. 相似度算法
  3. 为什么不用全文相似度?
  4. 3. 计算时机:构建期 vs 运行期
  5. 4. UI 决策
  6. 5. 完整数据流
  7. 6. 维护成本
架构前端
相关文章
  • 01
    Prefetch 的完整图景2026/07
  • 02
    用 View Transitions + Skeleton 消灭页面跳转的割裂感2026/07
  • 03
    「批注」功能的设计与演进2026/05
← 上一篇现代计算的基础设施(五):一次请求的完整旅程
下一篇 →打磨 iOS Web Clip 体验(一):原生质感的下拉刷新

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

「相关文章」是一个看起来简单的功能。大多数博客框架内置了这个能力,通常的实现是:找到和当前文章共享最多标签的几篇,列在底部。

但实现之前有几个问题值得先想清楚:

  • 标签匹配足够吗?系列文章之间的关联显然比标签更强,应该怎么处理?
  • 数据从哪来?构建时计算还是运行时计算?
  • 展示什么信息?标题、摘要、日期,哪些是有用的,哪些是噪音?

这篇文章记录设计决策的完整过程。


1. 内容建模: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),显式标注比隐式推断更可靠,维护成本也不高。


2. 相似度算法

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
每个共同 tag1
同 category0.5

系列匹配的分数设为 10,远超标签匹配(最多几分),确保同系列文章永远排在最前,剩余名额才由标签相似度竞争。category 分数最低(0.5),只在打平时起决定性作用。

score > 0 过滤掉没有任何关联的文章——如果一篇文章和当前文章完全没有交集,返回空比强行凑数更诚实。

为什么不用全文相似度?

最精确的方案是 TF-IDF(Chen 注:TF-IDF(词频-逆文档频率):衡量一个词对某篇文档的重要程度。词在文档中出现越频繁、在其他文档中越少见,分数越高。两篇文章的 TF-IDF 向量越相似,内容越接近。) 或向量嵌入(Chen 注:把文本映射为高维空间中的一个实数向量,语义相近的文本在向量空间中距离更近。常见实现是用预训练语言模型(如 text-embedding-3-small)对文章正文生成 embedding,再用余弦相似度排名。),对正文内容做语义匹配。但这需要在构建时处理所有文章的全文,计算量更大,实现更复杂,而且更重要的是,本站的文章本来就用标签手动标注了主题,标签是人工的语义归纳,不比机器的统计推断差。标签和系列字段已经足够。


3. 计算时机:构建期 vs 运行期

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,浏览器请求文章页时直接拿到已计算好的相关推荐,零运行时开销。

唯一的限制:相关推荐在构建时固定,不会因为后来新增的文章而自动更新。但这是个人博客,发布新文章时本来就要重新构建,这个限制在实践中不存在。


4. UI 决策

相关文章放在「上一篇 / 下一篇」导航之后、ReactionBar 之前。「上一篇 / 下一篇」是按时间顺序的导航,相关文章是按主题的导航,两者不冲突,依序排列。

展示内容只保留标题和日期,去掉了摘要。读者读完一篇文章后,处于「浏览模式」而非「选择模式」——不需要通过摘要来判断值不值得读,标题已经足够做决策。摘要反而增加了扫描时的认知负担。


5. 完整数据流

整条链路没有客户端状态,没有 useEffect,没有数据请求。RelatedNotes 是纯展示组件,接收 props,输出 HTML。


6. 维护成本

这套方案最大的优点是低维护成本:

  • 新增文章时,只要填写了正确的 tags 和 series,相关推荐自动更新,不需要手动维护任何引用关系。
  • 新开一个系列时,给系列中的所有文章加上相同的 series key,下次构建后所有文章互相推荐。
  • 算法调整(比如修改各权重)只需改 getRelatedNotes 一个函数,不影响任何展示层代码。

唯一需要人工介入的场景:新文章属于已有系列,但 slug 不带序号,这时需要手动加 series 字段。这是显式优于隐式的代价,可以接受。

.
filter
((
n
)
=>
n.slug
!==
slug)
.map((note) => {
let score = 0;
if (current.series && note.series === current.series) score += 10;
const sharedTags = (current.tags ?? []).filter(
(t) => (note.tags ?? []).includes(t)
);
score += sharedTags.length;
if (current.category && note.category === current.category) score += 0.5;
return { note, score };
});
return scored
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, count)
.map(({ note }) => note);
}