26
12/2008

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

The English version of this post

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

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

В проекте есть на данный момент 2 зоны:

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

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

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

Все решение описано для связки PHP5 (Symfony), MySQL, Sphinx. Как ставить Sphinx, я описывать не буду, эту информацию можно прочитать на официальном сайте. Скажу лишь, что под Mac OS X он легко ставиться с помощью macports.

Имеем такую модель БД (я ее упростил, чтобы было поближе к сути) с каким-то набором записей:

Конфигурируем sphinx для индексации и выдачи результатов поиска:

#articles
source article
{
type = mysql
sql_host = localhost
sql_user = root
sql_pass = root
sql_db = ili_lv
sql_sock = /tmp/mysql/mysql.sock

sql_query_range = SELECT MIN(id), MAX(id) FROM article
sql_range_step = 500
sql_query_pre = SET NAMES utf8
sql_query = \
SELECT id * 10 + 1 as id, category_id, 1 as row_type,\
UNIX_TIMESTAMP(created_at) as created_at, title, descr \
FROM article WHERE id >= $start AND id <= $end

sql_attr_uint = category_id
sql_attr_uint = row_type
sql_attr_timestamp = created_at

sql_query_info = SELECT title, descr \
FROM article WHERE id = ($id - 1) / 10
}

#categories
source category
{
#аналогичный блок параметров подключения к БД
#...

sql_query_range = SELECT MIN(id), MAX(id) FROM category
sql_range_step = 500
sql_query_pre = SET NAMES utf8
sql_query = \
SELECT id * 10 + 2 as id, tree_parent as category_id, 2 as row_type,\
UNIX_TIMESTAMP(created_at) as created_at, title, descr \
FROM category WHERE id >= $start AND id <= $end

sql_attr_uint = category_id
sql_attr_uint = row_type
sql_attr_timestamp = created_at

sql_query_info = SELECT title, descr \
FROM category WHERE id = ($id - 2) / 10
}

#geo_objects
source geo_object
{
#аналогичный блок параметров подключения к БД
#...

sql_query_range = SELECT MIN(id), MAX(id) FROM geo_object
sql_range_step = 500
sql_query_pre = SET NAMES utf8
sql_query = \
SELECT id * 10 + 3 as id, 0 as category_id, 3 as row_type,\
UNIX_TIMESTAMP(created_at) as created_at, title, descr \
FROM geo_object WHERE id >= $start AND id <= $end

sql_attr_uint = category_id
sql_attr_uint = row_type
sql_attr_timestamp = created_at

sql_query_info = SELECT title, descr \
FROM geo_object WHERE id = ($id - 3) / 10
}

index site_search
{
source = category
source = geo_object
source = article

path = /var/data/sphinx/site_search
docinfo = extern
morphology = stem_en, stem_ru
html_strip = 0
charset_type = utf-8
min_word_len = 2
}

Чуть по-подробнее о параметрах конфигурации. Разделы source, как понятно из названия, задают хранилища данных, откуда будет извлекаться индексируемая Sphinx информация. Такими хранилищами могут быть базы данных, текстовые файлы, html-файлы, xml и даже почтовые ящики. Этот раздел также описывает, какие поля хранилища будут индексироваться, в каком формате будет производиться индексация (выборка разовая или порционная) и ряд других параметров. В моем случае описано 3 source, все они ведут в одну и ту же базу данных MySQL, но в разные таблицы. Форматы конфигураций похожи, я опишу source article.

 	sql_query_range    = SELECT MIN(id), MAX(id) FROM article
sql_range_step = 500

Этими строками мы «указываем» Sphinx делать выборку из таблицы не полным select-ом, а порциями по 500 записей, чтобы не создавать избыточную нагрузку при индексации.

	sql_query          = \
SELECT id * 10 + 1 as id, category_id, 1 as row_type,\
UNIX_TIMESTAMP(created_at) as created_at, title, descr \
FROM article WHERE id >= $start AND id <= $end

 Это маска запроса, отправляемого Sphinx при индексации данных. Здесь важно 3 момента:

  • Определяется набор полей для индексации, в нашем случае это id, текстовые поля и поля-фильтры;
  • Первое поле используется Sphinx-ом как id в формируемом индексе . Т.к. id из разных таблиц могут совпадать, то применен такой метод формирования уникального id;
  • Поле row_type дает возможность определить, какого типа каждая из сохраненных записей в индексе Sphinx.

Далее идет описание атрибутов, которые можно использовать в качестве фильтров

	sql_attr_uint      = category_id
sql_attr_uint = row_type
sql_attr_timestamp = created_at

Ну и последний параметр — это маска запроса, который будет извлекать нужную нам информацию по найденным id:

	sql_query_info     = SELECT title, descr \
FROM geo_object WHERE id = ($id - 1) / 10

Далее в конфигурационном файле описывается самое важное — параметры индексации указанных нами source-ов с помощью секции index.

	source             = category
source = geo_object
source = article

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

