Блоги Нечёткий поиск и поисковые подсказки

Привет! Сегодня мы с вами доработаем поиск на одном конкретном сайте. Вот он knigi-pticy.ru. Это сайт, где одни люди размещают свои книги на продажу, а другие их покупают. Сайт сделан на DIAFAN.CMS. Книги — это товары магазина, предложения книги от продавцов — это цены товара — таблица {shop_price}. Конечно, это очень переработанный модуль магазина. Но поиск из коробки вполне вписывается.

На данный момент поиск никак не кастомизирован. Давайте посмотрим что он нам найдет по названию книги «Вино из одуванчиков». Так. Сама книга есть, но есть и мусор.

Сначала нам нужно проанализировать сам сайт. Для этого увеличиваем историю поиска до 100 запросов. Нужно последить несколько дней, чтобы набралась статистика. Это даст возможность понять что ищут пользователи и под эти запросы давать ответ.

Я вижу, что посетители достаточно активно используют поиск и ищут по названию, автору и издательству. На сайте сейчас 800 книг на продаже и еще больше предложений. Это не очень много, но каталогизация уже не справляется. Не удивительно, что пользователи идут в поиск. Вхождение полное или почти полное. Значит мы можем в настройках поиска указать «Искать все слова сразу» и убрать «Искать часть слова».

Теперь идем в файл конфигурации поиска магазина modules/shop/shop.search.php и указываем что нам индексировать. Мы оставим только товары и производителей — на сайте это таблица издательств. В товарах уберем описание и характеристики, так как по ним никто не ищет. Оставим название и автора — это кастомное поле. Для издательств оставляем название и добавим в базу данных поле for_search. Потом объясню для чего.

class Shop_search_config
{
    public $config = array(
        'shop' => array(
            'fields' => array('name', 'author'),
            'rating' => 6
        ),
        'shop_brand' => array(
            'fields' => array('name', 'for_search'),
            'rating' => 6
        )
    );
}

Теперь идем в конфигурационные файлы поиска для других модулей и убираем у них индексацию.

class News_search_config
{
    public $config = array();
}

На нашем сайте я просто удалила эти файлы, так как сайт мы не планируем обновлять. Они не будут восстановлены при генерировании темы.

Теперь я готова переиндексировать сайт. Что мы видим. Уже лучше. Запрос по названию товара дает релевантный ответ. Запрос по автору тоже ищет.

Далее что я сделаю. Применю красивое оформление для книг. Для этого есть отдельный шаблон modules/shop/views/shop.view.list_search.php. Добавлю туда верстку книг, что используется на сайте.

Вот так уже лучше. Итак, поиск ищет по названию книги, по автору. И посмотрим что с издательством. Ищет, но не красиво. Я придумала так. Если найдено издательство, то выводим еще и несколько товаров этого издательства. Для этого идем в шаблон поиска modules/search/views/search.view.rows.php.

Смотрите вот этот код выводит товары. Здесь условие — задан class, то есть свой шаблон оформления.

if (! empty($res["class"]))
{
    if(! empty($result["ajax"]))
    {
        $res["ajax"] = $result["ajax"];
    }
    echo $this->get($res["func"], $res["class"], $res);
}

Издательства не имеют свой шаблон. Они выводятся этим кодом.

else
{
    echo '<div class="search_list">';
    foreach ($res["rows"] as $row)
    {
        echo '
        <div class="search_name"><a href="'.https://user.diafan.ru/_HREF.$row["link"].'">'.$row["name"].'</a></div>
        <div class="search_text">'.$row["snippet"].'</div>';
    }
    echo '</div>';
}

Так как у нас всего две сущности индексируются, то я могу смело связать первую часть кода с книгами, а вторую с издательствами. Я добавлю заголовок «Издательства», добавлю шаблонный тег, который выведет товары издательства и добавлю ссылку на все книги.

echo '<h2 style="clear: both">Издательства</h2>';
echo '<div class="search_list">';
foreach ($res["rows"] as $row)
{
    echo '
    <p><a href="'.BASE_PATH_HREF.$row["link"].'">'.$row["name"].'</a></p>';
}
echo '</div>';
echo $this->htmleditor('<insert name="show_block" module="shop" brand_id="'.$row["id"].'" count="8" template="hit" images="1" images_variant="medium">');
echo '<p><a href="'.BASE_PATH_HREF.$row["link"].'">Все книги издательства '.$row["name"].'</a></p>';

Смотрим. Ох ты! Здорово!

