Магазин на Yii2, часть 16. Поиск по каталогу товаров, часть вторая

06.07.2019

Теги: Web-разработкаYii2ЗапросИнтернетМагазинКаталогТоваровПоискПрактикаФреймворк

У нашего поиска есть серьезная проблема — окончания слов. Например, в каталоге есть товар «Мужские зимние ботинки», а пользователь ищет «зимняя обувь». Этот товар не попадет в результаты поиска, потому что нет точного совпадения: в поисковом запросе используется слово «зимняя», а названии товара используется слово «зимние».

Для решения этой проблемы используем стеммер Портера, который отсекает окончания и суффиксы слова, оставляя только корень. Мне удалось найти на packagist.org готовый класс стеммера для русского языка. Его и будем использовать.

Стеммер Портера — алгоритм стемминга, опубликованный Мартином Портером в 1980 году. Оригинальная версия стеммера была предназначена для английского языка. Впоследствии Мартин создал проект «Snowball» и, используя основную идею алгоритма, написал стеммеры для распространённых индоевропейских языков, в том числе для русского.

Алгоритм не использует морфологический словарь, а только применяя последовательно ряд правил, отсекает окончания и суффиксы, основываясь на особенностях языка, в связи с чем работает быстро, но не всегда безошибочно.

Итак, устанавливаем пакет с использованием composer:

> composer require ladamalina/lingua-stem-ru

Но установка завершилась ошибкой:

[InvalidArgumentException]
Could not find a version of package ladamalina/lingua-stem-ru matching your minimum-stability (stable).
Require it with an explicit version constraint allowing its desired stability.

require [--dev] [--prefer-source] [--prefer-dist] [--no-progress] [--no-suggest] [--no-update] [--no-scripts]
[--update-no-dev] [--update-with-dependencies] [--update-with-all-dependencies] [--ignore-platform-reqs]
[--prefer-stable] [--prefer-lowest] [--sort-packages] [-o|--optimize-autoloader] [-a|--classmap-authoritative]
[--apcu-autoloader] [--] [<packages>]...

Это потому, что в composer.json параметр minimum-stability имеет значение stable. Так что еще одна попытка:

> composer require ladamalina/lingua-stem-ru:dev-master

Все, теперь нам доступен класс LinguaStemRu. Примеры использования класса:

$stemmer = new LinguaStemRu();
echo $stemmer->stem_word('Автомобиль') . "<br/>";
echo $stemmer->stem_word('Автомобилем') . "<br/>";
echo $stemmer->stem_word('Автомобиля') . "<br/>";
$stemmer = new LinguaStemRu();
echo $stemmer->stem_text('Любовь к Родине – это очень сильное чувство.');
любов к родин – это очен сильн чувство.

Искать будем по следующим полям таблиц базы данных:

  1. поле name таблицы product (название товара)
  2. поле keywords таблицы product (ключевые слова)
  3. поле name таблицы category (название категории)
  4. поле name таблицы brand (название бренда)

Вносим изменения в класс модели Product:

<?php
namespace app\models;

use Yii;
use yii\data\Pagination;
use yii\db\ActiveRecord;
use yii\db\Query;
use Stem\LinguaStemRu;

