Польза
Тегирование поисковой выдачи

Расширение проиндексированных заголовков индивидуальными поисковыми метками

31 ноября 2019 года
Битрикс, поиск, индексация, метки.

Поиск — один из важнейших сервисов любого сайта. Именно потому к поиску и его возможностям обычно много внимания. И чем вариативнее и «смышлённее» поиск, тем он полезнее.

В Битриксе поиск «среди контента» делится на поиск по заголовкам и общий поиск, при чём именно поиск по заголовкам находится на передовой. С задачей «искать в лоб» поиск справляется без проблем (разве что могут возникнуть вопросы к производительности хостинга или объёму данных), а вот усложнение условий, например чтобы поиск по заголовкам искал также по артикулу товара, бренду или, ещё сложнее, — по лингвистическим «синонимам» бренда (Самсунг = Samsung) — уже требует привлечение программиста. «Малой кровью» можно выкрутиться добавлением в заголовок товара всего, по чему должен быть произведён поиск (...и артикул ...и размер ...и бренды), но порой просто невозможно добавить всё, что только может понадобиться для поиска. Снижается привлекательность названий, страница начинает походить на поделку спаммеров и чёрных сеошников:

...

Смартфон Samsung Galaxy S10 SM-G973 DS 128GB Green (SM-G973FZGD), акция, купить, скидка, скидкой, Самсунг, зелёный, зеленый, AMOLED, DYNAMIC AMOLED, амолед, Gorilla Glass, горилла горила гласс...

...

Данная статья предназначена программистам, в ней обсуждается способ обучения Битрикса искать по любой релевантной лингвистике, без искажения основного заголовка. В качестве целевой задачи-примера используем как раз поиск по синонимам бренда.

Задача

Научить «поиск по заголовкам» искать по дополнительным лингвистическим меткам.

Как поиск ищет

Чтобы понять, как мы могли бы «рассказать» поиску о том, какие слова использовать в качестве дополнительных меток (я не хочу создавать путаницу и использовать термин «теги», потому что такой термин уже существует в поисковой индексации Битрикс), зайдём с обратной стороны — поймём как поиск подбирает варианты в ответ на поисковый запрос. 

Создадим элемент с заголовком, представленным выше. Переиндексируем поиск и заглянем в таблицу b_search_content_title:

Битрикс. Результаты индексации заголовка, таблица b_search_content_title

Как видим, система разделила наш заголовок на составные части. И все они ведут на единую запись в таблице b_search_content:

Битрикс. Привязка индексированных меток к записи в b_search_content_title

Каждый раз, когда мы решаем воспользоваться поиском по заголовку (компонент bitrix:search.title), всё сводится к запросу вида:

Битрикс. Mysql-запрос в процессе поиска по заголовкам

То есть поисковый запрос раскладывается на слова и ищутся вхождения в таблице b_search_content_title. По результатам определяются строки из b_search_content, которые и возвращаются для отображения на странице.

Имеем: целевая запись одна, меток к ней — сколько угодно. То есть, в контексте задачи, если мы в таблице b_search_content_title обеспечим наличие меток, связанных с целевой записью, поиск сможет искать по ним.

Способ, который нам не подходит

Обычно, когда встаёт задача расширить возможности поиска (например, чтобы поиск по заголовкам искал и по бренду/артикулу/цвету/...), задачу решают в лоб — «на лету» дополняют заголовок, который готовится к индексации, нужными словами. 

Для полноты картины, вкратце действия выглядят так:

  • подписываемся на событие search/BeforeIndex. В функцию-слушатель события в качестве аргумента приходит массив $arFields, приготовленный к индексации;
  • по значению в ключе MODULE_ID определяем, какой модуль сейчас индексируется (в контексте обсуждаемой подзадачи, нас интересует модуль iblock);
  • по значению в ключе ITEM_ID определяем код индексируемого элемента, читаем нужное свойство (бренд/цвет/...);
  • дописываем в ключ TITLE хвост из дополнительных меток;
  • возвращаем $arFields в качестве результата поиска.

Заголовок уходит в механизм индексации, там формируется нужный набор слов, но и заголовок в поисковую выдачу уходит «с хвостом». Именно наличие этого хвоста нас не устраивает, потому мы пойдём другим путём.

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

  • когда мы склеиваем заголовок и хвост, размещаем между ними какой-то уникальный набор символов, например: «_/=\_». То есть на индексацию уходит: Заголовок _/=\_ хвост;
  • перед публикацией результатов поиска на экран мы заголовки разрезаем по этому специальному набору символов и таким образом обеспечиваем и поиск по заголовку + хвосту, и публикацию заголовков в чистом виде.

Дополняем индексируемый заголовок метками

В процедуре индексации есть ещё одно событие: search/OnAfterIndexAdd.

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

