相关文章模块一直推送近似重复的页面

每篇文章底部的'相关文章'都是三条几乎一样的稿子。用 MMR 检测、打分'相关但不重复',别再给自己推荐重复内容。

读到”ChatGPT 一直把我退出登录”这篇底部,相关推荐里出现另外三篇:“ChatGPT 会话过期太快”、“ChatGPT 自动登出问题”、“ChatGPT 5 分钟后被登出”。三篇其实是同一个问题,只是过去一年追长尾时换着标题写了三遍。推荐器把它们顶上来,是因为每一个信号(标签重合、标题词 Jaccard、向量余弦)都说它们最相关。读者看到三条重复,跳出,Google 看到一个站点在给自己推荐重复内容。这是成熟内容站里比较难的一类问题,因为推荐器做的事情正是你最初让它做的:你要的是”相关”,它给的是”相似”——这是两件事。

最快的修法:加一个硬性近重复截断(Jaccard > 0.5 或余弦 > 0.92),在排序之前就过滤掉;然后对幸存的候选用 MMR(Maximal Marginal Relevance,最大边际相关)重排,让 top-3 用一点相关度换多样性。如果审计跑出来是真重复,就用 301 合并,而不是在小部件里藏起来。下面是详细版。

这件事的真正代价(截至 2026 年 6 月)

Google 并不会像老传言说的那样”惩罚”重复内容——它是把近重复的 URL 聚类,再从这一组里挑一个 canonical 来代表(先聚类,后定 canonical)。损害是间接但真实的:抓取预算被花在反复抓兄弟稿、而不是发现新页面上;外链和排名信号在整组里分散、无法汇聚到一篇;一个在 Search Console 里卡成 Duplicate without user-selected canonical 的页面,根本不会以它自己的身份被收录。当你自己的相关模块把三篇近重复互相链接时,你其实在主动喂这个聚类。参见 Google 的 canonicalization 文档:重定向是最强的 canonical 信号,rel="canonical" 较强,sitemap 收录较弱,而内链应当永远指向 canonical,绝不指向变体。

常见原因

按命中率从高到低排列。

1. 标签重合推荐器把近重复标签当成完美匹配

两篇文章 6 个标签里重 5 个,按标签重合排序它们永远互相置顶,哪怕回答的是同一个问题。

怎么判断:对每篇文章看 top-3 相关。如果标题跟宿主文章 70% 以上词重合,推荐器在奖励近重复。

2. embedding 相似度把重复排在互补之前

升级到 sentence-embedding 余弦后,近重复跑到 0.95+ 占第一,互补内容(规范修复、预防指南、相关工具)只有 0.7-0.8,被挤出面板。

怎么判断:把每个候选的余弦分打出来,如果 top-3 都 > 0.92,你推荐的是重复,不是相关。

3. 标题词重合是唯一特征

旧推荐器只对标题分词做 shingle。共享 3-4 个高频词(“ChatGPT”、“登录”、“失败”)的文章形成一个紧簇,只推自己人。

怎么判断:生成一张共推图。3-5 篇形成紧密小团、几乎没有出边的就是泄漏。

4. 真的存在该合并的重复,不是推荐器的锅

推荐器把重复浮上来,是因为重复真实存在。要修的是内容层的 canonical,不是更聪明的小部件。

怎么判断:把三篇”相关”读一遍,如果你说不出一句话区别,就是重复。小部件是症状不是 bug。

5. 多个 URL 指向同一个 canonical,泄漏到相关里

tag 页、分类页、旧 slug 都解析到同一篇 canonical。推荐器把每个 URL 当独立候选,三条全推。

怎么判断:相关条目经过重定向后,落到同一个 canonical URL。

6. 手动编辑挑选覆盖了算法,而编辑挑了重复

CMS 里支持”手动相关”字段,编辑选了流量最高的几篇兄弟稿,正好都是近重复。

怎么判断:把算法输出跟最终渲染的小部件对比。如果系统性偏向手动挑选、且挑选都是近重复,这是人不是模型。

7. 推荐器对宿主文章自己的主题簇没有权重衰减

所有推荐都从同一个 subcategory 来,没有多样性项,面板永远是来自 10 篇同簇的 3 篇。

怎么判断:统计所有渲染相关面板的 subcategory 分布。如果 80%+ 推荐都待在宿主 subcategory 内,模型没有多样性项。

你属于哪一类

