Sphinx для НЕ полнотекстового поиска

Sphinx – один из самых популярных движков полнотекстового поиска, в данной статье я расскажу как можно его использовать не по назначению…

Для начала опишу задачу поставленную перед нами заказчиком – есть некие документы в БД, каждому документу может соответствовать пачка кейвордов, да еще есть связи к таблицам языки/страны/города/типы, и так далее, чтобы лучше себе это представить приведу следующую диаграмму:

Пример такой сущности:

  • Документ: Письмо дяди Васи мастеру Феди. (и его содержимое)
  • Кейворды: Вася, письмо, Федя
  • Языки: Русский, Украинский
  • Страны: Украина
  • Города: Харьков, Богодухов, Чугуев

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

Sphinx – установка и настройка

Установка – тут все достаточно просто – скачиваем архив с сорцами, затем собираем:

 
./configure
make
make install

Потом настраиваем, приведу пример своего sphinx.conf:

## источник данных - существующая база данных
source src1
{
	# база данных - PostgreSQL
	type			= pgsql

	# настройки соединения
	sql_host			= localhost
	sql_user			= root
	sql_pass			= god-sex-love-secret
	sql_db				= project
	sql_port			= 5432	# default is 3306

	# основной запрос по которому будем индекс строить
	sql_query = \
	SELECT id, title, adult, status, blacklist, visits, rank, votes, stars, date_part('epoch', dateupdate) AS updated \
	FROM documents \
	WHERE (status = 1 OR status = 7) 
						
	# определяем числовые атрибуты
	sql_attr_uint = status
	sql_attr_uint = visits
	sql_attr_uint = rank
	sql_attr_uint = votes

	# булеан
	sql_attr_bool = adult
	sql_attr_bool = blacklist

	# дата и время в UNIX timestamp
	sql_attr_timestamp = updated

	# с плавающей запятой
	sql_attr_float = stars

	# multi-valued attribute (MVA) - для обеспечения связи многое ко многим 
	sql_attr_multi = uint keyword from query; SELECT document_id, keyword_id FROM document_keyword
	sql_attr_multi = uint language from query; SELECT document_id, language_id FROM document_language
	sql_attr_multi = uint country from query; SELECT document_id, country_id FROM document_country
	sql_attr_multi = uint city from query; SELECT document_id, city_id FROM document_city
	sql_attr_multi = uint type from query; SELECT document_id, type_id FROM document_type

	# для отладки из консоли
	sql_query_info		= SELECT * FROM document WHERE document_id=$id
}

## определения индекса
index project
{
	# берем источник описанный выше
	source			= src1
	# путь к индексам
	path			= /usr/local/sphinx/var/data/project
	# тип хранилища
	docinfo			= extern
	# memory locking for cached data (.spa and .spi), to prevent swapping
	mlock			= 0
	# нам необходимо точное соответствие - морфологию игнорируем
	morphology		= none
	# индексируем слова даже из одной буквы
	min_word_len		= 1
	# кодировочка
	charset_type		= utf-8
	# и еще раз
	charset_table		= 0..9, A..Z->a..z, _, a..z, U+410..U+42F->U+430..U+44F, U+430..U+44F
}
## настройки индексатора
indexer
{
        mem_limit = 32M
}

## настройки демона
searchd
{
	listen			= 127.0.0.1
	listen			= 3312
	read_timeout	= 5
	client_timeout	= 300
	max_children	= 0
	pid_file		= /usr/local/sphinx/var/log/searchd.pid
	max_matches		= 1000
}

Стартуем индексацию для всех прописанных индексов:

/usr/local/sphinx/bin/indexer --all
## если демон уже запущен
/usr/local/sphinx/bin/indexer --all --rotate

Поднимаем демона:

/usr/local/sphinx/bin/searchd

Пробуем поиск из консоли:

/usr/local/sphinx/bin/search keyword
#Sphinx 0.9.9-release (r2117)
#Copyright (c) 2001-2009, Andrew Aksyonoff

using config file '/usr/local/sphinx/etc/sphinx.conf'...
index 'project': query 'keyword ': returned 1000 matches of 2277 total in 0.014 sec

