Similar products and recommendations in an online store: how to use AI

05.11.2026 | Author: Marcin Wiercioch

“Similar products” and “customers also bought” sections are one of the simplest ways to increase the average order value, yet in many stores they are still filled with random products from the same category. In the second article of our series on AI in online stores, we show how to build more relevant recommendations from product vectors, order history and a language model. With code examples for WooCommerce.

Two different customer questions

Recommendations in an online store answer two completely different questions that are easy to confuse:

  • “What instead of this?” The customer is looking at a jacket but is not convinced by the colour, price or cut. They are looking for alternatives, that is, similar products.
  • “What goes with this?” The customer has already chosen the jacket and may need gloves, a hat or a waterproofing spray. These are complementary products.

Showing five other jackets on a product page makes sense. Showing five jackets in a cart that already contains a jacket is wasted space. That is why, before choosing a technology, it is worth deciding where each section appears and which question it answers.

How most stores do it

WooCommerce has a built-in “Related products” section that picks random products from the same categories and tags. Because it is random, the customer sees something different every time the page is refreshed, and in a broad category such as “Clothing” a jacket ends up next to socks. On top of that there are the manually filled “Upsells” and “Cross-sells” fields in the product editor. They work well, but only where someone has filled them in, and with several thousand products and new arrivals all the time, nobody keeps them up to date.

Below are three approaches that can be combined: similarity from vectors, order history and a language model suggesting what goes with what.

Similar products from vectors

In the previous article we turned every product into a vector, a point on a “map of meaning”, and saved it in the wp_product_embeddings table. The same data immediately gives us similar products: instead of looking for the points closest to the customer’s search phrase, we look for the points closest to a given product. No extra API call is needed, so showing recommendations costs nothing.

<?php
function get_similar_products(int $product_id, int $limit = 8): array
{
    global $wpdb;

    // Cache the result for a day, because product vectors rarely change
    $cache_key = 'similar_products_' . $product_id;
    $cached = get_transient($cache_key);
    if (is_array($cached)) {
        return $cached;
    }

    $table = $wpdb->prefix . 'product_embeddings';
    $vector = $wpdb->get_var($wpdb->prepare(
        "SELECT VEC_ToText(embedding) FROM {$table} WHERE product_id = %d",
        $product_id
    ));
    if (!$vector) {
        return [];
    }

    // Fetch several times more candidates, because some will be filtered out
    $candidate_ids = $wpdb->get_col($wpdb->prepare(
        "SELECT product_id FROM {$table}
         WHERE product_id != %d
         ORDER BY VEC_DISTANCE_COSINE(embedding, VEC_FromText(%s))
         LIMIT %d",
        $product_id,
        $vector,
        $limit * 4
    ));

    // Keep only products that are visible in the store and in stock
    $similar_ids = [];
    foreach ($candidate_ids as $candidate_id) {
        $candidate = wc_get_product($candidate_id);
        if ($candidate && $candidate->is_visible() && $candidate->is_in_stock()) {
            $similar_ids[] = (int) $candidate_id;
        }
        if (count($similar_ids) >= $limit) {
            break;
        }
    }

    set_transient($cache_key, $similar_ids, DAY_IN_SECONDS);

    return $similar_ids;
}

We hook the result into the built-in “Related products” section. By default WooCommerce shuffles this list, so we turn shuffling off to keep the most similar products first. If a product does not have a vector yet, the existing list stays:

<?php
add_filter('woocommerce_related_products', function ($related_ids, $product_id) {
    $similar_ids = get_similar_products((int) $product_id);

    return $similar_ids ?: $related_ids;
}, 10, 2);

add_filter('woocommerce_product_related_posts_shuffle', '__return_false');

Two adjustments that make a big difference in practice:

  • Variety. The products closest to a given product are often its own variants or almost identical models. Eight identical jackets in different colours help nobody. It is worth discarding products at a very small distance and limiting the number of products from a single manufacturer.
  • Similar price. An alternative at three times the price is rarely a real alternative. A simple filter, for example a price between 70% and 130% of the product being viewed, noticeably improves relevance.

“Customers also bought” from order history

The question “what goes with this?” is best answered by customers themselves. If many people buy a jacket together with specific gloves, that is the best recommendation possible. You do not need AI for this, just a well-written SQL query. WooCommerce keeps order items in the wp_wc_order_product_lookup table (the same one used by the Analytics panel), so counting what was bought together is easy.

There is one trap. A bestseller, such as a popular shopping bag, ends up in every other cart, so it comes out as “bought together” with every product. That is why we divide the number of shared orders by the product’s popularity. Then the products at the top are the ones bought together more often than their popularity alone would suggest:

SELECT other.product_id,
       COUNT(DISTINCT other.order_id) AS orders_together,
       COUNT(DISTINCT other.order_id) / popularity.orders_total AS score
FROM wp_wc_order_product_lookup base
JOIN wp_wc_order_product_lookup other
  ON other.order_id = base.order_id
 AND other.product_id != base.product_id
