Personalization

A Hybrid Recommendation Engine

⏱ 17 min

Be precise about what this lesson is, and isn’t, before writing any code: there is no WooCommerce “recommendation API.” What this lesson builds is a custom, self-hosted hybrid recommendation engine, combining two different, real signals, behavioral data from actual orders and structural data from product attributes, into one blended ranking. Course 7 built the behavioral half of this on its own, real co-purchase counts from order line items. This lesson keeps that signal and adds the second one, so a product with thin purchase history isn’t left with no recommendations at all, and phrases the result the same grounded way every recommendation-adjacent ability in this course does.

What you'll learn in this lesson
Why this is a custom pattern, not a built-in feature
WooCommerce's native "related products" is category/tag similarity only, not behavior-based, and there's no first-party co-purchase or hybrid feature to call.
Reusing Course 7's co-purchase computation as the behavioral signal
Not rebuilding it, extending it.
Adding a structural signal from category, tags, and price range
A second, independent score you can compute even with no order history at all.
Blending both signals into one ranked list
A weighted combination, with the weighting exposed as a filter, not hard-coded.
Prerequisites

Course 7’s Project: AI Product Recommendations lesson, this one extends its my_plugin_compute_co_purchases() function directly rather than rewriting it.

Step 1: The behavioral signal, unchanged from Course 7

Reuse my_plugin_compute_co_purchases( $product_id, $limit ) exactly as that lesson built it: real order-level co-occurrence counts from wc_get_orders(), cached for six hours. Nothing about that function changes here, it remains one of the two inputs to this lesson’s blend.

Step 2: A structural signal from category, tags, and price range

This signal needs no order history at all, which matters most for newer or low-volume products the behavioral signal can’t say anything useful about yet:

// File: my-plugin.php
function my_plugin_compute_structural_similarity( $product_id, $limit = 200 ) {
	$cache_key = 'my_plugin_structural_sim_' . $product_id;
	$cached    = get_transient( $cache_key );
	if ( false !== $cached ) {
		return $cached;
	}

	$product = wc_get_product( $product_id );
	if ( ! $product ) {
		return array();
	}

	$categories = wc_get_product_term_ids( $product_id, 'product_cat' );
	$tags       = wc_get_product_term_ids( $product_id, 'product_tag' );
	$price      = (float) $product->get_price();
	$price_low  = $price * 0.7;
	$price_high = $price * 1.3;

	$candidates = wc_get_products( array(
		'category' => $categories,
		'limit'    => $limit,
		'exclude'  => array( $product_id ),
		'status'   => 'publish',
	) );

	$scores = array();
	foreach ( $candidates as $candidate ) {
		$score = 0;

		$candidate_cats = wc_get_product_term_ids( $candidate->get_id(), 'product_cat' );
		$score += count( array_intersect( $categories, $candidate_cats ) ) * 2;

		$candidate_tags = wc_get_product_term_ids( $candidate->get_id(), 'product_tag' );
		$score += count( array_intersect( $tags, $candidate_tags ) );

		$candidate_price = (float) $candidate->get_price();
		if ( $candidate_price >= $price_low && $candidate_price <= $price_high ) {
			$score += 1;
		}

		if ( $score > 0 ) {
			$scores[ $candidate->get_id() ] = $score;
		}
	}

	arsort( $scores );
	$top = array_slice( $scores, 0, 10, true );

	set_transient( $cache_key, $top, 12 * HOUR_IN_SECONDS );
	return $top;
}

This score is deliberately simple and explainable: shared category counts double, shared tags count once, and a price within 30% either way adds one point. It’s a heuristic, not a machine-learned model, and it’s honest about that, the same way Course 7’s co-purchase counter was honest about being order-level co-occurrence rather than a weighted collaborative-filtering system.

Step 3: Blending both signals