Строчками

	path               = /var/data/sphinx/site_search
docinfo = extern

указываются параметры хранения индекса и полный путь к нему.

В чем еще одна прелесь Sphinx — он «из коробки» поддерживает английскую и русскую морфологию, позволяя приводить слова запроса к нормальной форме. При необходимости эту функциональность можно расширить

	morphology         = stem_en, stem_ru

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

Далее осталось только запустить индексацию.

muxx:~ muxx$ sudo searchd --stop
Sphinx 0.9.8.1-release (r1533)
Copyright (c) 2001-2008, Andrew Aksyonoff

using config file '/usr/local/etc/sphinx.conf'...
stop: succesfully sent SIGTERM to pid 5677

muxx:~ muxx$ sudo indexer --all
Sphinx 0.9.8.1-release (r1533)
Copyright (c) 2001-2008, Andrew Aksyonoff

using config file '/usr/local/etc/sphinx.conf'...
indexing index 'site_search'...
collected 759 docs, 0.0 MB
sorted 0.0 Mhits, 100.0% done
total 759 docs, 22171 bytes
total 0.028 sec, 785871.25 bytes/sec, 26903.45 docs/sec

muxx:~ muxx$ sudo searchd
Sphinx 0.9.8.1-release (r1533)
Copyright (c) 2001-2008, Andrew Aksyonoff

using config file '/usr/local/etc/sphinx.conf'...
creating server socket on 127.0.0.1:3312

muxx:~ muxx$ search мой сложный запрос
Sphinx 0.9.8.1-release (r1533)
Copyright (c) 2001-2008, Andrew Aksyonoff

using config file '/usr/local/etc/sphinx.conf'...
index 'site_search': query 'мой сложный запрос ': returned 0 matches of 0 total in 0.000 sec

words:
1. 'мо': 0 documents, 0 hits
2. 'сложн': 0 documents, 0 hits
3. 'запрос': 0 documents, 0 hits

muxx:~ muxx$

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

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

После того, как удостоверились, что демон работает, можно работать со Sphinx из Symfony.

Устанавливаем плагин sfSphinxPlugin, подключаем его в конфигурациях:

$this->enablePlugins(array('sfSphinxPlugin'));

и пишем небольшой пример запроса к демону:


$sphinx = new sfSphinxClient($options);

//устанавливаем числовые фильтры, если они заданы
if ($request->getParameter('category_id'))
$sphinx->setFilter('category_id', array($request->getParameter('category_id')));
if ($request->getParameter('row_type'))
$sphinx->setFilter('row_type', array($request->getParameter('row_type')));

$dateRange = $request->getParameter('date');
if ($dateRange['from'] || $dateRange['to'])
{
$sphinx->setFilterRange('created_at',
!empty($dateRange['from']) ? strtotime($dateRange['from']) : '',
!empty($dateRange['to']) ? strtotime($dateRange['to']) : '');
}
$this->results = $sphinx->Query($request->getParameter('s'), 'site_search');

if ($this->results === false)
{
$this->message = 'Запрос не выполнен: ' . $sphinx->GetLastError();
}
else
//если все путём, то достаем информацию по id индекса
//и выводим ее в template
$this->items = $this->retrieveResultRows($this->results);

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

8 Comments_5

Tnx! Очень интересно!
This looks awesome, can we expect an English translation?
I'll try to translate this post during the holidays.
Появился вопросик : а как установить ограничение setFilterRange, так, чтобы из результатов были отфильтрован не закрытый интервал.

Например setFilterRange('price', 10, 20) найдет все, с ценой от 10 до 20.

Как сделать так, чтобы находило все с ценой больше 10. Без верхнего ограничения?

Пока, насколько я знаю, такой возможности нет. Как вариант, можно указывать верхнее число заведомо больше, например, 10 млн.
Я уже придумал :)

Сделал так. Предположим нам нужно найти все элементы с `price` больше 10. Устанавливаем такой фильтр:

setFilterRange('price', 0, 10, true)

Четвертый параметр указывает на то, что найденое фильтром нужно исключить из результата. Это как раз то, что нам нужно.

можно каждый раз в source не перепивывать

#аналогичный блок параметров подключения к БД

#...

a польсоваться наследованием. Т.е. написать какой-то ParentSourcе, к котором будут описываться параметры подключения. А остальные sources будут наследоваться от него. Например:

source catalog : ParentSource

Далее идет описание атрибутов, которые можно использовать в качестве фильтров

Насколько я понимаю,

sql_attr_uint = category_id

это целочисленный, беззнаковый атрибут.

А как описывается целочисленный со знаком?

Сегодня "случайно" отсортировали записи на сайте по рейтингу и вверх всплыли записи с рейтингом 4294967295, а именно -1 в форме "со знаком"

Оставить comment
Показать другие цифры

В тексте комментария можно использовать теги <b><i><u><s><sup><code><pre>.
Адреса сайтов автоматически становятся ссылками.

_