displaying matches:
1. document=5761, weight=2, adult=0, status=1, blacklist=0, visits=0, rank=0, votes=0, stars=0.000000, updated=Sun Sep 20 20:22:07 2009, keyword=(318,323,611), language=(1), country=(), city=(), aim=(), type_first=()
2. document=351943, weight=2, adult=0, status=1, blacklist=0, visits=0, rank=0, votes=0, stars=0.000000, updated=Sun Sep 20 20:22:07 2009, keyword=(10480,10490), language=(1), country=(), city=(), aim=(), type_first=()
3. document=351956, weight=2, adult=0, status=1, blacklist=0, visits=0, rank=0, votes=0, stars=0.000000, updated=Sun Sep 20 20:22:07 2009, keyword=(10480,10490), language=(1), country=(), city=(), aim=(), type_first=()
...
words:
1. 'keyword': 2277 documents, 2614 hits

Связка PHP+Sphinx

А теперь, непосредственно поиск с использованием sphinxapi.php (читайте комментарии к коду):

// подключаем сфинкс
require ( "library/sphinxapi.php" );

// опущу получение данных из запроса
$sortby = "relev";
// $keywords - это массив id - т.е. до этого у нас должен быть запрос к нашей БД, который вытащит их по поисковой строке
// т.е. было "Вася, письмо, Федя" стало array(152, 345, 6342)
$keywords = array();
// тоже массивы id'шников
$languages = array();
$countries = array();
$cities = array();
$type = array();

// создаем инстанц клиента
$cl = new SphinxClient ();

// настройки
$cl->SetServer("localhost", 3312);
$cl->SetConnectTimeout(1);
$cl->SetLimits($offset, $limit); // постраничную навигацию организовываем тут
$cl->SetArrayResult (true);
$cl->SetRankingMode(SPH_RANK_PROXIMITY_BM25);
$cl->SetMatchMode(SPH_MATCH_EXTENDED);

// пишем select
// sphinx может сказать есть ли совпадение между двумя множествами, но не может сказать сколько их - приходится извращаться
// результирующий select будет иметь следующий вид:
// *, (IN(keyword, "152") + IN(keyword, "345") + IN(keyword, "6342") + ... ) AS relev
$select = "*, (IN(keyword,'.join(') + IN(keyword,',$keywords).')) AS relev";

$cl->SetSelect($select);

// накладываем фильтры
if (!empty($languages))
    $cl->SetFilter('language', $languages );
    
if (!empty($countries))
    $cl->SetFilter('country', $countries );
    
if (!empty($cities))
    $cl->SetFilter('city', $cities );
    
if (!empty($type))
    $cl->SetFilter('type_first', $type );

// применяем сортировку $sortby
switch ($sortby) {
    case 'relev':
        $cl->SetSortMode(SPH_SORT_EXTENDED, 'relev DESC');
        break;
    case 'visits':
        $cl->SetSortMode(SPH_SORT_EXTENDED, 'visits DESC, relev DESC');
        break;
    case 'stars':
        $cl->SetSortMode(SPH_SORT_EXTENDED, 'stars DESC, relev DESC, updated DESC');
        break;
    default:
        break;
}

// накладываем фильтр на поле обновления - нас интересуют записи за последний год
$cl->setFilterRange('updated', (time() - 60*60*24*365), time());

// теперь запрос к демону
$res = $cl->Query("", "project");