// File: my-plugin.php
function my_plugin_compute_hybrid_recommendations( $product_id ) {
	$behavioral = my_plugin_compute_co_purchases( $product_id );
	$structural = my_plugin_compute_structural_similarity( $product_id );

	$behavioral_weight = apply_filters( 'my_plugin_hybrid_behavioral_weight', 0.6 );
	$structural_weight = 1 - $behavioral_weight;

	$max_behavioral = ! empty( $behavioral ) ? max( $behavioral ) : 1;
	$max_structural = ! empty( $structural ) ? max( $structural ) : 1;

	$blended = array();
	foreach ( array_unique( array_merge( array_keys( $behavioral ), array_keys( $structural ) ) ) as $id ) {
		$b_score = ( $behavioral[ $id ] ?? 0 ) / $max_behavioral;
		$s_score = ( $structural[ $id ] ?? 0 ) / $max_structural;
		$blended[ $id ] = ( $b_score * $behavioral_weight ) + ( $s_score * $structural_weight );
	}

	arsort( $blended );
	return array_slice( $blended, 0, 5, true );
}

Normalizing each signal to a 0 to 1 range before weighting matters here, co-purchase counts and structural points live on completely different scales, and blending raw counts would let whichever signal happens to produce bigger numbers dominate regardless of the weight you set.

Exposing the weight as a filter, not a constant

apply_filters( 'my_plugin_hybrid_behavioral_weight', 0.6 ) lets a specific store lean harder on behavioral data once it has enough order volume to trust it, or lean on structural similarity for a newly launched catalog with little purchase history yet, without touching this function’s code.

Step 4: Registering the ability and phrasing the result

// File: my-plugin.php
wp_register_ability( 'my-plugin/hybrid-recommend-products', array(
	'label'       => __( 'Hybrid product recommendations', 'my-plugin' ),
	'description' => __( 'Recommends products using a blend of real co-purchase behavior and category/tag/price similarity, not a WooCommerce built-in feature.', 'my-plugin' ),
	'input_schema' => array(
		'type'       => 'object',
		'properties' => array( 'product_id' => array( 'type' => 'integer' ) ),
		'required'   => array( 'product_id' ),
	),
	'permission_callback' => '__return_true',
	'execute_callback' => function ( $input ) {
		$blended = my_plugin_compute_hybrid_recommendations( $input['product_id'] );
		if ( empty( $blended ) ) {
			return array( 'candidates' => array(), 'blurb' => '' );
		}

		$candidates = array();
		foreach ( $blended as $id => $score ) {
			$p = wc_get_product( $id );
			if ( $p ) {
				$candidates[] = array( 'id' => $id, 'name' => $p->get_name(), 'score' => round( $score, 3 ) );
			}
		}

		$prompt = sprintf(
			"Write a one-sentence product recommendation using ONLY these products, do " .
			"not add any product not listed: %s",
			wp_json_encode( $candidates )
		);

		return array(
			'candidates' => $candidates,
			'blurb'      => trim( wp_ai_client_prompt( $prompt )->generate_text() ),
		);
	},
	'meta' => array(
		'annotations' => array( 'readonly' => true ),
		'mcp'         => array( 'public' => true ),
	),
) );

Same anti-hallucination discipline as Course 7: the model phrases a sentence around candidates your own PHP already ranked, it never chooses or introduces a product.

Test it

wp-env run cli wp eval '
$result = wp_get_ability( "my-plugin/hybrid-recommend-products" )->execute( array( "product_id" => 42 ) );
print_r( $result["candidates"] );
echo $result["blurb"] . "\n";
'

Test against a product with rich order history and a recently added product with little or none. The second case is the actual proof this lesson earned its place: a behavioral-only system returns nothing useful for it, the hybrid blend still surfaces reasonable structural candidates.

Forgetting that both signals need independent caching

my_plugin_compute_co_purchases() and my_plugin_compute_structural_similarity() each carry their own transient cache with a different, appropriately chosen expiry, order data shifts slower than a six-hour window really requires, catalog structure changes even less often. Don’t share one cache key between them, invalidating one on purpose (a new order comes in) shouldn’t force a recompute of the other.

Recap

This hybrid engine blends two independently computed, independently cached signals, real co-purchase counts from order history and a category/tag/price structural score, into one weighted ranking, normalized so neither signal’s raw scale dominates the other by accident. It’s a genuinely custom pattern built entirely from your own store’s data and wp_ai_client_prompt(), not a WooCommerce feature that exists to be configured, and it covers the gap Course 7’s single-signal version left open: products with little or no purchase history still get a reasonable recommendation.

Resources & further reading

← Conversational Product Search Course Recap: Where Agentic Commerce Needs a Human in the Loop →