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

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");

Ссылки по теме

P.S. Спасибо kpumuk’y за своевременную онлайн консультацию ;)

17 thoughts on “Sphinx для НЕ полнотекстового поиска”

    1. А это возможно домашнее творчество Антона.
      Надо Андрею порекомендовать как альтернативу для стандартного. :)

  1. Давно использую не по назначению – это кстати очень хорошо помогает реалзовывать концепт NoSQL, похачить узкие места и упростить дизайн приложения.

    Кстати при надобности ворочания больших индексов – их можно разбивать на части и обрабатывать на разных машинах.

    1. Или в пределах одной машины, но на разных ядрах. Например таким образом можно заставить работать сфинкс на четырехядернике, используя все 4 ядра.

  2. А я не люблю SphinxAPI, предпочитаю SphinxSE. А по поводу использования сфинкса для выборки – поддерживаю, часто бывает необходимым решением.

  3. А я в подобной ситуации использовал в mysql тип данных SET, по сути это битовая маска, только с несколько кривым интерфейсом. С сфинксом не сравнивал, но выборка по 2 с гагом миллионам записей по 2м set полям и штук 5 разных INTов делалась за 300-400 миллисекунд. Правда пришлось еще бд подсказывать правильные индексы, а то в зависимости от where его иногда перглючивало и он выбирал далеко не самый оптимальный индекс.

  4. Гм. Зачем использовать sphinxapi, если есть расширение для пхп?

    Поставил себе из коробки и пользуешься, чем тягать за собой набор кода. Плюс быстрее работает, т.к. расширение.

    http://ua.php.net/sphinx .

    В моем случае вообще все было тривиально: yum install php-pecl-sphinx.x86_64 (Fedora). Думаю виндовое расширение тоже есть и для других юнихов тоже тривиально ставиться.

    Стартуем индексацию для всех прописанных индексов:

    По сути идекс один, поэтому просто /usr/local/sphinx/bin/indexer project –rotate, к тому же вешаем это в крон (почему-то это не упомянуто, т.к. данные надо периодически обновлять).

    $cl->SetSortMode(SPH_SORT_EXTENDED, ‘relev DESC’);

    Если сортировка нужна только для одного поля в DESC, то лучше использовать не EXTENDED, а SPH_SORT_ATTR_DESC, будет что-то типа $cl->SetSortMode(SPH_SORT_ATTR_DESC, ‘relev’);

    Но это так, скорее придирки :-)

    Для меня лично было откровенностью и подводными камнями, когда я делал у себя две вещи:

    1) что сфинкс возвращает только набор айди, которые потом надо снова вытягивать из базы
    2) что надо будет делать ORDER BY FIELD – благо FAQ в этом помог.

    Постараюсь завтра написать свой опыт у себя.

  5. Ну и еще бы я рекомендовал использовать такие вещи:

    sql_query_pre = SET NAMES utf8
    sql_query_pre = SET SESSION query_cache_type=OFF

    видимо у тебя поиск только по английским словам, поэтому первое тебе не понадобилось, но в общем случае полезно, второе просто нафиг отключает кеширование для сессии, которое не нужно.

  6. Кстати говоря, у меня сейчас в проекте в базе более 4 млн. записей и все это добро работает довольно быстро, не более 1-1.5 секунды по логу запросов searchd.

    Скоро напишу отдельную статью по поводу некоторых “приколов” сфинкса и разного рода оптимизаций.

  7. Всем привет! Подскажите, почему не работает следующая строка
    $res = $cl->Query(“SELECT * FROM goods WHERE MATCH(‘@pp кирпич’)”);
    Куда необходимо писать запросы?

    1. SphinxQL/SphinxSE (http://sphinxsearch.com/docs/2.0.1/)
      Для этого не нужно php api, прямо в mysql_query.
      SphinxQL – в конфиге прописываешь что “слушать” и делаешь mysql_connect к нему
      SphinxSE – плагин к MySQL, можно в том же коннекте и запрашивать

  8. Здравствуйте, не знаю жива ли еще ветка комментариев, но все же…
    Не много не по теме, но не знаю где бы задать вопрос.
    Я делаю свой первый сайт, и хотел бы к нему приделать поиск. Собственно количество статей будет не более 10к в среднем по 2к символов, скажите стоит ли мне к нему прикручивать свинкса?
    Спасибо за понимание и ответы.
    Сразу вопрос сколько будет стоить настройка свинкса вами, с более менее нормальным объяснением мне что к чему…
    Ответ с ценой моно сюда: antoncosak@gmail.com

  9. Обязательно следует. Даже не в плане супер скорости или сверх необходимости, а даже просто потому, что продукт ценный и пригодится всегда навык.

  10. Статья очень полезная, но я не совсем понял ее окончания. Антон, Вы на протяжение всей статьи рассказываете об одном способе, а в конце говорите, что это не фонтан и можно сделать быстрее и проще? Я правильно понял?

    1. Не совсем, при 4-х млн записей (а атрибутов было на порядок больше), нам пришлось пойти на компромисс и использовать полнотекст в ущерб первоначальной постановки задачи.

  11. Здравствуйте.
    Я столкнулась со след проблемой:
    запрос такой

    1
    2
    3
    4
    5
    6
    7
    8
    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

    Массив результата:

    01
    02
    03
    04
    05
    06
    07
    08
    09
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    ["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)
    }

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.