Sphinx – один из самых популярных движков полнотекстового поиска, в данной статье я расскажу как можно его использовать не по назначению…
Для начала опишу задачу поставленную перед нами заказчиком – есть некие документы в БД, каждому документу может соответствовать пачка кейвордов, да еще есть связи к таблицам языки/страны/города/типы, и так далее, чтобы лучше себе это представить приведу следующую диаграмму:
Пример такой сущности:
- Документ: Письмо дяди Васи мастеру Феди. (и его содержимое)
- Кейворды: Вася, письмо, Федя
- Языки: Русский, Украинский
- Страны: Украина
- Города: Харьков, Богодухов, Чугуев
Теперь о самом поиске, точнее, о там как его видит заказчик – поиск должен искать по точному вхождению кейвордов, за каждое такое вхождение должен документу начисляться один бал. Остальные связи используем для фильтра – т.е. ищем документы на англиском и русском – должно найти все документы, где есть хотя бы один из этих языков.
Sphinx – установка и настройка
Установка – тут все достаточно просто – скачиваем архив с сорцами, затем собираем:
1 2 3 | . /configure make make install |
Потом настраиваем, приведу пример своего sphinx.conf:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | ## источник данных - существующая база данных 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 } |
Стартуем индексацию для всех прописанных индексов:
1 2 3 | /usr/local/sphinx/bin/indexer --all ## если демон уже запущен /usr/local/sphinx/bin/indexer --all --rotate |
Поднимаем демона:
1 | /usr/local/sphinx/bin/searchd |
Пробуем поиск из консоли:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 | /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 (читайте комментарии к коду):
001 002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 024 025 026 027 028 029 030 031 032 033 034 035 036 037 038 039 040 041 042 043 044 045 046 047 048 049 050 051 052 053 054 055 056 057 058 059 060 061 062 063 064 065 066 067 068 069 070 071 072 073 074 075 076 077 078 079 080 081 082 083 084 085 086 087 088 089 090 091 092 093 094 095 096 097 098 099 100 101 102 103 104 105 106 107 108 109 | // подключаем сфинкс 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 и более секунды, пришлось пойти на небольшую хитрость – для вычисление совпадений таки использовать полнотекстовый поиск, и не заморачиваться с вычислением точных совпадений…
Изменения в конфиге:
1 2 3 4 5 6 7 8 9 | 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 части:
01 02 03 04 05 06 07 08 09 10 | // дабы много не переписывать из предыдущего примера, был добавлен алиас $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-х млн записей (а атрибутов было на порядок больше), нам пришлось пойти на компромисс и использовать полнотекст в ущерб первоначальной постановки задачи.
Здравствуйте.
Я столкнулась со след проблемой:
запрос такой
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)
}