То есть если заголовок товара звучит так: Смартфон Samsung Galaxy S10 SM-G973 DS 128GB Green (SM-G973FZGD) ...нам и не нужно его менять. Нам достаточно иметь возможность дополнить набор слов, которые попали в b_search_content_title ещё и своими, связав с записью в b_search_content

Событие OnAfterIndexAdd через пробрасываемые подписчику переменные даёт нам всё, что нужно: всё тот же массив $arFields и код ($ID) записи из b_search_content. Наша задача сводится к тому, чтобы прочитать сформировать метки, которыми мы дополним выдачу и доиндексировать их, с привязкой к целевой записи.

Наши действия:

  • подписываемся на событие search/OnAfterIndexAdd;
  • в функции-слушателе проверяем ключи $arFields (нужный модуль? нужный инфоблок? ...другие условия);
  • составляем строку из дополнительных меток;
  • до-индексируем составленный «хвост», с привязкой к целевой записи.

Подписываемся на событие search/OnAfterIndexAdd

Как правило, кастомное подписывание производят в файле
/bitrix/php_interface/init.php

В коде-примере ниже слушателем события является метод OnAfterIndexAdd класса \AlexeyGfi\SearchTitleExtender:

// Подписываемся на событие
\Bitrix\Main\EventManager::getInstance()->addEventHandler(
    'search', 'OnAfterIndexAdd',
    ['\AlexeyGfi\SearchTitleExtender', 'OnAfterIndexAdd']
);

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

// Регистрируем класс в автоподгрузчике Битрикса
\Bitrix\Main\Loader::registerAutoLoadClasses(
    null,
    [
        '\AlexeyGfi\SearchTitleExtender' =>
            '/bitrix/php_interface/include/lib/AlexeyGfi/SearchTitleExtender.php'
    ]
);

Слушаем событие и обрабатываем его

namespace AlexeyGfi;

use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Loader;

class SearchTitleExtender
{

    // Какие инфоблоки мы отслеживаем
    protected static $targetIblocks = [19, 22];

    /**
     * @param $searchContentId
     * @param $arFields
     */
    public static function OnAfterIndexAdd($searchContentId, &$arFields)
    {
        /**
         * Модуль: $arFields['MODULE_ID']
         * Код элемента: $arFields['ITEM_ID']
         * Код инфоблока: $arFields['PARAM2']
         */
        if (
            $arFields['MODULE_ID'] !== 'iblock' ||
            !$arFields['ITEM_ID'] ||
            !in_array($arFields['PARAM2'], self::$targetIblocks)
        ) {
            return;
        }

        /**
         * Метки, которые мы до-привяжем к тем,
         * что были определены из заголовка
         * и уже проиндексированы
         */
        $additionalWords = []; // Реализуем под задачу

        if (!empty($additionalWords)) {
            \CSearch::IndexTitle(
                $arFields["SITE_ID"],
                $searchContentId,
                implode(' ', $additionalWords)
            );
        }
    }
}

Таким образом мы:

  • не модифицируем оригинальный заголовок (в поисковую выдачу заголовок попадает в том виде, в каком он хранится в инфлоблоке);
  • можем дополнять связанные с целевой записью любым количеством меток.

Столовая ложка дёгтя

Решение, описанное выше, не достаточно для тех битрикс-сайтов, в которых поиск работает через сторонний сервис (как правило — сфинкс/sphinxsearch), так называемый механизм полно-текстового поиска.

Если у нас подключен сфинкс, поиск происходит следующим образом:

  • поисковый запрос приходит в метод Search класса CAllSearchTitle;
  • если подключен полно-текстовый поиск, запрос отдаётся ему;
  • сфинкс, получив запрос, ищет в своей внутренней базе и в случае успеха, возвращает список ID (!) записей таблицы b_search_content;
  • метод, получив набор кодов записей, читает их из таблицы b_search_content и возвращает ответ.

Соответственно, наша задача — найти способ во время индексации отдать сфинксу на индексацию заголовок с дополненными метками. И вот как раз тут нам не страшно отдать на индексацию заголовок с хвостом, потому что результат всё-равно возьмётся из b_search_content, а там у нас чистый заголовок.

Анализ кода в методе индексации
Битрикс. Sphinx-индексация заголовка
...показал, что сначала выполняется индексация сфинксом, а затем срабатывает событие search/OnAfterIndexAdd, на которое мы уже подписаны в init.php.

Таким образом, нам доступен финт, при котором мы можем дополнить заголовок своими метками и отдать его на повторную индексацию сфинксом. Да, двойная работа для сфинкса, но во-первых, это производится только при переиндексации, а во-вторых... сфинкс как раз и создан для индексации и поиска ¯\_(ツ)_/¯

