Организуем релевантный поиск по разнородным данным с помощью Sphinx

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

    Постановка задачи


    В проекте есть на данный момент 2 зоны:
    1. географическая зона, реализованная на базе Google Maps, которая отображает нанесенные пользователями на карту географические объекты (маркеры, маршруты и области);
    2. информационная зона, которая представляет собой большой иерархически организованный каталог, содержащий информационные материалы.
    Необходимо было решить задачу одновременного текстового поиска по 3-м типам объектов: географическим объектам, категориям информационной зоны и материалам информационной зоны — c возможностью фильтрации по дате публикации объектов и категориям, к которым они относятся.

    Решение задачи


    Все решение описано для связки PHP5 (Symfony), MySQL, Sphinx. Как ставить Sphinx, я описывать не буду, эту информацию можно прочитать на официальном сайте. Скажу лишь, что под Mac OS X он легко ставиться с помощью macports.
    Имеем такую модель БД (я ее упростил, чтобы было поближе к сути) с каким-то набором записей:
    Конфигурируем sphinx для индексации и выдачи результатов поиска:
    1. #articles
    2. source article
    3. {
    4.      type              = mysql
    5.      sql_host          = localhost
    6.      sql_user          = root
    7.      sql_pass          = root
    8.      sql_db             = ili_lv
    9.      sql_sock          = /tmp/mysql/mysql.sock
    10.      sql_query_range    = SELECT MIN(id), MAX(id) FROM article
    11.      sql_range_step     = 500
    12.      sql_query_pre     = SET NAMES utf8
    13.      sql_query         = \
    14.             SELECT id * 10 + 1 as id, category_id, 1 as row_type,\
    15.             UNIX_TIMESTAMP(created_at) as created_at, title, descr \
    16.             FROM article WHERE id >= $start AND id <= $end
    17.      sql_attr_uint     = category_id
    18.      sql_attr_uint     = row_type
    19.      sql_attr_timestamp = created_at
    20.      sql_query_info     = SELECT title, descr \
    21.                          FROM article WHERE id = ($id - 1) / 10
    22. }
    23. #categories
    24. source category
    25. {
    26.   #аналогичный блок параметров подключения к БД
    27.   #...
    28.      sql_query_range    = SELECT MIN(id), MAX(id) FROM category
    29.      sql_range_step     = 500
    30.      sql_query_pre     = SET NAMES utf8
    31.      sql_query         = \
    32.             SELECT id * 10 + 2 as id, tree_parent as category_id, 2 as row_type,\
    33.             UNIX_TIMESTAMP(created_at) as created_at, title, descr \
    34.             FROM category WHERE id >= $start AND id <= $end
    35.      sql_attr_uint     = category_id
    36.      sql_attr_uint     = row_type
    37.      sql_attr_timestamp = created_at
    38.      sql_query_info     = SELECT title, descr \
    39.                          FROM category WHERE id = ($id - 2) / 10
    40. }
    41. #geo_objects
    42. source geo_object
    43. {
    44.   #аналогичный блок параметров подключения к БД
    45.   #...
    46.      sql_query_range    = SELECT MIN(id), MAX(id) FROM geo_object
    47.      sql_range_step     = 500
    48.      sql_query_pre     = SET NAMES utf8
    49.      sql_query         = \
    50.             SELECT id * 10 + 3 as id, 0 as category_id, 3 as row_type,\
    51.             UNIX_TIMESTAMP(created_at) as created_at, title, descr \
    52.             FROM geo_object WHERE id >= $start AND id <= $end
    53.      sql_attr_uint     = category_id
    54.      sql_attr_uint     = row_type
    55.      sql_attr_timestamp = created_at
    56.      sql_query_info     = SELECT title, descr \
    57.                          FROM geo_object WHERE id = ($id - 3) / 10
    58. }
    59. index site_search
    60. {
    61.      source             = category
    62.      source             = geo_object
    63.      source             = article
    64.     
    65.      path              = /var/data/sphinx/site_search
    66.      docinfo            = extern
    67.      morphology         = stem_en, stem_ru
    68.      html_strip         = 0
    69.      charset_type      = utf-8
    70.      min_word_len      = 2
    71. }
    * This source code was highlighted with Source Code Highlighter.

    Чуть по-подробнее о параметрах конфигурации. Разделы source, как понятно из названия, задают хранилища данных, откуда будет извлекаться индексируемая Sphinx информация. Такими хранилищами могут быть базы данных, текстовые файлы, html-файлы, xml и даже почтовые ящики. Этот раздел также описывает, какие поля хранилища будут индексироваться, в каком формате будет производиться индексация (выборка разовая или порционная) и ряд других параметров. В моем случае описано 3 source, все они ведут в одну и ту же базу данных MySQL, но в разные таблицы.
    Форматы конфигураций похожи, я опишу source article.
    1.      sql_query_range    = SELECT MIN(id), MAX(id) FROM article
    2.      sql_range_step     = 500
    * This source code was highlighted with Source Code Highlighter.
    Этими строками мы «указываем» Sphinx делать выборку из таблицы не полным select-ом, а порциями по 500 записей, чтобы не создавать избыточную нагрузку при индексации.

    1.      sql_query         = \
    2.             SELECT id * 10 + 1 as id, category_id, 1 as row_type,\
    3.             UNIX_TIMESTAMP(created_at) as created_at, title, descr \
    4.             FROM article WHERE id >= $start AND id <= $end
    * This source code was highlighted with Source Code Highlighter.
     Это маска запроса, отправляемого Sphinx при индексации данных. Здесь важно 3 момента:
    • Определяется набор полей для индексации, в нашем случае это id, текстовые поля и поля-фильтры;
    • Первое поле используется Sphinx-ом как id в формируемом индексе. Т.к. id из разных таблиц могут совпадать, то применен такой метод формирования уникального id;
    • Поле row_type дает возможность определить, какого типа каждая из сохраненных записей в индексе Sphinx.

    Далее идет описание атрибутов, которые можно использовать в качестве фильтров
    1.      sql_attr_uint     = category_id
    2.      sql_attr_uint     = row_type
    3.      sql_attr_timestamp = created_at
    * This source code was highlighted with Source Code Highlighter.

    Ну и последний параметр — это маска запроса, который будет извлекать нужную нам информацию по найденным id:
    1.     sql_query_info     = SELECT title, descr \
    2.                          FROM geo_object WHERE id = ($id - 1) / 10
    * This source code was highlighted with Source Code Highlighter.

    Далее в конфигурационном файле описывается самое важное — параметры индексации указанных нами source-ов с помощью секции index.
    1.      source             = category
    2.      source             = geo_object
    3.      source             = article
    * This source code was highlighted with Source Code Highlighter.

    Очень важный момент — индекс может формироваться из нескольких source. Как показано выше, в индекс сливаются данные из трех таблиц. Представьте, как пришлось бы попотеть, чтобы организовать такой поиск с помощью БД! Здесь же мы просто можем делать запрос к данному индексу, получая при этом его отранжированные результаты.

    Строчками
    1.      path             = /var/data/sphinx/site_search
    2.      docinfo            = extern
    * This source code was highlighted with Source Code Highlighter.
    указываются параметры хранения индекса и полный путь к нему.

    В чем еще одна прелесь Sphinx — он «из коробки» поддерживает английскую и русскую морфологию, позволяя приводить слова запроса к нормальной форме. При необходимости эту функциональность можно расширить
    1.     morphology         = stem_en, stem_ru
    * This source code was highlighted with Source Code Highlighter.

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

    Далее осталось только запустить индексацию.
    1. muxx:~ muxx$ sudo searchd --stop
    2. Sphinx 0.9.8.1-release (r1533)
    3. Copyright (c) 2001-2008, Andrew Aksyonoff
    4. using config file '/usr/local/etc/sphinx.conf'...
    5. stop: succesfully sent SIGTERM to pid 5677
    6. muxx:~ muxx$ sudo indexer --all
    7. Sphinx 0.9.8.1-release (r1533)
    8. Copyright (c) 2001-2008, Andrew Aksyonoff
    9. using config file '/usr/local/etc/sphinx.conf'...
    10. indexing index 'site_search'...
    11. collected 759 docs, 0.0 MB
    12. sorted 0.0 Mhits, 100.0% done
    13. total 759 docs, 22171 bytes
    14. total 0.028 sec, 785871.25 bytes/sec, 26903.45 docs/sec
    15. muxx:~ muxx$ sudo searchd
    16. Sphinx 0.9.8.1-release (r1533)
    17. Copyright (c) 2001-2008, Andrew Aksyonoff
    18. using config file '/usr/local/etc/sphinx.conf'...
    19. creating server socket on 127.0.0.1:3312
    20. muxx:~ muxx$ search мой сложный запрос
    21. Sphinx 0.9.8.1-release (r1533)
    22. Copyright (c) 2001-2008, Andrew Aksyonoff
    23. using config file '/usr/local/etc/sphinx.conf'...
    24. index 'site_search': query 'мой сложный запрос ': returned 0 matches of 0 total in 0.000 sec
    25. words:
    26. 1. 'мо': 0 documents, 0 hits
    27. 2. 'сложн': 0 documents, 0 hits
    28. 3. 'запрос': 0 documents, 0 hits
    29. muxx:~ muxx$
    * This source code was highlighted with Source Code Highlighter.

    В листинге выше мы сначала останавливаем демон на случай, если он запущен. Затем выполняем индексацию. Можно увидеть, насколько высока скорость индексации у Sphinx.

    // В комментариях подсказали, что можно производить индексирование, не останавливая демон командой sudo indexer --rotate --all.

    Затем запускаем демон и выполняем пробный запрос. Sphinx показывает, как он разбивает запрос и нормализует слова в нем. В моем примере он отработал нормально, но ничего не нашел :)

    После того, как удостоверились, что демон работает, можно работать со Sphinx из Symfony.
    Устанавливаем плагин sfSphinxPlugin, подключаем его в конфигурациях:
    1. $this->enablePlugins(array('sfSphinxPlugin'));
    * This source code was highlighted with Source Code Highlighter.

    и пишем небольшой пример запроса к демону:
    1.   $sphinx = new sfSphinxClient($options);
    2.         
    3.   //устанавливаем числовые фильтры, если они заданы
    4.   if ($request->getParameter('category_id'))
    5.      $sphinx->setFilter('category_id', array($request->getParameter('category_id')));
    6.   if ($request->getParameter('row_type'))
    7.      $sphinx->setFilter('row_type', array($request->getParameter('row_type')));
    8.   $dateRange = $request->getParameter('date');
    9.   if ($dateRange['from'] || $dateRange['to'])
    10.   {
    11.      $sphinx->setFilterRange('created_at',
    12.                              !empty($dateRange['from']) ? strtotime($dateRange['from']) : '',
    13.                              !empty($dateRange['to'])  ? strtotime($dateRange['to'])  : '');
    14.   }
    15.   $this->results = $sphinx->Query($request->getParameter('s'), 'site_search');
    16.   if ($this->results === false)
    17.   {
    18.      $this->message = 'Запрос не выполнен: ' . $sphinx->GetLastError();
    19.   }
    20.   else
    21.      //если все путём, то достаем информацию по id индекса
    22.      //и выводим ее в template
    23.      $this->items = $this->retrieveResultRows($this->results);
    * This source code was highlighted with Source Code Highlighter.

    Надеюсь, из моего описания можно оценить все прелести Sphinx, я рассказал далеко не обо всех его возможностях, остальное при желании вы уже сможете самостоятельно изучить.

    PS: Просьба к тем у кого достаточно кармы — создайте блог Sphinx, я бы перенес туда статью.

    PS2: Спасибо всем! Блог создан, топик перенесен туда.
    Поделиться публикацией

    Комментарии 41

      0
      Статья хорошая. Но вот такой вопрос. Я бы в такой ситуации поступил так — сделал бы из трез таблиц view с дополнительным полем source=enum{article, category, geo} а потом из этого view сделал бы таблицу с полнотекстовым поиском(ну или можно было бы сделать триггеры на изменение данных в исходных таблицах). Конечно морфологии тут не было бы(не postgres ведь)). В чем приемущество сфинкса перед таким вариантом?
        +1
        Ну вот как раз в морфологии, и с релевантностью у сфинкса будут дела по-лучше, чем у mysql.
          +1
          Ну вот видите, сколько действий вам нужно делать, чтобы организовать поиск с помощью БД. Плюс вы столкнетесь со следующими проблемами:
          1. БД сможет использовать только полнотекстовый индекс, а если у вас еще будет фильтрация по другим параметрам, это будет производиться без индексов, т.е. медленно. В статье я поставил простую задачу, на самом деле мне нужно было фильтровать записи по десятку параметров;
          2. В Sphinx существует множество режимов поиска, в каждом из них идут свои «бонусы»;
          3. С помощью прстого указания между режимами SPH_MATCH_ALL, SPH_MATCH_ANY можно задать поиск по всех словам из фразы или по любому из слов и все это с учетом морфологии;
          4. В режиме SPH_MATCH_EXTENDED, помимо стандартных операторов AND, OR, NOT можно задавать близость слов: «example program»~5 – такое условие говорит Sphinx, что между словами example и program должно быть не более 5-ти слов; а также порог на количество слов: «Петя Пупкин пошел гулять по лесу»/3 возвращает те записи, где встречается хотя бы 3 из 6 слов в заданной фразе.
          5. В Sphinx введен режим SPH_MATCH_FULLSCAN, когда поисковая фраза пустая и заданы только фильтры и группирование. В документации пишут, что выборка записей по фильтрам идет в некоторых случаях даже быстрее, чем в MySQL. Я также перевел некоторые запросы с большими условиями на Sphinx, чтобы разгрузить БД.
          6. В индекс может понадобиться включить что-то не хранящееся в БД, а Sphinx, как я писал, умеет искать и по xml, html, почте и др.
            0
            На более-менее приличной по объему БД сфинкс в разы (а то и в десятки раз) сделает мускуль по производительности. Проверено.
            +2
            Большое спасибо за статью! Как раз уже начинал копать в сторону сфинкс, скоро придется тоже поиск организовывать на сайте.
            А кармы, я думаю, у вас будет скоро много(я принял в этом участие :) ) и сможете сами создать блог)
              +1
              Демона останавливать не обязательно, достаточно indexer'у дать параметр --rotate название_индкса или --rotate all
                0
                Да, спасибо, это очень ценно, упустил этот момент.
                +1
                Создал: habrahabr.ru/blogs/sphinx

                Спасибо за статью, для своих проектов на symfony использовали lucene. Теперь, думаю стоит посмотреть в сторону Sphinx.
                  0
                  Спасибо за блог!
                  –1
                  Он только под Виндовс? Жаль
                    +2
                    Нет, вы что) ставится везде, где только можно — и Linux, и макось.
                      0
                      Вот список из оф. документации:
                      – Linux 2.4.x, 2.6.x (various distributions)
                      – Windows 2000, XP
                      – FreeBSD 4.x, 5.x, 6.x
                      – NetBSD 1.6, 3.0
                      – Solaris 9, 11
                      – Mac OS X
                        0
                        нет
                          0
                          Кто он? Sphinx кросс-платформенный
                          0
                          >> Представьте, как пришлось бы попотеть, чтобы организовать такой поиск с помощью БД!
                          те же самые 3 запроса, которые использовались при индексации, объединённые через UNION.
                            0
                            Я имел ввиду в том числе и эффективность выполнения таких запросов.
                              0
                              Если записей, скажем, 4млн, сфинкс оставит далеко позади mysql по скорости, тем более при полнотекстовом поиске.
                                0
                                прочитай внимательнее его абзац :-)
                                это предложение преподносится в контексте того, что «как круто, сфинкс умеет индексировать из N источников». Т.е. фичей преподносится возможность индексации кучи сорсов, а не производительность — с этой точки зрения мой комментарий вполне уместен: mysql через UNION может делать то же самое. м?

                                по поводу скорости — отлично представляю насколько сфинкс куче mysql.
                              0
                              Да и для Symfony есть неплохой плагин, позволяющий работать с Sphinx
                                0
                                Создание индекса Sphinx'а надо периодически запускать, или он это делает сам?
                                  0
                                  Да, надо запускать, повесить в кроне задачу, например.
                                  0
                                  Я бы создал все-таки три индекса. Sphinx API позволяет искать по нескольким индексам сразу. Зато при наличии трех индексов вместо одного мы можем спокойно искать по каждому из них отдельно в случае надобности.
                                  И второе преимущество различных индексов — не приходится вводить лишнии манипуляции с id-шниками
                                    0
                                    Фильтр по row_type позволить искать по отдельным индексам, а со вторым в принципе согласен.
                                    0
                                    Чтобы не писать конфиг коннекта в каждом source, его можно определить в отдельном source (скажем, generic) и во всех остальных его наследовать. Так будет много проще
                                    • НЛО прилетело и опубликовало эту надпись здесь
                                        0
                                        source generic {конфиг коннекта}
                                        source foo : generic {конфиг для нашего конктретного source}
                                        • НЛО прилетело и опубликовало эту надпись здесь
                                          • НЛО прилетело и опубликовало эту надпись здесь
                                        +1
                                        По поводу источника данных.
                                        View — я бы не советовал использовать, так как при достаточно большом обьеме данных, даже используя частичную выборку из view вы положите базу данных ($start, $stop). Плюс если вы хотите действительно не напрягать mysql то не слудет при обращении в view делать какие либо условия на больших обьемах данных (например: where source = 'article', limit и тд, но это только пример 8) ).

                                        Стоит делать отдельные индексы (article, category, geo).
                                        Какие плюсы мы получим:
                                        1. индексы могут содержать разное количество полей, часть полей могут быть в будущем использованы для специфического поиска к примеру по категориям.
                                        2. можно гибко работать с весами для разных индесков
                                        3. можно производить реиндексацию только изменившегося индекса (при полной реиндексации), а это снижение нагрузки. В данном случае чаще будет обновляться article, чем category и geo.

                                        Ну и наспоследок, при использовании Sphinx сразу закладывайте в архитектуру работу с частичными индексами (индексы содержащие изменившиеся данные за определенный промежуток времени) и их обьединением с основным индексом.
                                          0
                                          Замечания все верны, но, к сожалению, все уместить в одну статью нереально. Если у хабрасообщества будет желание, я напишу продолжение.

                                          PS. Эту статью меня попросили перевести на английский, надеюсь, это поспособствует популяризации Sphinx за рубежом :)
                                            0
                                            когда же будет продолжение?
                                          0
                                          Уже приходилось прикручивать Sphinx к Symfony для организации поиска на новой версии проекта своей компании. Что я могу сказать по этому поводу. Порадовала производительность — поиск просто летает, страницы с поисковыми запросами отдаются быстрее всех на сайте! В то же время пришлось немало помучиться, наступив на неслабое количество граблей. Во-первых крайне не понравился написанный на PHP 4 и крайне неудобный в использовании Sphinx API. Я решил написать более удобную обертку, использующую всю мощь ООП в PHP 5. В не меньшей степени раздражало и необходимость применения сфинксовых костылей типа метода SetArrayResult(). Ну и самый пипец был уже после выкладки — неожидано выяснилось, что пустой поиск поиск по одному из индексов выдает довольно странную ошибку. К счастью она не я первый на нее наткнулся:

                                          www.sphinxsearch.com/forum/view.html?id=2070

                                          В индексе не было ни одного атрибута, поэтому возникла ошибка. Добавление атрибута решило проблему.

                                          Одним словом вывод один — для работы со Sphinx нужно хорошо уметь работать напильником :).
                                            0
                                            Такая же история ;) И класс sfSphinxClient толком наследовать не получается, потому как private $res, а чужой код мы стараемся не изменять ;)
                                            С вашей версией обёртки ознакомиться где-нибудь можно?
                                            0
                                            SELECT id * 10 + 1 as id — скажите пожалуйста о какой уникальности здесь идёт речь ??
                                            К примеру:
                                            21 * 10 + 1 = 211
                                            22 * 10 + 1 = 221
                                            Собственно хочется спросить — и чо? В чём разница между 21, 22 и 211, 221?
                                              0
                                              Понятно, что id одной таблицы уникальны. Здесь же, если вы заметили, объединяются записи из 3-х таблиц, а в этом случае у нас вполне могут попасться записи с одинаковым id=21, например. Поэтому для первой таблицы 21 * 10 + 1 = 211, для второй 21 * 10 + 2 = 212 и т.п. Таким способом можно объединить до 10-ти таблиц.
                                              Если этого не хватает, меняем 10 на 100, и проблема решается на долгую перспективу.
                                              –1
                                              я просто думал, что должно быть какое то по оригинальней решение ))
                                              Ведь если, грубо говоря, в одной таблице записей не больше 50, а во второй больше 500, то даже делая id * 10 + 1 as id, то идентификаторы всё равно будут пересекаться… т.е. это далеко не универсальное решение. Так что эта «перспектива» весьма туманна. Я как то читал на баше пост, где тип жаловался на инфляцию и говорил, что ему в его какой то биллинговой софтине пришлось все типы дынных int заменить на long из-за того, что цены быстро растут)))

                                              P.S. Всё равно не ясно, что в этой комбинации решает еденица — id * 10 + 1? ))
                                                0
                                                с чего бы им пересекаться? все идентификаторы первой таблицы кончаются на 1, второй — на 2.
                                                0
                                                с чего бы им пересекаться? все идентификаторы первой таблицы кончаются на 1, второй — на 2.
                                                  0
                                                  подключайтесь к переводу мануала:
                                                  translated.by/you/sphinx-0-9-9-reference-manual/trans/
                                                    0
                                                    Кстати говоря, на 0.9.9 такое уже не сработает — Sphinx требует совпадения полей для всех источников индекса.
                                                      0
                                                      Спасибо, статья очень помогла для старта, тоже была задача поиска по разношерстным данным.
                                                      Хотел бы лишь подчеркнуть небольшие неточности и недосказанности.
                                                      Во-первых,
                                                      >sql_query_info
                                                      >Ну и последний параметр — это маска запроса, который будет извлекать нужную нам информацию по найденным id
                                                      стоить заметить, что этот параметр работает только при поиске через консоль, то есть предназначен для дебага и через API работать не будет

                                                      Во-вторых,
                                                      при указании в индексе двух и более источников, нужно позаботиться о том, чтобы в них было одинаковое количество полей,
                                                      можно это решить как '' as `required_field_name` для несовпадающих поле или использовать для каждого источника свой индекс

                                                      Это те вещи с которыми столкнулся лично я, может помогут кому-то еще.

                                                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                                      Самое читаемое