Антон Шевчук // Web-разработчик

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

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 за своевременную онлайн консультацию ;)

© Антон Шевчук 2007-2016