Дополняем код в блоке с условием наличия дополнительных меток и получаем:

    
// Было
if (!empty($additionalWords)) {
    \CSearch::IndexTitle(
        $arFields["SITE_ID"],
        $searchContentId,
        implode(' ', $additionalWords)
    );
}

// Стало
if (!empty($additionalWords)) {
    \CSearch::IndexTitle(
        $arFields["SITE_ID"],
        $searchContentId,
        implode(' ', $additionalWords)
    );

    $arFields["TITLE"] .= implode(' ', $additionalWords);
    \CSearchFullText::getInstance()->replace($searchContentId, $arFields);
}
    

Чайная ложка дёгтя

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

Алгоритм индексации в контексте этой ситуации построен следующим образом:

  • приходит запрос на переиндексацию;
  • код ищет, есть ли уже для текущей пары модуль + код элемента запись в таблице b_search_content;
  • если записи нет, запускается ветка создания поискового индекса (в которую мы уже интегрировались);
  • если же запись уже есть, берётся её $ID и php-интерпретатор идёт по альтернативной ветке. И ложка дёгтя в этой ветке в том, что сначала наружу выбрасывается событие search/OnBeforeIndexUpdate, а уже после выполняется обновление поискового индекса как в базе данных так и в сфинксе:
    Битрикс. Обновление поискового индекса
    ...то есть мы по-прежнему можем добавить в базу данных Битрикс свои метки, но не можем перезаписать поисковый индекс в сфинксе.

Ухватываемся за условие «если же запись уже есть», вспоминаем про то, что на входе в метод значится событие search/BeforeIndex и решаем реагировать на это событие, чтобы:

  • определить, какая запись в b_search_content связана с индексируемым элементом;
  • удалять все метки из b_search_content_title, залинкованные на эту запись;
  • удалять из сфинкса данные, залинкованные на эту же запись;
  • удалить запись из b_search_content.

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

Подписываемся на событие search/BeforeIndex

\Bitrix\Main\EventManager::getInstance()->addEventHandler(
    'search', 'BeforeIndex',
    ['\AlexeyGfi\SearchTitleExtender', 'BeforeIndex']
);

Слушаем событие и обрабатываем его

/**
 * @param array $arFields
 */
public static function BeforeIndex($arFields = [])
{
    /**
     * Модуль: $arFields['MODULE_ID']
     * Код элемента: $arFields['ITEM_ID']
     * Код инфоблока: $arFields['PARAM2']
     */
    if (
        $arFields['MODULE_ID'] !== 'iblock' ||
        !$arFields['ITEM_ID']
    ) {
        return;
    }

    global $DB;
    $DB->StartTransaction();

    /**
     * Нам для чистки в сфинксе понадобится,
     * потому сначала читаем а потом удаляем
     */
    $result = $DB->Query(
        sprintf(
            'SELECT ID
                    FROM b_search_content
                    WHERE ITEM_ID="%s"',
            $arFields['ITEM_ID']
        )
    );

    $arr = $result->Fetch();
    if (empty($arr)) {
        return;
    }

    /**
     * Чистим сразу в обоих таблицах
     */
    $DB->Query(
        sprintf(
            '
            DELETE b_search_content, b_search_content_title
            FROM b_search_content
                INNER JOIN b_search_content_title
                    ON b_search_content_title.SEARCH_CONTENT_ID = b_search_content.ID
            WHERE
                b_search_content.ITEM_ID = "%s";
            ',
            $arFields['ITEM_ID']
        )
    );

    $DB->Commit();

    /**
     * Чистим и в сфинксе.
     * (!) Класс работы со сфинксом не имеет метода очистки,
     * только замены (replace)
     *
     * ...потому мы на целевую запись скармливаем ему
     * пустые поля
     */
    $emptyKeys = ['URL', 'TITLE', 'BODY'];
    array_walk($arFields, static function(&$item, $key) use ($emptyKeys) {
        if (in_array($key, $emptyKeys)) {
            $item = '';
        }
    });

    \CSearchFullText::getInstance()->replace($arr['ID'], $arFields);
}

Обсуждение статьи 3

Написать
Юрий
В mysql запросах лучше записать вместо
WHERE ITEM_ID =%s',
$arFields['ITEM_ID']
Вот так:
WHERE ITEM_ID = "%s"',
$arFields['ITEM_ID']
И во втором слчае тоже
WHERE
b_search_content.ITEM_ID = "%s";
',
$arFields['ITEM_ID']

Потому что в таблице, если это не курс значение может быть не integer, а строка,
например если это раздел его ITEM_ID будет равен S3 и mysql выбросит ошибку
Ответить
Юрий
Курс, ага, спалился, элемент инфоблока
Ответить
Битриксоид из Колхоза Битриксоид из Колхоза
Юрий, спасибо за уточнение!
Поправил.
Ответить
Написать