JOIN (
    SELECT product_id, COUNT(DISTINCT order_id) AS orders_total
    FROM wp_wc_order_product_lookup
    WHERE date_created >= NOW() - INTERVAL 12 MONTH
    GROUP BY product_id
) popularity ON popularity.product_id = other.product_id
WHERE base.product_id = 123
  AND base.date_created >= NOW() - INTERVAL 12 MONTH
GROUP BY other.product_id, popularity.orders_total
HAVING orders_together >= 3
ORDER BY score DESC
LIMIT 8;

The HAVING condition discards random pairs that appeared in only one or two orders. With a large number of orders this query is heavy, so we do not run it on every page view. We recalculate the results for all products once a day in the background (for example with Action Scheduler or cron) and save them, for example in product meta fields.

The limitation of this approach is obvious: a new product has no order history yet, and a small store may not have enough of it. This is where a language model helps.

Complementary products with the help of a language model

Vectors answer the question “what is similar”, not “what goes with what”: the closest items to a jacket will always be other jackets. A language model, such as the ones behind ChatGPT, on the other hand has general knowledge about the world. It knows that gloves go well with a jacket, and descaler and filters with a coffee machine.

We do not have to ask the model about every product separately. It is enough to ask about each category: “a customer is buying something from the Jackets category, which categories in our store might they need?”. With a few dozen categories, that is a few dozen requests, made only once. The model chooses only from the categories we actually have, and we get the answer in JSON format, which is easy to process further:

<?php
// Categories that naturally complement a purchase from a given category
function suggest_complementary_categories(string $category_name, array $all_categories): array
{
    $prompt = "You run an online store. A customer is buying a product from the \"{$category_name}\" category. "
        . "Choose up to 4 product categories from the list below that naturally complement such a purchase. "
        . "Do not choose categories with products that could replace it. "
        . "Reply in JSON format: {\"categories\": [\"category name\"]}.\n\n"
        . "Store categories:\n- " . implode("\n- ", $all_categories);

    $response = wp_remote_post('https://api.openai.com/v1/chat/completions', [
        'timeout' => 30,
        'headers' => [
            'Authorization' => 'Bearer ' . OPENAI_API_KEY,
            'Content-Type' => 'application/json',
        ],
        'body' => wp_json_encode([
            // for example the cheapest current model offered by the provider
            'model' => OPENAI_CHAT_MODEL,
            'messages' => [['role' => 'user', 'content' => $prompt]],
            'response_format' => ['type' => 'json_object'],
        ]),
    ]);

    if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
        return [];
    }

    $data = json_decode(wp_remote_retrieve_body($response), true);
    $answer = json_decode($data['choices'][0]['message']['content'] ?? '', true);

    // The model can make mistakes, so keep only categories that exist in the store
    return array_values(array_intersect($answer['categories'] ?? [], $all_categories));
}

It is worth treating the result as a suggestion rather than a final decision. We save it in a category meta field and show it in the admin panel, so that someone on the team can review and correct it in a few minutes. That is still a huge saving compared with filling in cross-sells manually for every product.

With the list of complementary categories, the “Goes well with” section shows their bestsellers or, even better, the products closest in meaning to the product being viewed. A winter trekking jacket then gets trekking gloves, not evening gloves.

How to combine them

Each approach has its strengths, so a combination works best:

Place in the store Customer’s question Source of recommendations
Product page, “Related products” section What instead of this? Vectors, with price and availability filters
Product page, “Goes well with” section What goes with this? Order history, and for new products complementary categories
Cart What else is missing? Order history for the products in the cart
No search results Is there anything similar? Vectors of the search phrase

What to watch out for

  • Do not recommend what the customer already has. Products in the cart and recently purchased products should disappear from recommendations.
  • Availability. Recommending a product that is out of stock is wasted space and frustrates the customer.
  • Performance. Calculate everything you can in advance and keep it in cache. A product page cannot wait for heavy queries or for an API response.
  • Measurement. Check how many people click recommendations and how many products end up in the cart from them. A new version of the section is best compared with the old one in an A/B test, for example in PostHog.

How much it costs

Similar products from vectors cost nothing beyond what we described in the previous article: we already have the product vectors, and the neighbour search itself happens in the database. Order history is just SQL. The only new cost is the requests for complementary categories, made once per category. With a few dozen categories and a cheap model, the cost is a few cents. Model prices change often, so check the provider’s current price list before implementing.

As with search, the real cost is implementation time: choosing filters, reviewing the suggested categories and measuring the results.

What’s next

Product vectors, order history and a language model are also the ingredients of a shopping assistant that chats with customers, knows the real catalogue and does not make up products that do not exist. How to build one, and what not to give it access to, is the topic of the next article in our series on AI in online stores.

Marcin Wiercioch Marcin Wiercioch

full stack developer

Co-founder of Okinet, PHP developer, full stack developer, Linux administrator and technology enthusiast with 20 years of experience. Lately I have been focusing especially on optimising and automating development environments, which makes the web applications we build efficient, secure and easy to develop further.

All articles by this author

Share

Rate this article

Let’s talk
about your project

+48 506 160 480
biuro@okinet.pl

or write to us