你观察到的症状最可能的原因看哪一步
top-3 标题在重复宿主的高频词标题词 / 标签重合(原因 1、3)第 1-2 步
余弦分全都 > 0.92embedding 把重复排在互补前(原因 2)第 1-3 步
三篇之间说不出一句话区别真重复(原因 4)第 4 步
同一篇文章通过不同 URL 出现两次重定向 / canonical 泄漏(原因 5)第 5 步
小部件跟算法输出不一致手动编辑挑选(原因 6)第 6 步
每个面板都是同一 subcategory没有多样性项(原因 7)第 3 步

最短修复路径

第 1 步:构建时算一份”重复检测”信号

为每个文章对算一个相似分并落盘。推荐器用它做硬截断。词 shingle 的 Jaccard 便宜、确定、不依赖 embedding API,构建期就能跑;文章上千篇时,把内层循环换成 MinHash/LSH,避免 O(n^2) 爆炸。

// scripts/article-similarity.mjs
import fs from 'node:fs';
import { encode } from 'gpt-tokenizer';

function shingles(text, n = 5) {
  const tokens = encode(text.toLowerCase());
  const set = new Set();
  for (let i = 0; i <= tokens.length - n; i++) {
    set.add(tokens.slice(i, i + n).join('-'));
  }
  return set;
}

function jaccard(a, b) {
  const inter = [...a].filter(x => b.has(x)).length;
  return inter / (a.size + b.size - inter);
}

const articles = loadAllArticles();
const shing = new Map(articles.map(a => [a.slug, shingles(a.title + ' ' + a.description + ' ' + a.body.slice(0, 2000))]));
const pairs = [];
for (let i = 0; i < articles.length; i++) {
  for (let j = i + 1; j < articles.length; j++) {
    const s = jaccard(shing.get(articles[i].slug), shing.get(articles[j].slug));
    if (s > 0.4) pairs.push({ a: articles[i].slug, b: articles[j].slug, score: s });
  }
}
fs.writeFileSync('data/similar.json', JSON.stringify(pairs, null, 2));

阈值要在自己的语料上校准:Jaccard > 0.5 的对几乎一定是推荐器必须排除的近重复;0.4-0.5 是灰区,值得人工扫一眼。

第 2 步:推荐器加硬过滤

无论你的相似函数是标签重合、向量、还是两者,都先过近重复过滤排序:

const NEAR_DUP_THRESHOLD = 0.4;
function isNearDup(host, candidate) {
  const key = [host, candidate].sort().join('|');
  return (similarityMap.get(key) ?? 0) > NEAR_DUP_THRESHOLD;
}
const survivors = candidates.filter(c => !isNearDup(host.slug, c.slug));

有时小部件会少于 3 条。这是好事——空着也比误导好。

第 3 步:对幸存候选用 MMR 重排,而不是简单罚分

过滤掉了明显的重复,但你仍可能剩下三篇近邻,各自都勉强压在截断线下,却彼此重复。MMR(Maximal Marginal Relevance,最大边际相关)就是这个问题的标准解法:它贪心地挑下一个既跟宿主相关、又跟已选项不相似的候选,这样面板就不会堆三份同一个想法。公式是 MMR = argmax over candidates of ( λ * Rel(host, c) - (1 - λ) * max Sim(c, alreadySelected) ),其中 λ(lambda)是相关度和多样性之间的旋钮——从 0.7 起步。

function rerankMMR(host, survivors, lambda = 0.7, k = 3) {
  const selected = [];
  const pool = [...survivors];
  while (selected.length < k && pool.length) {
    let best = null, bestScore = -Infinity;
    for (const c of pool) {
      const rel = embeddingCosine(host, c);
      const maxSim = selected.length
        ? Math.max(...selected.map(s => embeddingCosine(s, c)))
        : 0;
      const mmr = lambda * rel - (1 - lambda) * maxSim;
      if (mmr > bestScore) { bestScore = mmr; best = c; }
    }
    selected.push(best);
    pool.splice(pool.indexOf(best), 1);
  }
  return selected;
}

如果你没有 embedding,可以用 subcategory 匹配来近似 Sim:候选跟宿主同 subcategory 时减一个固定分(比如 0.15),调到 top-3 最多只有 1 条同 subcategory 邻居。用真实成对相似度的 MMR 严格更好,因为它按真实内容做多样化,而不只是按分类法。

第 4 步:决策:合并还是差异化

审计跑出近重复对之后,你只有两个选项:

  • 合并:留一篇 canonical,其他 301。一篇流量明显领先、其他是变体时最合适。301 是你能给 Google 的最强信号,用来把这一簇收拢——比单独的 rel="canonical" 更强。
  • 差异化:把每一篇改写到回答不同子问题。每篇都有独立外链或独立流量时合适。

