Доработка поиска по заголовкам
В Битриксе поиск делится на поиск по заголовкам
и общий поиск
, именно поиск по заголовкам находится на передовой. С задачей поиск справляется без проблем, а вот усложнение условий например чтобы поиск по заголовкам искал также по артикулу товара, уже требует привлечение программиста.
Как ищет поиск
Чтобы понять как мы могли бы рассказать поиску о том, какие слова использовать в качестве дополнительных меток, зайдём с обратной стороны и поймём как поиск подбирает варианты в ответ на поисковый запрос.
Создадим элемент с заголовком:
Смартфон Samsung Galaxy S10 SM-G973 DS 128GB Green (SM-G973FZGD), акция, купить, скидка, скидкой, Самсунг, зелёный, зеленый, AMOLED, DYNAMIC AMOLED, амолед, Gorilla Glass, горилла горила гласс
Переиндексируем поиск и заглянем в таблицу b_search_content_title
:
Как видим, система разделила наш заголовок на составные части. И все они ведут на единую запись в таблице b_search_content
:
Каждый раз, когда мы решаем воспользоваться поиском по заголовку, всё сводится к запросу вида:
Поисковый запрос раскладывается на слова и ищутся вхождения в таблице b_search_content_title
. По результатам определяются строки из b_search_content
, которые и возвращаются для отображения на странице.
Целевая запись одна, меток к ней — сколько угодно. То есть, в контексте задачи, если мы в таблице b_search_content_title
обеспечим наличие меток, связанных с целевой записью, поиск сможет искать по ним.
Дополняем индексируемый заголовок метками
В процедуре индексации есть событие: 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)
);
}
}
}
Таким образом:
- Не модифицируем оригинальный заголовок, в поисковую выдачу заголовок попадает в том виде, в каком он хранится в инфлоблоке
- Можем дополнять связанные с целевой записью любым количеством меток
Sphinx
Решение описанное выше, не достаточно для тех сайтов, в которых поиск работает через сторонний сервис (как правило это сфинкс/sphinxsearch
, так называемый механизм полно-текстового поиска.
Если у нас подключен сфинкс, поиск происходит следующим образом:
- Поисковый запрос приходит в метод
Search
классаCAllSearchTitle
- Если подключен полно-текстовый поиск, запрос отдаётся ему
- Сфинкс получив запрос, ищет в своей внутренней базе и в случае успеха, возвращает
список ID (!)
записей таблицыb_search_content
- Метод получив набор кодов записей, читает их из таблицы
b_search_content
и возвращает ответ
Наша задача, найти способ во время индексации отдать сфинксу на индексацию заголовок с дополненными метками. И вот как раз тут нам не страшно отдать на индексацию заголовок с хвостом, потому что результат всё-равно возьмётся из b_search_content
, там у нас чистый заголовок.
Анализ кода в методе индексации показал, что сначала выполняется индексация сфинксом, а затем срабатывает событие 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);
}