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 за своевременную онлайн консультацию ;)
Зачем в индексе держать данные старше одного года, раз вы их не используете ?
а мне больше интересно, что это за логотип? :)
А это возможно домашнее творчество Антона.
Надо Андрею порекомендовать как альтернативу для стандартного. :)
Давно использую не по назначению – это кстати очень хорошо помогает реалзовывать концепт NoSQL, похачить узкие места и упростить дизайн приложения.
Кстати при надобности ворочания больших индексов – их можно разбивать на части и обрабатывать на разных машинах.
Или в пределах одной машины, но на разных ядрах. Например таким образом можно заставить работать сфинкс на четырехядернике, используя все 4 ядра.
А я не люблю SphinxAPI, предпочитаю SphinxSE. А по поводу использования сфинкса для выборки – поддерживаю, часто бывает необходимым решением.
А я в подобной ситуации использовал в mysql тип данных SET, по сути это битовая маска, только с несколько кривым интерфейсом. С сфинксом не сравнивал, но выборка по 2 с гагом миллионам записей по 2м set полям и штук 5 разных INTов делалась за 300-400 миллисекунд. Правда пришлось еще бд подсказывать правильные индексы, а то в зависимости от where его иногда перглючивало и он выбирал далеко не самый оптимальный индекс.
Гм. Зачем использовать sphinxapi, если есть расширение для пхп?
Поставил себе из коробки и пользуешься, чем тягать за собой набор кода. Плюс быстрее работает, т.к. расширение.
http://ua.php.net/sphinx .
В моем случае вообще все было тривиально: yum install php-pecl-sphinx.x86_64 (Fedora). Думаю виндовое расширение тоже есть и для других юнихов тоже тривиально ставиться.
По сути идекс один, поэтому просто /usr/local/sphinx/bin/indexer project –rotate, к тому же вешаем это в крон (почему-то это не упомянуто, т.к. данные надо периодически обновлять).
Если сортировка нужна только для одного поля в DESC, то лучше использовать не EXTENDED, а SPH_SORT_ATTR_DESC, будет что-то типа $cl->SetSortMode(SPH_SORT_ATTR_DESC, ‘relev’);
Но это так, скорее придирки :-)
Для меня лично было откровенностью и подводными камнями, когда я делал у себя две вещи:
1) что сфинкс возвращает только набор айди, которые потом надо снова вытягивать из базы
2) что надо будет делать ORDER BY FIELD – благо FAQ в этом помог.
Постараюсь завтра написать свой опыт у себя.
Ну и еще бы я рекомендовал использовать такие вещи:
sql_query_pre = SET NAMES utf8
sql_query_pre = SET SESSION query_cache_type=OFF
видимо у тебя поиск только по английским словам, поэтому первое тебе не понадобилось, но в общем случае полезно, второе просто нафиг отключает кеширование для сессии, которое не нужно.
Кстати говоря, у меня сейчас в проекте в базе более 4 млн. записей и все это добро работает довольно быстро, не более 1-1.5 секунды по логу запросов searchd.
Скоро напишу отдельную статью по поводу некоторых “приколов” сфинкса и разного рода оптимизаций.
Всем привет! Подскажите, почему не работает следующая строка
$res = $cl->Query(“SELECT * FROM goods WHERE MATCH(‘@pp кирпич’)”);
Куда необходимо писать запросы?
SphinxQL/SphinxSE (http://sphinxsearch.com/docs/2.0.1/)
Для этого не нужно php api, прямо в mysql_query.
SphinxQL – в конфиге прописываешь что “слушать” и делаешь mysql_connect к нему
SphinxSE – плагин к MySQL, можно в том же коннекте и запрашивать
Здравствуйте, не знаю жива ли еще ветка комментариев, но все же…
Не много не по теме, но не знаю где бы задать вопрос.
Я делаю свой первый сайт, и хотел бы к нему приделать поиск. Собственно количество статей будет не более 10к в среднем по 2к символов, скажите стоит ли мне к нему прикручивать свинкса?
Спасибо за понимание и ответы.
Сразу вопрос сколько будет стоить настройка свинкса вами, с более менее нормальным объяснением мне что к чему…
Ответ с ценой моно сюда: antoncosak@gmail.com
Обязательно следует. Даже не в плане супер скорости или сверх необходимости, а даже просто потому, что продукт ценный и пригодится всегда навык.
Статья очень полезная, но я не совсем понял ее окончания. Антон, Вы на протяжение всей статьи рассказываете об одном способе, а в конце говорите, что это не фонтан и можно сделать быстрее и проще? Я правильно понял?
Не совсем, при 4-х млн записей (а атрибутов было на порядок больше), нам пришлось пойти на компромисс и использовать полнотекст в ущерб первоначальной постановки задачи.
Здравствуйте.
Я столкнулась со след проблемой:
запрос такой
$sphinx->SetMatchMode( SPH_MATCH_EXTENDED2 );
если делать запрос типа
– $result = $sphinx->Query(‘@description ‘.$queryEscape, ‘search_articles’);
где $queryEscape запрос типа (слово | *слово*)
то результат есть
– $result = $sphinx->Query(‘@title ‘.$queryEscape, ‘search_articles’);
то результат пустой.
Хотя в таблице и заголовок и описание содержит данное слово.
Почему в возвращаемом массиве нет в fields поля title
Массив результата: