Klasyczna wyszukiwarka szuka słów, a klienci coraz częściej opisują potrzeby: „coś ciepłego na rower w listopadzie” albo „prezent dla taty, który lubi grillować”. W pierwszym artykule z serii o AI w sklepach internetowych pokazujemy, jak działa wyszukiwanie po znaczeniu, czym są bazy wektorowe i jak krok po kroku dodać takie wyszukiwanie do sklepu na WooCommerce. Z przykładami kodu i realnymi kosztami.
Wyszukiwanie po znaczeniu w sklepie internetowym: AI i bazy wektorowe
29.10.2026 | Autor: Marcin Wiercioch
Kiedy słowa nie wystarczają
W poprzednim artykule przejrzeliśmy klasyczne wyszukiwarki, od LIKE w SQL po Elasticsearch. Wszystkie działają na tej samej zasadzie: szukają w danych produktów słów, które wpisał klient. Nawet najlepsza obsługa odmiany i literówek nie pomoże, gdy klient i sklep używają zupełnie innych słów:
- „sofa” i „kanapa”, „laptop” i „notebook”: ta sama rzecz, inne słowo.
- „coś ciepłego na rower w listopadzie”: klient opisuje sytuację, a produkt nazywa się „Rękawiczki rowerowe zimowe Softshell”.
- „prezent dla taty, który lubi grillować”: w żadnym opisie produktu nie ma słowa „tata”.
- „hoodie” w sklepie, który sprzedaje „bluzy z kapturem”.
Klasyczne rozwiązanie to ręcznie prowadzony słownik synonimów. Działa, ale wymaga ciągłej pracy i nigdy nie nadąża za pomysłowością klientów. Wyszukiwanie po znaczeniu (nazywane też semantycznym) podchodzi do problemu od drugiej strony: zamiast porównywać słowa, porównuje sens.
Jak komputer rozumie znaczenie
Sercem całego rozwiązania jest model AI, który zamienia tekst na wektor, czyli listę liczb. Takich liczb jest zwykle od kilkuset do kilku tysięcy, a w dokumentacjach ten wynik nazywa się po angielsku embedding. Same liczby nic nie mówią człowiekowi, ale mają jedną kluczową cechę: teksty o podobnym znaczeniu dostają podobne liczby.
Najłatwiej wyobrazić to sobie jako mapę. Na zwykłej mapie każde miejsce ma dwie współrzędne, a miejsca położone blisko siebie mają podobne współrzędne. Model AI układa teksty na „mapie znaczeń”, która zamiast dwóch ma na przykład 1536 wymiarów. „Sofa” i „kanapa” lądują tuż obok siebie, a „kanapka” daleko, choć wygląda prawie jak „kanapa”. Tak samo „coś ciepłego na rower w listopadzie” wyląduje blisko zimowych rękawiczek rowerowych, bielizny termoaktywnej i kominów na szyję.
Wyszukiwanie sprowadza się więc do trzech kroków:
- Każdy produkt w sklepie zamieniamy na wektor i zapisujemy. Robimy to raz, przy dodaniu lub zmianie produktu.
- Frazę wpisaną przez klienta zamieniamy na wektor tym samym modelem.
- Szukamy produktów, których wektory leżą najbliżej wektora frazy.
Do przechowywania wektorów i szybkiego szukania najbliższych sąsiadów służą bazy wektorowe. O nich za chwilę, najpierw sam model.
Skąd wziąć model
Są dwie drogi. Pierwsza to gotowe API: wysyłamy tekst, dostajemy wektor. Takie modele udostępniają m.in. OpenAI (np. text-embedding-3-small), Google, Cohere i Voyage AI. To najszybszy start, a koszt jest niewielki (liczymy go na końcu artykułu).
Druga droga to model uruchomiony na własnym serwerze. Popularne modele otwarte, takie jak BGE-M3 czy multilingual-e5, dobrze radzą sobie z polskim. Są też modele trenowane specjalnie na polskich tekstach. Własny model to brak opłat za zapytania i pełna kontrola nad danymi, ale też serwer (najlepiej z kartą graficzną) i jego utrzymanie. Porównanie jakości modeli w różnych językach znajdziesz w rankingu MTEB.
Niezależnie od wyboru obowiązuje jedna zasada: produkty i frazy klientów muszą być zamieniane tym samym modelem. Wektory z różnych modeli są ze sobą nieporównywalne, więc zmiana modelu oznacza przeliczenie całego katalogu od nowa.
Co zamieniamy na wektor
Jakość wyników w dużej mierze zależy od tego, jaki tekst opisuje produkt. Cały długi opis z tabelą wymiarów, informacją o gwarancji i zwrotach rozmywa znaczenie. Lepiej zbudować krótki, konkretny tekst: nazwa, kategoria, najważniejsze cechy i krótki opis. W WooCommerce może to wyglądać tak:
<?php
// Krótki tekst opisujący produkt, który zamienimy na wektor
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[] = 'Kategoria: ' . 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)));
}
Dla przykładowego produktu wynik wygląda mniej więcej tak:
Rękawiczki rowerowe zimowe Softshell Pro
Kategoria: Rowery, Odzież rowerowa, Rękawiczki
Materiał: softshell, polar
Temperatura: od -5 do 10°C
Zastosowanie: rower, bieganie
Wiatroszczelne rękawiczki z ociepleniem na chłodne dni, z wstawkami do obsługi ekranu dotykowego.
Taki tekst model rozumie jako całość: rower, zimno, ochrona dłoni. Dlatego fraza „coś ciepłego na rower w listopadzie” trafi właśnie tutaj, choć poza słowem „rower” nie ma z opisem nic wspólnego.
Gdzie trzymać wektory
Wektor to po prostu lista liczb, więc można go zapisać w zwykłej kolumnie i przy każdym wyszukiwaniu porównać frazę ze wszystkimi produktami po kolei. Przy kilkuset produktach to nawet zadziała. Przy dziesiątkach tysięcy potrzebny jest specjalny indeks, który znajduje najbliższe punkty bez porównywania frazy z każdym produktem. Najczęściej spotkasz indeks HNSW: to sieć połączeń między sąsiadującymi punktami, po której wyszukiwanie przeskakuje coraz bliżej celu. Wynik jest przybliżony, ale w praktyce prawie zawsze taki sam jak przy dokładnym porównaniu, a wielokrotnie szybszy.
Taki indeks znajdziemy w trzech miejscach:
- W bazach, które sklep już ma. PostgreSQL z rozszerzeniem pgvector to dziś najpopularniejszy wybór. MariaDB od wersji 11.7 ma wbudowany typ VECTOR z indeksem, co jest dobrą wiadomością dla sklepów na WooCommerce i PrestaShop. MySQL ma typ VECTOR od wersji 9, ale szybkie wyszukiwanie po wektorach oferuje tylko w chmurowej wersji HeatWave.
- W silnikach wyszukiwania z poprzedniego artykułu. Elasticsearch, OpenSearch, Meilisearch, Typesense i Manticore potrafią przechowywać wektory obok zwykłego indeksu tekstowego.
- W dedykowanych bazach wektorowych. Qdrant, Weaviate, Milvus czy Chroma do uruchomienia na własnym serwerze albo Pinecone jako usługa. Mają najwięcej możliwości, ale to kolejny element infrastruktury do utrzymania.
Dla typowego sklepu najrozsądniej zacząć od bazy, którą już mamy. Dedykowana baza wektorowa ma sens przy milionach rekordów albo wtedy, gdy wektory służą do wielu rzeczy naraz.
Przykład: wyszukiwanie po znaczeniu w WooCommerce
Pokażemy cały proces na MariaDB, bo na niej działa większość instalacji WooCommerce. Zaczynamy od tabeli na wektory. Model text-embedding-3-small zwraca 1536 liczb, stąd rozmiar kolumny:
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;
-- 20 produktów najbliższych znaczeniowo frazie klienta
SELECT product_id
FROM wp_product_embeddings
ORDER BY VEC_DISTANCE_COSINE(embedding, VEC_FromText('[0.0123, -0.0456, ...]'))
LIMIT 20;
Teraz zamiana tekstu na wektor. Klucz API trzymamy w wp-config.php jako stałą OPENAI_API_KEY, nigdy w kodzie motywu:
<?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;
}
Wektor odświeżamy przy każdej zmianie produktu, ale z dwoma zabezpieczeniami. Po pierwsze, zapytanie do API wykonujemy w tle przez Action Scheduler (jest wbudowany w WooCommerce), żeby nie spowalniać zapisu w panelu. Po drugie, zapamiętujemy skrót (hash) tekstu produktu i wołamy API tylko wtedy, gdy tekst naprawdę się zmienił. Zmiana ceny czy stanu magazynowego nie powinna kosztować ani grosza.
<?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');
// Zapytanie do API zlecamy w tle, żeby zapis produktu w panelu był natychmiastowy
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';
// Tekst się nie zmienił, więc nie płacimy za ponowne przeliczenie
$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)
));
}
Przy pierwszym uruchomieniu trzeba jeszcze przeliczyć cały istniejący katalog, np. jednorazowym poleceniem WP-CLI, które dla każdego produktu wywoła update_product_embedding. Samo wyszukiwanie wygląda tak:
<?php
function semantic_search(string $search_query, int $limit = 20, float $max_distance = 0.6): array
{
global $wpdb;
// Wektory fraz zapamiętujemy na tydzień, żeby nie płacić dwa razy za to samo
$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
));
// Odrzucamy produkty zbyt odległe znaczeniowo od frazy
$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'));
}
Próg odległości (w przykładzie 0.6) trzeba dobrać na własnych danych: wpisz kilkadziesiąt typowych fraz i sprawdź, od jakiej wartości wyniki przestają mieć sens. Bez progu wyszukiwanie po znaczeniu zawsze coś zwróci, nawet na frazę „asdfgh”.
Jeśli sklep działa na PostgreSQL, ta sama tabela z rozszerzeniem pgvector wygląda tak:
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);
-- operator <=> zwraca odległość między wektorami: im mniejsza, tym bliższe znaczenie
SELECT product_id, embedding <=> '[0.0123, -0.0456, ...]' AS distance
FROM product_embeddings
ORDER BY embedding <=> '[0.0123, -0.0456, ...]'
LIMIT 20;
Wyszukiwanie hybrydowe, czyli najlepsze z obu światów
Wyszukiwanie po znaczeniu nie zastępuje klasycznego, tylko je uzupełnia. Wektory słabo radzą sobie tam, gdzie liczy się każdy znak:
- Kody produktów. Dla modelu „XR-200” i „XR-300” to prawie to samo, a dla klienta to dwa różne produkty.
- Liczby i rozmiary. „Buty rozmiar 43” może zwrócić buty w rozmiarze 41.
- Zaprzeczenia. „Sukienka bez rękawów” bywa bardzo blisko „sukienki z długim rękawem”, bo obie frazy dotyczą rękawów.
- Nazwy marek, zwłaszcza mało znanych, których model nigdy wcześniej nie widział.
Dlatego w praktyce stosuje się wyszukiwanie hybrydowe. Tę samą frazę puszczamy jednocześnie przez klasyczną wyszukiwarkę (np. tabelę indeksu z poprzedniego artykułu) i przez wektory, a potem łączymy obie listy. Najprostsza i zaskakująco skuteczna metoda łączy wyniki według pozycji: produkt dostaje punkty za miejsce na każdej z list, więc najwyżej lądują te, które są wysoko na obu. Metoda nazywa się Reciprocal Rank Fusion i mieści się w kilku linijkach:
<?php
// Łączymy dwie listy wyników według pozycji: wysoko na obu listach znaczy wysoko w wyniku
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);
}
Gotową listę identyfikatorów przekazujemy do WP_Query przez parametry post__in i orderby ustawione na post__in. Dzięki temu reszta sklepu (szablon wyników, filtry, stronicowanie) działa bez zmian. Warto też dodać prostą regułę: jeśli fraza wygląda jak kod produktu, czyli jest krótka i zawiera cyfry, pomijamy wektory i zostajemy przy klasycznym wyszukiwaniu.
Silniki z poprzedniego artykułu, takie jak Elasticsearch, OpenSearch czy Meilisearch, mają wyszukiwanie hybrydowe wbudowane. Tam ten krok to kwestia konfiguracji, a nie własnego kodu.
Na co uważać
- Szybkość. Zapytanie do API modelu dodaje zwykle od kilkudziesięciu do kilkuset milisekund. Podpowiedzi w trakcie pisania lepiej zostawić klasycznej wyszukiwarce, a po znaczeniu szukać dopiero po zatwierdzeniu frazy. Pomaga też zapamiętywanie wektorów popularnych fraz, jak w przykładzie wyżej.
- Prywatność. Do zewnętrznego API trafiają opisy produktów i frazy wpisywane przez klientów. Opisy produktów są publiczne, ale frazy warto traktować ostrożnie: sprawdź warunki przetwarzania danych u dostawcy i wybierz przetwarzanie w UE, jeśli jest dostępne. Pełną kontrolę daje tylko własny model.
- Jakość danych. Model nie wymyśli cech, których nie ma w opisie. Produkt opisany jednym zdaniem będzie trudny do znalezienia także po znaczeniu.
- Pomiar. Zanim włączysz nową wyszukiwarkę wszystkim, porównaj ją ze starą: odsetek wyszukiwań bez wyników, kliknięcia w wyniki i sprzedaż z wyszukiwania. Takie porównanie łatwo przeprowadzić np. testem A/B w PostHog.
Ile to kosztuje
Przy modelach dostępnych przez API płaci się za ilość przetworzonego tekstu, liczoną w tokenach (to fragmenty słów, w polskim zwykle 2 do 3 na słowo). Weźmy sklep z 10 000 produktów, w którym tekst jednego produktu ma około 150 słów. Cały katalog to wtedy 3 do 4 milionów tokenów. Przy cenach tańszych modeli, takich jak text-embedding-3-small, jednorazowe przeliczenie całego katalogu kosztuje kilkadziesiąt groszy. Frazy klientów są krótkie, więc nawet 100 000 wyszukiwań miesięcznie to koszt liczony w groszach. Ceny zmieniają się często, dlatego przed wdrożeniem sprawdź aktualny cennik dostawcy.
Prawdziwym kosztem nie jest więc samo API, tylko czas wdrożenia: przygotowanie tekstów produktów, dobranie progów i połączenie z klasyczną wyszukiwarką.
Co dalej
Raz zbudowana baza wektorów produktów przydaje się nie tylko w wyszukiwarce. Na tych samych wektorach działają rekomendacje typu „podobne produkty” (wystarczy szukać najbliższych sąsiadów danego produktu zamiast frazy) oraz asystent zakupowy, który odpowiada na pytania klientów na podstawie prawdziwego katalogu, zamiast zmyślać. O tym w kolejnych artykułach z serii o AI w sklepach internetowych.



