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