Pull to refresh

Пример Sphinx поиска на реальном проекте — магазин автозапчастей Tecdoc

Reading time9 min
Views97K
Вкратце: статья будет полезна тем программистам, кто уже заинтересовался релевантным поиском и прочитал статьи по стартовой установке сфинкс поиска, погонял на тестовых примерах и таких же синтетических задачах. Часто эти примеры не дают ответа на вопрос, а как же ощутить реальную пользу от поискового модуля Sphinx в сравнении с другими более простыми вариантами поиска. Примеры кода в статье — на php+smarty, Sphinx 2.0.1-beta, база данных — mysql, исходники и дамп структуры базы выложены отдельным архивом в подвале. В статье описан пример использования таких особенностей сфинкса, как:
  • Создание единого конфиг файла для windows development и linux production
  • SetMatchMode(SPH_MATCH_EXTENDED2) и почему SPH_MATCH_ANY и другие не подходят для реального поиска
  • SetSortMode(SPH_SORT_RELEVANCE), SetFieldWeights — сортировка по релевантности и установка весов для полей индекса
  • SetLimits(0,20) — ограничение вывода результатов
  • AddQuery, RunQueries — построение мультизапросов
  • SetFilter, ResetFilters — добавление фильтрации в мулльтизапросе для ограничения получаемых данных
  • Wordforms — использование синонимов и преодоление ограничений для нестандартных словоформ, как «C#»

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

1. Вступление


Если сфинкс еще не установлен и хотите начать, ссылка на статью для новичка: Создание ознакомительного поискового движка на Sphinx + php. Потестировать и посмотреть как работает этот поиск можно по адресу autoklad.biz/?action=search.

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

Авто запчастями, а точнее продажей интернет магазинов по запчастям, наша компания занимается давно, плодотворно и довольно успешно. Но в силу ряда причин релевантный поиск понадобился только сейчас. Основная причина скорее всего в том, что большинству запчастей полнотекстовый поиск не подходит. Но есть 5-10% товаров, для которых он катастрофически нужен и без него уж никак. А наш стандартный поиск с прямыми по своей сути кросс связями и указанием четкой модели и марки авто из tecdoc, для этой группы товаров не работает. Пример таких «неправильных» товаров: масла, шины, аккумуляторы и другие подобные.

Средний прайс по запчастям небольшой рядовой компании — 2-10 млн позиций, соответственно 10% от этой базы и будут занимать нужные нам данные. То есть индекс в приведенных ниже примерах строится по базе около 300 тысяч документов.

2. Создание единого конфиг файла для windows development и linux production ОС


Решаемая проблема — конфигурационные файлы машины разработчика и продакшин сервера отличаются, а при разработке нужно оперативно обновлять неустоявшуюся структуру и постоянно менять эти сфинкс конфиги. Усугубилось в нашем случае тем, что эти конфиги на сервере нужно делить с отдельным проектом другой команды разработки, а такой секции как «include *.conf» в сфинксе пока не предусмотрено.

На локальной windows машине конфиг лежит в «D:\Sphinx\sphinx.conf», на сервере в "/etc/sphinx/sphinx.conf", причем на линукс машине создана символическая ссылка на обновляемый скриптом Search->CreateConfigFile() файл в /var/www/autoklad.com.ua/imgbank/sphinx/sphinx.conf. Локальный файл обновляется прямо в папку, так как он не мешает соседям.

Исходный код методов:
public function CreateConfigFile()
{
	$sConfigFilePath=Db::GetConstant('sphinx:config_file_path',SERVER_PATH.'/imgbank/sphinx/');
	$sConfigFileName='sphinx.conf';
	$sConfigTemplate=Db::GetConstant('sphinx:config_template','production');

	if (!file_exists($sConfigFilePath)) mkdir($sConfigFilePath);

	$sTopSection.=$this->GetPriceGroupConfig();
	Base::$tpl->assign('sTopSection',$sTopSection);
	$sFileContent=Base::$tpl->fetch($this->sPrefix.'/config_sphinx_'.$sConfigTemplate.'.tpl');

	file_put_contents($sConfigFilePath.$sConfigFileName,$sFileContent);
}
private function GetPriceGroupConfig()
{
	Base::$tpl->assign('sDataFilePath',Base::GetConstant('sphinx:data_file_path','/var/data/'));

	return Base::$tpl->fetch($this->sPrefix.'/config_price_group.tpl');
}


