本文约 5,808 字,预计阅读 15 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:文末那几篇”相关文章”,到底该怎么来
文章底部放一组相关文章,是提升停留时长和内部链接密度的标准做法。看似简单,实现方案却有四五种,而且差别不小:
- 主题自带的”相关文章”模块,多半是按同分类随机取几条,质量一般;
- Jetpack 的 Related Posts 模块,把计算放到云端;
- YARPP、Contextual Related Posts 这类专用插件,本地算相关度并缓存;
- 自己写一段
WP_Query,按标签/分类重合度取。
选哪个,取决于三个问题:数据量多大(几百篇还是几万篇)、要不要中文语义相关(见 中文搜索不好用的根因)、以及能不能接受外部依赖(Jetpack 需要连 WordPress.com)。
原理分析:相关度是怎么算出来的
主流做法有四种,代价完全不同:
| 方案 | 相关度依据 | 计算位置 | 主要代价 |
|---|---|---|---|
| 同分类/同标签随机 | 分类法重合 | 本地 MySQL | 查询简单,相关性一般 |
| 标题+正文全文匹配 | 词频(TF-IDF) | 本地,需要索引 | 需要额外索引表,中文分词是问题 |
| 云端语义(Jetpack) | Elasticsearch | 云端 | 需要连接 WordPress.com,国内可能不稳 |
| 插件本地缓存(YARPP/CRP) | 词频 + 分类,结果入缓存表 | 本地 | 首次计算慢,缓存表会膨胀 |
几个关键事实:
- 按分类/标签取是最廉价的方案,命中
wp_term_relationships的索引,几十万篇文章也能毫秒级返回; - 全文相关度(YARPP 那一类)对中文效果有限,因为中文没有空格,插件内部的 tokenizer 通常按标点切分,长句会变成一个词;
- Jetpack 的 Related Posts 在云端用 Elasticsearch 计算,中文处理比本地插件好,但依赖外网连接,国内站点常出现”相关文章一直加载不出来”;
- 任何方案都必须缓存,否则每次访问文章页都要做一次多表 JOIN。
保姆级实操
第一步:小站点先试 Related Posts By Taxonomy
如果文章量在几千以内、分类体系清晰,最省心的是 Related Posts By Taxonomy:纯本地、按分类法算相关度、有缓存。它提供短代码和模板函数:
// 取相关文章(该函数由插件提供)
$related = km_rpbt_related_posts_by_taxonomy( get_the_ID(), array(
'taxonomies' => array( 'category', 'post_tag' ),
'posts_per_page' => 6,
'post_types' => array( 'post' ),
) );
if ( $related ) {
echo '<ul class="related-list">';
foreach ( $related as $p ) {
printf(
'<li><a href="%s">%s</a></li>',
esc_url( get_permalink( $p->ID ) ),
esc_html( get_the_title( $p->ID ) )
);
}
echo '</ul>';
}
也可以直接用短代码 [related_posts_by_tax] 放进文章模板。
第二步:中等站点考虑 YARPP 或 Contextual Related Posts
两者都按”标题+正文词频 + 分类”算相关度,并把结果写进自己的缓存表,前台直接读缓存。配置要点:
- YARPP:后台「Settings → YARPP」里调 “Threshold”(相关度阈值)与 “Display options”,勾选 “Cache results”;提供
related_posts()模板函数与[yarpp]短代码; - Contextual Related Posts:后台「Contextual Related Posts」里设置展示数量、是否显示缩略图、是否排除某些分类;提供小工具与短代码,可直接在内容后追加。
两者共同的配置纪律:开启缓存,并定期清理缓存表。缓存表会随文章更新不断膨胀,一年不清理可能涨到几百 MB。
第三步:自建方案(可控性最高)
自建的核心是”按标签/分类重合度排序 + 结果缓存到 transient”:
function mimangfei_get_related_posts( $post_id, $limit = 6 ) {
$key = 'mimangfei_rel_' . $post_id;
$cached = get_transient( $key );
if ( false !== $cached ) {
return $cached;
}
// 取当前文章的标签与分类 ID
$tags = wp_get_post_tags( $post_id, array( 'fields' => 'ids' ) );
$cats = wp_get_post_categories( $post_id, array( 'fields' => 'ids' ) );
$args = array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $limit,
'post__not_in' => array( $post_id ),
'ignore_sticky_posts' => true,
'no_found_rows' => true,
'orderby' => 'date',
'tax_query' => array( 'relation' => 'OR' ),
);
if ( $tags ) {
$args['tax_query'][] = array(
'taxonomy' => 'post_tag',
'field' => 'term_id',
'terms' => $tags,
);
}
if ( $cats ) {
$args['tax_query'][] = array(
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => $cats,
);
}
// 标签/分类都没有时,退化为最新文章
if ( ! $tags && ! $cats ) {
unset( $args['tax_query'] );
}
$query = new WP_Query( $args );
$related = wp_list_pluck( $query->posts, 'ID' );
set_transient( $key, $related, 12 * HOUR_IN_SECONDS );
return $related;
}
// 文章更新时清掉自己的相关缓存
add_action( 'save_post', function ( $post_id, $post ) {
if ( 'post' === $post->post_type ) {
delete_transient( 'mimangfei_rel_' . $post_id );
}
}, 10, 2 );
前台渲染:
$ids = mimangfei_get_related_posts( get_the_ID() );
if ( $ids ) {
echo '<div class="related-box"><h3>相关文章</h3><ul>';
foreach ( $ids as $id ) {
printf(
'<li><a href="%s">%s</a></li>',
esc_url( get_permalink( $id ) ),
esc_html( get_the_title( $id ) )
);
}
echo '</ul></div>';
}
第四步:让结果更”相关”的两个改进
- 同标签优先于同分类:拆成两次查询,先取同标签的,不够再补同分类的;
- 主分类加权:如果站点用 Yoast 或自定义的主分类字段,让主分类相同的文章排在前面。
// 先按标签取,不足再用分类补齐
$by_tag = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 6,
'post__not_in' => array( $post_id ),
'tag__in' => $tags,
'no_found_rows' => true,
) );
$ids = wp_list_pluck( $by_tag->posts, 'ID' );
if ( count( $ids ) < 6 ) {
$need = 6 - count( $ids );
$by_cat = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => $need,
'post__not_in' => array_merge( array( $post_id ), $ids ),
'category__in' => $cats,
'no_found_rows' => true,
) );
$ids = array_merge( $ids, wp_list_pluck( $by_cat->posts, 'ID' ) );
}
常见踩坑
orderby => rand取相关。 随机数排序会让 MySQL 全表排序并写临时表,且完全绕过查询缓存,几千篇文章时明显变慢。- 没缓存,每篇文章页多一次多表 JOIN。 用 transient 或对象缓存存 ID 列表即可,别存整段 HTML(改样式时要批量清缓存)。
- Jetpack 相关文章在国内加载不出来。 它是云端计算的,接口不通时前台就是空白。国内站点优先本地方案。
- 缓存表无限膨胀。 YARPP/CRP 的缓存表要定期清理,或限制缓存有效期。
- 相关文章里出现已下线/私密内容。 自建查询必须显式写
post_status => publish,并排除post__not_in当前文章。 - 相关文章取不到,因为文章没有标签。 退化策略要写好,否则文末一片空白。
- 案例: 某站用一款全文相关度插件,文章到 8000 篇之后,文章页 TTFB 从 400ms 涨到 1.6s。慢查询显示插件每次都要对
wp_posts做一次LIKE '%词%'的匹配并遍历候选集。最终改成”标签优先 + 分类补齐 + transient 缓存 ID”的自建方案,查询从每次 3 条降到 0 条(命中缓存),TTFB 回到 350ms,相关度在人工抽查 50 篇后反而更准(因为站点的标签体系维护得不错)。结论:相关度算法不是越复杂越好,能利用好已有分类法索引的方案性价比最高。
总结
选型建议一句话:站点分类体系清晰 → 用 Related Posts By Taxonomy 或自建按分类法取;需要语义相关且能接受外部依赖 → Jetpack;数据量中等且懒得维护 → YARPP / Contextual Related Posts,但必须开缓存并定期清表。
自建方案的必备三件套:no_found_rows => true 省掉计数、tax_query 走索引、结果 ID 存 transient 并在 save_post 时失效。中文站点尤其要避开”全文词频”这类依赖分词的方案,除非你已经做好了分词(参考 中文搜索不好用的根因)。如果还想让相关模块出现在聚合页或侧边栏,字段读取的开销可以顺手按 ACF get_field 性能优化 的方法压一压。