// вывод результатов
if ( $res === false ) {
  echo "Query failed: " . $cl->GetLastError() . ".\n";
} else {
  if ( $cl->GetLastWarning() ) {
      echo "WARNING: " . $cl->GetLastWarning() . "\n";
  }
  echo  "total found: <strong>{$res['total_found']}</strong> at <strong>{$res['time']} sec</strong><hr/>";
  
  if ( ! empty($res["matches"]) ) {

      // это вывод того, что нашел Sphinx
      // такая подробная информация будет возвращаться только, если установлен параметр $cl->SetArrayResult (true);
      foreach ( $res["matches"] as $doc => $docinfo ) {
            if (!isset($docinfo['attrs']['relev'])) $docinfo['attrs']['relev'] = 0;
            echo "id: {$docinfo['id']}<br/>";
            echo "weight: {$docinfo['weight']}<br/>";
            echo "relevance: {$docinfo['attrs']['relev']}<br/>";
            echo "votes: {$docinfo['attrs']['votes']}<br/>";
            echo "stars: {$docinfo['attrs']['stars']}<br/>";
            echo "rank: {$docinfo['attrs']['rank']}<br/>";
            echo "visits: {$docinfo['attrs']['visits']}<br/>";
            echo "keywords: ".join(',', $docinfo['attrs']['keyword'] )."<br/>";
            echo "languages: ".join(',', $docinfo['attrs']['language'] )."<br/>";
            echo "countries: ".join(',', $docinfo['attrs']['country'] )."<br/>";
            echo "cities: ".join(',', $docinfo['attrs']['city'] )."<br/>";
            echo "types: ".join(',', $docinfo['attrs']['type'] )."<br/>";
            echo "updated: ".date("Y-m-d H:i",$docinfo['attrs']['updated'])."<br/>";
            echo "<hr/>";
      }

      // это был вывод того, что нам вернул Sphinx, для вывода необходимой информации надо постучаться к нашей БД
      // нам же надо взять ID всех документов
      // для правильной работы следующего кода отключите параметр $cl->SetArrayResult (true);
      $ids = array_keys($result['matches']);

      // и выведем в порядке, которые нам нашептал Sphinx
      // данный пример подходит для MySQL в PostgreSQL для эмуляции конструкции ORDER BY FIELD используют ORDER BY CASE
      $id_list = implode(',', $ids);
      $sql = sprintf('SELECT * FROM `documents` WHERE `id` IN (%s) ORDER BY FIELD(`id`, %s)', $id_list, $id_list);
  }
}

Таки полнотекстовый

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

Изменения в конфиге:

source src1
{
	# добавлено поле keywords - сие есть заранее подготовленное поле, обновляемое по тригеру
           # содержит (array_to_string(array_agg(k.value), ', ') AS keywords
	sql_query = \
	SELECT id, title, keywords, adult, status, blacklist, visits, rank, votes, stars, date_part('epoch', dateupdate) AS updated \
	FROM documents \
	WHERE (status = 1 OR status = 7) 
}

Изменения в PHP части:

// дабы много не переписывать из предыдущего примера, был добавлен алиас
$select = "*, @weight AS relev";

// запрос претерпел изменения
// теперь он имеет вид @keywords ("Вася"|"письмо"|"Федя")
$query = '("'.join('"|"',$keywords).'")';
$query = '@keywords '.$query;

// теперь запрос к демону
$res = $cl->Query($query, "project");

Ссылки по теме

P.S. Спасибо kpumuk’y за своевременную онлайн консультацию ;)