Шаблон config_price_group.tpl, остальные — в архиве, чтобы не растягивать статью
source price_group
{ldelim}
	type = mysql

	sql_host = {$aDbConf.Host}
	sql_user = {$aDbConf.User}
	sql_pass = {$aDbConf.Password}
	sql_db = {$aDbConf.Database}
	sql_query_pre = SET NAMES utf8
	sql_query_pre = SET CHARACTER SET utf8

	sql_query = \
		select p.id \
		, p.code as code \
		, c.title as brand \
		, if(ifnull(cp.name_rus,'')<>'', cp.name_rus, ifnull(p.part_rus,'')) as part_name \
		, pgr.name as price_group_name \
		, p.id_price_group as id_price_group \
	 from price as p \
		 left join cat_part as cp on cp.item_code=p.item_code \
		 inner join cat as c on p.pref=c.pref \
		 inner join provider_virtual as pv on p.id_provider=pv.id_provider \
		 inner join user_provider as up on pv.id_provider_virtual=up.id_user \
		 inner join provider_group as pg on up.id_provider_group=pg.id \
		 inner join user as u on up.id_user=u.id and u.visible=1 \
		 inner join currency as cu on up.id_currency=cu.id \
		 inner join price_group as pgr on pgr.id=p.id_price_group \
		 where 1=1

	sql_attr_uint = id_price_group

	sql_query_info = SELECT * FROM price WHERE id=$id
{rdelim}


index price_group
{ldelim}
	source = price_group
	path = {$sDataFilePath}price_group/index
	morphology = stem_ru
	min_word_len = 3
	charset_type = utf-8

	min_infix_len = 3
	#min_prefix_len = 3
	enable_star = 1
{rdelim}


Запрос можно было бы упростить представлением (view), но насколько я понял этого делать не рекомендуют и прямой запрос к данным с джойнами эффективнее по соображениям создаваемой нагрузки при индексировании.

Значение констант, которые берутся из бд для локального сайта
sphinx:data_file_path	D:/Sphinx/data/	 
sphinx:config_template	local	 
sphinx:config_file_path	D:/Sphinx/


Значение констант, которые берутся из бд для продакшин сайта
sphinx:data_file_path	/var/data/
sphinx:config_template	production
sphinx:config_file_path	/var/www/autoklad.com.ua/imgbank/sphinx/


В результате работы для локального сфинкса мы имеем вот такой конфиг файл:
http://www.mstarproject.com/temp/3/sphinx/sphinx.conf

3. SetMatchMode(SPH_MATCH_EXTENDED2) и почему SPH_MATCH_ANY и другие не подходят для реального поиска


Для того, чтобы работала морфология и в запросе «масла Castrol 5W40» нашлись документы с текстом «Масло» и «15W40» — нужно одновременно использовать символ "*" и поиск по словоформе «масл», а для этого нужен построитель запросов, который работает именно в режиме «SPH_MATCH_EXTENDED2». Есть также SPH_MATCH_EXTENDED, но как я понял это старая версия и рекомендуют использовать новую версию режима.

В режиме SPH_MATCH_ANY нельзя добиться того, чтобы при увеличении слов в запросе — количество результатов уменьшалось. В режиме SPH_MATCH_ALL нельзя добиться одновременной работы режима по частичному вхождению и словоформе. Другие режимы вскользь просмотрел, пока не пригодились, поэтому ничего сказать по ним не могу.

Сам запрос к сфинксу по фразе «масла Castrol 5W40» будет выглядеть так:
(масло | *масло*) & (Castrol | *Castrol*) & (5W40 | *5W40*)


Важно: в конфиге используемого индекса должно быть 2 строки:
min_infix_len = 3
enable_star = 1

