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 ‘переглядів’:
$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 “failed Query:” . $cl->GetLastError() . “.\n”;
} else {
if ( $cl->GetLastWarning() ) {
echo “WARNING:” . $cl->GetLastWarning() . “\n”;
}
echo “total found: {$res[‘total_found’]} о {$res[‘time’]} sec”;
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’]}
“;
echo “weight: {$docinfo[‘weight’]}
“;
echo “relevance: {$docinfo[‘attrs’][‘relev’]}
“;
echo “votes: {$docinfo[‘attrs’][‘votes’]}
“;
echo “stars: {$docinfo[‘attrs’][‘stars’]}
“;
echo “rank: {$docinfo[‘attrs’][‘rank’]}
“;
echo “visits: {$docinfo[‘attrs’][‘переглядів’]}
“;
echo “keywords: “.join(‘,’, $docinfo[‘attrs’][‘keyword’] ).”
“;
echo “languages: “.join(‘,’, $docinfo[‘attrs’][‘language’] ).”
“;
echo “countries: “.join(‘,’, $docinfo[‘attrs’][‘country’] ).”
“;
echo “cities: “.join(‘,’, $docinfo[‘attrs’][‘city’] ).”
“;
echo “types: “.join(‘,’, $docinfo[‘attrs’][‘type’] ).”
“;
echo “updated: “.date(“Y-m-d H:i”,$docinfo[‘attrs’][‘updated’]).”
“;
echo “”;
}
// це був висновок того, що нам повернув 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 за своєчасну онлайн консультацію 😉