17 thoughts on “Sphinx для НЕ полнотекстового поиска”

    1. А это возможно домашнее творчество Антона.
      Надо Андрею порекомендовать как альтернативу для стандартного. :)

  1. Давно использую не по назначению – это кстати очень хорошо помогает реалзовывать концепт NoSQL, похачить узкие места и упростить дизайн приложения.

    Кстати при надобности ворочания больших индексов – их можно разбивать на части и обрабатывать на разных машинах.

    1. Или в пределах одной машины, но на разных ядрах. Например таким образом можно заставить работать сфинкс на четырехядернике, используя все 4 ядра.

  2. А я не люблю SphinxAPI, предпочитаю SphinxSE. А по поводу использования сфинкса для выборки – поддерживаю, часто бывает необходимым решением.

  3. А я в подобной ситуации использовал в mysql тип данных SET, по сути это битовая маска, только с несколько кривым интерфейсом. С сфинксом не сравнивал, но выборка по 2 с гагом миллионам записей по 2м set полям и штук 5 разных INTов делалась за 300-400 миллисекунд. Правда пришлось еще бд подсказывать правильные индексы, а то в зависимости от where его иногда перглючивало и он выбирал далеко не самый оптимальный индекс.

  4. Гм. Зачем использовать sphinxapi, если есть расширение для пхп?

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

    http://ua.php.net/sphinx .

    В моем случае вообще все было тривиально: yum install php-pecl-sphinx.x86_64 (Fedora). Думаю виндовое расширение тоже есть и для других юнихов тоже тривиально ставиться.

    Стартуем индексацию для всех прописанных индексов:

    По сути идекс один, поэтому просто /usr/local/sphinx/bin/indexer project –rotate, к тому же вешаем это в крон (почему-то это не упомянуто, т.к. данные надо периодически обновлять).

    $cl->SetSortMode(SPH_SORT_EXTENDED, ‘relev DESC’);

    Если сортировка нужна только для одного поля в DESC, то лучше использовать не EXTENDED, а SPH_SORT_ATTR_DESC, будет что-то типа $cl->SetSortMode(SPH_SORT_ATTR_DESC, ‘relev’);

    Но это так, скорее придирки :-)

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

    1) что сфинкс возвращает только набор айди, которые потом надо снова вытягивать из базы
    2) что надо будет делать ORDER BY FIELD – благо FAQ в этом помог.

    Постараюсь завтра написать свой опыт у себя.

  5. Ну и еще бы я рекомендовал использовать такие вещи:

    sql_query_pre = SET NAMES utf8
    sql_query_pre = SET SESSION query_cache_type=OFF

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

  6. Кстати говоря, у меня сейчас в проекте в базе более 4 млн. записей и все это добро работает довольно быстро, не более 1-1.5 секунды по логу запросов searchd.

    Скоро напишу отдельную статью по поводу некоторых “приколов” сфинкса и разного рода оптимизаций.

  7. Всем привет! Подскажите, почему не работает следующая строка
    $res = $cl->Query(“SELECT * FROM goods WHERE MATCH(‘@pp кирпич’)”);
    Куда необходимо писать запросы?

    1. SphinxQL/SphinxSE (http://sphinxsearch.com/docs/2.0.1/)
      Для этого не нужно php api, прямо в mysql_query.
      SphinxQL – в конфиге прописываешь что “слушать” и делаешь mysql_connect к нему
      SphinxSE – плагин к MySQL, можно в том же коннекте и запрашивать

  8. Здравствуйте, не знаю жива ли еще ветка комментариев, но все же…
    Не много не по теме, но не знаю где бы задать вопрос.
    Я делаю свой первый сайт, и хотел бы к нему приделать поиск. Собственно количество статей будет не более 10к в среднем по 2к символов, скажите стоит ли мне к нему прикручивать свинкса?
    Спасибо за понимание и ответы.
    Сразу вопрос сколько будет стоить настройка свинкса вами, с более менее нормальным объяснением мне что к чему…
    Ответ с ценой моно сюда: antoncosak@gmail.com

  9. Обязательно следует. Даже не в плане супер скорости или сверх необходимости, а даже просто потому, что продукт ценный и пригодится всегда навык.

  10. Статья очень полезная, но я не совсем понял ее окончания. Антон, Вы на протяжение всей статьи рассказываете об одном способе, а в конце говорите, что это не фонтан и можно сделать быстрее и проще? Я правильно понял?

    1. Не совсем, при 4-х млн записей (а атрибутов было на порядок больше), нам пришлось пойти на компромисс и использовать полнотекст в ущерб первоначальной постановки задачи.

  11. Здравствуйте.
    Я столкнулась со след проблемой:
    запрос такой

    	sql_query		= \
    		SELECT a.id as id, a.title, a.description            
    		, 'articles' AS service \
    		, 15 AS service_id \
    		, 1 AS type_index \
                    , 0 AS search_header \
    		FROM articles a \
    		WHERE a.status = 1 and timediff(now(), cat_art.publish_start) >= 0
    

    $sphinx->SetMatchMode( SPH_MATCH_EXTENDED2 );
    если делать запрос типа
    – $result = $sphinx->Query(‘@description ‘.$queryEscape, ‘search_articles’);
    где $queryEscape запрос типа (слово | *слово*)
    то результат есть

    – $result = $sphinx->Query(‘@title ‘.$queryEscape, ‘search_articles’);
    то результат пустой.

    Хотя в таблице и заголовок и описание содержит данное слово.
    Почему в возвращаемом массиве нет в fields поля title

    Массив результата:

      ["fields"]=>
      array(1) {
        [0]=>
        string(11) "description"
        [1]=>
      }
      ["attrs"]=>
      array(5) {
        ["title"]=>
        int(2)
        ["service"]=>
        int(7)
        ["service_id"]=>
        int(1)
        ["type_index"]=>
        int(1)
        ["search_header"]=>
        int(1)
      }
    

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.