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

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");
Ссылки по теме
- Официальная документация (перевод)
- Sphinx. Установка и первичная настройка
- Sphinx – настоящее быстрого поиска
- Организуем релевантный поиск по разнородным данным с помощью Sphinx
P.S. Спасибо kpumuk’y за своевременную онлайн консультацию ;)