class Product extends ActiveRecord {
    /*.....*/
    public function getSearchResult($search, $page) {
        $search = $this->cleanSearchString($search);
        if (empty($search)) {
            return [null, null];
        }

        // пробуем извлечь данные из кеша
        $key = 'search-'.md5($search).'-page-'.$page;
        $data = Yii::$app->cache->get($key);

        if ($data === false) { // данных нет в кеше, получаем их заново
            // разбиваем поисковый запрос на отдельные слова
            $temp = explode(' ', $search);
            $words = [];
            $stemmer = new LinguaStemRu();
            foreach ($temp as $item) {
                if (iconv_strlen($item) > 3) {
                    // получаем корень слова
                    $words[] = $stemmer->stem_word($item);
                } else {
                    $words[] = $item;
                }
            }
            $relevance = "IF (`product`.`name` LIKE '%" . $words[0] . "%', 3, 0)";
            $relevance .= " + IF (`product`.`keywords` LIKE '%" . $words[0] . "%', 2, 0)";
            $relevance .= " + IF (`category`.`name` LIKE '%" . $words[0] . "%', 1, 0)";
            $relevance .= " + IF (`brand`.`name` LIKE '%" . $words[0] . "%', 1, 0)";
            for ($i = 1; $i < count($words); $i++) {
                $relevance .= " + IF (`product`.`name` LIKE '%" . $words[$i] . "%', 3, 0)";
                $relevance .= " + IF (`product`.`keywords` LIKE '%" . $words[$i] . "%', 2, 0)";
                $relevance .= " + IF (`category`.`name` LIKE '%" . $words[$i] . "%', 1, 0)";
                $relevance .= " + IF (`brand`.`name` LIKE '%" . $words[$i] . "%', 1, 0)";
            }
            $query = (new Query())
                ->select([
                    'id' => 'product.id',
                    'name' => 'product.name',
                    'price' => 'product.price',
                    'image' => 'product.image',
                    'hit' => 'product.hit',
                    'new' => 'product.new',
                    'sale' => 'product.sale',
                    'relevance' => $relevance
                ])
                ->from('product')
                ->join('INNER JOIN', 'category', 'category.id = product.category_id')
                ->join('INNER JOIN', 'brand', 'brand.id = product.brand_id')
                ->where(['like', 'product.name', $words[0]])
                ->orWhere(['like', 'product.keywords', $words[0]])
                ->orWhere(['like', 'category.name', $words[0]])
                ->orWhere(['like', 'brand.name', $words[0]]);
            for ($i = 1; $i < count($words); $i++) {
                $query = $query->orWhere(['like', 'product.name', $words[$i]]);
                $query = $query->orWhere(['like', 'product.keywords', $words[$i]]);
                $query = $query->orWhere(['like', 'category.name', $words[$i]]);
                $query = $query->orWhere(['like', 'brand.name', $words[$i]]);
            }
            $query = $query->orderBy(['relevance' => SORT_DESC]);
            
            // посмотрим, какой SQL-запрос был сформирован
            // print_r($query->createCommand()->getRawSql());

            // постраничная навигация
            $pages = new Pagination([
                'totalCount' => $query->count(),
                'pageSize' => Yii::$app->params['pageSize'],
                'forcePageParam' => false,
                'pageSizeParam' => false
            ]);
            $products = $query
                ->offset($pages->offset)
                ->limit($pages->limit)
                ->all();
            // сохраняем полученные данные в кеше
            $data = [$products, $pages];
            Yii::$app->cache->set($key, $data);
        }

        return $data;
    }
    /*.....*/
}

Для поискового запроса «зимняя обувь» будет сформирован SQL-запрос:

SELECT
    `product`.`id` AS `id`,
    `product`.`name` AS `name`,
    `product`.`price` AS `price`,
    `product`.`image` AS `image`,
    `product`.`hit` AS `hit`,
    `product`.`new` AS `new`,
    `product`.`sale` AS `sale`,
    IF (`product`.`name` LIKE '%зимн%', 3, 0) +
    IF (`product`.`keywords` LIKE '%зимн%', 2, 0) +
    IF (`category`.`name` LIKE '%зимн%', 1, 0) +
    IF (`brand`.`name` LIKE '%зимн%', 1, 0) +
    IF (`product`.`name` LIKE '%обув%', 3, 0) +
    IF (`product`.`keywords` LIKE '%обув%', 2, 0) +
    IF (`category`.`name` LIKE '%обув%', 1, 0) +
    IF (`brand`.`name` LIKE '%обув%', 1, 0) AS `relevance`
FROM 
    `product`
    INNER JOIN `category` ON `category`.`id` = `product`.`category_id`
    INNER JOIN `brand` ON `brand`.`id` = `product`.`brand_id`
WHERE
    `product`.`name` LIKE '%зимн%' OR
    `product`.`keywords` LIKE '%зимн%' OR
    `category`.`name` LIKE '%зимн%' OR
    `brand`.`name` LIKE '%зимн%' OR
    `product`.`name` LIKE '%обув%' OR
    `product`.`keywords` LIKE '%обув%' OR
    `category`.`name` LIKE '%обув%' OR
    `brand`.`name` LIKE '%обув%'
ORDER BY
    `relevance` DESC

Поиск: Web-разработка • Yii2 • Запрос • Интернет магазин • Каталог товаров • Поиск • Практика • Фреймворк

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.