ElasticSearch. Начало работы. Часть 2 из 3
Поиск по каталогу товаров
Допустим, есть сайт интернет-магазина на PHP, нужно сделать быстрый поиск по каталогу товаров. Таблица БД products содержит поля — code (код, артикул), name (торговое наименование), title (функциональное наименование), brand_id (идентификатор производителя, внешний ключ), group_id (идентификатор функциональной группы, внешний ключ). Производители и функциональные группы — в отдельных таблицах базы данных.
Нельзя сказать заранее, как будет искать пользователь сайта — конкретно «ABC-123/DE», абстрактно «Смартфон 6 дюймов» или по коду (артикулу). Но нужно, чтобы торговое наименование и код (артикул) имели больший вес, чем остальные поля. Это самые важные поля, чаще всего пользователи ищут конкретный товар по торговому наименованию или по артикулу.
По мере ввода поискового запроса — нужно показывать подсказки, что удалось найти — еще до того, как пользователь ввел поисковый запрос полностью. При этом, если первые введенные символы совпадают с первыми символами любого из полей — нужно повышать релевантность таких товаров в выдаче. Если пользователь ввел «Re» — то в первую очередь нужно показывать товары, у которых в торговом наименовании первые символы тоже «Re» (например, «Redmi A5», «Redmi 14C»). Или, если первые символы «Xi» — то в первую очередь показывать товары бренда «Xiaomi».
Торговое наименование часто представляет собой не целые слова, а некую комбинацию из цифр и букв. Причем, тут возможны различные варианты как в базе данных интернет-магазина, там и при вводе пользователем. Например, товар «ABC-123/DE» может быть сохранен в базе данных как «ABC 123 DE», «ABC 123/DE», «ABC123DE». И пользователь при вводе не будет задумываться, где нужно ввести дефис, где пробел, где слэш. Поэтому нужна предварительная обработка торгового наименования перед записью в индекс.
Функциональное наименование дополняет торговое наименование, потому как по «ABC-123/DE» довольно трудно понять, что это — холодильник, телевизор или смартфон. И обычно имеет вид «Холодильник двухкамерный белый» или «Телевизор 32 дюйма, LED, HD, YaOS, черный» или «Смартфон Android 14, 4Гб/64Гб, камера 12Мп, серый».
Создание индекса catalog_search
Создаем json-файл, который описывает все поля документа
$ nano mappings.json
{ "settings": { "analysis": { "char_filter": { "only_letter_digit": { "type": "pattern_replace", "pattern": "[^\\p{L}\\p{N}]+", "replacement": " " }, "split_letter_digit": { "type": "pattern_replace", "pattern": "(\\p{L})(\\p{N})|(\\p{N})(\\p{L})", "replacement": "$1$3 $2$4" } }, "analyzer": { "only_matter_chars": { "type": "custom", "char_filter": [ "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim" ] } } } }, "mappings": { "properties": { "name": { "type": "text", "analyzer": "only_matter_chars" }, "func_name": { "type": "text", "analyzer": "only_matter_chars" }, "brand": { "type": "text", "analyzer": "only_matter_chars" }, "code": { "type": "text", "analyzer": "keyword", "search_analyzer": "only_matter_chars" } } } }
Мы определили кастомный анализатор only_matter_chars, который сначала с помощью only_letter_digit заменит все символы, кроме букв и цифр, на пробел. Потом с помощью фильтра split_letter_digit добавит пробел между цифра-буква и между буква-цифра. Наконец, разобьет текст по пробелам и приведет к нижнему регистру.
Теперь с помощью утилиты curl создаем индекс catalog_search
$ curl -X PUT 'localhost:9200/catalog_search' -H 'Content-Type: application/json' --data-binary "@mappings.json"
Если что-то пошло не так, полностью удалить индекс можно с помощью запроса
$ curl -X DELETE 'localhost:9200/catalog_search'
Скрипт для создания индекса
Для этого установим интерпретатор PHP, расширение для работы c базой данных MySQL и менеджер пакетов.
$ sudo apt install php-cli php-mysql composer
Для работы с ElasticSearch установим PHP-клиента через composer. Обратите внимание на указание версии — это важный момент. Мне пришлось удалить последнюю версию PHP-клиента, потому что он не работал с 8-ой версией ElasticSearch.
$ mkdir /home/evgeniy/elasticsearch $ cd /home/evgeniy/elasticsearch $ composer require elasticsearch/elasticsearch:"^8.0"
Скрипт для создания индекса catalog_search, можно запускать повторно
$ nano /home/evgeniy/elasticsearch/create-index.php
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; use Elastic\Elasticsearch\Exception\ClientResponseException; use Elastic\Elasticsearch\Exception\ServerResponseException; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_search'; $mappings = 'mappings.json'; if (!file_exists($mappings)) { die("Файл с маппингом {$mappings} не найден" . PHP_EOL); } $indexBody = json_decode(file_get_contents($mappings), true); if (json_last_error() !== JSON_ERROR_NONE) { die("Некорректный JSON в файле {$mappings}, ошибка " . json_last_error_msg() . PHP_EOL); } try { $client = ClientBuilder::create() ->setHosts([$hostPort]) ->build(); $indexExists = $client->indices()->exists(['index' => $indexName])->asBool(); if ($indexExists) { $client->indices()->delete(['index' => $indexName]); } $params = [ 'index' => $indexName, 'body' => $indexBody ]; $client->indices()->create($params); } catch (ClientResponseException | ServerResponseException $e) { die('Ошибка Elasticsearch: ' . $e->getMessage() . PHP_EOL); } catch (\Exception $e) { die('Общая ошибка: ' . $e->getMessage() . PHP_EOL); }
Добавление товаров в индекс
Скрипт для добавления товаров в индекс, можно запускать повторно
$ nano /home/evgeniy/elasticsearch/add-to-index.php
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; $db_host = '127.0.0.1'; $db_name = 'catalog'; $db_user = 'catalog'; $db_pass = 'qwerty'; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_search'; try { $client = ClientBuilder::create()->setHosts([$hostPort])->build(); $pdo = new PDO("mysql:host={$db_host};dbname={$db_name};charset=utf8", $db_user, $db_pass); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (Exception $e) { die('Ошибка подключения: ' . $e->getMessage()); } // Если индекс не сущестует — завершаем работу и сообщаем причину if (! $client->indices()->exists(['index' => $indexName])->asBool()) { die("Индекс {$indexName} не существует, завершение работы"); } // Полная очистка индекса перед новой загрузкой товаров try { $client->deleteByQuery([ 'index' => $indexName, 'body' => [ 'query' => [ 'match_all' => new \stdClass() ] ] ]); } catch (\Exception $e) { die('Ошибка при очистке индекса: ' . $e->getMessage() . PHP_EOL); } // Добавление в индекс товаров из базы данных MySQL $batchSize = 1000; // обрабатываем по 1000 товаров за раз $offset = 0; while (true) { // получаем порцию товаров $stmt = $pdo->prepare(' SELECT p.id AS id, p.code AS code, p.name AS name, p.title AS func_name, b.name AS brand, g.name AS func_group FROM products p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN groups g ON p.group_id = g.id ORDER BY p.id LIMIT :limit OFFSET :offset '); $stmt->bindValue(':limit', $batchSize, PDO::PARAM_INT); $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); $stmt->execute(); $products = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($products)) { break; // все товары закончились } // готовим пачку документов для добавления в индекс $bulk_params = ['body' => []]; foreach ($products as $product) { $bulk_params['body'][] = [ 'index' => [ '_index' => $indexName, '_id' => (string)$product['id'] ] ]; $doc_body = [ 'code' => $product['code'], 'name' => $product['name'], 'func_name' => $product['func_name'], 'func_group' => $product['func_group'], 'brand' => $product['brand'], ]; $bulk_params['body'][] = $doc_body; } // добавляем подготовленную пачку документов в индекс if (!empty($bulk_params['body'])) { try { $response = $client->bulk($bulk_params); // проверяем на частичные ошибки внутри успешного ответа if ($response['errors']) { echo "Обнаружены ошибки при индексации пачки, начиная с offset {$offset}" . PHP_EOL; } } catch (Exception $e) { echo "Не удалось отправить пачку товаров в индекс, начиная с offset {$offset}" . PHP_EOL; die('Сообщение: ' . $e->getMessage() . PHP_EOL); } } echo 'Проиндексирована порция ' . count($products) . " товаров (смещение: {$offset})" . PHP_EOL; $offset += $batchSize; }
Поиск по каталогу, первый вариант
Скрипт для поиска товара, первый вариант
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_search'; if (!isset($argv[1])) { die("Использование: php {$argv[0]} «поисковый запрос»" . PHP_EOL); } try { $client = ClientBuilder::create()->setHosts([$hostPort])->build(); } catch (Exception $e) { die('Ошибка подключения к Elasticsearch: ' . $e->getMessage()); } if (! $client->indices()->exists(['index' => $indexName])->asBool()) { die("Индекс {$indexName} не существует, завершение работы"); } $searchQuery = iconv_substr($argv[1], 0, 50); $searchQuery = trim($searchQuery); $params = [ 'index' => $indexName, 'body' => [ 'size' => 10, 'query' => [ 'bool' => [ 'should' => [ // Поиск отдельных слов, достаточно совпадения любого слова из поискового // запроса (operotor => or), можно разрешить опечатки (fuzziness => 1) ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 3]]], ['match' => ['code' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 2]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], // Поиск всех слов (operotor => and) поискового запроса в тех полях, которые // могут содержать несколько слов, можно разрешить опечатки (fuzziness => 1) ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 2]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 1]]], // Все слова запроса идут в том же порядке, как у товара + совпадение префикса. // Можно использовать slop:1, чтобы разрешить одно лишнее слово в наименовании. ['match_phrase_prefix' => ['name' => ['query' => $searchQuery, 'boost' => 3]]], ['match_phrase_prefix' => ['brand' => ['query' => $searchQuery, 'boost' => 2]]], ['match_phrase_prefix' => ['func_name' => ['query' => $searchQuery, 'boost' => 1]]], ], 'minimum_should_match' => 1 ] ] ] ]; try { $response = $client->search($params); $hits = $response['hits']['hits']; if (count($hits) >= 0) { foreach ($hits as $hit) { printf( 'Балл: %.2f | (%s) %s | %s | %s' . PHP_EOL, $hit['_score'], $hit['_source']['code'], $hit['_source']['name'], $hit['_source']['brand'], $hit['_source']['func_name'], ); } } else { echo "По запросу '{$searchQuery}' ничего не найдено" . PHP_EOL; } } catch (Exception $e) { die('Ошибка поиска: ' . $e->getMessage()) . PHP_EOL; }
Поиск по каталогу, второй вариант
Очень часто в результатах поиска по запросу «ABC-123/DE» на первом месте товар «Чехол для ABC-123/DE», а сам товар «ABC-123/DE» — на втором или третьем месте. Давайте это исправим — для этого добавим поле name.first_chars_match и условие запроса на поиск, который будет повышать релевантность, если начало поискового запроса совпадает с началом какого-либо поля.
{ "settings": { "analysis": { "char_filter": { "only_letter_digit": { "type": "pattern_replace", "pattern": "[^\\p{L}\\p{N}]+", "replacement": " " }, "split_letter_digit": { "type": "pattern_replace", "pattern": "(\\p{L})(\\p{N})|(\\p{N})(\\p{L})", "replacement": "$1$3 $2$4" } }, "analyzer": { "only_matter_chars": { "type": "custom", "char_filter": [ "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim" ] }, "string_first_chars": { "type": "custom", "char_filter": [ "only_letter_digit", "split_letter_digit" ], "tokenizer": "keyword", "filter": [ "lowercase", "trim" ] } } } }, "mappings": { "properties": { "name": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "string_first_chars" } } }, "func_name": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "string_first_chars" } } }, "brand": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "string_first_chars" } } }, "code": { "type": "text", "analyzer": "keyword", "search_analyzer": "only_matter_chars" } } } }
Создаем индекс заново и добавляем товары
$ php create-index.php $ php add-to-index.php
В скрипте поиска изменим только условие — остальное не трогаем
$params = [ 'index' => $indexName, 'body' => [ 'size' => 10, 'query' => [ 'bool' => [ 'should' => [ // Поиск отдельных слов, достаточно совпадения любого слова из поискового // запроса (operotor => or), можно разрешить опечатки (fuzziness => 1) ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 3]]], ['match' => ['code' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 2]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], // Поиск всех слов (operotor => and) поискового запроса в тех полях, которые // могут содержать несколько слов, можно разрешить опечатки (fuzziness => 1) ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 2]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 1]]], // Все слова запроса идут в том же порядке, как у товара + совпадение префикса. // Можно использовать slop:1, чтобы разрешить одно лишнее слово в наименовании. ['match_phrase_prefix' => ['name' => ['query' => $searchQuery, 'boost' => 3]]], ['match_phrase_prefix' => ['brand' => ['query' => $searchQuery, 'boost' => 2]]], ['match_phrase_prefix' => ['func_name' => ['query' => $searchQuery, 'boost' => 1]]], // Начало поискового запроса совпадает с началом какого-либо поля в индексе. // Фактически, здесь нет поиска по фразе, а только поиск по префиксу. Поскольку // в результате работы анализатора будет получен один-единственный токен. ['match_phrase_prefix' => ['name.first_chars_match' => ['query' => $searchQuery, 'boost' => 9]]], ['match_phrase_prefix' => ['brand.first_chars_match' => ['query' => $searchQuery, 'boost' => 6]]], ['match_phrase_prefix' => ['func_name.first_chars_match' => ['query' => $searchQuery, 'boost' => 3]]], ], 'minimum_should_match' => 1 ] ] ] ];
В результате работы анализатора string_first_chars при добавлении в индекс товара «ABC-123/DE» — будет создан токен ["abc 123 de"]. Обратите внимание — только один токен, тогда как в результате работы анализатора only_matter_chars — будут созданы три токена ["abc", "123", "de"]. Допустим, пользователь ввел поисковый запрос «ABC123», он будет преобразован в токен ["abc 123"]. Поиск match_phrase_prefix воспримет этот токен как префикс — поскольку последний токен всегда считается префиксом.
prefix — но который в этой ситуации использовать нельзя. Поиск prefix, как мы уже знаем, это term-level query, то есть, поисковый запрос не будет обработан анализатором. Это значит, что введенная пользователем строка «ABC123» будет сравниваться с токеном ["abc 123 de"] — совпадения не будет!
Поиск по каталогу, третий вариант
Очень часто в наменовании товара перепутаны буквы латиницы и кириллицы. Да и пользователи при поиске не всегда знают, как правильно вводить — «С2000» (кириллица) или «C2000» (латиница). Давайте это исправим и будем везде латиницу заменять на кириллицу — как при добавлении в индекс, так и при поиске.
{ "settings": { "analysis": { "char_filter": { "eng_rus_mapping": { "type": "mapping", "mappings": [ "A=>а", "a=>а", "B=>в", "C=>с", "c=>с", "E=>е", "e=>е", "H=>н", "K=>к", "k=>к", "M=>м", "O=>о", "o=>о", "P=>р", "p=>р", "Y=>у", "y=>у", "X=>х", "x=>х" ] }, "only_letter_digit": { "type": "pattern_replace", "pattern": "[^\\p{L}\\p{N}]+", "replacement": " " }, "split_letter_digit": { "type": "pattern_replace", "pattern": "(\\p{L})(\\p{N})|(\\p{N})(\\p{L})", "replacement": "$1$3 $2$4" } }, "analyzer": { "only_matter_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim" ] }, "string_first_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "keyword", "filter": [ "lowercase", "trim" ] } } } }, "mappings": { "properties": { "name": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "string_first_chars" } } }, "func_name": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "string_first_chars" } } }, "brand": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "string_first_chars" } } }, "code": { "type": "text", "analyzer": "keyword", "search_analyzer": "only_matter_chars" } } } }
Создаем индекс заново и добавляем товары
$ php create-index.php $ php add-to-index.php
Результаты поиска без использования маппинга eng_rus_mapping
$ php catalog-search.php "С2000" # кириллица Балл: 153.26 | (209593) С2000-БКИ | Болид | Блок индикации с клавиатурой Балл: 150.79 | (004041) С2000-СТ | Болид | Извещатель охранный поверхностный звуковой адресный Балл: 150.79 | (004047) С2000-СМК | Болид | Извещатель охранный магнитоконтактный адресный Балл: 150.79 | (004119) С2000-4 | Болид | Прибор приемно-контрольный Балл: 150.79 | (004127) С2000-ПИ | Болид | Преобразователь/повторитель/разделитель интерфейса Балл: 150.79 | (004133) С2000-Proxy | Болид | Считыватель бесконтактный Балл: 150.79 | (004137) С2000-К | Болид | Клавиатура Балл: 150.79 | (004155) С2000-КДЛ | Болид | Контроллер двухпроводной линии связи Балл: 150.79 | (004229) С2000-КС | Болид | Пульт контроля и управления светодиодный Балл: 150.79 | (004233) С2000-2 | Болид | Контроллер доступа
$ php catalog-search.php "C2000" # латиница Балл: 24.74 | (218059) HD-2000 | EVIDENCE | Дополнительный встроенный жесткий диск 2000 Гб Балл: 24.18 | (241928) VX-2000 (TOA) | TOA | Менеджер системы VX-2000 Балл: 23.61 | (253124) 20.0.00.C | O&C | Электромеханическая защелка без планки Балл: 22.11 | (245568) Кронштейн для EDS 2000 | Crow | Кронштейн для извещателей EDS 2000 Балл: 20.36 | (228212) Лицевая наклейка С2000-БКИ | Болид | Запасная лицевая наклейка С2000-БКИ Балл: 20.36 | (230640) Лицевая наклейка С2000-БИ | Болид | Запасная лицевая наклейка С2000-БИ Балл: 19.43 | (218722) С2000-Т исп. 01 | Болид | Контроллер технологический c ЖКИ Балл: 18.55 | (017105) BREAKGLASS 2000 | Pyronix | Извещатель охранный поверхностный звуковой Балл: 18.55 | (221569) EDS-2000 | Crow | Извещатель охранный объемный комбинированный Балл: 18.55 | (229305) iC-2000 | HID | карта iCLASS
Результаты поиска с использованием маппинга eng_rus_mapping
$ php catalog-search.php "С2000" # кириллица Балл: 147.27 | (209593) С2000-БКИ | Болид | Блок индикации с клавиатурой Балл: 144.84 | (004041) С2000-СТ | Болид | Извещатель охранный поверхностный звуковой адресный Балл: 144.84 | (004047) С2000-СМК | Болид | Извещатель охранный магнитоконтактный адресный Балл: 144.84 | (004119) С2000-4 | Болид | Прибор приемно-контрольный Балл: 144.84 | (004127) С2000-ПИ | Болид | Преобразователь/повторитель/разделитель интерфейса Балл: 144.84 | (004133) С2000-Proxy | Болид | Считыватель бесконтактный Балл: 144.84 | (004137) С2000-К | Болид | Клавиатура Балл: 144.84 | (004155) С2000-КДЛ | Болид | Контроллер двухпроводной линии связи Балл: 144.84 | (004229) С2000-КС | Болид | Пульт контроля и управления светодиодный Балл: 144.84 | (004233) С2000-2 | Болид | Контроллер доступа
$ php catalog-search.php "C2000" # латиница Балл: 147.27 | (209593) С2000-БКИ | Болид | Блок индикации с клавиатурой Балл: 144.84 | (004041) С2000-СТ | Болид | Извещатель охранный поверхностный звуковой адресный Балл: 144.84 | (004047) С2000-СМК | Болид | Извещатель охранный магнитоконтактный адресный Балл: 144.84 | (004119) С2000-4 | Болид | Прибор приемно-контрольный Балл: 144.84 | (004127) С2000-ПИ | Болид | Преобразователь/повторитель/разделитель интерфейса Балл: 144.84 | (004133) С2000-Proxy | Болид | Считыватель бесконтактный Балл: 144.84 | (004137) С2000-К | Болид | Клавиатура Балл: 144.84 | (004155) С2000-КДЛ | Болид | Контроллер двухпроводной линии связи Балл: 144.84 | (004229) С2000-КС | Болид | Пульт контроля и управления светодиодный Балл: 144.84 | (004233) С2000-2 | Болид | Контроллер доступа
Поиск по каталогу, четвертый вариант
Функциональное наименование содержит слова на русском языке, нам нужно выделять корни слов, чтобы поиск работал корректно. Например, если пользователь ищет «Кожаные чехлы черные» — товар «Чехол для телефона, кожа, черный» не будет найден. При индексации будут созданы токены ["чехол", "для", "телефона", "кожа", "черный"], при поиске будут созданы токены ["кожаные", "чехлы", "черные"] — нет ни одного совпадения. Нам нужно, чтобы при индексации и при поиске были получены токены ["кож", "чех", "черн"].
{ "settings": { "analysis": { "char_filter": { "eng_rus_mapping": { "type": "mapping", "mappings": [ "A=>а", "a=>а", "B=>в", "C=>с", "c=>с", "E=>е", "e=>е", "H=>н", "K=>к", "k=>к", "M=>м", "O=>о", "o=>о", "P=>р", "p=>р", "Y=>у", "y=>у", "X=>х", "x=>х" ] }, "only_letter_digit": { "type": "pattern_replace", "pattern": "[^\\p{L}\\p{N}]+", "replacement": " " }, "split_letter_digit": { "type": "pattern_replace", "pattern": "(\\p{L})(\\p{N})|(\\p{N})(\\p{L})", "replacement": "$1$3 $2$4" } }, "analyzer": { "only_matter_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim" ] }, "string_first_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "keyword", "filter": [ "lowercase", "trim" ] }, "name_with_stemmer": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim", "russian_stemmer" ] } }, "filter": { "russian_stemmer": { "type": "stemmer", "language": "russian" } } } }, "mappings": { "properties": { "name": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "string_first_chars" } } }, "func_name": { "type": "text", "analyzer": "name_with_stemmer", "fields": { "first_chars_match": { "type": "text", "analyzer": "string_first_chars" } } }, "brand": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "string_first_chars" } } }, "code": { "type": "text", "analyzer": "keyword", "search_analyzer": "only_matter_chars" } } } }
Создаем индекс заново и добавляем товары
$ php create-index.php $ php add-to-index.php
Поиск по каталогу, пятый вариант
С поиском по коду (артикулу) не очень хорошо сейчас. Артикул товара — это шесть цифр, при вводе поискового запроса «123456 2345», поиск match — найдет код «123456». Но ничего не сможет найти по «2345», потому что поле индекса code содержит токен из 6 цифр ["234567"]. Для товара с кодом «234567» нужны токены ["23456", "2345"] — тогда при вводе части кода, этот товар будет найден.
{ "settings": { "analysis": { "char_filter": { "eng_rus_mapping": { "type": "mapping", "mappings": [ "A=>а", "a=>а", "B=>в", "C=>с", "c=>с", "E=>е", "e=>е", "H=>н", "K=>к", "k=>к", "M=>м", "O=>о", "o=>о", "P=>р", "p=>р", "Y=>у", "y=>у", "X=>х", "x=>х" ] }, "only_letter_digit": { "type": "pattern_replace", "pattern": "[^\\p{L}\\p{N}]+", "replacement": " " }, "split_letter_digit": { "type": "pattern_replace", "pattern": "(\\p{L})(\\p{N})|(\\p{N})(\\p{L})", "replacement": "$1$3 $2$4" } }, "analyzer": { "only_matter_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim" ] }, "string_first_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "keyword", "filter": [ "lowercase", "trim" ] }, "name_with_stemmer": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim", "russian_stemmer" ] }, "like_code_ngram": { "type": "custom", "tokenizer": "keyword", "filter": [ "like_code_ngram" ] } }, "filter": { "russian_stemmer": { "type": "stemmer", "language": "russian" }, "like_code_ngram": { "type": "edge_ngram", "min_gram": 4, "max_gram": 5 } } } }, "mappings": { "properties": { "name": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "string_first_chars" } } }, "func_name": { "type": "text", "analyzer": "name_with_stemmer", "fields": { "first_chars_match": { "type": "text", "analyzer": "string_first_chars" } } }, "brand": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "string_first_chars" } } }, "code": { "type": "text", "analyzer": "keyword", "search_analyzer": "only_matter_chars", "fields": { "like_code_ngram": { "type": "text", "analyzer": "like_code_ngram", "search_analyzer": "only_matter_chars" } } } } } }
Создаем индекс заново и добавляем товары
$ php create-index.php $ php add-to-index.php
В скрипте поиска изменим только условие — остальное не трогаем
$params = [ 'index' => $indexName, 'body' => [ 'size' => 10, 'query' => [ 'bool' => [ 'should' => [ // Поиск отдельных слов, достаточно совпадения любого слова из поискового // запроса (operotor => or), можно разрешить опечатки (fuzziness => 1) ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 2]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], // Поиск всех слов (operotor => and) поискового запроса в тех полях, которые // могут содержать несколько слов, можно разрешить опечатки (fuzziness => 1) ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 2]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 1]]], // Все слова запроса идут в том же порядке, как у товара + совпадение префикса. // Можно использовать slop:1, чтобы разрешить одно лишнее слово в наименовании. ['match_phrase_prefix' => ['name' => ['query' => $searchQuery, 'boost' => 3]]], ['match_phrase_prefix' => ['brand' => ['query' => $searchQuery, 'boost' => 2]]], ['match_phrase_prefix' => ['func_name' => ['query' => $searchQuery, 'boost' => 1]]], // Начало поискового запроса совпадает с началом какого-либо поля в индексе. // Фактически, здесь нет поиска по фразе, а только поиск по префиксу. Поскольку // в результате работы анализатора будет получен один-единственный токен. ['match_phrase_prefix' => ['name.first_chars_match' => ['query' => $searchQuery, 'boost' => 9]]], ['match_phrase_prefix' => ['brand.first_chars_match' => ['query' => $searchQuery, 'boost' => 6]]], ['match_phrase_prefix' => ['func_name.first_chars_match' => ['query' => $searchQuery, 'boost' => 3]]], // Поиск по коду (артикулу) товара, когда совпадают 4 или 5 цифр из запроса ['match' => ['code.like_code_ngram' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 4]]], // Поиск по коду (артикулу) товара, когда совпадают все шесть цифр запроса ['match' => ['code' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 5]]], ], 'minimum_should_match' => 1 ] ] ] ];
Поиск по каталогу, шестой вариант
Поиск match_phrase_prefix — весьма затратный, так что иногда возникает необходимость замены на что-то полегче — обычно это edge_ngram. Давайте так и сделаем, для этого добавим анализаторы field_first_chars и query_first_chars.
Анализатор field_first_chars при индексации товара «ABC-123/DE» будет создавать такие токены
["ав", "авс", "авс 1", "авс 12", "авс 123", "авс 123 d", "авс 123 dе"]
Анализатор query_first_chars для поискового запроса «ABC123D» будет создавать только один токен
["авс 123 d"]
В итоге у нас получится замена match_phrase_prefix, но ElasticSearch будет искать токен "авс 123 d", вместо того, чтобы искать префикс "авс 123 d" — это намного легче и быстрее.
{ "settings": { "analysis": { "char_filter": { "eng_rus_mapping": { "type": "mapping", "mappings": [ "A=>а", "a=>а", "B=>в", "C=>с", "c=>с", "E=>е", "e=>е", "H=>н", "K=>к", "k=>к", "M=>м", "O=>о", "o=>о", "P=>р", "p=>р", "Y=>у", "y=>у", "X=>х", "x=>х" ] }, "only_letter_digit": { "type": "pattern_replace", "pattern": "[^\\p{L}\\p{N}]+", "replacement": " " }, "split_letter_digit": { "type": "pattern_replace", "pattern": "(\\p{L})(\\p{N})|(\\p{N})(\\p{L})", "replacement": "$1$3 $2$4" } }, "analyzer": { "only_matter_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim" ] }, "query_first_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "keyword", "filter": [ "lowercase", "trim" ] }, "field_first_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "keyword", "filter": [ "lowercase", "trim", "first_chars_ngram", "trim" ] }, "name_with_stemmer": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim", "russian_stemmer" ] }, "like_code_ngram": { "type": "custom", "tokenizer": "keyword", "filter": [ "like_code_ngram" ] } }, "filter": { "russian_stemmer": { "type": "stemmer", "language": "russian" }, "like_code_ngram": { "type": "edge_ngram", "min_gram": 4, "max_gram": 5 }, "first_chars_ngram": { "type": "edge_ngram", "min_gram": 2, "max_gram": 30 } } } }, "mappings": { "properties": { "name": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "field_first_chars", "search_analyzer": "query_first_chars" } } }, "func_name": { "type": "text", "analyzer": "name_with_stemmer", "fields": { "first_chars_match": { "type": "text", "analyzer": "field_first_chars", "search_analyzer": "query_first_chars" } } }, "brand": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "field_first_chars", "search_analyzer": "query_first_chars" } } }, "code": { "type": "text", "analyzer": "keyword", "search_analyzer": "only_matter_chars", "fields": { "like_code_ngram": { "type": "text", "analyzer": "like_code_ngram", "search_analyzer": "only_matter_chars" } } } } } }
Создаем индекс заново и добавляем товары
$ php create-index.php $ php add-to-index.php
В скрипте поиска изменим только условие — остальное не трогаем
$params = [ 'index' => $indexName, 'body' => [ 'size' => 10, 'query' => [ 'bool' => [ 'should' => [ // Поиск отдельных слов, достаточно совпадения любого слова из поискового // запроса (operotor => or), можно разрешить опечатки (fuzziness => 1) ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], // Поиск всех слов (operotor => and) поискового запроса в тех полях, которые // могут содержать несколько слов, можно разрешить опечатки (fuzziness => 1) ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 2]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 1]]], // Все слова запроса идут в том же порядке, как у товара + совпадение префикса. // Можно использовать slop:1, чтобы разрешить одно лишнее слово в наименовании. ['match_phrase_prefix' => ['name' => ['query' => $searchQuery, 'boost' => 3]]], ['match_phrase_prefix' => ['brand' => ['query' => $searchQuery, 'boost' => 2]]], ['match_phrase_prefix' => ['func_name' => ['query' => $searchQuery, 'boost' => 1]]], // Начало поискового запроса совпадает с началом какого-либо поля в индексе ['match' => ['name.first_chars_match' => ['query' => $searchQuery, 'boost' => 9]]], ['match' => ['brand.first_chars_match' => ['query' => $searchQuery, 'boost' => 6]]], ['match' => ['func_name.first_chars_match' => ['query' => $searchQuery, 'boost' => 3]]], // Поиск по коду (артикулу) товара, когда совпадают 4 или 5 цифр из запроса ['match' => ['code.like_code_ngram' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 4]]], // Поиск по коду (артикулу) товара, когда совпадают все шесть цифр запроса ['match' => ['code' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 5]]], ], 'minimum_should_match' => 1 ] ] ] ];
Поиск по каталогу, седьмой вариант
Остался еще один поиск match_phrase_prefix — давайте его тоже заменим на edge_ngram. Полноценной замены мне не удалось придумать, только некий примитивный аналог. Даже не знаю, есть ли смысл в такой замене, мне кажется — скорее нет, чем да.
Для товара «ABC-123/DE» анализатор string_word_word будет создавать такие токены при индексации
["с 1", "3 d"]
Для поискового запроса «ABC123» анализатор string_word_word сформирует только один токен
["с 1"]
Анализатор берет последнюю букву первого слова + пробел + первую букву второго слова. Потом последнюю букву второго слова + пробел + первую букву третьего слова. Как к этому прикрутить stemmer — не придумал, так что это должно неплохо работать для торгового наименования, но существенно хуже для функционального наименования.
{ "settings": { "analysis": { "char_filter": { "eng_rus_mapping": { "type": "mapping", "mappings": [ "A=>а", "a=>а", "B=>в", "C=>с", "c=>с", "E=>е", "e=>е", "H=>н", "K=>к", "k=>к", "M=>м", "O=>о", "o=>о", "P=>р", "p=>р", "Y=>у", "y=>у", "X=>х", "x=>х" ] }, "only_letter_digit": { "type": "pattern_replace", "pattern": "[^\\p{L}\\p{N}]+", "replacement": " " }, "split_letter_digit": { "type": "pattern_replace", "pattern": "(\\p{L})(\\p{N})|(\\p{N})(\\p{L})", "replacement": "$1$3 $2$4" } }, "analyzer": { "only_matter_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim" ] }, "query_first_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "keyword", "filter": [ "lowercase", "trim" ] }, "field_first_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "keyword", "filter": [ "lowercase", "trim", "first_chars_ngram", "trim" ] }, "string_word_word": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "string_word_word", "filter": [ "lowercase", "trim" ] }, "name_with_stemmer": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim", "russian_stemmer" ] }, "like_code_ngram": { "type": "custom", "tokenizer": "keyword", "filter": [ "like_code_ngram" ] } }, "filter": { "russian_stemmer": { "type": "stemmer", "language": "russian" }, "like_code_ngram": { "type": "edge_ngram", "min_gram": 4, "max_gram": 5 }, "first_chars_ngram": { "type": "edge_ngram", "min_gram": 2, "max_gram": 30 } }, "tokenizer": { "string_word_word": { "type": "pattern", "pattern": "[\\p{L}\\p{N}] [\\p{L}\\p{N}]", "group": 0 } } } }, "mappings": { "properties": { "name": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "field_first_chars", "search_analyzer": "query_first_chars" }, "near_words_match": { "type": "text", "analyzer": "string_word_word" } } }, "func_name": { "type": "text", "analyzer": "name_with_stemmer", "fields": { "first_chars_match": { "type": "text", "analyzer": "field_first_chars", "search_analyzer": "query_first_chars" } } }, "brand": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "field_first_chars", "search_analyzer": "query_first_chars" } } }, "code": { "type": "text", "analyzer": "keyword", "search_analyzer": "only_matter_chars", "fields": { "like_code_ngram": { "type": "text", "analyzer": "like_code_ngram", "search_analyzer": "only_matter_chars" } } } } } }
Создаем индекс заново и добавляем товары
$ php create-index.php $ php add-to-index.php
В скрипте поиска изменим только условие — остальное не трогаем
$params = [ 'index' => $indexName, 'body' => [ 'size' => 10, 'query' => [ 'bool' => [ 'should' => [ // Поиск отдельных слов, достаточно совпадения любого слова из поискового // запроса (operotor => or), можно разрешить опечатки (fuzziness => 1) ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], // Поиск всех слов (operotor => and) поискового запроса в тех полях, которые // могут содержать несколько слов, можно разрешить опечатки (fuzziness => 1) ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 2]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 1]]], // Два слова запроса идут в том же порядке, как у товара (не точно, оценка // по последней букве одного слова и первой букве следующего слова) ['match' => ['name.near_words_match' => ['query' => $searchQuery, 'boost' => 3]]], ['match' => ['brand.near_words_match' => ['query' => $searchQuery, 'boost' => 2]]], ['match' => ['func_name.near_words_match' => ['query' => $searchQuery, 'boost' => 1]]], // Начало поискового запроса совпадает с началом какого-либо поля в индексе ['match' => ['name.first_chars_match' => ['query' => $searchQuery, 'boost' => 9]]], ['match' => ['brand.first_chars_match' => ['query' => $searchQuery, 'boost' => 6]]], ['match' => ['func_name.first_chars_match' => ['query' => $searchQuery, 'boost' => 3]]], // Поиск по коду (артикулу) товара, когда совпадают 4 или 5 цифр из запроса ['match' => ['code.like_code_ngram' => ['query' => $searchQuery, 'boost' => 4]]], // Поиск по коду (артикулу) товара, когда совпадают все шесть цифр запроса ['match' => ['code' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 5]]], ], 'minimum_should_match' => 1 ] ] ] ];
Чтобы проверить, как это работает — попробуем выполнить поиск с только одним условием, потом с двумя условиями
$params = [ 'index' => $indexName, 'body' => [ 'size' => 5, 'query' => [ 'bool' => [ 'should' => [ ['match' => ['name' => ['query' => $searchQuery, 'boost' => 3]]], // первое условие ['match_phrase_prefix' => ['name' => ['query' => $searchQuery, 'boost' => 3]]], // второе условие ['match' => ['name.near_words_match' => ['query' => $searchQuery, 'boost' => 3]]], // третье условие ], 'minimum_should_match' => 1 ] ] ] ];
$ php catalog-search.php "DS-2CD2522FWD" # первое условие Балл: 61.94 | (249287) DS-2CD2522FWD-IS (2.8) | Hikvision | IP-камера купольная Балл: 61.94 | (255483) DS-2CD2522FWD-IWS (2.8) | Hikvision | IP-камера купольная уличная Балл: 59.85 | (249285) DS-2CD2522FWD-IS (4.0) | Hikvision | IP-камера купольная Балл: 59.85 | (249286) DS-2CD2522FWD-IS (6.0) | Hikvision | IP-камера купольная Балл: 59.85 | (255484) DS-2CD2522FWD-IWS (4.0) | Hikvision | IP-камера купольная уличная ..........
$ php catalog-search.php "DS-2CD2522FWD" # первое + второе условие Балл: 121.78 | (249287) DS-2CD2522FWD-IS (2.8) | Hikvision | IP-камера купольная Балл: 121.78 | (255483) DS-2CD2522FWD-IWS (2.8) | Hikvision | IP-камера купольная уличная Балл: 119.69 | (249285) DS-2CD2522FWD-IS (4.0) | Hikvision | IP-камера купольная Балл: 119.69 | (249286) DS-2CD2522FWD-IS (6.0) | Hikvision | IP-камера купольная Балл: 119.69 | (255484) DS-2CD2522FWD-IWS (4.0) | Hikvision | IP-камера купольная уличная ..........
$ php catalog-search.php "DS-2CD2522FWD" # первое + третье условие Балл: 108.06 | (249287) DS-2CD2522FWD-IS (2.8) | Hikvision | IP-камера купольная Балл: 108.06 | (255483) DS-2CD2522FWD-IWS (2.8) | Hikvision | IP-камера купольная уличная Балл: 105.97 | (249285) DS-2CD2522FWD-IS (4.0) | Hikvision | IP-камера купольная Балл: 105.97 | (249286) DS-2CD2522FWD-IS (6.0) | Hikvision | IP-камера купольная Балл: 105.97 | (255484) DS-2CD2522FWD-IWS (4.0) | Hikvision | IP-камера купольная уличная ..........
Это работает, но заметно хуже, чем поиск match_phrase_prefix — возможны случайные совпадения последней буквы предыдущего и первой буквы следующего + не учитывается вся фраза целиком, а только пары предыдущий-следующий.
Поиск по каталогу, восьмой вариант
Для товаров есть данные по объемам продаж — и хотелось бы это использовать, чтобы популярные товары были выше в результатах поиска. Для этого в индекс добавим еще одно поле sales_amount — и будем увеличивать итоговый _score (максимум на 10%).
{ "settings": { "analysis": { "char_filter": { "eng_rus_mapping": { "type": "mapping", "mappings": [ "A=>а", "a=>а", "B=>в", "C=>с", "c=>с", "E=>е", "e=>е", "H=>н", "K=>к", "k=>к", "M=>м", "O=>о", "o=>о", "P=>р", "p=>р", "Y=>у", "y=>у", "X=>х", "x=>х" ] }, "only_letter_digit": { "type": "pattern_replace", "pattern": "[^\\p{L}\\p{N}]+", "replacement": " " }, "split_letter_digit": { "type": "pattern_replace", "pattern": "(\\p{L})(\\p{N})|(\\p{N})(\\p{L})", "replacement": "$1$3 $2$4" } }, "analyzer": { "only_matter_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim" ] }, "query_first_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "keyword", "filter": [ "lowercase", "trim" ] }, "field_first_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "keyword", "filter": [ "lowercase", "trim", "first_chars_ngram", "trim" ] }, "name_with_stemmer": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim", "russian_stemmer" ] }, "like_code_ngram": { "type": "custom", "tokenizer": "keyword", "filter": [ "like_code_ngram" ] } }, "filter": { "russian_stemmer": { "type": "stemmer", "language": "russian" }, "like_code_ngram": { "type": "edge_ngram", "min_gram": 4, "max_gram": 5 }, "first_chars_ngram": { "type": "edge_ngram", "min_gram": 2, "max_gram": 30 } } } }, "mappings": { "properties": { "name": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "field_first_chars", "search_analyzer": "query_first_chars" } } }, "func_name": { "type": "text", "analyzer": "name_with_stemmer", "fields": { "first_chars_match": { "type": "text", "analyzer": "field_first_chars", "search_analyzer": "query_first_chars" } } }, "brand": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "field_first_chars", "search_analyzer": "query_first_chars" } } }, "code": { "type": "text", "analyzer": "keyword", "search_analyzer": "only_matter_chars", "fields": { "like_code_ngram": { "type": "text", "analyzer": "like_code_ngram", "search_analyzer": "only_matter_chars" } } }, "sales_amount": { "type": "double" } } } }
Создаем индекс заново и добавляем товары
$ php create-index.php $ php add-to-index.php
sales_amount — для тестирования просто для некоторых товаров установить значение 1 млн, для всех остальных товаров — ноль.
В скрипте поиска изменим только условие — остальное не трогаем
$params = [ 'index' => $indexName, 'body' => [ 'size' => 10, 'query' => [ 'function_score' => [ // сюда помещаем основной запрос для поиска 'query' => [ 'bool' => [ 'should' => [ // все условия поиска остаются здесь без изменений (седьмой вариант) ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 2]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 1]]], ['match_phrase_prefix' => ['name' => ['query' => $searchQuery, 'boost' => 3]]], ['match_phrase_prefix' => ['brand' => ['query' => $searchQuery, 'boost' => 2]]], ['match_phrase_prefix' => ['func_name' => ['query' => $searchQuery, 'boost' => 1]]], ['match' => ['name.first_chars_match' => ['query' => $searchQuery, 'boost' => 9]]], ['match' => ['brand.first_chars_match' => ['query' => $searchQuery, 'boost' => 6]]], ['match' => ['func_name.first_chars_match' => ['query' => $searchQuery, 'boost' => 3]]], ['match' => ['code.like_code_ngram' => ['query' => $searchQuery, 'boost' => 4]]], ['match' => ['code' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 5]]], ], 'minimum_should_match' => 1 ] ], // добавляем функции для модификации _score 'functions' => [ [ 'script_score' => [ 'script' => [ // цель: вернуть множитель от 1.0 до 1.1 'source' => " // 1. Используем логарифм, чтобы сгладить разницу в продажах double salesLog = Math.log1p(doc['sales_amount'].value); // 2. Выбираем ожидаемый максимум для логарифма. // log1p(1,000,000) ~ 13.8. Возьмем с запасом, например, 14. // Товары с продажами ~1 млн. получат максимальный буст. double maxLog = 14.0; // 3. Нормализуем значение логарифма в диапазон [0, 1] double scaled = Math.min(1.0, salesLog / maxLog); // 4. Превращаем [0, 1] в бонус [0, 0.1] (наши 10%) double bonus = scaled * 0.1; // 5. Возвращаем базовый множитель 1.0 + бонус return 1.0 + bonus; " ] ] ] ], // умножаем исходный _score на результат работы скрипта 'boost_mode' => 'multiply' ] ] ] ];
Посмотрим, как изменился результат поиска по сравнению с предыдущим вариантом
$ php catalog-search7.php "ип 212" Балл: 359.72 | (005014) ИП 212-43 | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-эл... Балл: 359.72 | (005048) ИП 212-45 | Рубеж | Извещатель пожарный дымовой оптико-электронный точечный Балл: 359.72 | (206591) ИП 212-95 | Рубеж | Извещатель пожарный дымовой оптико-электронный точечный Балл: 359.72 | (206592) ИП 212-87 | Рубеж | Извещатель пожарный дымовой оптико-электронный точечный Балл: 359.72 | (207474) ИП 212-91 | Юнитест | Извещатель пожарный дымовой оптико-электронный точечный ..........
$ php catalog-search.php "ип 212" Балл: 397.85 | (207474) ИП 212-91 | Юнитест | Извещатель пожарный дымовой оптико-электронный точечный | 1000000.000000 Балл: 370.99 | (204559) ИП 212-50М2 | Рубеж | Извещатель пожарный дымовой оптико-электронный точечный | 1000000.000000 Балл: 362.11 | (005014) ИП 212-43 | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-... | 0.000000 Балл: 362.11 | (005048) ИП 212-45 | Рубеж | Извещатель пожарный дымовой оптико-электронный точечный | 0.000000 Балл: 362.11 | (206591) ИП 212-95 | Рубеж | Извещатель пожарный дымовой оптико-электронный точечный | 0.000000 ..........
Поиск по каталогу, девятый вариант
Как правило, чем короче поле документа, где найден термин — тем лучше этот документ отвечает поисковому запросу. Поэтому добавим бустинг, который будет повышать в выдаче товары с коротким торговым наименованием, для этого нам потребуется под-поле name.calc_length.
{ "settings": { "analysis": { "char_filter": { "eng_rus_mapping": { "type": "mapping", "mappings": [ "A=>а", "a=>а", "B=>в", "C=>с", "c=>с", "E=>е", "e=>е", "H=>н", "K=>к", "k=>к", "M=>м", "O=>о", "o=>о", "P=>р", "p=>р", "Y=>у", "y=>у", "X=>х", "x=>х" ] }, "only_letter_digit": { "type": "pattern_replace", "pattern": "[^\\p{L}\\p{N}]+", "replacement": " " }, "split_letter_digit": { "type": "pattern_replace", "pattern": "(\\p{L})(\\p{N})|(\\p{N})(\\p{L})", "replacement": "$1$3 $2$4" } }, "analyzer": { "only_matter_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim" ] }, "query_first_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "keyword", "filter": [ "lowercase", "trim" ] }, "field_first_chars": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "keyword", "filter": [ "lowercase", "trim", "first_chars_ngram", "trim" ] }, "name_with_stemmer": { "type": "custom", "char_filter": [ "eng_rus_mapping", "only_letter_digit", "split_letter_digit" ], "tokenizer": "whitespace", "filter": [ "lowercase", "trim", "russian_stemmer" ] }, "like_code_ngram": { "type": "custom", "tokenizer": "keyword", "filter": [ "like_code_ngram" ] } }, "filter": { "russian_stemmer": { "type": "stemmer", "language": "russian" }, "like_code_ngram": { "type": "edge_ngram", "min_gram": 4, "max_gram": 5 }, "first_chars_ngram": { "type": "edge_ngram", "min_gram": 2, "max_gram": 30 } } } }, "mappings": { "properties": { "name": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "field_first_chars", "search_analyzer": "query_first_chars" }, "calc_length": { "type": "keyword" } } }, "func_name": { "type": "text", "analyzer": "name_with_stemmer", "fields": { "first_chars_match": { "type": "text", "analyzer": "field_first_chars", "search_analyzer": "query_first_chars" } } }, "brand": { "type": "text", "analyzer": "only_matter_chars", "fields": { "first_chars_match": { "type": "text", "analyzer": "field_first_chars", "search_analyzer": "query_first_chars" } } }, "code": { "type": "text", "analyzer": "keyword", "search_analyzer": "only_matter_chars", "fields": { "like_code_ngram": { "type": "text", "analyzer": "like_code_ngram", "search_analyzer": "only_matter_chars" } } }, "sales_amount": { "type": "double" } } } }
Создаем индекс заново и добавляем товары
$ php create-index.php $ php add-to-index.php
В скрипте поиска изменим только условие — остальное не трогаем
$params = [ 'index' => $indexName, 'body' => [ 'size' => 10, 'query' => [ 'function_score' => [ // сюда помещаем основной запрос для поиска 'query' => [ 'bool' => [ 'should' => [ ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 2]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 1]]], ['match_phrase_prefix' => ['name' => ['query' => $searchQuery, 'boost' => 3]]], ['match_phrase_prefix' => ['brand' => ['query' => $searchQuery, 'boost' => 2]]], ['match_phrase_prefix' => ['func_name' => ['query' => $searchQuery, 'boost' => 1]]], ['match' => ['name.first_chars_match' => ['query' => $searchQuery, 'boost' => 9]]], ['match' => ['brand.first_chars_match' => ['query' => $searchQuery, 'boost' => 6]]], ['match' => ['func_name.first_chars_match' => ['query' => $searchQuery, 'boost' => 3]]], ['match' => ['code.like_code_ngram' => ['query' => $searchQuery, 'boost' => 4]]], ['match' => ['code' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 5]]], ], 'minimum_should_match' => 1 ] ], // добавляем функции для модификации _score 'functions' => [ [ 'script_score' => [ 'script' => [ // цель: вернуть множитель от 1.0 до 1.1 'source' => " // 1. Используем логарифм, чтобы сгладить разницу в продажах double salesLog = Math.log1p(doc['sales_amount'].value); // 2. Выбираем ожидаемый максимум для логарифма. // log1p(1,000,000) ~ 13.8. Возьмем с запасом, например, 14. // Товары с продажами ~1 млн. получат максимальный буст. double maxLog = 14.0; // 3. Нормализуем значение логарифма в диапазон [0, 1] double scaled = Math.min(1.0, salesLog / maxLog); // 4. Превращаем [0, 1] в бонус [0, 0.1] (наши 10%) double bonus = scaled * 0.1; // 5. Возвращаем базовый множитель 1.0 + бонус return 1.0 + bonus; " ] ] ], [ 'script_score' => [ 'script' => [ // цель: вернуть множитель от 1.1 до 1.0 'source' => " int ideal_length = 10; // наименования этой длины получают макс.бонус double max_bonus = 0.1; // максимальный бонус для идеальной длины — 10% double scale = 10.0; // скорость падения бонуса для длинных наименований int curr_length = doc['name.calc_length'].value.length(); if (curr_length <= ideal_length) { return 1.0 + max_bonus; } // Чем больше разница с ideal_length, тем меньше будет бонус. Сейчас // установлены значения: за длину в два раза больше идеальной — бонус // в два раза меньше, за длину в три раза больше — в три раза меньше. double decay = (curr_length - ideal_length) / scale; double bonus = max_bonus / (1.0 + decay); return 1.0 + bonus; " ] ] ] ], // умножаем исходный _score на результат работы скрипта 'boost_mode' => 'multiply' ] ] ] ];
Для проверки работы бустинга по длине торгового наименования оставим только условие поиска по бренду и уберем влияние скриптов. Так мы получим товары одного бренда с разной длиной наименования — и будет легко сравнить результат с бустингом по длине и без бустинга.
$params = [ 'index' => $indexName, 'body' => [ 'size' => 10, 'query' => [ 'function_score' => [ // сюда помещаем основной запрос для поиска 'query' => [ 'bool' => [ 'should' => [ ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], ], 'minimum_should_match' => 1 ] ], // добавляем функции для модификации _score 'functions' => [ [ 'script_score' => [ 'script' => [ // цель: вернуть множитель от 1.0 до 1.1 'source' => " return 1.0; " ] ] ], [ 'script_score' => [ 'script' => [ // цель: вернуть множитель от 1.1 до 1.0 'source' => " return 1.0; " ] ] ] ], // умножаем исходный _score на результат работы скрипта 'boost_mode' => 'multiply' ] ] ] ];
$ php catalog-search.php "ДКС" Балл: 3.06 | (007034) Коробка 100х100х50 IP55 (53800) | ДКС | Коробка ответвительная с 6 кабельными вводами Балл: 3.06 | (007042) Коробка 240х190х90 IP55 (54200) | ДКС | Коробка ответвительная с 12 кабельными вводами Балл: 3.06 | (007045) Коробка IP56 100х100х50 (53810) | ДКС | Коробка ответвительная с гладкими стенками, IP56 Балл: 3.06 | (007076) Коробка 150х110х70 IP55 (54000) | ДКС | Коробка ответвительная с 10 кабельными вводами Балл: 3.06 | (007079) Коробка 120х80х50мм IP55 (53900) | ДКС | Коробка ответвительная с 6 кабельными вводами ..........
$ php catalog-search.php "ДКС" Балл: 3.37 | (208119) PTCE 37501 | ДКС | Пластина для заземления для металлических лотков 50х80х100мм Балл: 3.37 | (250827) INFO650SI | ДКС | Линейно-интерактивный источник бесперебойного питания Балл: 3.37 | (250828) INFO850SI | ДКС | Линейно-интерактивный источник бесперебойного питания Балл: 3.37 | (250833) SMALLB1A10 | ДКС | Однофазный источник бесперебойного питания Балл: 3.37 | (250834) SMALLB2A10 | ДКС | Однофазный источник бесперебойного питания ..........
Поиск по каталогу, десятый вариант
Теоретически, match_bool_prefix лучше подходит для первых двух групп условий, чем простой match поиск. Потому что позволяет найти не только совпадения токенов, но и учитывает префикс.
$params = [ 'index' => $indexName, 'body' => [ 'size' => 10, 'query' => [ 'function_score' => [ // сюда помещаем основной запрос для поиска 'query' => [ 'bool' => [ 'should' => [ ['match_bool_prefix' => ['name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 3]]], ['match_bool_prefix' => ['brand' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], ['match_bool_prefix' => ['func_name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], ['match_bool_prefix' => ['name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 3]]], ['match_bool_prefix' => ['brand' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 2]]], ['match_bool_prefix' => ['func_name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 1]]], ['match_phrase_prefix' => ['name' => ['query' => $searchQuery, 'boost' => 3]]], ['match_phrase_prefix' => ['brand' => ['query' => $searchQuery, 'boost' => 2]]], ['match_phrase_prefix' => ['func_name' => ['query' => $searchQuery, 'boost' => 1]]], ['match' => ['name.first_chars_match' => ['query' => $searchQuery, 'boost' => 9]]], ['match' => ['brand.first_chars_match' => ['query' => $searchQuery, 'boost' => 6]]], ['match' => ['func_name.first_chars_match' => ['query' => $searchQuery, 'boost' => 3]]], ['match' => ['code.like_code_ngram' => ['query' => $searchQuery, 'boost' => 4]]], ['match' => ['code' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 5]]], ], 'minimum_should_match' => 1 ] ], // добавляем функции для модификации _score 'functions' => [ [ 'script_score' => [ 'script' => [ // цель: вернуть множитель от 1.0 до 1.1 'source' => " // 1. Используем логарифм, чтобы сгладить разницу в продажах double salesLog = Math.log1p(doc['sales_amount'].value); // 2. Выбираем ожидаемый максимум для логарифма. // log1p(1,000,000) ~ 13.8. Возьмем с запасом, например, 14. // Товары с продажами ~1 млн. получат максимальный буст. double maxLog = 14.0; // 3. Нормализуем значение логарифма в диапазон [0, 1] double scaled = Math.min(1.0, salesLog / maxLog); // 4. Превращаем [0, 1] в бонус [0, 0.1] (наши 10%) double bonus = scaled * 0.1; // 5. Возвращаем базовый множитель 1.0 + бонус return 1.0 + bonus; " ] ] ] ], // умножаем исходный _score на результат работы скриптов 'boost_mode' => 'multiply' ] ] ] ];
Но при тестировании выяснилось, что каких-то заметных улучшений в поиске не произошло, все примерно как при использовании match-поиска. Если учесть тот момент, что prefix-поиск более требователен к ресурсам, то нет особого смысла использовать match_bool_prefix. Условия поиска дублируют друг друга, чтобы учесть все варианты — и прирост _score настолько незначительный, что не влияет на итоговый результат.
$ php catalog-search.php "ип 212 4" # девятый вариант Балл: 1752.22 | (005014) ИП 212-43 | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный Балл: 1752.22 | (005048) ИП 212-45 | Рубеж | Извещатель пожарный дымовой оптико-электронный точечный Балл: 1680.86 | (005029) ИП 212-4С | Ирсэт | Извещатель пожарный дымовой оптико-электронный точечный Балл: 1680.86 | (005063) ИП 212-4СБ | Ирсэт | Извещатель пожарный дымовой оптико-электронный точечный Балл: 1612.00 | (005047) ИП 212-43М | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный Балл: 1612.00 | (005074) ИП 212-41М | Рубеж | Извещатель пожарный дымовой оптико-электронный точечный Балл: 1598.68 | (200078) ИП 212-43МК | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный Балл: 1433.50 | (005056) ИП 212-44 (ДИП-44) | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный... Балл: 1406.05 | (203029) Тестер для ИП 212-41М | Рубеж | Тестирующее устройство для ИП 212-41М Балл: 1340.81 | (005060) ИП 212-44 с МС-01 | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный...
$ php catalog-search.php "ип 212 4" # десятый вариант Балл: 1794.15 | (005014) ИП 212-43 | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный Балл: 1794.15 | (005048) ИП 212-45 | Рубеж | Извещатель пожарный дымовой оптико-электронный точечный Балл: 1667.57 | (005029) ИП 212-4С | Ирсэт | Извещатель пожарный дымовой оптико-электронный точечный Балл: 1667.57 | (005063) ИП 212-4СБ | Ирсэт | Извещатель пожарный дымовой оптико-электронный точечный Балл: 1650.92 | (005047) ИП 212-43М | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный Балл: 1650.92 | (005074) ИП 212-41М | Рубеж | Извещатель пожарный дымовой оптико-электронный точечный Балл: 1637.28 | (200078) ИП 212-43МК | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный Балл: 1468.40 | (005056) ИП 212-44 (ДИП-44) | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный... Балл: 1454.21 | (203029) Тестер для ИП 212-41М | Рубеж | Тестирующее устройство для ИП 212-41М Балл: 1373.73 | (005060) ИП 212-44 с МС-01 | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный...
Проверка работы анализаторов
Иногда получается так, что ожидаешь один результат от анализатора, а получаешь совсем другой. В этом случае полезно посмотреть, какие токены были получены в результате работы анализатора при добавлении в индекс и при обработке поискового запроса.
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_search'; $debug1 = true; $debug2 = true; if (!isset($argv[1])) { die("Использование: php {$argv[0]} «поисковый запрос»" . PHP_EOL); } try { $client = ClientBuilder::create()->setHosts([$hostPort])->build(); } catch (Exception $e) { die('Ошибка подключения к Elasticsearch: ' . $e->getMessage()); } if (! $client->indices()->exists(['index' => $indexName])->asBool()) { die("Индекс {$indexName} не существует, завершение работы"); } if ($debug1) { // Посмотреть, какие токены будут получены при обработке запроса разными анализаторами $searchQuery = trim($argv[1]); $analyzers = [ 'only_matter_chars', 'query_first_chars', 'name_with_stemmer', ]; foreach ($analyzers as $analyzer) { $params = [ 'index' => $indexName, 'body' => [ 'analyzer' => $analyzer, 'text' => $searchQuery, ] ]; try { $response = $client->indices()->analyze($params); $tokens = $response->asArray()['tokens']; if (count($tokens) > 0) { $tokens = array_column($tokens, 'token'); $tokens = '"' . implode('", "', $tokens) . '"'; echo "{$analyzer}: [{$tokens}]" . PHP_EOL; } else { echo "{$analyzer}: []" . PHP_EOL; } } catch (Exception $e) { echo 'Ошибка при анализе: ' . $e->getMessage() . PHP_EOL; } } echo PHP_EOL; } $searchQuery = iconv_substr($argv[1], 0, 50); $searchQuery = trim($searchQuery); $params = [ 'index' => $indexName, 'body' => [ 'size' => 10, 'query' => [ 'function_score' => [ // сюда помещаем основной запрос для поиска 'query' => [ 'bool' => [ 'should' => [ ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 1]]], ['match' => ['name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 3]]], ['match' => ['brand' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 2]]], ['match' => ['func_name' => ['query' => $searchQuery, 'operator' => 'and', 'boost' => 1]]], ['match_phrase_prefix' => ['name' => ['query' => $searchQuery, 'boost' => 3]]], ['match_phrase_prefix' => ['brand' => ['query' => $searchQuery, 'boost' => 2]]], ['match_phrase_prefix' => ['func_name' => ['query' => $searchQuery, 'boost' => 1]]], ['match' => ['name.first_chars_match' => ['query' => $searchQuery, 'boost' => 9]]], ['match' => ['brand.first_chars_match' => ['query' => $searchQuery, 'boost' => 6]]], ['match' => ['func_name.first_chars_match' => ['query' => $searchQuery, 'boost' => 3]]], ['match' => ['code.like_code_ngram' => ['query' => $searchQuery, 'boost' => 4]]], ['match' => ['code' => ['query' => $searchQuery, 'operator' => 'or', 'boost' => 5]]], ], 'minimum_should_match' => 1 ] ], // добавляем функции для модификации _score 'functions' => [ [ 'script_score' => [ 'script' => [ // цель: вернуть множитель от 1.0 до 1.1 'source' => " // 1. Используем логарифм, чтобы сгладить разницу в продажах double salesLog = Math.log1p(doc['sales_amount'].value); // 2. Выбираем ожидаемый максимум для логарифма. // log1p(1,000,000) ~ 13.8. Возьмем с запасом, например, 14. // Товары с продажами ~1 млн. получат максимальный буст. double maxLog = 14.0; // 3. Нормализуем значение логарифма в диапазон [0, 1] double scaled = Math.min(1.0, salesLog / maxLog); // 4. Превращаем [0, 1] в бонус [0, 0.1] (наши 10%) double bonus = scaled * 0.1; // 5. Возвращаем базовый множитель 1.0 + бонус //return 1.0 + bonus; return 1.0; " ] ] ], [ 'script_score' => [ 'script' => [ // цель: вернуть множитель от 1.1 до 1.0 'source' => " int ideal_length = 10; // наименования этой длины получают макс.бонус double max_bonus = 0.1; // максимальный бонус для идеальной длины 10% double scale = 10.0; // скорость падения бонуса для длинных имен int curr_length = doc['name.calc_length'].value.length(); if (curr_length <= ideal_length) { return 1.0 + max_bonus; } // Чем больше разница с ideal_length, тем меньше будет бонус. Сейчас //установлены значения: за длину в два раза больше идеальной — бонус // в два раза меньше, за длину в три раза больше — в три раза меньше. double decay = (curr_length - ideal_length) / scale; double bonus = max_bonus / (1.0 + decay); return 1.0 + bonus; " ] ] ] ], // умножаем исходный _score на результат работы скрипта 'boost_mode' => 'multiply' ] ] ] ]; try { $response = $client->search($params); $hits = $response['hits']['hits']; if (count($hits) >= 0) { foreach ($hits as $hit) { printf( 'Балл: %.2f | (%s) %s | %s | %s' . PHP_EOL, $hit['_score'], $hit['_source']['code'], $hit['_source']['name'], $hit['_source']['brand'], $hit['_source']['func_name'], ); if ($debug2) { // Посмотреть, какие токены будут получены при обработке полей анализаторами $params = [ 'index' => $indexName, 'id' => $hit['_id'], 'body' => [ 'fields' => [ 'code', 'code.like_code_ngram', 'name', 'name.first_chars_match', 'func_name' ] ] ]; $response = $client->termvectors($params); if (isset($response['term_vectors'])) { foreach ($response['term_vectors'] as $fieldName => $data) { if (isset($data['terms'])) { $tokens = array_keys($data['terms']); echo ' ' . $fieldName . ': ["' . implode('", "', $tokens) . '"]' . PHP_EOL; } else { echo ' ' . $fieldName . ': []' . PHP_EOL; } } } } } } else { echo "По запросу '{$searchQuery}' ничего не найдено" . PHP_EOL; } } catch (Exception $e) { die('Ошибка поиска: ' . $e->getMessage()) . PHP_EOL; }
$ php catalog-search.php "ИП 212 43" only_matter_chars: ["ип", "212", "43"] query_first_chars: ["ип 212 43"] name_with_stemmer: ["ип", "212", "43"] Балл: 1276.16 | (005014) ИП 212-43 | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный name: ["212", "43", "ип"] name.first_chars_match: ["ип", "ип 2", "ип 21", "ип 212", "ип 212 4", "ип 212 43"] code: ["005014"] code.like_code_ngram: ["0050", "00501"] func_name: ["автономн", "дымов", "извещател", "оптик", "пожарн", "электрон"] Балл: 1200.15 | (005047) ИП 212-43М | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный name: ["212", "43", "ип", "м"] name.first_chars_match: ["ип", "ип 2", "ип 21", "ип 212", "ип 212 4", "ип 212 43", "ип 212 43 м"] code: ["005047"] code.like_code_ngram: ["0050", "00504"] func_name: ["автономн", "дымов", "извещател", "оптик", "пожарн", "электрон"] Балл: 1190.23 | (200078) ИП 212-43МК | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный name: ["212", "43", "ип", "мк"] name.first_chars_match: ["ип", "ип 2", "ип 21", "ип 212", "ип 212 4", "ип 212 43", "ип 212 43 м", "ип 212 43 мк"] code: ["200078"] code.like_code_ngram: ["2000", "20007"] func_name: ["автономн", "дымов", "извещател", "оптик", "пожарн", "электрон"]
$ php catalog-search.php "200078 00504" only_matter_chars: ["200078", "00504"] query_first_chars: ["200078 00504"] name_with_stemmer: ["200078", "00504"] Балл: 52.12 | (200078) ИП 212-43МК | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный name: ["212", "43", "ип", "мк"] name.first_chars_match: ["ип", "ип 2", "ип 21", "ип 212", "ип 212 4", "ип 212 43", "ип 212 43 м", "ип 212 43 мк"] code: ["200078"] code.like_code_ngram: ["2000", "20007"] func_name: ["автономн", "дымов", "извещател", "оптик", "пожарн", "электрон"] Балл: 48.16 | (005047) ИП 212-43М | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный name: ["212", "43", "ип", "м"] name.first_chars_match: ["ип", "ип 2", "ип 21", "ип 212", "ип 212 4", "ип 212 43", "ип 212 43 м"] code: ["005047"] code.like_code_ngram: ["0050", "00504"] func_name: ["автономн", "дымов", "извещател", "оптик", "пожарн", "электрон"] Балл: 48.16 | (005048) ИП 212-45 | Рубеж | Извещатель пожарный дымовой оптико-электронный точечный name: ["212", "45", "ип"] name.first_chars_match: ["ип", "ип 2", "ип 21", "ип 212", "ип 212 4", "ип 212 45"] code: ["005048"] code.like_code_ngram: ["0050", "00504"] func_name: ["дымов", "извещател", "оптик", "пожарн", "точечн", "электрон"]
$ php catalog-search.php "200078 00504" only_matter_chars: ["200078", "00504"] query_first_chars: ["200078 00504"] name_with_stemmer: ["200078", "00504"] Балл: 52.12 | (200078) ИП 212-43МК | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный name: ["212", "43", "ип", "мк"] name.first_chars_match: ["ип", "ип 2", "ип 21", "ип 212", "ип 212 4", "ип 212 43", "ип 212 43 м", "ип 212 43 мк"] code: ["200078"] code.like_code_ngram: ["2000", "20007"] func_name: ["автономн", "дымов", "извещател", "оптик", "пожарн", "электрон"] Балл: 48.16 | (005047) ИП 212-43М | ИВС-Сигналспецавтоматика | Извещатель пожарный дымовой оптико-электронный автономный name: ["212", "43", "ип", "м"] name.first_chars_match: ["ип", "ип 2", "ип 21", "ип 212", "ип 212 4", "ип 212 43", "ип 212 43 м"] code: ["005047"] code.like_code_ngram: ["0050", "00504"] func_name: ["автономн", "дымов", "извещател", "оптик", "пожарн", "электрон"] Балл: 48.16 | (005048) ИП 212-45 | Рубеж | Извещатель пожарный дымовой оптико-электронный точечный name: ["212", "45", "ип"] name.first_chars_match: ["ип", "ип 2", "ип 21", "ип 212", "ип 212 4", "ип 212 45"] code: ["005048"] code.like_code_ngram: ["0050", "00504"] func_name: ["дымов", "извещател", "оптик", "пожарн", "точечн", "электрон"]
Обновление товаров в индексе
Товары в базе данных могут обновляться, в этом случае нужно обновить индекс. Для этого добавим поле updated в таблицу products, которое принимает значение true, когда товар обновляется. И выполним запрос к базе данных на изменение значения этого поля для 10 случайных товаров.
ALTER TABLE `products` ADD COLUMN `updated` BOOLEAN NOT NULL DEFAULT FALSE, ADD INDEX `idx_updated` (`updated`);
UPDATE `products` SET `updated` = true ORDER BY RAND() LIMIT 10;
Напишем скрипт, который будет обновлять индекс для товаров, которые изменилось в базе данных
$ nano /home/evgeniy/elasticsearch/update-index.php
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; $db_host = 'localhost'; $db_name = 'catalog'; $db_user = 'catalog'; $db_pass = 'qwerty'; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_search'; try { $client = ClientBuilder::create()->setHosts([$hostPort])->build(); $pdo = new PDO("mysql:host={$db_host};dbname={$db_name};charset=utf8", $db_user, $db_pass); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (Exception $e) { die('Ошибка подключения: ' . $e->getMessage()); } // Получаем только обновленные товары $query = ' SELECT p.id, p.code, p.name, p.title AS func_name, b.name as brand FROM products p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN `groups` g ON p.group_id = g.id WHERE p.updated = 1 '; $stmt = $pdo->query($query); $products = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($products)) { die('Нет товаров для обновления индекса' . PHP_EOL); } // Подготовка и отправка пакетного запроса $params = ['body' => []]; $idsToReset = []; foreach ($products as $row) { $idsToReset[] = $row['id']; $params['body'][] = [ 'index' => [ '_index' => $indexName, '_id' => $row['id'] ] ]; $params['body'][] = [ 'code' => $row['code'], 'name' => $row['name'], 'func_name' => $row['func_name'], 'brand' => $row['brand'], 'func_group' => $row['func_group'] ]; } $response = $client->bulk($params); // Сбрасываем флаг updated в таблице products $idsToReset = implode(',', $idsToReset); $updateStmt = $pdo->prepare("UPDATE `products` SET `updated` = false WHERE `id` IN ({$idsToReset})"); $updateStmt->execute(); echo 'Успешно обновлено ' . count($idsToReset) . ' товаров' . PHP_EOL;
Удаление товаров из индекса
Товары могут удаляться из каталога — в этом случае мы должны удалить эти товары из индекса
$ nano /home/evgeniy/elasticsearch/delete-index.php
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; $db_host = 'localhost'; $db_name = 'catalog'; $db_user = 'catalog'; $db_pass = 'qwerty'; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_search'; try { $client = ClientBuilder::create()->setHosts([$hostPort])->build(); $pdo = new PDO("mysql:host={$db_host};dbname={$db_name};charset=utf8", $db_user, $db_pass); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (Exception $e) { die('Ошибка подключения: ' . $e->getMessage()); } // Получаем все актуальные id товаров из базы данных $stmt = $pdo->query('SELECT id FROM products WHERE 1'); $databaseIds = array_flip($stmt->fetchAll(PDO::FETCH_COLUMN, 0)); // Итерируемся по индексу Elasticsearch и ищем лишние $idsToDelete = []; $params = [ 'index' => $indexName, 'scroll' => '1m', // как долго поддерживать сессию скроллинга 'size' => 1000, // сколько документов получать за один раз 'body' => [ 'query' => ['match_all' => new stdClass()], '_source' => false // нам не нужны сами данные, только id ] ]; $response = $client->search($params); // Пока есть результаты, продолжаем скроллинг while (isset($response['hits']['hits']) && count($response['hits']['hits']) > 0) { foreach ($response['hits']['hits'] as $hit) { $docId = $hit['_id']; // если id из индекса не существует в $databaseIds — удалить if (!isset($databaseIds[$docId])) { $idsToDelete[] = $docId; } } // получаем следующую порцию данных $response = $client->scroll([ 'scroll_id' => $response['_scroll_id'], 'scroll' => '1m' ]); } // Удаляем лишние документы одним пакетным запросом if (!empty($idsToDelete)) { $bulkParams = ['body' => []]; foreach ($idsToDelete as $id) { $bulkParams['body'][] = [ 'delete' => [ '_index' => $indexName, '_id' => $id ] ]; } $client->bulk($bulkParams); echo 'Лишние товары успешно удалены из индекса' . PHP_EOL; } else { echo 'Лишних товаров в индексе не найдено' . PHP_EOL; }
Фильтры для каталога товаров
Допустим, есть сайт интернет-магазина на PHP, нужно добавить возможность фильтрации товаров каталога. В каталоге есть многуровневые категории, которые связаны отношениями родитель-потомок, каждый товар принадлежит какому-то бренду, каждый товар принадлежит функциональной группе. В одну функциональную группу входят товары, имеющие одинановый функционал — следовательно, у таких товаров одинаковый набор свойств (параметров), по которым их можно фильтровать.
Нам нужна возможность фильтрации товаров в каждой категории каталога. При этом категория может содержать товары разных функциональных групп (особенно, если это категория верхнего уровня) — например, «Холодильник» или «Пылесос». Поэтому, прежде чем показывать пользователю фильтры, нужно предложить ему выбрать функционал. После этого нужно получить новый список товаров — с учетом текущей категории и выбранного функционала. Для этого списка товаров нужно получить фильтры — то есть, набор свойств (параметров), характерных для этого функционала с учетом выбранной категории.
Для каждого значения каждого фильтра нужно показывать, сколько товаров будет найдено, если пользователь выберет это значение. После выбора какого-то значения фильтра — нужно опять получить список товаров и фильтры. При этом, часть значений фильтров будут «пустые» — это значит, что при выборе такого значения ничего не будет найдено. Но мы все равно будем их показывать, чтобы пользователю было понятно, что каждый новый выбор уменьшает кол-во вариантов. При этом, фильтр по бренду есть всегда — у каждого товара есть бренд, для показа этого фильтра не нужно выбирать функционал.
Кроме того, добавим фильтры на странице списка товаров бренда и на странице списка товаров функциональной группы. На странице товаров бренда нужно сперва выбрать функциональную группу, потом показать товары и фильтры (с учетом бренда и функционала). На странице функциональной группы изначально показываются все фильтры, характерные для функционала. Дополнительно на странице функционала показывается фильтр по бренду.
Таблицы базы данных каталога товаров
Таблица БД products содержит поля — id (идентификатор), code (код, артикул), name (наименование), category_id (идентификатор категории, внешний ключ), brand_id (идентификатор бренда, внешний ключ), group_id (идентификатор функциональной группы, внешний ключ), new (метка «Новинка»), hit (метка «Лидер продаж»).
Таблица БД categories содержит поля — id (идентификатор), name (наименование), parent_id (идентификатор родителя). Таблица БД groups содержит поля — id (идентификатор), name (наименование). Таблица БД brands содержит поля — id (идентификатор), name (наименование).
Таблица БД params (свойства товаров, фильтры) содержит поля — id (идентификатор), name (наименование, например «Напряжение питания»). Таблица БД values (значения свойств товаров, фильтры) содержит поля — id (идентификатор), name (наименование, например «12 Вольт»).
Таблица БД product_param_value содержит поля — product_id (идентификатор товара), param_id (идентификатор свойства), value_id (идентификатор значения свойства). Эта таблица хранит, у каких товаров какие значения свойств.
Создание индекса catalog_filter
Структура документа индекса catalog_filter, который нужно создать
{ "code": "A-54321", // код (артикул) "name": "Смартфон Model X", // наименование товара "group": { "id": 1, "name": "Смартфон" }, "category_ids": [1, 10, 55], // массив id всех категорий, от корневой до текущей "params": [ // массив всех характеристик товара { "param_id": "1", "param_name": "Диагональ экрана", "value_id": "5", "value_name": "6.1" }, { "param_id": "2", "param_name": "Объем памяти", "value_id": 12, "value_name": "2 Гб" }, { "param_id": "3", "param_name": "Цвет", "value_id": 25, "value_name": "Черный" }, { "param_id": "brands", "param_name": "Бренды", "value_id": "37", "value_name": "Samsung" }, { "param_id": "labels", "param_name": "Метки", "value_id": "new", "value_name": "Новинка" } ] }
Создаем json-файл, который описывает все поля документа
$ nano mappings.json
{ "settings": { "analysis": { "analyzer": { "default": { "type": "russian" } } } }, "mappings": { "dynamic": "strict", "properties": { "code": { "type": "keyword" }, "name": { "type": "text", "fields": { "keyword": { "type": "keyword" } } }, "group": { "properties": { "id": { "type": "keyword" }, "name": { "type": "text", "fields": { "keyword": { "type": "keyword" } } } } }, "category_ids": { "type": "keyword" }, "params": { "type": "nested", "properties": { "param_id": { "type": "keyword" }, "param_name": { "type": "keyword" }, "value_id": { "type": "keyword" }, "value_name": { "type": "keyword" } } } } } }
Бренд и метка — это тоже фильтры, поэтому не будем под них создавать отдельные поля, а поместим в параметры подбора — так будет проще. Для нас не важно, как называется параметр подбора — «Напряжение питания» или «Бренд», для нас не важно, каким будет значение параметра — «12 Вольт» или «Samsung». Но функциональная группа будет отдельным полем в структуре документа — это тоже фильтр, но особенный, от него зависит — какой набор параметров подбора будет показан при выборе функционала.
С помощью утилиты curl создаем индекс catalog_filter
$ curl -X PUT 'localhost:9200/catalog_filter' -H 'Content-Type: application/json' --data-binary "@mappings.json"
Удалить индекс и все документы в индексе можно с помощью запроса
$ curl -X DELETE 'localhost:9200/catalog_filter'
Удалить все документы без удаления индекса, можно с помощью запроса
$ curl -X POST 'localhost:9200/catalog_filter/_delete_by_query'
{ "query": { "match_all": {} } }
Скрипт для создания индекса catalog_filter, можно запускать повторно
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; use Elastic\Elasticsearch\Exception\ClientResponseException; use Elastic\Elasticsearch\Exception\ServerResponseException; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_filter'; $mappings = 'mappings.json'; if (!file_exists($mappings)) { die("Файл с маппингом {$mappings} не найден" . PHP_EOL); } $indexBody = json_decode(file_get_contents($mappings), true); if (json_last_error() !== JSON_ERROR_NONE) { die("Некорректный JSON в файле {$mappings}, ошибка " . json_last_error_msg() . PHP_EOL); } try { $client = ClientBuilder::create() ->setHosts([$hostPort]) ->build(); $indexExists = $client->indices()->exists(['index' => $indexName])->asBool(); if ($indexExists) { $client->indices()->delete(['index' => $indexName]); } $params = [ 'index' => $indexName, 'body' => $indexBody ]; $client->indices()->create($params); } catch (ClientResponseException | ServerResponseException $e) { die('Ошибка Elasticsearch: ' . $e->getMessage() . PHP_EOL); } catch (\Exception $e) { die('Общая ошибка: ' . $e->getMessage() . PHP_EOL); }
Скрипт для добавления товаров в индекс, можно запускать повторно
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; $db_host = '127.0.0.1'; $db_name = 'catalog'; $db_user = 'catalog'; $db_pass = 'qwerty'; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_filter'; try { $client = ClientBuilder::create()->setHosts([$hostPort])->build(); $pdo = new PDO("mysql:host={$db_host};dbname={$db_name};charset=utf8", $db_user, $db_pass); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (Exception $e) { die('Ошибка подключения: ' . $e->getMessage()); } // Если индекс не сущестует — завершаем работу и сообщаем причину if ( ! $client->indices()->exists(['index' => $indexName])->asBool()) { die("Индекс {$indexName} не существует, завершение работы"); } // Полная очистка индекса перед новой загрузкой товаров try { $client->deleteByQuery([ 'index' => $indexName, 'body' => [ 'query' => [ 'match_all' => new \stdClass() ] ] ]); } catch (\Exception $e) { die('Ошибка при очистке индекса: ' . $e->getMessage() . PHP_EOL); } function getCategoryPath(PDO $pdo, int $categoryId): array { $path = []; $currentId = $categoryId; $stmt = $pdo->prepare('SELECT parent FROM categories WHERE id = ?'); while ($currentId > 0) { $path[] = (string)$currentId; $stmt->execute([$currentId]); $category = $stmt->fetch(); $currentId = $category ? (string)$category['parent'] : '0'; } return array_reverse($path); } // Добавление в индекс товаров из базы данных MySQL $batchSize = 1000; // обрабатываем по 1000 товаров за раз $offset = 0; while (true) { // получаем порцию товаров $stmt = $pdo->prepare(' SELECT p.id AS id, p.code AS code, p.name AS name, p.category_id AS category_id, b.id AS brand_id, b.name AS brand_name, g.id AS group_id, g.name AS group_name, p.new AS new, p.hit AS hit FROM products p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN groups g ON p.group_id = g.id ORDER BY p.id LIMIT :limit OFFSET :offset '); $stmt->bindValue(':limit', $batchSize, PDO::PARAM_INT); $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); $stmt->execute(); $products = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($products)) { break; // все товары закончились } $productIds = implode(',', array_column($products, 'id')); $paramStmt = $pdo->prepare(" SELECT ppv.product_id AS product_id, ppv.param_id AS param_id, par.name as param_name, ppv.value_id AS value_id, val.name as value_name FROM product_param_value ppv INNER JOIN params par ON ppv.param_id = par.id INNER JOIN `values` val ON ppv.value_id = val.id WHERE ppv.product_id IN ({$productIds}) "); $paramStmt->execute(); $temp = $paramStmt->fetchAll(PDO::FETCH_ASSOC); $prod_params = []; // подготовка для удобной работы foreach ($temp as $param) { $prod_params[$param['product_id']][] = [ 'param_id' => (string)$param['param_id'], 'param_name' => $param['param_name'], 'value_id' => (string)$param['value_id'], 'value_name' => $param['value_name'], ]; } // готовим пачку документов для добавления в индекс $bulk_params = ['body' => []]; foreach ($products as $product) { $bulk_params['body'][] = [ 'index' => [ '_index' => $indexName, '_id' => (string)$product['id'] ] ]; // Группа, бренд, метки — это тоже параметры подбора (фильтры). // Бренд и метки добавим в параметры подбора, а группу оставим // отдельно — потому что это «особенный» фильтр. $prod_params_extended = $prod_params[$product['id']] ?? []; $prod_params_extended[] = [ 'param_id' => 'brands', 'param_name' => 'Бренды', 'value_id' => (string)$product['brand_id'], 'value_name' => $product['brand_name'], ]; if ($product['new']) { $prod_params_extended[] = [ 'param_id' => 'labels', 'param_name' => 'Метки', 'value_id' => 'new', 'value_name' => 'Новинка', ]; } if ($product['hit']) { $prod_params_extended[] = [ 'param_id' => 'labels', 'param_name' => 'Метки', 'value_id' => 'hit', 'value_name' => 'Лидер продаж', ]; } $doc_body = [ 'code' => $product['code'], 'name' => $product['name'], 'group' => [ 'id' => (string)$product['group_id'], 'name' => $product['group_name'] ], 'category_ids' => getCategoryPath($pdo, $product['category_id']), 'params' => $prod_params_extended, ]; $bulk_params['body'][] = $doc_body; } // добавляем подготовленную пачку документов в индекс if (!empty($bulk_params['body'])) { try { $response = $client->bulk($bulk_params); // проверяем на частичные ошибки внутри успешного ответа if ($response['errors']) { echo "Обнаружены ошибки при индексации пачки, начиная с offset {$offset}" . PHP_EOL; } } catch (Exception $e) { echo "Не удалось отправить пачку товаров в индекс, начиная с offset {$offset}" . PHP_EOL; die('Сообщение: ' . $e->getMessage() . PHP_EOL); } } echo 'Проиндексирована порция ' . count($products) . " товаров (смещение: $offset)" . PHP_EOL; $offset += $batchSize; }
Получение списка товаров категории
Запрос на получение товаров в категории (и во всех ее потомках)
$ curl -X GET 'localhost:9200/catalog_filter/_search' -H 'Content-Type: application/json' -d 'json-данные'
{ "query": { "bool": { "filter": [ { "term": { "category_ids": "10" } } ] } }, "size": 10, // количество товаров на странице "from": 0 // первая страница результатов }
Запрос на получение товаров в категории с учетом выбраного функционала
$ curl -X GET 'localhost:9200/catalog_filter/_search' -H 'Content-Type: application/json' -d 'json-данные'
{ "query": { "bool": { "filter": [ { "term": { "category_ids": "10" } }, { "term": { "group.id": "20" } } ] } }, "size": 10, // количество товаров на странице "from": 0 // первая страница результатов }
Скрипт для получения товаров в категории — с учетом того, что может быть выбран функционал и/или параметры
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_filter'; // Условия для поиска товаров — категория, группа и параметры $options = getopt('', ['categ_id:', 'group_id:', 'parval_ids:', 'size:', 'from:']); $categ_id = isset($options['categ_id']) ? $options['categ_id'] : null; $group_id = isset($options['group_id']) ? $options['group_id'] : null; $parval_ids = isset($options['parval_ids']) ? $options['parval_ids'] : null; $size = isset($options['size']) ? (int)$options['size'] : 10; $from = isset($options['from']) ? (int)$options['from'] : 0; $client = ClientBuilder::create()->setHosts([$hostPort])->build(); // Если индекс не сущестует — завершаем работу и сообщаем причину if ( ! $client->indices()->exists(['index' => $indexName])->asBool()) { die("Индекс {$indexName} не существует, завершение работы"); } // Преоборазуем параметры и значения к удобному виду if (isset($parval_ids)) { $param_value_ids = []; // 1. Разбиваем строку на группы "123.234.345,456.678" -> ["123.234.345", "456.678"] $temp = explode(',', $parval_ids); foreach ($temp as $item) { // 2. Разбиваем каждую группу "123.234.345" -> ["123", "234", "345"] $parts = explode('.', $item); $param_id = array_shift($parts); // id параметра $value_ids = $parts; // ids значений $param_value_ids[$param_id] = $value_ids; } } // Собираем все условия поиска товаров в один массив $query_filter = []; if (isset($categ_id)) { $query_filter[] = ['term' => ['category_ids' => $categ_id]]; } if (isset($group_id)) { $query_filter[] = ['term' => ['group.id' => $group_id]]; } if (isset($param_value_ids)) { foreach ($param_value_ids as $param_id => $value_ids) { $nested_filter = [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => $param_id]], ['terms' => ['params.value_id' => $value_ids]] ] ] ] ] ]; $query_filter[] = $nested_filter; } } // Формируем тело запроса на поиск товаров $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => $query_filter ] ], 'size' => $size, 'from' => $from ] ]; // Посмотреть запрос (для проверки и отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); print_r($result); // Товары, которые удовлетворяют всем условиям $products = []; foreach ($response['hits']['hits'] as $hit) { $products[] = $hit['_source']; } $count = $response['hits']['total']['value']; // Выводим результат в консоль if (isset($categ_id)) { echo 'Выбранная категория: ' . $categ_id . PHP_EOL; } if (isset($group_id)) { echo 'Выбранная группа: ' . $group_id . PHP_EOL; } if (isset($param_value_ids)) { echo 'Выбранные фильтры и значения: ' . PHP_EOL; print_r($param_value_ids); } if ($count > 0) { echo 'Всего найдено товаров: ' . $count . PHP_EOL; echo 'Список товаров, с позиции ' . $from . PHP_EOL; foreach ($products as $product) { echo $product['name'] . PHP_EOL; } } else { echo PHP_EOL . 'Товары не найдены' . PHP_EOL; }
Выполним скрипт для категории 458, с учетом того, что выбран функционал 18
$ php category-products.php --categ_id=458 --group_id=18 Выбранная категория: 458 Выбранная группа: 18 Всего найдено товаров: 328 Список товаров, с позиции 0 Alfa-40 Alfa-90 Delta-90 Delta-160 Delta-160/Plus Sigma-320/S Sigma-160/M Sigma-320/M Sigma-320/M Expert Sigma-320/L
Массив $result содержит все данные по найденным товарам, которые мы записали в индекс
Array ( [took] => 8 [timed_out] => [_shards] => Array ( [total] => 1 [successful] => 1 [skipped] => 0 [failed] => 0 ) [hits] => Array ( [total] => Array ( [value] => 328 [relation] => eq ) [max_score] => 0 [hits] => Array ( [0] => Array ( [_index] => catalog_filter [_id] => 232899 [_score] => 0 [_source] => Array ( [code] => 232899 [name] => Alfa-40 [group] => Array ( [id] => 18 [name] => Видеорегистратор NVR ) [category_ids] => Array ( [0] => 185 [1] => 437 [2] => 458 [3] => 469 ) [params] => Array ( [0] => Array ( [param_id] => 10 [param_name] => Видеовыходы [value_id] => 533 [value_name] => Есть ) [1] => Array ( [param_id] => 75 [param_name] => Порты РоЕ, шт [value_id] => 521 [value_name] => Нет ) [3] => Array ( [param_id] => 76 [param_name] => Поддержка ONVIF [value_id] => 522 [value_name] => Есть ) [4] => Array ( [param_id] => brands [param_name] => Бренды [value_id] => 641 [value_name] => ДевЛайн ) ) ) ) .......... ) ) )
Пораметры подбора для фильтрации можно передавать в виде строки 123.234.345,456.567. Это означает, что параметр «Напряжение питания (ID=123)» имеет значение «12 Вольт (ID=234)» или «24 Вольт (ID=345)», а параметр «Ток потребления (ID=456)» имеет значение «100 мА (ID=567)».
На самом деле, этот скрипт может выбирать не только товары категории, но и товары выбранного функционала или выбранного бренда — для этого просто не нужно передавать идентификатор категории.
Получение фильтров для категории каталога
На первый взгляд может показаться, что для получения фильтров достаточно выполнить агрегации по функциональным группам и параметрам подбора. И в какой-то мере это действительно так — но часть условий выборки товаров нужно убирать, чтобы получить агрегации. Например, если включен отбор по функциональной группе — то агрегация по группам уже не имеет смысла (будут выбраны только товары этой группы, агрегация будет содержать только эту группу).
Когда пользователь перешел в категорию каталога — нужно показать фильтр по функционалу, бренду, метке. Потому что эти фильтры универсальные, любой товар имеет функционал, у любого товара есть бренд, любой товар может иметь метку «Новинка» или «Лидер продаж». Фильтры типа «Напряжение питания» или «Ток потребления» будем показывать только после того, как пользователь выберет функциональную группу.
У каждого функционала — свой набор параметров подбора. Параметр подбора «Диагональ» имеет смысл для функционала «Телевизор», но не имеет смысла для функционала «Холодильник». Кроме того, список возможных брендов для функционала «Телевизор» не совпадает со списком возможных брендов для функционала «Холодильник». Так что для агрегации по функциональной группе — будем убирать все уловия выборки, кроме категории.
Фильтры для категории каталога (группа еще не выбрана)
Скрипт для получения фильтров по группе, бренду, метке — когда пользователь перешел в категорию, но группу не выбрал. При этом пользователь может выбрать бренд или метку. При выборе бренда — изменяется кол-во доступных элементов фильтра по метке. При выборе метки — изменяется кол-во доступных элементов фильтра по бренду. Но, выбор бренда и/или метки — не оказывает влияния на фильтр по функциональной группе.
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_filter_new'; $options = getopt('', ['categ_id:', 'parval_ids:']); $categ_id = isset($options['categ_id']) ? $options['categ_id'] : null; $parval_ids = isset($options['parval_ids']) ? $options['parval_ids'] : null; $client = ClientBuilder::create()->setHosts([$hostPort])->build(); // Если индекс не сущестует — завершаем работу и сообщаем причину if (!$client->indices()->exists(['index' => $indexName])->asBool()) { die("Индекс {$indexName} не существует, завершение работы"); } // Если категория не задана — завершаем работу и сообщаем причину if (!isset($categ_id)) { die('Не передан идентификатор категории, завершение работы'); } // Преоборазуем параметры и значения к удобному для работы виду if (isset($parval_ids)) { $param_value_ids = []; // 1. Разбиваем строку на группы "123.234.345,456.678" -> ["123.234.345", "456.678"] $temp = explode(',', $parval_ids); foreach ($temp as $item) { // 2. Разбиваем каждую группу "123.234.345" -> ["123", "234", "345"] $parts = explode('.', $item); $param_id = array_shift($parts); // id параметра $value_ids = $parts; // ids значений $param_value_ids[$param_id] = $value_ids; } } // Две вспомогательные переменные для использования при построении запросов $agg_groups_pattern = [ 'composite' => [ 'sources' => [ // для сортировки по имени первым должно быть поле name ['name' => ['terms' => ['field' => 'group.name.keyword']]], ['id' => ['terms' => ['field' => 'group.id']]] ], // групп не должно быть больше 100, чтобы запрос вернул все группы 'size' => 100 ] ]; $agg_params_pattern = [ 'nested' => ['path' => 'params'], 'aggs' => [ 'params_and_values' => [ 'composite' => [ 'sources' => [ // для сортировки по имени первым должно быть поле name ['p_name' => ['terms' => ['field' => 'params.param_name']]], ['p_id' => ['terms' => ['field' => 'params.param_id']]], ['v_name' => ['terms' => ['field' => 'params.value_name']]], ['v_id' => ['terms' => ['field' => 'params.value_id']]], ], // всего возможных вариантов пар параметр-значение должно // быть не больше 200, чтобы запрос вернул все варианты 'size' => 200 ] ] ] ];
/* * Сначала получим все допустимые варианты групп, брендов и меток — это состояние * фильтров, когда пользователь еще ничего не выбал, а только перешел в категорию */ $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['category_ids' => $categ_id]] ] ] ], 'aggs' => [ 'agg_groups' => $agg_groups_pattern, 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ] ]; // Посмотреть запрос (для отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); // Массив всех доступных групп для категории, то есть фильтр по группе $agg_groups = $result['aggregations']['agg_groups']['buckets']; $all_groups = []; foreach ($agg_groups as $group) { $all_groups[$group['key']['id']] = [ 'id' => $group['key']['id'], 'name' => $group['key']['name'], 'count' => $group['doc_count'], 'ckecked' => false, ]; } // echo 'Массив всех доступных групп для категории, то есть фильтр по группе' . PHP_EOL; // print_r($all_groups); $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; // Массив всех брендов для категории, нет пустых (нулевых) элементов $all_brands = []; // Массив всех меток для категории, нет пустых (нулевых) элементов $all_labels = []; foreach ($agg_params as $pair) { if ($pair['key']['p_id'] === 'brands') { $all_brands[$pair['key']['v_id']] = [ 'id' => $pair['key']['v_id'], 'name' => $pair['key']['v_name'], 'count' => $pair['doc_count'], ]; } if ($pair['key']['p_id'] === 'labels') { $all_labels[$pair['key']['v_id']] = [ 'id' => $pair['key']['v_id'], 'name' => $pair['key']['v_name'], 'count' => $pair['doc_count'], ]; } } // echo 'Массив всех брендов для категории, нет пустых (нулевых) элементов' . PHP_EOL; // print_r($all_brands); // echo 'Массив всех меток для категории, нет пустых (нулевых) элементов' . PHP_EOL; // print_r($all_labels);
/* * Когда включен фильтр по бренду — получаем доступные элементы фильтра по метке. * Выбор бренда или брендов — уменьшает количество доступных элементов фильтра по * метке. Недоступные (нулевые) элементы фильтра по метке, когда включен фильтр по * бренду, тоже будем показывать. Для этого добавляем их в итоговый результат. */ if (isset($param_value_ids['brands'])) { // Получаем доступные элементы фильтра по меткам с помощью запроса $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['category_ids' => $categ_id]], [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => 'brands']], ['terms' => ['params.value_id' => $param_value_ids['brands']]] ] ] ] ] ] ] ] ], 'aggs' => [ 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ] ]; // Посмотреть запрос (для отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); // Массив меток, когда включен фильтр по бренду, нет пустых (нулевых) элементов $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; $labels_brand_checked = []; foreach ($agg_params as $pair) { if ($pair['key']['p_id'] === 'labels') { $labels_brand_checked[$pair['key']['v_id']] = [ 'id' => $pair['key']['v_id'], 'name' => $pair['key']['v_name'], 'count' => $pair['doc_count'], ]; } } // echo 'Массив меток, когда включен фильтр по бренду, нет пустых (нулевых) элементов' . PHP_EOL; // print_r($labels_brand_checked); // Добавляем недоступные (нулевые) элементы в массив доступных элементов $all_labels_brand_checked = []; foreach ($all_labels as $v_id => $value) { $v_checked = isset($param_value_ids['labels']) && in_array($v_id, $param_value_ids['labels']); $all_labels_brand_checked[$v_id] = [ 'id' => $value['id'], 'name' => $value['name'], 'count' => $labels_brand_checked[$v_id]['count'] ?? 0, 'checked' => $v_checked, ]; } // echo 'Массив всех меток, включен фильтр по бренду, добавлены нулевые элементы' . PHP_EOL; // print_r($all_labels_brand_checked); }
/* * Когда включен фильтр по метке — получаем доступные элементы фильтра по бренду. * Выбор метки или меток — уменьшает количество доступных элементов фильтра по * бренду. Недоступные (нулевые) элементы фильтра по бренду, когда включен фильтр * по метке, тоже будем показывать. Для этого добавляем их в итоговый результат. */ if (isset($param_value_ids['labels'])) { // Получаем доступные элементы фильтра по брендам с помощью запроса $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['category_ids' => $categ_id]], [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => 'labels']], ['terms' => ['params.value_id' => $param_value_ids['labels']]] ] ] ] ] ] ] ] ], 'aggs' => [ 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ] ]; // Посмотреть запрос (для отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); // удобнее работать с массивом // Массив брендов, когда включен фильтр по метке, нет нулевых элементов $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; $brands_label_checked = []; foreach ($agg_params as $pair) { if ($pair['key']['p_id'] === 'brands') { $brands_label_checked[$pair['key']['v_id']] = [ 'id' => $pair['key']['v_id'], 'name' => $pair['key']['v_name'], 'count' => $pair['doc_count'], ]; } } // echo 'Массив брендов, когда включен фильтр по метке, нет нулевых элементов' . PHP_EOL; // print_r($brands_label_checked); // Добавляем недоступные (нулевые) элементы в массив доступных элементов $all_brands_label_checked = []; foreach ($all_brands as $v_id => $value) { $v_checked = isset($param_value_ids['brands']) && in_array($v_id, $param_value_ids['brands']); $all_brands_label_checked[$v_id] = [ 'id' => $value['id'], 'name' => $value['name'], 'count' => $brands_label_checked[$v_id]['count'] ?? 0, 'checked' => $v_checked, ]; } // echo 'Массив брендов, включен фильтр по метке, добавлены нулевые элементы' . PHP_EOL; // print_r($all_brands_label_checked); }
/* * Все готово, формируем итоговые фильтры по группе, бренду, метке */ $group_filter = $all_groups; $brand_filter = $all_brands; $label_filter = $all_labels; if (isset($param_value_ids['brands'])) { // Элементы фильтра по меткам изменяются при выборе бренда — // уменьшается кол-во товаров, некторые элементы недоступны $label_filter = $all_labels_brand_checked; // Элементы фильтра по брендам не изменяются при выборе бренда } if (isset($param_value_ids['labels'])) { // Элементы фильтра по брендам изменяются при выборе метки — // уменьшается кол-во товаров, некторые элементы недоступны $brand_filter = $all_brands_label_checked; // Элементы фильтра по меткам не изменяются при выборе метки } echo 'фильтр по группе' . PHP_EOL; print_r($group_filter); echo 'фильтр по бренду' . PHP_EOL; print_r($brand_filter); echo 'фильтр по метке' . PHP_EOL; print_r($label_filter);
Выполним скрипт, чтобы получить фильтры для раздела каталога, когда выбран бренд и метка
$ php category-filters.php --categ_id=33 --parval_ids=brands.109,labels.hit
фильтр по группе
Array
(
[131] => Array
(
[id] => 1311
[name] => Смартфрн
[count] => 27
[ckecked] =>
)
[371] => Array
(
[id] => 371
[name] => Холодильник
[count] => 22
[ckecked] =>
)
.....
)
фильтр по бренду
Array
(
[580] => Array
(
[id] => 580
[name] => Samsung
[count] => 0
[checked] =>
)
[335] => Array
(
[id] => 335
[name] => Xiaomi
[count] => 0
[checked] =>
)
.....
)
фильтр по метке
Array
(
[new] => Array
(
[id] => new
[name] => Новинка
[count] => 0
[checked] =>
)
[hit] => Array
(
[id] => hit
[name] => Лидер продаж
[count] => 4
[checked] => 1
)
)
Фильтры для категории каталога (группа уже выбрана)
Когда пользователь выбрал функциональную группу — можно показывать все прочие фильтры, характерные для этого функционала. Мы здесь повторяем функционал предыдущего скрипта, еще раз получая все функциональные группы, формируя фильтр по функционалу.
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_filter_new'; $options = getopt('', ['categ_id:', 'group_id:', 'parval_ids:']); $categ_id = isset($options['categ_id']) ? $options['categ_id'] : null; $group_id = isset($options['group_id']) ? $options['group_id'] : null; $parval_ids = isset($options['parval_ids']) ? $options['parval_ids'] : null; $client = ClientBuilder::create()->setHosts([$hostPort])->build(); // Если индекс не сущестует — завершаем работу и сообщаем причину if (!$client->indices()->exists(['index' => $indexName])->asBool()) { die("Индекс {$indexName} не существует, завершение работы"); } // Если категория не задана — завершаем работу и сообщаем причину if (!isset($categ_id)) { die('Не передан идентификатор категории, завершение работы'); } // Если группа не задана — завершаем работу и сообщаем причину if (!isset($group_id)) { die('Не передан идентификатор группы, завершение работы'); } // Преоборазуем параметры и значения к удобному для работы виду if (isset($parval_ids)) { $param_value_ids = []; // 1. Разбиваем строку на группы "123.234.345,456.678" -> ["123.234.345", "456.678"] $temp = explode(',', $parval_ids); foreach ($temp as $item) { // 2. Разбиваем каждую группу "123.234.345" -> ["123", "234", "345"] $parts = explode('.', $item); $param_id = array_shift($parts); // id параметра $value_ids = $parts; // ids значений $param_value_ids[$param_id] = $value_ids; } } // Две вспомогательные переменные для использования при построении запросов $agg_groups_pattern = [ 'composite' => [ 'sources' => [ // для сортировки по имени первым должно быть поле name ['name' => ['terms' => ['field' => 'group.name.keyword']]], ['id' => ['terms' => ['field' => 'group.id']]] ], // групп не должно быть больше 100, чтобы запрос вернул все группы 'size' => 100 ] ]; $agg_params_pattern = [ 'nested' => ['path' => 'params'], 'aggs' => [ 'params_and_values' => [ 'composite' => [ 'sources' => [ // для сортировки по имени первым должно быть поле name ['p_name' => ['terms' => ['field' => 'params.param_name']]], ['p_id' => ['terms' => ['field' => 'params.param_id']]], ['v_name' => ['terms' => ['field' => 'params.value_name']]], ['v_id' => ['terms' => ['field' => 'params.value_id']]], ], // всего возможных вариантов пар параметр-значение должно // быть не больше 200, чтобы запрос вернул все варианты 'size' => 200 ] ] ] ];
/* * Сначала получим все допустимые варианты значений групп — это состояние, когда * пользователь перешел в раздел каталога, но еще не выбрал группу. Этот список * всегда остается неизменным, не зависит от выбора бренда/метки/диагонали и т.п. */ $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['category_ids' => $categ_id]], ] ] ], 'aggs' => [ 'agg_groups' => $agg_groups_pattern, ], 'size' => 0 // товары не нужны, только агрегации ], ]; // Посмотреть запрос (для отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); // Массив всех доступных групп для категории, то есть фильтр по группе $agg_groups = $result['aggregations']['agg_groups']['buckets']; $all_groups = []; foreach ($agg_groups as $group) { $all_groups[$group['key']['id']] = [ 'id' => $group['key']['id'], 'name' => $group['key']['name'], 'count' => $group['doc_count'], 'checked' => $group['key']['id'] === $group_id, ]; } // echo 'Массив всех доступных групп для категории, то есть фильтр по группе' . PHP_EOL; // print_r($all_groups);
/* * Теперь получим все допустимые варианты фильтров для категории, когда * выбрана группа, но еще не выбраны бренд/метка/диагональ/цвет и т.п. */ $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['category_ids' => $categ_id]], ['term' => ['group.id' => $group_id]], ] ] ], 'aggs' => [ 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ], ]; // Посмотреть запрос (для отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; $all_filters = []; foreach ($agg_params as $pair) { $p_id = $pair['key']['p_id']; $p_name = $pair['key']['p_name']; // если такого параметра еще нет в нашем массиве — создаем if (!isset($all_filters[$p_id])) { $all_filters[$p_id] = [ 'id' => $p_id, 'name' => $p_name, 'values' => [], 'checked' => false, // выбрано хотя бы одно значение фильтра? ]; } // добавляем значение в массив значений для этого параметра $v_id = $pair['key']['v_id']; $v_name = $pair['key']['v_name']; $v_checked = isset($param_value_ids[$p_id]) && in_array($v_id, $param_value_ids[$p_id]); $all_filters[$p_id]['values'][$v_id] = [ 'id' => $v_id, 'name' => $v_name, 'count' => $pair['doc_count'], 'checked' => $v_checked, // какие значения фильтра были выбраны? ]; $all_filters[$p_id]['checked'] = $all_filters[$p_id]['checked'] || $v_checked; } // echo 'Массив всех фильтров для [категория+группа], нет пустых (нулевых) элементов' . PHP_EOL; // print_r($all_filters); $all_checked_filters = array_filter($all_filters, fn($item) => $item['checked']); $all_no_used_filters = array_filter($all_filters, fn($item) => !$item['checked']);
/* * Если пользователь выбрал фильтры («Бренд», «Диагональ», «Цвет»), то допустимые * значения остальных фильтров («Метка», «Память», «WiFi») можно получить, если * задать эти условия отбора («Бренд», «Диагональ», «Цвет») при выборке товаров. */ $query_filter = [ ['term' => ['category_ids' => $categ_id]], ['term' => ['group.id' => $group_id]] ]; foreach ($all_checked_filters as $p_id => $filter) { $checked = array_filter($filter['values'], fn($item) => $item['checked']); $ids = array_column($checked, 'id'); $query_filter[] = [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => $p_id]], ['terms' => ['params.value_id' => $ids]], ] ] ] ] ]; } $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => $query_filter ] ], 'aggs' => [ 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ], ]; // Посмотреть запрос (для отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; $no_used_filters = []; foreach ($agg_params as $pair) { // нам интересны только не выбранные фильтры if (array_key_exists($pair['key']['p_id'], $all_no_used_filters)) { $p_id = $pair['key']['p_id']; $p_name = $pair['key']['p_name']; // если такого фильтра еще нет в нашем массиве — создаем if (!isset($no_used_filters[$p_id])) { $no_used_filters[$p_id] = [ 'id' => $p_id, 'name' => $p_name, 'values' => [], 'checked' => false, ]; } // добавляем значение в массив значений для этого фильтра $v_id = $pair['key']['v_id']; $v_name = $pair['key']['v_name']; $no_used_filters[$p_id]['values'][$v_id] = [ 'id' => $v_id, 'name' => $v_name, 'count' => $pair['doc_count'], 'checked' => false ]; } } // Добавляем недоступные (нулевые) элементы в массив доступных элементов $no_used_with_empty = []; foreach ($all_no_used_filters as $p_id => $filter) { $no_used_with_empty[$p_id] = [ 'id' => $filter['id'], 'name' => $filter['name'], 'values' => [], 'checked' => false, ]; foreach ($filter['values'] as $v_id => $value) { $no_used_with_empty[$p_id]['values'][$v_id] = [ 'id' => $value['id'], 'name' => $value['name'], 'count' => $no_used_filters[$p_id]['values'][$v_id]['count'] ?? 0, 'checked' => false, ]; } }
/* * Допустимые значения уже выбраного фильтра можно получить, если убрать условие по * этому фильтру. Если пользователь выбрал фильтры («Бренд», «Диагональ», «Цвет»), * чтобы получить значения фильтра «Бренд» — нужно задать только «Диагональ» и «Цвет» */ if (count($all_checked_filters) > 1) { $checked_filters = []; // Нужно выполнить столько запросов к Elastic, сколько фильтров включено, // для каждого запроса один фильтр отключаем, для него получаем значения foreach ($all_checked_filters as $p_id => $filter) { $temp = $all_checked_filters; // включенные фильтры, один отключаем unset($temp[$p_id]); $query_filter = [ ['term' => ['category_ids' => $categ_id]], ['term' => ['group.id' => $group_id]] ]; foreach ($temp as $key => $value) { $checked = array_filter($value['values'], fn($item) => $item['checked']); $ids = array_column($checked, 'id'); $query_filter[] = [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => $key]], ['terms' => ['params.value_id' => $ids]], ] ] ] ] ]; } $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => $query_filter ] ], 'aggs' => [ 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ], ]; $response = $client->search($query_params); $result = $response->asArray(); // В ответе нужно найти только тот фильтр, который отключили $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; foreach ($agg_params as $pair) { if ($pair['key']['p_id'] === $p_id) { $p_name = $pair['key']['p_name']; if (!isset($checked_filters[$p_id])) { $checked_filters[$p_id] = [ 'id' => $p_id, 'name' => $p_name, 'values' => [], 'checked' => $all_filters[$p_id]['checked'], ]; } $v_id = $pair['key']['v_id']; $v_name = $pair['key']['v_name']; $checked_filters[$p_id]['values'][$v_id] = [ 'id' => $v_id, 'name' => $v_name, 'count' => $pair['doc_count'], 'checked' => $all_filters[$p_id]['values'][$v_id]['checked'] ]; } } } // Добавляем недоступные (нулевые) элементы в массив доступных элементов $checked_with_empty = []; foreach ($all_checked_filters as $p_id => $filter) { $checked_with_empty[$p_id] = [ 'id' => $filter['id'], 'name' => $filter['name'], 'values' => [], 'checked' => $filter['checked'], ]; foreach ($filter['values'] as $v_id => $value) { $checked_with_empty[$p_id]['values'][$v_id] = [ 'id' => $value['id'], 'name' => $value['name'], 'count' => $checked_filters[$p_id]['values'][$v_id]['count'] ?? 0, 'checked' => $value['checked'], ]; } } } elseif (count($all_checked_filters) === 1) { // Когда отмечен только один фильтр — не нужно выполнять запрос к Elastic $checked_filters = $all_checked_filters; $checked_with_empty = $all_checked_filters; } else { $checked_filters = []; $checked_with_empty = []; }
/* * Все готово, формируем итоговые фильтры по группе и всем прочим */ $group_filter = $all_groups; $other_filters = $checked_with_empty + $no_used_with_empty; print_r($group_filter); print_r($other_filters);
Выполним скрипт, чтобы получить фильтры для раздела каталога, когды выбрана функциональная группа и два параметр подбора
$ php category-group-filters.php --categ_id=33 --group_id=181 --parval_ids=brands.580,labels.hit
Фильтр по группе
Array
(
[131] => Array
(
[id] => 131
[name] => Смартфрн
[count] => 27
[ckecked] => 1
)
[371] => Array
(
[id] => 371
[name] => Холодильник
[count] => 22
[ckecked] =>
)
.....
)
Все прочие фильтры
Array
(
[236] => Array
(
[id] => 236
[name] => Диагональ
[values] => Array
(
[1990] => Array
(
[id] => 1990
[name] => 5 дюймов
[count] => 8
[checked] => 0
)
[1995] => Array
(
[id] => 1995
[name] => 6 дюймов
[count] => 7
[checked] =>
)
)
[checked] => 0
)
.....
[brands] => Array
(
[id] => brands
[name] => Бренды
[values] => Array
(
[580] => Array
(
[id] => 580
[name] => Samsung
[count] => 0
[checked] => 1
)
[335] => Array
(
[id] => 335
[name] => Xiaomi
[count] => 0
[checked] =>
)
.....
)
[checked] =>
)
[labels] => Array
(
[id] => labels
[name] => Метки
[values] => Array
(
[new] => Array
(
[id] => new
[name] => Новинка
[count] => 2
[checked] =>
)
[hit] => Array
(
[id] => hit
[name] => Лидер продаж
[count] => 3
[checked] => 1
)
)
[checked] => 1
)
)
Когда пользователь выбрал какие-то фильтры (в нашем случае «Бренд» и «Метка») — можно получить значения для не выбранных фильтров, если задать условия по бренду и метке при выборке товаров.
{ "query": { "bool": { "filter": [ { "term": { "category_ids": "33" } }, { "term": { "group.id": "181" } }, { "nested": { "path": "params", "query": { "bool": { "filter": [ { "term": { "params.param_id": "brands" } }, { "terms": { "params.value_id": [ "109" ] } } ] } } } }, { "nested": { "path": "params", "query": { "bool": { "filter": [ { "term": { "params.param_id": "labels" } }, { "terms": { "params.value_id": [ "hit" ] } } ] } } } } ] } }, "aggs": { "agg_params": { "nested": { "path": "params" }, "aggs": { "params_and_values": { "composite": { "sources": [ { "p_name": { "terms": { "field": "params.param_name" } } }, { "p_id": { "terms": { "field": "params.param_id" } } }, { "v_name": { "terms": { "field": "params.value_name" } } }, { "v_id": { "terms": { "field": "params.value_id" } } } ], "size": 200 } } } } }, "size": 0 }
Когда пользователь выбрал какие-то фильтры (в нашем случае «Бренд» и «Метка») — значения для фильтра по бренду можно получить, если отключить условие отбора по бренду при выборке товаров.
{ "query": { "bool": { "filter": [ { "term": { "category_ids": "33" } }, { "term": { "group.id": "181" } }, { "nested": { "path": "params", "query": { "bool": { "filter": [ { "term": { "params.param_id": "labels" } }, { "terms": { "params.value_id": [ "hit" ] } } ] } } } } ] } }, "aggs": { "agg_params": { "nested": { "path": "params" }, "aggs": { "params_and_values": { "composite": { "sources": [ { "p_name": { "terms": { "field": "params.param_name" } } }, { "p_id": { "terms": { "field": "params.param_id" } } }, { "v_name": { "terms": { "field": "params.value_name" } } }, { "v_id": { "terms": { "field": "params.value_id" } } } ], "size": 200 } } } } }, "size": 0 }
Получение фильтров для страницы группы
Очень похоже на получение фильтров для категории, когда функциональная группа уже выбрана — только не используем ID категории в условиях отбора.
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_filter_new'; $options = getopt('', ['group_id:', 'parval_ids:']); $group_id = isset($options['group_id']) ? $options['group_id'] : null; $parval_ids = isset($options['parval_ids']) ? $options['parval_ids'] : null; $client = ClientBuilder::create()->setHosts([$hostPort])->build(); // Если индекс не сущестует — завершаем работу и сообщаем причину if (!$client->indices()->exists(['index' => $indexName])->asBool()) { die("Индекс {$indexName} не существует, завершение работы"); } // Если группа не задана — завершаем работу и сообщаем причину if (!isset($group_id)) { die('Не передан идентификатор группы, завершение работы'); } // Преоборазуем параметры и значения к удобному для работы виду if (isset($parval_ids)) { $param_value_ids = []; // 1. Разбиваем строку на группы "123.234.345,456.678" -> ["123.234.345", "456.678"] $temp = explode(',', $parval_ids); foreach ($temp as $item) { // 2. Разбиваем каждую группу "123.234.345" -> ["123", "234", "345"] $parts = explode('.', $item); $param_id = array_shift($parts); // id параметра $value_ids = $parts; // ids значений $param_value_ids[$param_id] = $value_ids; } } // Вспомогательная переменная для использования при построении запросов $agg_params_pattern = [ 'nested' => ['path' => 'params'], 'aggs' => [ 'params_and_values' => [ 'composite' => [ 'sources' => [ // для сортировки по имени первым должно быть поле name ['p_name' => ['terms' => ['field' => 'params.param_name']]], ['p_id' => ['terms' => ['field' => 'params.param_id']]], ['v_name' => ['terms' => ['field' => 'params.value_name']]], ['v_id' => ['terms' => ['field' => 'params.value_id']]], ], // всего возможных вариантов пар параметр-значение должно // быть не больше 200, чтобы запрос вернул все варианты 'size' => 200 ] ] ] ];
/* * Сначала получим все допустимые варианты фильтров, когда пользователь * перешел на страницу функционала, но еще не применил ни одного фильтра */ $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['group.id' => $group_id]], ] ] ], 'aggs' => [ 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ], ]; // Посмотреть запрос (для отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; $all_filters = []; foreach ($agg_params as $pair) { $p_id = $pair['key']['p_id']; $p_name = $pair['key']['p_name']; // если такого параметра еще нет в нашем массиве — создаем if (!isset($all_filters[$p_id])) { $all_filters[$p_id] = [ 'id' => $p_id, 'name' => $p_name, 'values' => [], 'checked' => false, // выбрано хотя бы одно значение фильтра? ]; } // добавляем значение в массив значений для этого параметра $v_id = $pair['key']['v_id']; $v_name = $pair['key']['v_name']; $v_checked = isset($param_value_ids[$p_id]) && in_array($v_id, $param_value_ids[$p_id]); $all_filters[$p_id]['values'][$v_id] = [ 'id' => $v_id, 'name' => $v_name, 'count' => $pair['doc_count'], 'checked' => $v_checked, // какие значения фильтра были выбраны? ]; $all_filters[$p_id]['checked'] = $all_filters[$p_id]['checked'] || $v_checked; } // echo 'Массив всех фильтров для функционала, нет пустых (нулевых) элементов' . PHP_EOL; // print_r($all_filters); $all_checked_filters = array_filter($all_filters, fn($item) => $item['checked']); $all_no_used_filters = array_filter($all_filters, fn($item) => !$item['checked']);
/* * Если пользователь выбрал фильтры («Бренд», «Диагональ», «Цвет»), то допустимые * значения остальных фильтров («Метка», «Память», «WiFi») можно получить, если * задать эти условия отбора («Бренд», «Диагональ», «Цвет») при выборке товаров. */ $query_filter = [ ['term' => ['group.id' => $group_id]], ]; foreach ($all_checked_filters as $p_id => $filter) { $checked = array_filter($filter['values'], fn($item) => $item['checked']); $ids = array_column($checked, 'id'); $query_filter[] = [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => $p_id]], ['terms' => ['params.value_id' => $ids]], ] ] ] ] ]; } $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => $query_filter ] ], 'aggs' => [ 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ], ]; // Посмотреть запрос (для отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; $no_used_filters = []; foreach ($agg_params as $pair) { // нам интересны только не выбранные фильтры if (array_key_exists($pair['key']['p_id'], $all_no_used_filters)) { $p_id = $pair['key']['p_id']; $p_name = $pair['key']['p_name']; // если такого фильтра еще нет в нашем массиве — создаем if (!isset($no_used_filters[$p_id])) { $no_used_filters[$p_id] = [ 'id' => $p_id, 'name' => $p_name, 'values' => [], 'checked' => false, ]; } // добавляем значение в массив значений для этого фильтра $v_id = $pair['key']['v_id']; $v_name = $pair['key']['v_name']; $no_used_filters[$p_id]['values'][$v_id] = [ 'id' => $v_id, 'name' => $v_name, 'count' => $pair['doc_count'], 'checked' => false ]; } } // Добавляем недоступные (нулевые) элементы в массив доступных элементов $no_used_with_empty = []; foreach ($all_no_used_filters as $p_id => $filter) { $no_used_with_empty[$p_id] = [ 'id' => $filter['id'], 'name' => $filter['name'], 'values' => [], 'checked' => false, ]; foreach ($filter['values'] as $v_id => $value) { $no_used_with_empty[$p_id]['values'][$v_id] = [ 'id' => $value['id'], 'name' => $value['name'], 'count' => $no_used_filters[$p_id]['values'][$v_id]['count'] ?? 0, 'checked' => false, ]; } }
/* * Допустимые значения уже выбраного фильтра можно получить, если убрать условие по * этому фильтру. Если пользователь выбрал фильтры («Бренд», «Диагональ», «Цвет»), * чтобы получить значения фильтра «Бренд» — нужно задать только «Диагональ» и «Цвет» */ if (count($all_checked_filters) > 1) { $checked_filters = []; // Нужно выполнить столько запросов к Elastic, сколько фильтров включено, // для каждого запроса один фильтр отключаем, для него получаем значения foreach ($all_checked_filters as $p_id => $filter) { $temp = $all_checked_filters; // включенные фильтры, один отключаем unset($temp[$p_id]); $query_filter = [ ['term' => ['group.id' => $group_id]], ]; foreach ($temp as $key => $value) { $checked = array_filter($value['values'], fn($item) => $item['checked']); $ids = array_column($checked, 'id'); $query_filter[] = [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => $key]], ['terms' => ['params.value_id' => $ids]], ] ] ] ] ]; } $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => $query_filter ] ], 'aggs' => [ 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ], ]; $response = $client->search($query_params); $result = $response->asArray(); // В ответе нужно найти только тот фильтр, который отключили $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; foreach ($agg_params as $pair) { if ($pair['key']['p_id'] === $p_id) { $p_name = $pair['key']['p_name']; if (!isset($checked_filters[$p_id])) { $checked_filters[$p_id] = [ 'id' => $p_id, 'name' => $p_name, 'values' => [], 'checked' => $all_filters[$p_id]['checked'], ]; } $v_id = $pair['key']['v_id']; $v_name = $pair['key']['v_name']; $checked_filters[$p_id]['values'][$v_id] = [ 'id' => $v_id, 'name' => $v_name, 'count' => $pair['doc_count'], 'checked' => $all_filters[$p_id]['values'][$v_id]['checked'] ]; } } } // Добавляем недоступные (нулевые) элементы в массив доступных элементов $checked_with_empty = []; foreach ($all_checked_filters as $p_id => $filter) { $checked_with_empty[$p_id] = [ 'id' => $filter['id'], 'name' => $filter['name'], 'values' => [], 'checked' => $filter['checked'], ]; foreach ($filter['values'] as $v_id => $value) { $checked_with_empty[$p_id]['values'][$v_id] = [ 'id' => $value['id'], 'name' => $value['name'], 'count' => $checked_filters[$p_id]['values'][$v_id]['count'] ?? 0, 'checked' => $value['checked'], ]; } } } elseif (count($all_checked_filters) === 1) { // Когда отмечен только один фильтр — не нужно выполнять запрос к Elastic $checked_filters = $all_checked_filters; $checked_with_empty = $all_checked_filters; } else { $checked_filters = []; $checked_with_empty = []; }
/* * Все готово, формируем итоговые фильтры для страницы группы */ $group_filters = $checked_with_empty + $no_used_with_empty; echo 'Фильтры для страницы группы' . PHP_EOL; print_r($group_filters);
Выполним скрипт, чтобы получить фильтры для страницы функционала, когды выбраны бренд и метка
$ php group-filters.php --group_id=473 --parval_ids=brands.109,labels.hit
Фильтры для страницы бренда (группа не выбрана)
Очень похоже на получение фильтров для категории — но существенно проще. Когда группа еще не выбрана — получаем доступные группы и метки одним запросом. Когда группа уже выбрана — все по аналогии с категорий, только постоянное условие — не ID категории, а ID бренда.
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_filter_new'; $options = getopt('', ['brand_id:', 'parval_ids:']); $brand_id = isset($options['brand_id']) ? $options['brand_id'] : null; $parval_ids = isset($options['parval_ids']) ? $options['parval_ids'] : null; $client = ClientBuilder::create()->setHosts([$hostPort])->build(); // Если индекс не сущестует — завершаем работу и сообщаем причину if (!$client->indices()->exists(['index' => $indexName])->asBool()) { die("Индекс {$indexName} не существует, завершение работы"); } // Если бренд не задан — завершаем работу и сообщаем причину if (!isset($brand_id)) { die('Не передан идентификатор бренда, завершение работы'); } // Преоборазуем параметры и значения к удобному для работы виду if (isset($parval_ids)) { $param_value_ids = []; // 1. Разбиваем строку на группы "123.234.345,456.678" -> ["123.234.345", "456.678"] $temp = explode(',', $parval_ids); foreach ($temp as $item) { // 2. Разбиваем каждую группу "123.234.345" -> ["123", "234", "345"] $parts = explode('.', $item); $param_id = array_shift($parts); // id параметра $value_ids = $parts; // ids значений $param_value_ids[$param_id] = $value_ids; } } // Две вспомогательные переменные для использования при построении запросов $agg_groups_pattern = [ 'composite' => [ 'sources' => [ // для сортировки по имени первым должно быть поле name ['name' => ['terms' => ['field' => 'group.name.keyword']]], ['id' => ['terms' => ['field' => 'group.id']]] ], // групп не должно быть больше 100, чтобы запрос вернул все группы 'size' => 100 ] ]; $agg_params_pattern = [ 'nested' => ['path' => 'params'], 'aggs' => [ 'params_and_values' => [ 'composite' => [ 'sources' => [ // для сортировки по имени первым должно быть поле name ['p_name' => ['terms' => ['field' => 'params.param_name']]], ['p_id' => ['terms' => ['field' => 'params.param_id']]], ['v_name' => ['terms' => ['field' => 'params.value_name']]], ['v_id' => ['terms' => ['field' => 'params.value_id']]], ], // всего возможных вариантов пар параметр-значение должно // быть не больше 200, чтобы запрос вернул все варианты 'size' => 200 ] ] ] ]; /* * Сначала получим все допустимые варианты групп и меток — пользователь * еще не выбрал ни одного фильтра, а только перешел на страницу бренда */ $query_filter = [ // упрощенный синтаксис, поскольку только одно условие 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => 'brands']], ['term' => ['params.value_id' => $brand_id]], ] ] ] ] ]; $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => $query_filter, ] ], 'aggs' => [ 'agg_groups' => $agg_groups_pattern, 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ] ]; // Посмотреть запрос (для отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); // Массив всех доступных групп для страницы бренда, то есть фильтр по группе $agg_groups = $result['aggregations']['agg_groups']['buckets']; $all_groups = []; foreach ($agg_groups as $group) { $all_groups[$group['key']['id']] = [ 'id' => $group['key']['id'], 'name' => $group['key']['name'], 'count' => $group['doc_count'], 'checked' => isset($group_id) && $group['key']['id'] == $group_id ]; } // echo 'Массив всех доступных групп для страницы бренда, то есть фильтр по группе' . PHP_EOL; // print_r($all_groups); // Массив всех доступных меток для страницы бренда, то есть фильтр по метке $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; $all_labels = []; foreach ($agg_params as $pair) { if ($pair['key']['p_id'] === 'labels') { $v_id = $pair['key']['v_id']; $v_name = $pair['key']['v_name']; $v_checked = isset($param_value_ids['labels']) && in_array($v_id, $param_value_ids['labels']); $all_labels[$pair['key']['v_id']] = [ 'id' => $v_id, 'name' => $v_name, 'count' => $pair['doc_count'], 'checked' => $v_checked, ]; } } // echo 'Массив всех доступных меток для страницы бренда, то есть фильтр по метке' . PHP_EOL; // print_r($all_labels); /* * Все готово, формируем итоговые фильтры для страницы группы */ echo 'Фильтр по группе' . PHP_EOL; print_r($all_groups); echo 'Фильтр по метке' . PHP_EOL; print_r($all_labels);
Фильтры для страницы бренда (группа выбрана)
Когда группа выбрана — все по аналогии с категорий, только постоянным условием будет ID бренда вместо ID категории.
<?php require 'vendor/autoload.php'; use Elastic\Elasticsearch\ClientBuilder; $hostPort = 'http://localhost:9200'; $indexName = 'catalog_filter_new'; $options = getopt('', ['brand_id:', 'group_id:', 'parval_ids:']); $brand_id = isset($options['brand_id']) ? $options['brand_id'] : null; $group_id = isset($options['group_id']) ? $options['group_id'] : null; $parval_ids = isset($options['parval_ids']) ? $options['parval_ids'] : null; $client = ClientBuilder::create()->setHosts([$hostPort])->build(); // Если индекс не сущестует — завершаем работу и сообщаем причину if (!$client->indices()->exists(['index' => $indexName])->asBool()) { die("Индекс {$indexName} не существует, завершение работы"); } // Если бренд не задан — завершаем работу и сообщаем причину if (!isset($brand_id)) { die('Не передан идентификатор бренда, завершение работы'); } // Если группа не задана — завершаем работу и сообщаем причину if (!isset($group_id)) { die('Не передан идентификатор группы, завершение работы'); } // Преоборазуем параметры и значения к удобному для работы виду if (isset($parval_ids)) { $param_value_ids = []; // 1. Разбиваем строку на группы "123.234.345,456.678" -> ["123.234.345", "456.678"] $temp = explode(',', $parval_ids); foreach ($temp as $item) { // 2. Разбиваем каждую группу "123.234.345" -> ["123", "234", "345"] $parts = explode('.', $item); $param_id = array_shift($parts); // id параметра $value_ids = $parts; // ids значений $param_value_ids[$param_id] = $value_ids; } } // Две вспомогательные переменные для использования при построении запросов $agg_groups_pattern = [ 'composite' => [ 'sources' => [ // для сортировки по имени первым должно быть поле name ['name' => ['terms' => ['field' => 'group.name.keyword']]], ['id' => ['terms' => ['field' => 'group.id']]] ], // групп не должно быть больше 100, чтобы запрос вернул все группы 'size' => 100 ] ]; $agg_params_pattern = [ 'nested' => ['path' => 'params'], 'aggs' => [ 'params_and_values' => [ 'composite' => [ 'sources' => [ // для сортировки по имени первым должно быть поле name ['p_name' => ['terms' => ['field' => 'params.param_name']]], ['p_id' => ['terms' => ['field' => 'params.param_id']]], ['v_name' => ['terms' => ['field' => 'params.value_name']]], ['v_id' => ['terms' => ['field' => 'params.value_id']]], ], // всего возможных вариантов пар параметр-значение должно // быть не больше 200, чтобы запрос вернул все варианты 'size' => 200 ] ] ] ];
/* * Сначала получим все допустимые варианты значений групп — это состояние, когда * пользователь перешел на страницу бренда, но еще не выбрал группу. Этот список * групп всегда остается неизменным, не зависит от прочих параметров подбора. */ $query_filter = [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => 'brands']], ['term' => ['params.value_id' => $brand_id]], ] ] ] ] ]; $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => $query_filter, ] ], 'aggs' => [ 'agg_groups' => $agg_groups_pattern, ], 'size' => 0 // товары не нужны, только агрегации ] ]; // Посмотреть запрос (для отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); // Массив всех доступных групп для бренда, то есть фильтр по группе $agg_groups = $result['aggregations']['agg_groups']['buckets']; $all_groups = []; foreach ($agg_groups as $group) { $all_groups[$group['key']['id']] = [ 'id' => $group['key']['id'], 'name' => $group['key']['name'], 'count' => $group['doc_count'], 'checked' => $group['key']['id'] === $group_id, ]; } // echo 'Массив всех доступных групп для бренда, то есть фильтр по группе' . PHP_EOL; // print_r($all_groups);
/* * Теперь получим все допустимые варианты фильтров для страницы бренда, когда * выбрана группа, но еще не выбраны метка/диагональ/память/цвет и т.п. */ $query_filter = [ ['term' => ['group.id' => $group_id]], [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => 'brands']], ['term' => ['params.value_id' => $brand_id]], ] ] ] ] ] ]; $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => $query_filter, ] ], 'aggs' => [ 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ] ]; // Посмотреть запрос (для отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; $all_filters = []; foreach ($agg_params as $pair) { $p_id = $pair['key']['p_id']; if ($p_id === 'brands') { continue; // фильтр по бренду нам не нужен } $p_name = $pair['key']['p_name']; // если такого параметра еще нет в нашем массиве — создаем if (!isset($all_filters[$p_id])) { $all_filters[$p_id] = [ 'id' => $p_id, 'name' => $p_name, 'values' => [], 'checked' => false, // выбрано хотя бы одно значение фильтра? ]; } // добавляем значение в массив значений для этого параметра $v_id = $pair['key']['v_id']; $v_name = $pair['key']['v_name']; $v_checked = isset($param_value_ids[$p_id]) && in_array($v_id, $param_value_ids[$p_id]); $all_filters[$p_id]['values'][$v_id] = [ 'id' => $v_id, 'name' => $v_name, 'count' => $pair['doc_count'], 'checked' => $v_checked, // какие значения фильтра были выбраны? ]; $all_filters[$p_id]['checked'] = $all_filters[$p_id]['checked'] || $v_checked; } // echo 'Массив всех фильтров для бренда, нет пустых (нулевых) элементов' . PHP_EOL; // print_r($all_filters); $all_checked_filters = array_filter($all_filters, fn($item) => $item['checked']); $all_no_used_filters = array_filter($all_filters, fn($item) => !$item['checked']);
/* * Если пользователь выбрал фильтры («Метка», «Диагональ», «Цвет»), то допустимые * значения остальных фильтров («Матрица», «Память», «WiFi») можно получить, если * задать эти условия отбора («Метка», «Диагональ», «Цвет») при выборке товаров. */ $query_filter = [ ['term' => ['group.id' => $group_id]], [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => 'brands']], ['term' => ['params.value_id' => $brand_id]], ] ] ] ] ] ]; foreach ($all_checked_filters as $p_id => $filter) { $checked = array_filter($filter['values'], fn($item) => $item['checked']); $ids = array_column($checked, 'id'); $query_filter[] = [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => $p_id]], ['terms' => ['params.value_id' => $ids]], ] ] ] ] ]; } $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => $query_filter ] ], 'aggs' => [ 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ], ]; // Посмотреть запрос (для отладки) $query = json_encode($query_params['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // echo $query . PHP_EOL; $response = $client->search($query_params); $result = $response->asArray(); $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; $no_used_filters = []; foreach ($agg_params as $pair) { // нам интересны только не выбранные фильтры if (array_key_exists($pair['key']['p_id'], $all_no_used_filters)) { $p_id = $pair['key']['p_id']; $p_name = $pair['key']['p_name']; // если такого фильтра еще нет в нашем массиве — создаем if (!isset($no_used_filters[$p_id])) { $no_used_filters[$p_id] = [ 'id' => $p_id, 'name' => $p_name, 'values' => [], 'checked' => false, ]; } // добавляем значение в массив значений для этого фильтра $v_id = $pair['key']['v_id']; $v_name = $pair['key']['v_name']; $no_used_filters[$p_id]['values'][$v_id] = [ 'id' => $v_id, 'name' => $v_name, 'count' => $pair['doc_count'], 'checked' => false ]; } } // Добавляем недоступные (нулевые) элементы в массив доступных элементов $no_used_with_empty = []; foreach ($all_no_used_filters as $p_id => $filter) { $no_used_with_empty[$p_id] = [ 'id' => $filter['id'], 'name' => $filter['name'], 'values' => [], 'checked' => false, ]; foreach ($filter['values'] as $v_id => $value) { $no_used_with_empty[$p_id]['values'][$v_id] = [ 'id' => $value['id'], 'name' => $value['name'], 'count' => $no_used_filters[$p_id]['values'][$v_id]['count'] ?? 0, 'checked' => false, ]; } }
/* * Допустимые значения уже выбраного фильтра можно получить, если убрать условие по * этому фильтру. Если пользователь выбрал фильтры («Метка», «Диагональ», «Цвет»), * чтобы получить значения фильтра «Метка» — нужно задать только «Диагональ» и «Цвет» */ if (count($all_checked_filters) > 1) { $checked_filters = []; // Нужно выполнить столько запросов к Elastic, сколько фильтров включено, // для каждого запроса один фильтр отключаем, для него получаем значения foreach ($all_checked_filters as $p_id => $filter) { $temp = $all_checked_filters; // включенные фильтры, один отключаем unset($temp[$p_id]); $query_filter = [ ['term' => ['group.id' => $group_id]], [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => 'brands']], ['term' => ['params.value_id' => $brand_id]], ] ] ] ] ] ]; foreach ($temp as $key => $value) { $checked = array_filter($value['values'], fn($item) => $item['checked']); $ids = array_column($checked, 'id'); $query_filter[] = [ 'nested' => [ 'path' => 'params', 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['params.param_id' => $key]], ['terms' => ['params.value_id' => $ids]], ] ] ] ] ]; } $query_params = [ 'index' => $indexName, 'body' => [ 'query' => [ 'bool' => [ 'filter' => $query_filter ] ], 'aggs' => [ 'agg_params' => $agg_params_pattern, ], 'size' => 0 // товары не нужны, только агрегации ], ]; $response = $client->search($query_params); $result = $response->asArray(); // В ответе нужно найти только тот фильтр, который отключили $agg_params = $result['aggregations']['agg_params']['params_and_values']['buckets']; foreach ($agg_params as $pair) { if ($pair['key']['p_id'] === $p_id) { $p_name = $pair['key']['p_name']; if (!isset($checked_filters[$p_id])) { $checked_filters[$p_id] = [ 'id' => $p_id, 'name' => $p_name, 'values' => [], 'checked' => $all_filters[$p_id]['checked'], ]; } $v_id = $pair['key']['v_id']; $v_name = $pair['key']['v_name']; $checked_filters[$p_id]['values'][$v_id] = [ 'id' => $v_id, 'name' => $v_name, 'count' => $pair['doc_count'], 'checked' => $all_filters[$p_id]['values'][$v_id]['checked'] ]; } } } // Добавляем недоступные (нулевые) элементы в массив доступных элементов $checked_with_empty = []; foreach ($all_checked_filters as $p_id => $filter) { $checked_with_empty[$p_id] = [ 'id' => $filter['id'], 'name' => $filter['name'], 'values' => [], 'checked' => $filter['checked'], ]; foreach ($filter['values'] as $v_id => $value) { $checked_with_empty[$p_id]['values'][$v_id] = [ 'id' => $value['id'], 'name' => $value['name'], 'count' => $checked_filters[$p_id]['values'][$v_id]['count'] ?? 0, 'checked' => $value['checked'], ]; } } } elseif (count($all_checked_filters) === 1) { // Когда отмечен только один фильтр — не нужно выполнять запрос к Elastic $checked_filters = $all_checked_filters; $checked_with_empty = $all_checked_filters; } else { $checked_filters = []; $checked_with_empty = []; }
/* * Все готово, формируем итоговые фильтры для страницы бренда */ $other_filters = $checked_with_empty + $no_used_with_empty; echo 'Фильтр по группе' . PHP_EOL; print_r($all_groups); echo 'Все прочие фильтры' . PHP_EOL; print_r($other_filters);
Выполним скрипт, чтобы получить фильтры для страницы бренда
$ php brand-group-filters.php --brand_id=42 --group_id=474 --parval_ids=160.249
- ElasticSearch. Начало работы. Часть 1 из 3
- SSH как SOCKS сервер
- RESTfull API приложение на фреймворке Express.js
- Let's Encrypt. Получение и обновление сертификатов
- Настройка кластера MariaDB Galera на сервере Ubuntu 18.04
- Утилита командной строки Wget
- Postfix и Dovecot. Установка и настройка. Часть 2 из 2
Поиск: API • HTTP • HTTPS • Linux • База данных • Поиск • Сервер • Индекс • Elasticsearch • Kibana • Logstash