Первая позволяет искать по частичному вхождению слово справа и слева, то есть с конца и с начала слова. Вторая строка позволяет использовать в запросе "*". Можно использовать min_prefix_len, если нужно к примеру только вхождения слева (с начала) слова.

Функция, которая обрабатывает входящую строку и формирует правильный запрос:
private function GetSphinxKeyword($sQuery)
{
	$aRequestString=preg_split('/[\s,-]+/', $sQuery, 5);
	if ($aRequestString) {
		foreach ($aRequestString as $sValue)
		{
			if (strlen($sValue)>3)
			{
				$aKeyword[] .= "(".$sValue." | *".$sValue."*)";
			}
		}
		$sSphinxKeyword = implode(" & ", $aKeyword);
	}
	return $sSphinxKeyword;
}


Результат запроса можно протестировать по адресу: http://autoklad.biz/?action=search&search[query]=%D0%BC%D0%B0%D1%81%D0%BB%D0%BE%20Castrol%205W40&search[id_price_group]=35
Ниже результатов поиска выведен результирующий массив, который возвращает сфинкс для обработки — обратить внимание на секцию [words], в котором указано, по каким словам какое количество документов найдено. Другие секции не менее важны, но о них пока речь не идет.

Также очень частым на форуме и сайте разработчиков вопросом является «Как поднять повыше точное вхождение фразы?», то есть чтобы вес документа «Искомое_слово» был выше «Искомое_слово а также еще кучу текста». Ответ — нужно использовать SPH_RANK_SPH04, специально созданный под эту типовую задачу, как я понял.

4. SetSortMode(SPH_SORT_RELEVANCE), SetFieldWeights — сортировка по релевантности и установка весов для полей индекса


Данный метод определяет, какие результаты будут выше в отсортированном массиве данных, возвращаемых сфинксом. В случае с SPH_SORT_RELEVANCE — результат будет отсортирован по т.н. «релевантности». Релевантность, как бы нам того не хотелось, работает по чисто арифметическим правилам, а не так как у гугл или яндекс поиска. То есть никакой магии: перемножение и сложение веса индекса, веса поля, количества вхождения искомого слова в документ и частота этого слова в других документах.

Мы на вход задаем в самом простом случае веса для полей индекса:
$oSphinxClient->SetFieldWeights(array (
		'code' => 50,
		'brand' => 40,
		'part_name' => 10,
		'price_group_name' => 5,
		));

а на выходе получаем отсортированный по «релевантности»=«суммарному весу» массив, где вес — это целочисленное значение. Этими числами можно управлять, настраивая релевантность под себя, то есть более важному полю нужно присваивать больший вес. В нашем примере самое важное поле — это код запчасти «code».

5. SetLimits(0,20) — ограничение вывода результатов


Данный метод самый простой, работает аналогично майскл-евскому limit 0,20 и нужен соответственно для того же: для получения порционных данных для построения степперов. В нашем проекте нужны просто первые 20 (константа) результатов, так как дальше по шагам, если их будет 3 и более смысла идти нету.

6. AddQuery, RunQueries — построение мультизапросов


Мультизапросы — очень удобное решение пакетных запросов, когда нужно послать не один запрос сфинксу, а несколько. В нашем примере это отправка всем группам запчастей одного и того же запроса для получения списка групп и количества записей в каждой группе. То есть посылается около 100 запросов, а возвращается один результат в одном соединении к сфинксу. Также «решено» ограничение в 32 максимально допустимых одновременных запросов в одном пакете запросов.

Пример кода:
$aPriceGroup=Db::GetAll(Base::GetSql("Price/Group",array(
'visible'=>1,
"where"=>" and pg.code_name is not null",
)));
if ($aPriceGroup) {
	$aResultAll=array();
	$i=0;

	foreach ($aPriceGroup as $aValue) {

		$oSphinxClient->SetFilter('id_price_group', array($aValue['id']));
		$iQuery = $oSphinxClient->AddQuery($sSphinxKeyword, 'price_group');
		$oSphinxClient->ResetFilters();
		$bAddedUnrunQuery=true;

		$aPriceGroupAssoc[$iQuery+(32*$i)]=$aValue;

		if ($iQuery && !($iQuery % 31) ) {
			$aResultQuery=$oSphinxClient->RunQueries();
			$aResultAll=array_merge($aResultAll,$aResultQuery);

			$sLastError=$oSphinxClient->GetLastError();
			$i++;
			$bAddedUnrunQuery=false;
		}
	}
	if ($bAddedUnrunQuery) {
		$aResultQuery=$oSphinxClient->RunQueries();
		$aResultAll=array_merge($aResultAll,$aResultQuery);
	}
}

