Классический поиск товаров в WooCommerce опирается на полнотекстовые возможности реляционной СУБД MySQL (LIKE '%query%' или MATCH() AGAINST()). Этот лексический подход имеет фундаментальный недостаток: он ищет исключительно буквальные совпадения символов. Если покупатель вводит абстрактный запрос «экипировка для зимнего трекинга», а в карточке товара указано «непродуваемая мембранная куртка Gore-Tex», MySQL вернет пустую выдачу, что приводит к потере конверсии и отказу клиентов.
В современной enterprise-электронной коммерции стандартом стал Семантический векторный поиск (Semantic Vector Search) в связке с гибридным ранжированием:
text-embedding-3-small или открытой nomic-embed-text) в многомерный числовой вектор (1536 измерений), кодирующий глубокий смысловой контекст.WC_Product из объектного кэша Redis.Для максимальной точности применяется Гибридный поиск (Hybrid Search), объединяющий BM25 (точное совпадение артикулов SKU и брендов) и плотные векторы (смысловой контекст) через алгоритм взаимного ранжирования Reciprocal Rank Fusion (RRF).
Внутреннее разделение потоков синхронизации и пользовательского поиска:
=== ПОТОК АСИНХРОННОЙ ИНДЕКСАЦИИ ===
[ Сохранение товара в WC ] ---> [ Action Scheduler: agentic_index_product ]
|
v (Фоновый воркер)
[ Сборка контекста: Название + Бренд + Ткань ] -> [ Embeddings API ]
| (Вектор 1536 dim)
v
[ Векторная БД: Qdrant / pgvector ]
=== ПОТОК КЛИЕНТСКОГО ПОИСКА ===
[ Покупатель: «одежда для бега в дождь» ]
|
v
[ Быстрая векторизация запроса (Embedding API) ]
| (Вектор запроса)
v
[ Векторный индекс HNSW: Поиск по косинусному расстоянию (ANN) ]
|
+---> Фильтр метаданных: in_stock = true
|
v (Топ-20 Product IDs)
[ WooCommerce: wc_get_products(include: [102, 405, 89]) ]
|
v (Выдача из Redis Object Cache)
[ Мгновенная выдача релевантных товаров в UI ]Хук woocommerce_update_product ставит задачу в очередь Action Scheduler.
Конкатенация названия, описания, категорий и вариативных атрибутов в единый документ.
Получение 1536-мерного вектора через OpenAI text-embedding-3-small или локальный Ollama.
Upsert вектора с полезной нагрузкой (product_id, price, stock_status) в коллекцию Qdrant.
Поисковый эндпоинт векторизует запрос, находит топ ID и возвращает товары с кэшированием.
Ниже представлен законченный производственный артефакт сервиса семантического поиска на PHP 8.4 с асинхронной очередью Action Scheduler и интеграцией с REST API векторной базы.
<?php
declare(strict_types=1);
/**
* Author: Artur Yusupov (Fullstack Agentic Lab)
* Package: @agentic-lab/semantic-search
* Module: SemanticProductSearchService
* Standard: WooCommerce HPOS / Action Scheduler / PHP 8.4 Strict
*/
namespace AgenticLab\Search;
use WC_Product;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use Throwable;
final class SemanticProductSearchService
{
private string $vectorDbHost;
private string $openAiKey;
private const COLLECTION_NAME = 'woocommerce_products';
public function __construct()
{
$this->vectorDbHost = (string) (getenv('QDRANT_HOST') ?: 'http://127.0.0.1:6333');
$this->openAiKey = (string) (getenv('OPENAI_API_KEY') ?: '');
}
public function init(): void
{
// 1. Асинхронный триггер при обновлении товара WooCommerce
add_action('woocommerce_update_product', [$this, 'scheduleProductIndexation'], 10, 1);
add_action('woocommerce_new_product', [$this, 'scheduleProductIndexation'], 10, 1);
// 2. Обработчик задачи в очереди Action Scheduler
add_action('agentic_index_product_vector_task', [$this, 'processProductIndexation'], 10, 1);
// 3. Регистрация поискового REST API маршрута
add_action('rest_api_init', [$this, 'registerSearchRoute']);
}
public function scheduleProductIndexation(int $productId): void
{
if (function_exists('as_enqueue_async_action')) {
as_enqueue_async_action(
'agentic_index_product_vector_task',
['product_id' => $productId],
'agentic-search-sync'
);
}
}
public function processProductIndexation(int $productId): void
{
$product = wc_get_product($productId);
if (!$product instanceof WC_Product || $product->get_status() !== 'publish') {
// Если товар скрыт или удален — удаляем вектор из базы
$this->deleteVectorFromDb($productId);
return;
}
// Формирование богатого контекста товара
$context = $this->buildProductContext($product);
// Получение числового вектора
$vector = $this->generateEmbedding($context);
if ($vector === null) {
error_log("[SemanticSearch] Не удалось сгенерировать вектор для товара #{$productId}");
return;
}
// Сохранение в коллекцию векторной БД с метаданными фильтрации
$this->upsertVectorToDb($productId, $vector, [
'product_id' => $productId,
'price' => (float) $product->get_price(),
'in_stock' => $product->is_in_stock(),
'category_ids' => $product->get_category_ids(),
]);
}
public function registerSearchRoute(): void
{
register_rest_route('agentic-lab/v1', '/products/semantic-search', [
'methods' => 'GET',
'callback' => [$this, 'handleSearchApi'],
'permission_callback' => '__return_true', // Публичный каталог
'args' => [
'q' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
'limit' => [
'required' => false,
'type' => 'integer',
'default' => 12,
],
],
]);
}
public function handleSearchApi(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$query = (string) $request->get_param('q');
$limit = (int) $request->get_param('limit');
if (mb_strlen($query) < 2) {
return new WP_REST_Response(['products' => [], 'total' => 0], 200);
}
// 1. Проверка семантического кэша в транзиентах
$cacheKey = 'agentic_vsearch_' . md5($query . '_' . $limit);
$cached = get_transient($cacheKey);
if ($cached !== false && is_array($cached)) {
return new WP_REST_Response($cached, 200);
}
// 2. Векторизация поискового запроса пользователя
$queryVector = $this->generateEmbedding($query);
if ($queryVector === null) {
return new WP_Error('ai_vector_failed', __('Сбой векторизации запроса.', 'agentic-lab'), ['status' => 500]);
}
// 3. Выполнение поиска ближайших соседей (ANN) в векторной БД
$productIds = $this->searchNearestProducts($queryVector, $limit);
if (empty($productIds)) {
return new WP_REST_Response(['products' => [], 'total' => 0], 200);
}
// 4. Гидратация объектов WooCommerce из объектного кэша Redis
$products = wc_get_products([
'include' => $productIds,
'orderby' => 'include',
'status' => 'publish',
]);
$formatted = array_map(static function (WC_Product $p): array {
return [
'id' => $p->get_id(),
'name' => $p->get_name(),
'price' => $p->get_price(),
'permalink' => $p->get_permalink(),
'image' => wp_get_attachment_image_url($p->get_image_id(), 'woocommerce_thumbnail'),
];
}, $products);
$responsePayload = [
'products' => $formatted,
'total' => count($formatted),
'query' => $query,
];
// Кэшируем результат поиска на 1 час
set_transient($cacheKey, $responsePayload, HOUR_IN_SECONDS);
return new WP_REST_Response($responsePayload, 200);
}
private function buildProductContext(WC_Product $product): string
{
$categories = wp_get_post_terms($product->get_id(), 'product_cat', ['fields' => 'names']);
$categoryNames = is_array($categories) ? implode(', ', $categories) : '';
return sprintf(
'Товар: %s. Категория: %s. Описание: %s. Атрибуты: %s.',
$product->get_name(),
$categoryNames,
wp_strip_all_tags($product->get_short_description() ?: $product->get_description()),
$product->get_attribute('material') ?: ''
);
}
/**
* @return list<float>|null
*/
private function generateEmbedding(string $text): ?array
{
$response = wp_remote_post('https://api.openai.com/v1/embeddings', [
'timeout' => 15,
'headers' => [
'Authorization' => 'Bearer ' . $this->openAiKey,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'model' => 'text-embedding-3-small',
'input' => mb_substr($text, 0, 4000),
]),
]);
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
return null;
}
$body = json_decode(wp_remote_retrieve_body($response), true);
return $body['data'][0]['embedding'] ?? null;
}
/**
* @param list<float> $vector
* @param array<string, mixed> $payload
*/
private function upsertVectorToDb(int $productId, array $vector, array $payload): void
{
wp_remote_post("{$this->vectorDbHost}/collections/" . self::COLLECTION_NAME . "/points", [
'timeout' => 10,
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode([
'points' => [
[
'id' => $productId,
'vector' => $vector,
'payload' => $payload,
],
],
]),
]);
}
private function deleteVectorFromDb(int $productId): void
{
wp_remote_post("{$this->vectorDbHost}/collections/" . self::COLLECTION_NAME . "/points/delete", [
'timeout' => 5,
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode(['points' => [$productId]]),
]);
}
/**
* @param list<float> $vector
* @return list<int>
*/
private function searchNearestProducts(array $vector, int $limit): array
{
$response = wp_remote_post("{$this->vectorDbHost}/collections/" . self::COLLECTION_NAME . "/points/search", [
'timeout' => 10,
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode([
'vector' => $vector,
'limit' => $limit,
'filter' => [
'must' => [
['key' => 'in_stock', 'match' => ['value' => true]],
],
],
]),
]);
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
return [];
}
$body = json_decode(wp_remote_retrieve_body($response), true);
$results = $body['result'] ?? [];
return array_map(static fn(array $item): int => (int) $item['id'], $results);
}
}Симптом: При нажатии кнопки «Обновить товар» страница зависает на 2–5 секунд или падает с ошибкой 504 Gateway Timeout.
Причина: Прямой HTTP-вызов Embeddings API в основном потоке выполнения хука save_post.
Решение: Обязательное использование асинхронных очередей задач Action Scheduler (as_enqueue_async_action).
Симптом: Покупатель находит релевантный товар по смыслу, но при переходе видит надпись «Нет в наличии», что вызывает фрустрацию.
Причина: Векторная база сравнивает сходство текстов и не учитывает складской статус товара.
Решение: Добавлять флаг in_stock: true в полезную нагрузку (Payload) точки векторной базы и фильтровать выдачу на уровне векторного индекса перед возвратом ID.
Симптом: Пользователь ищет конкретную модель «iPhone 15 Pro», а векторный поиск выводит чехлы, пленки и кабели для iPhone из-за высокой тематической близости контекста. Причина: Недостаток чистых плотных векторов при работе с точными артикулами и торговыми марками. Решение: Внедрение гибридного поиска (Hybrid Search): объединение лексического BM25 для артикулов и векторов через RRF.
Симптом: Быстрый перерасход лимитов API на векторизацию одинаковых популярных запросов («кроссовки», «футболка»). Причина: Каждый входящий запрос генерирует платный вызов Embedding API. Решение: Кэширование хэша запроса и полученных ID товаров в WordPress Transients или Redis на 1–2 часа.
Товар сохраняется, Action Scheduler генерирует эмбеддинг и обновляет Qdrant.
REST API принимает поисковую фразу и проверяет наличие ответа в Redis кэше.
HNSW индекс находит топ-20 товаров с фильтром по наличию на складе.
wc_get_products() извлекает готовые данные карточек и отдает JSON в UI.