不要让它们”以重复存在但被小部件藏起来”。藏起来的重复还会出现在 tag 页、sitemap、搜索结果里,Google 照样把它们聚类——你只是在一个入口藏住了症状。

第 5 步:候选先过一次重定向再推荐

function canonicalSlug(slug) {
  const target = redirectMap.get(`/zh/articles/${slug}/`);
  if (target) return target.replace(/^\/zh\/articles\//, '').replace(/\/$/, '');
  return slug;
}
const candidates = rawCandidates.map(c => ({ ...c, slug: canonicalSlug(c.slug) }));

按解析后的 slug 去重。否则同一篇 canonical 会出现两到三次,因为 tag/分类/旧 slug 的 URL 各自作为独立候选进来了。这也是 Search Console 里 Duplicate, Google chose a different canonical than user 这个状态的解法:你的内链(相关模块就是其中之一)必须指向 canonical,而不是变体。

第 6 步:审计编辑手动挑选

如果 CMS 支持”编辑精选相关”字段,跑一次性审计:

for (const article of articles) {
  for (const manual of article.manualRelated || []) {
    const sim = similarityMap.get([article.slug, manual].sort().join('|')) || 0;
    if (sim > 0.4) console.log(`Manual pick near-dup: ${article.slug} -> ${manual} (${sim})`);
  }
}

把这份单子退给编辑,要求每一对都按”合并/差异化”逐一定夺。

第 7 步:CI 护栏

全站近重复对超过 N 对就让构建失败。趋势比绝对数重要——如果连续三周上涨,在影响簇里冻结新发文,先消化再加内容。

怎么确认修好了

  1. 抽查最严重的那篇。 打开之前出现三条近重复的文章,确认渲染出来的相关面板现在是几个不同的答案(或少于 3 条,这也可以接受)。
  2. 重跑审计。 node scripts/article-similarity.mjs 应该报告渲染出来的相关对里没有任何一对超过你的截断线。跟上一次的结果做 diff。
  3. 看 subcategory 分布。 抽样一批页面,top-3 在目录允许时应至少跨 2 个 subcategory。
  4. 盯 Search Console。 接下来几周,受影响簇在 Pages 里 Duplicate without user-selected canonical 的数量应当往下走,而不是往上。这有几天到几周的滞后,别指望当天就变。

哪些不该算在你头上

真正覆盖长尾的内容站永远会有近邻。门槛不是”没有两篇相似”,而是”没有相关面板把同一个答案推三遍”。兄弟稿之间 Jaccard 在 0.3-0.4 是健康的,也是有用的。

容易误判成

  • “推荐模型有问题。“——模型可能在”找相似”上很好。问题是你让它找”相关”,它给了”相似”,这是两件事。
  • “我们要再写一些,把这个簇撑开。“——加更多通常更糟。簇致密是话题本身就窄。
  • “标签体系太粗。“——有时确实,但跟小部件推不推重复无关。先修小部件,再审计标签。

预防

  • 构建时跑成对 Jaccard / embedding 余弦,矩阵落盘(语料大了上 MinHash/LSH)。
  • 推荐器在排序之前先过近重复过滤,再用 MMR 对幸存候选重排。
  • 加多样性项,使 top-3 在目录允许时至少跨 2 个 subcategory。
  • 候选 slug 过一次重定向表再去重。
  • 季度审计:Jaccard > 0.5 的对全部进入”合并/差异化”决策日志。
  • CI 给全站近重复对总数加上限,涨了就在受影响簇里暂停发文。

FAQ

  • Google 会因为这些重复惩罚我的站吗? 不会。Google 是把近重复 URL 聚类、挑一个 canonical,而不是施加惩罚。代价是间接的:排名信号分散、抓取预算被浪费,以及卡在 Duplicate without user-selected canonical 的页面永远不会以自己的身份被收录。
  • 为什么不能只是在小部件里隐藏重复、不合并? 因为它们还会出现在 tag 页、sitemap、搜索里,Google 照样聚类。在一个入口隐藏,不能修底层的重复本身。
  • 少推几条会不会伤互动? 一般不会。三条不重复的链接比三条重复的更有点击,因为读者真的会点不重复的。
  • MMR 里 lambda 该设多少?0.7(偏相关)起步,如果面板还是显得重复就往 0.5 调。每改一次都重看一遍 top-3 的分布。
  • Jaccard 还是 embedding,该信哪个阈值? 两个都用:Jaccard > 0.5 做便宜、确定的构建期闸门;有向量时再加 embedding 余弦 > 0.92 做第二道闸。两个都要在你自己手工标注的样本对上校准。

相关

标签: #内容运营 #排查 #SEO #internal-linking #duplicate-content #recommendations