WooCommerce product recommendations are a recommender system with the volume turned way down. Related products come from shared categories and tags, which is content-based filtering, the same family of math that ranks your social feed. The difference between suggestions that feel sharp and suggestions that feel random is almost always which signals feed the ranking, and how hard the recent ones are weighted.
The trigger for this post was Ivo Bernardo’s piece on Towards Data Science. He builds a news recommender in about thirty lines of Python, points it at Microsoft’s MIND dataset of real click logs, and then watches what three idle clicks do to one user’s feed. It’s the clearest demo of the mechanism I’ve come across, and more of it transfers to stores than you’d guess.
What the math actually computes
Two old techniques sit under nearly every recommender. Collaborative filtering finds users who behave like you and recommends what they liked. Content-based filtering looks at the traits of things you already engaged with and finds lookalikes. Bernardo’s demo uses the first kind: a sparse user by article matrix of ones and zeros, one row per user, cosine similarity between the target user and everyone else, keep the fifty nearest neighbors, then serve the articles those neighbors clicked weighted by how close each one is. That’s the whole engine. The MIND sample he runs it on has about 50,000 users, 51,000 articles and 156,000 impression sessions, and none of it needs deep learning to produce a feed that looks familiar.
Cosine similarity itself is unglamorous. It measures the angle between two vectors: a small angle means two click histories point the same way, ninety degrees means they share nothing. A sports reader and another sports reader align. A sports reader and a lifestyle reader mostly don’t. That angle, computed a few million times, is what most people call the algorithm.
Why recency weighting is the part worth stealing
In the demo, a user with a history of twenty five sports articles clicks three political pieces in one evening. With plain counts nothing happens, because twenty five beats three, and the feed barely moves. Then he adds a time decay multiplier, an illustrative one where recent clicks count up to a hundred times more than old ones. Real platforms publish no decay constants, and the author says so plainly, but the direction is real: political news jumps from 13% of the recommended feed to 40%, and sports drops out of first place after one evening.
What gets lost matters as much as what gets added. The feed didn’t just get more political, it got narrower, and that’s the real cost of aggressive decay. Stores want a mild version of the same signal, because purchase intent is recent. Someone browsing hiking boots this session is more likely to buy hiking socks than whatever they bought in March, which is why a recently viewed block usually earns its spot. But a store also cares about the next order, not only this session, so the decay can’t be brutal.
The WooCommerce product recommendations you already have
Out of the box, core related products are pure content-based filtering with no learning involved: products that share a category or tag with the current one, sorted randomly, and you can’t hand pick them in core, you steer them with taxonomy. Upsells and cross-sells are the opposite, fully manual curation on the Linked Products tab. All of that is documented, worth knowing before you build something on top.
The random order reads like laziness, but it’s a decent guard against bubbles. A similarity-ranked list can converge on the same handful of items, a random shuffle over a matching pool can’t. If you want collaborative signals, the store’s equivalent of click logs is order history, and that means a plugin or custom code. The hook to know is woocommerce_related_products, which filters the IDs that wc_get_related_products() returns.
A soft recency boost you can ship
The version I ship borrows the idea without the runaway. It fills any empty related product slots with items from whatever categories sit in the cart, so core results keep their place and the cart is the only recency signal. The moment the cart is empty, the store behaves exactly like stock WooCommerce.
<?php
add_filter( 'woocommerce_related_products', 'bbioon_cart_aware_related', 10, 3 );
function bbioon_cart_aware_related( $related_ids, $product_id, $args ) {
if ( ! WC()->cart || WC()->cart->is_empty() ) {
return $related_ids;
}
// Recency signal: categories the shopper is acting on right now.
$cart_cats = array();
foreach ( WC()->cart->get_cart() as $item ) {
$terms = get_the_terms( $item['product_id'], 'product_cat' );
if ( is_array( $terms ) ) {
$cart_cats = array_merge( $cart_cats, wp_list_pluck( $terms, 'term_id' ) );
}
}
$target = 4;
if ( isset( $args['posts_per_page'] ) ) {
$target = (int) $args['posts_per_page'];
} elseif ( isset( $args['limit'] ) ) {
$target = (int) $args['limit'];
}
$slots = $target - count( $related_ids );
if ( empty( $cart_cats ) || $slots < 1 ) {
return $related_ids;
}
// Fill empty slots only, so core results keep their place.
$extra = wc_get_products( array(
'limit' => $slots,
'status' => 'publish',
'category' => array_map( 'absint', array_unique( $cart_cats ) ),
'exclude' => array_merge( array( $product_id ), $related_ids ),
'orderby' => 'rand',
'return' => 'ids',
) );
return array_merge( $related_ids, $extra );
}
It only fills gaps, so a healthy related section looks untouched. The base query that wc_get_related_products() runs is cached in a transient for about a day, which is fine here, because the filter still runs on every request and the extra lookup is cheap. The honest caveat is page caching. If product pages are served from a full page cache, per-visitor output like this either needs a cache exclusion or a small frontend swap, and that’s why I keep the boost subtle rather than aggressive.
Where I wouldn’t copy the feed
A feed optimizes session length because that’s the product it sells. A store optimizes orders and repeat visits, and over-narrowing works against both. If every product page only surfaces the categories a shopper already touched, the rest of the catalog goes dark, and the long tail is often where the margin lives. The research Bernardo cites, that negative news links get shared roughly 1.91 times more than positive ones, is a feed problem. The part that transfers to commerce is the narrowing, so inject some variety on purpose.
None of this matters on a forty product store, shoppers see everything anyway. It starts to matter somewhere past a few hundred SKUs, though I’m honestly not sure where the line sits, it depends on how much of the catalog one visitor can actually browse. Whatever you personalize, keep search and category pages healthy, because feed and ad traffic arrives already decided, while search is where shoppers find things they didn’t know you carried. That’s a bigger lever than any related products tweak, and growing your site’s audience is a technical problem before it’s a content one. If AI answer engines now send part of that traffic, that’s its own checklist too, and optimizing WordPress for AI search engines is where I’d start.
If you want this tuned on your own store, whether that means fixing a related section that keeps showing the wrong items, tightening product categories so the defaults finally work, or building recommendations from real order history, that’s work I do regularly, and it usually starts with a taxonomy cleanup rather than new code.
On a client site this week I’d do two things. Audit category and tag discipline on the top twenty products, because that alone fixes most related sections I get asked about. Then put the cart-aware fill above behind a flag and watch whether it moves anything before anyone falls in love with it. The math is the easy part; the taxonomy is what decides what shoppers actually see.