По причине того, что у выполняемого задания были конечные сроки, — вникнуть во все тонкости запросов на старте, задача не ставилась. Поэтому я наверняка написал велосипед, который решает задачу «группированного» запроса, аналогичного group by в mysql. С другой стороны, если бы я разобрался с группировкой в сфинкс — не было бы примера, где можно использовать мультизапросы.

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

7. SetFilter, ResetFilters — добавление фильтрации в мультизапросе для ограничения получаемых данных


Для того, чтобы использовать фильтры — нужно сначала прописать в конфиг индекса поля, по которым будет использоваться фильтрация. В нашем примере это поле id_price_group:
sql_attr_uint = id_price_group

Соответственно в коде используется вот так:
foreach ($aPriceGroup as $aValue) {
$oSphinxClient->SetFilter('id_price_group', array($aValue['id']));
$iQuery = $oSphinxClient->AddQuery($sSphinxKeyword, 'price_group');
$oSphinxClient->ResetFilters();
//...
}

То есть в цикле foreach для каждого запроса в мультизапросе сначала устанавливается фильтр, а после добавления — сбрасывается, чтобы для других запросов он не работал. По-моему все логично, очевидно и трудностей возникнуть не должно.

8. Wordforms — использование синонимов и преодоление ограничений для нестандартных словоформ, как «C#»


Чтобы работали синонимы и нестандартные (свои) словоформы — нужно в конфиг индекса включить файл с такими словоформами:
wordforms = D:\Sphinx\data\wordforms.txt

Сам файл может содержать к примеру такой набор данных в UTF-8 кодировке:
bosh > bosch
бошш > bosch
CASTROL > CASTROLL
кастрол > CASTROLL
кастролл > CASTROLL

То есть с левой стороны все возможные синонимы — с правой значения этих слов. Причем слева не должно быть к примеру «bosch», если он уже есть справа. По крайней мере если это сделать — поиск ведет себя не так, как я ожидал.

В нашем примере можно использовать запрос «масла кастрол 5W40» и он найдет то же, что и «масла Castrol 5W40». В примере с «C#» нужно включать такие нестандартные словоформы, чтобы они не обрабатывались по стандартной схеме индекса и работали вручную именно так, как вы их настроите. Только вы знаете, какой именно смысл в вашем проекте несет фраза, к примеру «C#» = «ДО ДИЕЗ для музыкантов»

Данного функционала нету в конфиг файле и примере на сервере, приведен только пример, но еще не внедрен в существующей структуре синонимов проекта.

Исходные коды, архивы, ссылки на полезные сайты


* Неофициальная вики документация, в том числе и на не английском языках http://sphinxsearch.com/wiki/doku.php

* Архив урезанных исходников примера автозапчастей http://www.mstarproject.com/temp/3/sphinx/sphinxsearch_soruce.zip

* Архив структуры урезанной бд примера http://www.mstarproject.com/temp/3/sphinx/sphinxsearch_db_structure.zip

* Архив урезанной бд примера (43 MB) с данными http://www.mstarproject.com/temp/3/sphinx/sphinxsearch_db_data.zip

* Оплаченная спонсором рекламная ссылка: разработка интернет магазина tecdoc+sphinxsearch

Буду рад конструктивной критике и постараюсь ответить на возникшие вопросы. На конференцию в Санкт-Петербурге скорее всего не поеду: очень неудобный перелет, да и зима как никак. Решил, что пользы будет больше от статьи, а с автором сфинкса можно встретиться в Украине, нужно только подождать.
Tags:
Hubs:
+45
Comments47

Articles

Change theme settings