Друзья, кто из вас читает книги по саморазвитию? Или вообще любит качественные книги? Если «Манн, Иванов и Фербер» вызывает ассоциации в голове, значит вы знаете, что это издательство еще называют «МИФ» и ищут его по этому слову. Но сейчас запрос «Миф» нам даст только книги про Древнюю Грецию и мифы воспитания.

Вот для этого я создала поле for_search. Помните мы добавили его для индексации. Я вывела его в интерфейс редактирования издательств.

// modules/shop/admin/shop.admin.brand.php
public $variables = array (
    'main' => array (
    //...
    'for_search' => array(
        'type' => 'textarea',
        'name' => 'Для поиска на сайте',
        'help' => 'Сюда добавлять поисковые запросы, которые выдают пустые результаты, но должны относиться к этой книге.',
    ),

Задаем здесь «МИФ» и что же. Поиск ищет издательство.

Не вижу смысла делать его для книг, так как книги обычно ищут по полному имени. Однако. Пользователи могу ошибиться в названии и не получить результат.

Давайте сознательно сделаем опечатку. Что же. Будем внедрять нечёткий поиск.

В PHP есть две функции для нечёткого поиска. levenshtein — вычисляет расстояние Левенштейна между двумя строками. Расстояние Левенштейна — это минимальное количество операций вставки одного символа на другой, необходимых для превращения одной строки в другую. И similar_text — степень похожести по алгоритму Оливера.

В документации PHP сказано, что первая функция оптимальнее. Давайте ею воспользуемся.

Итак. Нам поступил запрос. И, если мы ничего не находим, то должны сравнить его со всеми успешными запросами по алгоритму Левенштейна. Значит, нам нужна база успешных запросов. Я создала таблицу {search_success}, где будут записаны успешные запросы.

CREATE TABLE `diafan_search_success` (
  `id` int(11) UNSIGNED NOT NULL COMMENT 'идентификатор',
  `name` text COMMENT 'поисковый запрос',
  `translit` text NOT NULL
)

Я пишу так. Если поиск дал результат, то запрос добавляем в базу данных, если его еще там нет. Я сразу записываю транслитерацию запроса, так как с кириллицей функции Левенштейна будет работать сложнее.

//modules/search/search.model.php

$nen = DB::query_result("SELECT COUNT(DISTINCT r.id) FROM {search_results} AS r "
//...
_LANG);
if($nen)
{
    if(! DB::query_result("SELECT id FROM {search_success} WHERE name='%h'", $search))
    {
        DB::query("INSERT INTO {search_success} (name, translit) VALUES ('%h', '%h')", $search, $this->diafan->translit($search));
    }
}

Запросы сравниваем с транслитерацей нашего запроса по алгоритму Левенштейна. Находим минимальное количество замен, но не больше половины самого запроса. И ищем снова.

if($nen)
{
    //...
}
else
{
    $new_s = '';
    $s = strtolower($this->diafan->translit($search));
    $min = strlen($s) / 2;
    $all = DB::query_fetch_all("SELECT * FROM {search_success}");
    foreach($all as $a)
    {
        $min_c = levenshtein($s, $a["translit"]);
        if($min_c < $min)
        {
            $min = $min_c;
            $new_s = $a["name"];
        }
    }
    if($new_s)
    {
        $search = $new_s;
        // поиск
    }
}

Я вынесла поиск в функцию, чтобы два раза не перепечатывать код.

if (! empty($search))
{
    $this->s($search, $nen, $search_words, $temp_table, $where, $order);
    if($nen)
    {
if($new_s)
{
    $search = $new_s;
    $this->s($search, $nen, $search_words, $temp_table, $where, $order);
}
private function s(&$search, &$nen, &$search_words, &$temp_table, &$where, &$order)
{
    Custom::inc('includes/searchwords.php');
    $searchwords = new Searchwords();
    //...

        $nen = DB::query_result("SELECT COUNT(DISTINCT r.id) FROM {search_results} AS r "
        //...
        _LANG);
    }
}

Проверим. Введем запрос «Вино из одуванчиков». Сейчас запрос добавлен в базу успешных запросов. А теперь с ошибкой. Вау!

Ну-ка. А такой эксперимент. «Ходит» - вот книга, где есть это слово. «Хоббит». И введем «Хобит» с одной Б. Хм. Вывелось «Ходит». Как же так. Оказалось, что количество преобразований и в том и в другом случае равно. Поэтому функция Левенштейна даёт равную цифру.

Проверка показала, что similar_text даёт результат точнее. Но она тяжелее. Тогда давайте ее выключать только в том случае, если найдено два схожих запроса с одинаковым индексом Левенштейна.

$s = strtolower($this->diafan->translit($search));
$min = strlen($s) / 2;
$new_s = '';
$similar = 0;
$all = DB::query_fetch_all("SELECT * FROM {search_success}");
foreach($all as $a)
{
    $min_c = levenshtein($s, $a["translit"]);
    if($min_c < $min)
    {
        $new_s = $a["name"];
        $min = $min_c;
        $similar = similar_text($s, $a["translit"]);
    }
    if($min_c == $min)
    {
        $similar_c = similar_text($s, $a["translit"]);
        if($similar < $similar_c)
        {
            $new_s = $a["name"];
            $min = $min_c;
            $similar = $similar_c;
        }
    }
}
if($new_s)
{
    $search = $new_s;
    $this->s($search, $nen, $search_words, $temp_table, $where, $order);
}

Меня беспокоит то, что алгоритм этот совсем не оптимален. Сама функция Левенштейна очень затратная. Я бы согласилась вызвать ее раз 5 не больше. Давайте ограничим запрос на поиск успешных запросов. Ведь у нас уже есть наша поисковая фраза без окончаний, обработанная по алгоритму Стеммера Портера. Это функционал CMS. Мы можем найти хотя бы одно слово. Ну вот уже лучше.

$w = array();
foreach($search_words as $sw)
{
    $w[] = " name LIKE '".$sw."%%'";
}
$all = DB::query_fetch_all("SELECT * FROM {search_success} WHERE ".implode(" OR ", $w));

Есть проблема. Односложные запросы. Например, есть книга «Шантарам». Вот я ввела и она появилась в базе успешных запросов. Теперь я делаю ошибку. Чёрт, ничего не выходит.

Дело в том, что даже без окончания слово не удовлетворяет запрос на вхождение. Я пошла в базу и обнаружила, что книги очень редко называют одним словом. Например, односложное название на Ш всего одно. Ох-хо-хо! Вот и ключ! Если запрос состоит из одного слова, ищем по первой букве, без вхождения пробела. Вот и наш «Шантрам» найден.

foreach($search_words as $sw)
{
    if(count($search_words) == 1)
    {
        $sw = utf::substr($sw, 0, 1);
        $w[] = " name LIKE '".$sw."%%' AND name NOT LIKE '%% %%'";
    }
    else
    {
        $w[] = " name LIKE '".$sw."%%'";
    }
}

Очередная проблема решена. Да! Мне вспоминается фильм «Марсианин». Так шаг за шагом мы вернемся на свою планету.

А ну-ка попробую ошибиться в имени автора «Свен Нурквист».

Ничего не найдено. Это понятно. Ведь нет успешного запроса в базе. Что же делать? Вообще то мы уже сейчас знаем все успешные запросы — это названия книг, названия издательств и имена авторов. Можно просто записать их в таблицу успешных запросов и будет нам счастье.

Но хотелось бы, чтобы это происходило автоматом при добавлении нового товара как индексация для поиска. Вот и добавим это в индексацию.

Итак. Будем чистить эту таблицу при полной индексации и просто запишем в таблицу все названия.

//modules/search/search.inc.php
public function index_all()
{
    //...
    DB::query("TRUNCATE TABLE {search_keywords}");
    DB::query("TRUNCATE TABLE {search_success}");

    $rows = DB::query_fetch_all("SELECT [name], author FROM {shop} WHERE [act]='1' AND trash='0'");
    $ns = array();
    foreach($rows as $row)
    {
        if(! in_array($row["name"], $ns))
        {
            $ns[] = $row["name"];
            $n = strip_tags(html_entity_decode($row["name"]));
            DB::query("INSERT INTO {search_success} (name, translit) VALUES ('%h', '%h')", $n, strtolower($this->diafan->translit($n)));
        }
        if(! in_array($row["author"], $ns))
        {
            $ns[] = $row["author"];
            $n = strip_tags(html_entity_decode($row["author"]));
            DB::query("INSERT INTO {search_success} (name, translit) VALUES ('%h', '%h')", $n, strtolower($this->diafan->translit($n)));
        }
    }

    $rows = DB::query_fetch_all("SELECT [name] FROM {shop_brand} WHERE [act]='1' AND trash='0'");
    $ns = array();
    foreach($rows as $row)
    {
        if(! in_array($row["name"], $ns))
        {
            $ns[] = $row["name"];
            DB::query("INSERT INTO {search_success} (name, translit) VALUES ('%h', '%h')", $row["name"], strtolower($this->diafan->translit($row["name"])));
        }
    }

А если индексация отдельного товара или нескольких товаров или издательств, то напишем добавление запроса в функцию index_item(). Условие — пустой site_id — дает нам возможность выполнить код только при редактировании отдельной книги или издательства.

//modules/search/search.inc.php
private function index_item($row, $config, $table_name, $site_id = 0)
{
    //...
    if(empty($site_id) && ($table_name == 'shop' || $table_name == 'shop_brand'))
    {
        if(isset($row["name"._LANG]))
        {
            $name = $row["name"._LANG];
        }
        else
        {
            $name = $row["name"];
        }
        if(! empty($name))
        {
            $name = strip_tags(html_entity_decode($name));
            if(! DB::query_result("SELECT * FROM {search_success} WHERE name='%h'", $name))
            {
                DB::query("INSERT INTO {search_success} (name, translit) VALUES ('%h', '%h')", $name, strtolower($this->diafan->translit($name)));
            }
        }
        if(! empty($row["author"]))
        {
            $row["author"] = strip_tags(html_entity_decode($row["author"]));
            if(! DB::query_result("SELECT * FROM {search_success} WHERE name='%h'", $row["author"]))
            {
                DB::query("INSERT INTO {search_success} (name, translit) VALUES ('%h', '%h')", $row["author"], strtolower($this->diafan->translit($row["author"])));
            }
        }
    }

Готово. Переиндексируем.

Вот наш Свен Нурквист, который на самом деле Свен Нурдквист.

Ну что же. Все эти кривые запросы все таки остаются затратными. А давайте сделаем так, чтобы пользователь вообще не ошибался. Ведь у нас теперь есть таблица запросов. Я создают JS-файл к шаблону.

На событие ввода символа в строке поиска опишу функцию, которая будет отправлять Ajax-запрос к модулю и выводить возможные поисковые фразы.

$(document).click(function(event){
    if($(event.target).closest(".js_search_result").length)
    {
        return true;
    }
    $(".js_search_result").fadeOut("slow");
});
$(document).on('click', '.js_search_result p', function(){
    var self = $(this);
    $(".input_search").val(self.text());
    $('.search_form').submit();
});

$(".input_search").keyup(function(){
    var self = $(this);
    if($(this).val())
    {
        $.ajax({
            'type':'POST',
            'data':{
                'module':'search',
                'action':'words',
                'searchword' : self.val()
            },
            'dataType': 'JSON',
            success: function(resp){
                self.parents('form').next('.js_search_result').remove();
                if (resp.data){
                    self.parents('form').after('<div class="js_search_result search_result"></div>');
                    $('.js_search_result').html(prepare(resp.data));
                }
            }
        });
    }
    else
    {
        self.parents('form').next('.js_search_result').remove();
    }
}.debounce(200));

Я не буду подробно останавливаться на этом коде. Если вы не понимаете что тут написано, добро пожаловать на мой обучающий курс. 5 и 6 урок вам все разъяснят. Они в бесплатном доступе.

Обратите внимание на функцию debounce. Это функция задержки обработчика. Она описана в DIAFAN.CMS в файле site.js. Очень удобно её применять на событие keyup, чтобы не было наслоения результатов.

Вот как теперь выглядит обработчик.

//modules/search/search.php
public function action()
{
    if(! empty($_POST["action"]))
    {
        switch($_POST["action"])
        {
            case 'words':
                return $this->action->words();

            case 'search':
                $this->action->search();
                break;
        }
    }
}

Функция words() подключается на переменную $_POST[action] = words. Она отдает 3 поисковых запроса, начинающихся с введенной части запроса.

//modules/search/search.action.php
public function words()
{
    $_REQUEST["searchword"] = (! empty($_POST["searchword"]) ? $_POST["searchword"] : '');
    if($_REQUEST["searchword"])
    {
        $result["rows"] = DB::query_fetch_all("SELECT * FROM {search_success} WHERE name LIKE '%s%%' LIMIT 3", $_REQUEST["searchword"]);
        $this->result["data"] = $this->diafan->_tpl->get('words', 'search', $result);
    }
    $this->result["result"] = 'success';
}

Если мы нажимаем на строку с результатом, то подставляем запрос и сабмитим форму поиска.

Итак, что мы видим.

«Свен...». О! Мне даже не пришлось вспоминать эту витиеватую финскую фамилию.

Ну вот теперь все прекрасно.

Меня смущает одна деталь. В истории поиска был запрос «Екатеринбург». Это явно не издательство и не книга. Кто-то хотел вывести все предложения книг, доступные в его городе. Но как это проиндексировать. У нас даже страницы нет, где была бы такая выборка.

Город есть в таблицу {shop_price}. В целом я могу придумать запрос, который будет отдавать нужный мне результат. Но это не вписывается в логику модуля поиска. С другой стороны пользователю все равно какая у нас тут логика. Он ввел «Екатеринбург» и если увидел книгу из своего города, то стал нашим клиентом.

Итак. Я добавила следующее. Если поисковый запрос состоит из одного слова, то ищем хотя бы одно предложение из предполагаемого города. Находим, значит это точно город. Тогда количество найденных товаров считаем запросом. Разбор запроса мы не производим. Пагинацию оставляем. И дальше запрос книг опять нашим SQL-запросом. Результаты форматируем под наш шаблон. Иначе наш обычный алгоритм.

//modules/search/search.model.php
if (! empty($search))
{
    if(! strpos(' ', $search))
    {
        if(DB::query_result("SELECT id FROM {shop_price} WHERE location='%s' AND trash='0' AND act='1' LIMIT 1", $search))
        {
            $nen = DB::query_result("SELECT COUNT(*) FROM {shop_price} WHERE location='%s' AND trash='0' AND act='1'", $search);
            $is_location = true;
        }
    }
    if(empty($is_location))
    {
        $nen = 0;
        $this->s($search, $nen, $search_words, $temp_table, $where, $order);

        //...
    }

    ////navigation//
    //...
    $this->result["paginator"] = $this->diafan->_tpl->get('get', 'paginator', $this->result["paginator"]);
    if(! empty($is_location))
    {
        Custom::inc('modules/shop/shop.model.php');
        $class = new Shop_model($this->diafan);
        $this->result["rows"]['shop']["view_rows"] = 'rows';
        $this->result["rows"]['shop']["class"] = 'shop';
        $this->result["rows"]['shop']["func"]  = 'list_search';
        $this->result["rows"]['shop']["rows"] = DB::query_range_fetch_all("SELECT s.id, s.[name], s.[anons], s.timeedit, s.site_id, s.brand_id, s.no_buy, s.article, s.[measure_unit], s.hit, s.new, s.action, s.is_file FROM {shop} AS s
        INNER JOIN {shop_price} AS p ON p.good_id=s.id
        WHERE p.location='%s' AND p.trash='0' AND p.act='1' AND s.trash='0' AND s.[act]='1' GROUP BY s.id ORDER BY s.sort DESC", $search, $this->diafan->_paginator->polog, $this->diafan->_paginator->nastr);

        $class->elements($this->result["rows"]['shop']["rows"], "list", "block");
        $this->result["rows"]['shop']["hide_compare"] = true;
        $count = count($this->result["rows"]['shop']["rows"]);
    }
    else
    {
        if(! empty($this->result["ajax"])){$this->diafan->_paginator->nastr = 6;}
        if($nen)
        {
            $rows_search = DB::query_range_fetch_all(
        //...
                    $this->result["rows"][$table_name] = $result;
                }
            }
        }
    }
}

$this->result["count"] = $this->diafan->_paginator->nen;
$this->result["count_start"] = $this->result["count"] ? ($this->diafan->_paginator->page - 1) * $this->diafan->_paginator->nastr + 1 : 0;

Да! Есть! Очень круто!

Вот и все. Пользуйтесь.

Комментарии

23 сентябряDmitry (weissfl): Делайте пожалуйста ссылки на сторонние сайта с target="_blank" )
23 сентябряDmitry (weissfl): А за материал спасибо. Очень полезно.
23 сентябряМарина Дорохина (summer): Dmitry, я как гугл, без таргетов. Не перестаю Вами удивляться, обязательно найдете замечания. Вам бы в тестеры.
23 сентябряDmitry (weissfl):
Цитата
Не перестаю Вами удивляться, обязательно найдете замечания.

Да просто читаешь ты текст, тыкаешь по ссылке вместо того чтобы продолжить читать, а открывается другой сайт на этом месте ))
23 сентябряЕвгений Михайлович (abaimov7): магазина modules/search/shop.search.php и указываем

Чего то там не найду файла
23 сентябряМарина Дорохина (summer): Евгений Михайлович, упс! Поправила на modules/shop/shop.search.php
25 сентябряАндрей (R4W): Поиск давно являлся "болью". Хорошо бы еще бэкенд для работы со sphinx реализовать