Classic search looks for words, while customers increasingly describe their needs: “something warm for cycling in November” or “a gift for a dad who loves barbecuing”. In the first article of our series on AI in online stores, we show how search by meaning works, what vector databases are and how to add this kind of search to a WooCommerce store step by step. With code examples and real costs.
Search by meaning in an online store: AI and vector databases
29.10.2026 | Author: Marcin Wiercioch
When words are not enough
In the previous article we reviewed classic search engines, from LIKE in SQL to Elasticsearch. They all work on the same principle: they look for the words the customer typed in the product data. Even the best handling of word forms and typos will not help when the customer and the store use completely different words:
- “sofa” and “couch”, “laptop” and “notebook”: the same thing, a different word.
- “something warm for cycling in November”: the customer describes a situation, while the product is called “Softshell Winter Cycling Gloves”.
- “a gift for a dad who loves barbecuing”: no product description contains the word “dad”.
- “hoodie” in a store that sells “hooded sweatshirts”.
The classic solution is a manually maintained synonym dictionary. It works, but it requires constant effort and never keeps up with customers’ creativity. Search by meaning (also called semantic search) approaches the problem from the other side: instead of comparing words, it compares meaning.
How a computer understands meaning
At the heart of the whole solution is an AI model that turns text into a vector, that is, a list of numbers. There are usually several hundred to several thousand of these numbers, and in documentation the result is called an embedding. The numbers themselves mean nothing to a human, but they have one key property: texts with similar meanings get similar numbers.
The easiest way to imagine it is as a map. On an ordinary map, every place has two coordinates, and places close to each other have similar coordinates. An AI model places texts on a “map of meaning” which, instead of two dimensions, has for example 1,536. “Sofa” and “couch” land right next to each other, while “cushion” lands far away, even though it is related to furniture. In the same way, “something warm for cycling in November” lands close to winter cycling gloves, thermal underwear and neck warmers.
Search therefore comes down to three steps:
- We turn every product in the store into a vector and save it. We do this once, when a product is added or changed.
- We turn the customer’s search phrase into a vector with the same model.
- We look for the products whose vectors lie closest to the vector of the phrase.
Vector databases are used to store vectors and quickly find the nearest neighbours. More on them in a moment; first, the model itself.
Where to get a model
There are two ways. The first is a ready-made API: you send text and get a vector back. Such models are offered by OpenAI (for example text-embedding-3-small), Google, Cohere and Voyage AI, among others. This is the fastest way to start, and the cost is low (we calculate it at the end of the article).
The second way is a model running on your own server. Popular open models such as BGE-M3 or multilingual-e5 handle many languages well. There are also models trained specifically for particular languages. Your own model means no fees per request and full control over your data, but also a server (ideally with a GPU) and its maintenance. You can compare the quality of models in different languages in the MTEB leaderboard.
Whichever you choose, one rule applies: products and customer phrases must be converted with the same model. Vectors from different models cannot be compared with each other, so changing the model means recalculating the whole catalogue from scratch.
What we turn into a vector
The quality of the results depends largely on the text that describes the product. A long description with a size chart, warranty and returns information dilutes the meaning. It is better to build a short, specific text: name, category, key attributes and a short description. In WooCommerce it can look like this:
<?php
// A short text describing the product, which we will turn into a vector
function build_product_embedding_text(WC_Product $product): string
{
$parts = [$product->get_name()];
$categories = wp_get_post_terms($product->get_id(), 'product_cat', ['fields' => 'names']);
if (!is_wp_error($categories) && $categories) {
$parts[] = 'Category: ' . implode(', ', $categories);
}
foreach ($product->get_attributes() as $attribute) {
$values = $attribute->is_taxonomy()
? wc_get_product_terms($product->get_id(), $attribute->get_name(), ['fields' => 'names'])
: $attribute->get_options();
$parts[] = wc_attribute_label($attribute->get_name()) . ': ' . implode(', ', $values);
}
$parts[] = wp_strip_all_tags($product->get_short_description());
return trim(implode("\n", array_filter($parts)));
}
For an example product, the result looks roughly like this:
Softshell Pro Winter Cycling Gloves
Category: Bikes, Cycling clothing, Gloves
Material: softshell, fleece
Temperature: from -5 to 10°C
Use: cycling, running
Windproof insulated gloves for cold days, with touchscreen-compatible fingertips.
The model understands such a text as a whole: cycling, cold, protecting your hands. That is why the phrase “something warm for cycling in November” lands right here, even though apart from the word “cycling” it has nothing in common with the description.
Where to store vectors
A vector is simply a list of numbers, so you could save it in an ordinary column and compare the phrase with every product one by one on each search. With a few hundred products that will even work. With tens of thousands, you need a special index that finds the nearest points without comparing the phrase with every product. The most common one is the HNSW index: a network of connections between neighbouring points, along which the search jumps closer and closer to the target. The result is approximate, but in practice almost always the same as an exact comparison, and many times faster.
You will find such an index in three places:
- In databases your store already has. PostgreSQL with the pgvector extension is the most popular choice today. MariaDB has had a built-in VECTOR type with an index since version 11.7, which is good news for stores running on WooCommerce and PrestaShop. MySQL has had a VECTOR type since version 9, but offers fast vector search only in the HeatWave cloud edition.
- In the search engines from the previous article. Elasticsearch, OpenSearch, Meilisearch, Typesense and Manticore can store vectors alongside the regular text index.
- In dedicated vector databases. Qdrant, Weaviate, Milvus or Chroma to run on your own server, or Pinecone as a service. They offer the most features, but they are yet another piece of infrastructure to maintain.
For a typical store, the most sensible approach is to start with the database you already have. A dedicated vector database makes sense with millions of records or when vectors are used for many things at once.
Example: search by meaning in WooCommerce
We will show the whole process on MariaDB, because most WooCommerce installations run on it. We start with a table for the vectors. The text-embedding-3-small model returns 1,536 numbers, hence the column size:
CREATE TABLE wp_product_embeddings (
product_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
content_hash CHAR(32) NOT NULL,
embedding VECTOR(1536) NOT NULL,
VECTOR INDEX (embedding) M=8 DISTANCE=cosine
) ENGINE=InnoDB;
-- the 20 products closest in meaning to the customer's phrase
SELECT product_id
FROM wp_product_embeddings
ORDER BY VEC_DISTANCE_COSINE(embedding, VEC_FromText('[0.0123, -0.0456, ...]'))
LIMIT 20;
Now the conversion of text into a vector. We keep the API key in wp-config.php as the OPENAI_API_KEY constant, never in the theme code:
<?php
// Zamiana tekstu na wektor przez API OpenAI
function fetch_embedding(string $text): ?array
{
$response = wp_remote_post('https://api.openai.com/v1/embeddings', [
'timeout' => 15,
'headers' => [
'Authorization' => 'Bearer ' . OPENAI_API_KEY,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'model' => 'text-embedding-3-small',
'input' => $text,
]),
]);
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
return null;
}
$data = json_decode(wp_remote_retrieve_body($response), true);
return $data['data'][0]['embedding'] ?? null;
}
We refresh the vector whenever a product changes, but with two safeguards. First, we make the API request in the background with Action Scheduler (built into WooCommerce), so that saving in the admin panel is not slowed down. Second, we store a hash of the product text and call the API only when the text has really changed. A change of price or stock level should not cost a penny.
<?php
add_action('woocommerce_new_product', 'queue_product_embedding');
add_action('woocommerce_update_product', 'queue_product_embedding');
add_action('refresh_product_embedding', 'update_product_embedding');
// Run the API request in the background so that saving a product in the admin panel is instant
function queue_product_embedding(int $product_id): void
{
if (!as_has_scheduled_action('refresh_product_embedding', [$product_id], 'product-embeddings')) {
as_enqueue_async_action('refresh_product_embedding', [$product_id], 'product-embeddings');
}
}
function update_product_embedding(int $product_id): void
{
global $wpdb;
$product = wc_get_product($product_id);
if (!$product) {
return;
}
$text = build_product_embedding_text($product);
$hash = md5($text);
$table = $wpdb->prefix . 'product_embeddings';
// The text has not changed, so we do not pay for recalculating it
$current_hash = $wpdb->get_var($wpdb->prepare(
"SELECT content_hash FROM {$table} WHERE product_id = %d",
$product_id
));
if ($current_hash === $hash) {
return;
}
$embedding = fetch_embedding($text);
if (!$embedding) {
return;
}
$wpdb->query($wpdb->prepare(
"REPLACE INTO {$table} (product_id, content_hash, embedding) VALUES (%d, %s, VEC_FromText(%s))",
$product_id,
$hash,
wp_json_encode($embedding)
));
}
On the first run you also need to recalculate the whole existing catalogue, for example with a one-off WP-CLI command that calls update_product_embedding for every product. The search itself looks like this:
<?php
function semantic_search(string $search_query, int $limit = 20, float $max_distance = 0.6): array
{
global $wpdb;
// Cache phrase vectors for a week so we do not pay twice for the same thing
$cache_key = 'query_embedding_' . md5($search_query);
$embedding = get_transient($cache_key);
if (!$embedding) {
$embedding = fetch_embedding($search_query);
if (!$embedding) {
return [];
}
set_transient($cache_key, $embedding, WEEK_IN_SECONDS);
}
$table = $wpdb->prefix . 'product_embeddings';
$vector = wp_json_encode($embedding);
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT product_id, VEC_DISTANCE_COSINE(embedding, VEC_FromText(%s)) AS distance
FROM {$table}
ORDER BY VEC_DISTANCE_COSINE(embedding, VEC_FromText(%s))
LIMIT %d",
$vector,
$vector,
$limit
));
// Discard products that are too far in meaning from the phrase
$rows = array_filter($rows, function ($row) use ($max_distance) {
return (float) $row->distance <= $max_distance;
});
return array_map('intval', wp_list_pluck($rows, 'product_id'));
}
The distance threshold (0.6 in the example) has to be tuned on your own data: type in a few dozen typical phrases and check at which value the results stop making sense. Without a threshold, search by meaning always returns something, even for the phrase “asdfgh”.
If your store runs on PostgreSQL, the same table with the pgvector extension looks like this:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE product_embeddings (
product_id BIGINT PRIMARY KEY,
content_hash CHAR(32) NOT NULL,
embedding vector(1536) NOT NULL
);
CREATE INDEX idx_product_embeddings ON product_embeddings
USING hnsw (embedding vector_cosine_ops);
-- the <=> operator returns the distance between vectors: the smaller it is, the closer the meaning
SELECT product_id, embedding <=> '[0.0123, -0.0456, ...]' AS distance
FROM product_embeddings
ORDER BY embedding <=> '[0.0123, -0.0456, ...]'
LIMIT 20;
Hybrid search: the best of both worlds
Search by meaning does not replace classic search, it complements it. Vectors struggle wherever every character matters:
- Product codes. To the model, “XR-200” and “XR-300” are almost the same, but to the customer they are two different products.
- Numbers and sizes. “Shoes size 43” may return shoes in size 41.
- Negations. “Sleeveless dress” can be very close to “long-sleeved dress”, because both phrases are about sleeves.
- Brand names, especially lesser-known ones the model has never seen before.
That is why hybrid search is used in practice. We run the same phrase through classic search (for example the index table from the previous article) and through vectors at the same time, and then merge both lists. The simplest and surprisingly effective method merges results by position: a product gets points for its place on each list, so the ones that rank high on both end up at the top. The method is called Reciprocal Rank Fusion and fits in a few lines:
<?php
// Merge two result lists by position: high on both lists means high in the result
function merge_search_results(array $keyword_ids, array $semantic_ids, int $k = 60): array
{
$scores = [];
foreach ([$keyword_ids, $semantic_ids] as $ids) {
foreach (array_values($ids) as $position => $product_id) {
$scores[$product_id] = ($scores[$product_id] ?? 0) + 1 / ($k + $position + 1);
}
}
arsort($scores);
return array_keys($scores);
}
We pass the final list of IDs to WP_Query through the post__in parameter, with orderby set to post__in. This way the rest of the store (the results template, filters, pagination) works unchanged. It is also worth adding a simple rule: if the phrase looks like a product code, that is, it is short and contains digits, skip the vectors and stick to classic search.
The engines from the previous article, such as Elasticsearch, OpenSearch or Meilisearch, have hybrid search built in. There, this step is a matter of configuration rather than custom code.
What to watch out for
- Speed. A request to the model’s API usually adds tens to hundreds of milliseconds. It is better to leave search-as-you-type suggestions to classic search and search by meaning only once the phrase is submitted. Caching the vectors of popular phrases, as in the example above, also helps.
- Privacy. Product descriptions and the phrases customers type are sent to an external API. Product descriptions are public, but phrases should be treated with care: check the provider’s data processing terms and choose processing in the EU if available. Only your own model gives you full control.
- Data quality. The model will not invent attributes that are not in the description. A product described in one sentence will be hard to find by meaning too.
- Measurement. Before you switch the new search on for everyone, compare it with the old one: the share of searches with no results, clicks on results and sales from search. Such a comparison is easy to run, for example as an A/B test in PostHog.
How much it costs
With models available through an API, you pay for the amount of text processed, measured in tokens (fragments of words). Take a store with 10,000 products in which the text for one product is about 150 words. The whole catalogue is then a few million tokens. At the prices of cheaper models such as text-embedding-3-small, recalculating the whole catalogue once costs a few cents. Customer phrases are short, so even 100,000 searches a month cost pennies. Prices change often, so check the provider’s current price list before implementing.
So the real cost is not the API itself, but the implementation time: preparing product texts, tuning thresholds and combining it with classic search.
What’s next
Once built, a database of product vectors is useful beyond search. The same vectors power “similar products” recommendations (you just look for the nearest neighbours of a given product instead of a phrase) and a shopping assistant that answers customers’ questions based on the real catalogue instead of making things up. More on that in the next articles of our series on AI in online stores.



