Приклад реалізації autocomplete з використанням FTS движка PostgreSQL tsearch2

Введення
Коли-то давно я написав для себе щоденник для ведення справ, заміток і фіксації руху по різним завданням. Зроблений він був спочатку на зв'язці PHP + Kohana 2 + PostgreSQL. З часом я переписав все на Yii (першої і тоді єдиною версії). Для повнотекстового пошуку був задіяний вбудований в PostgreSQL движок tsearch2. Багато років я користувався системою, потроху її розвивав і прийшов до того, що обсяг текстів в ній накопичився пристойний. Пошуком доводиться користуватися дуже часто і для підвищення його зручності я задумав прикрутити до нього autocomplete зі складу пакета JQuery UI.

Реалізація
Щоб все було правильно, вибір підказок повинен ґрунтуватися на тому ж індексі, що і сам пошук. Всі тексти в мене зберігаються в окремій таблиці під назвою «texts». Ось її структура:

Table "public.texts"
Column | Type | Modifiers 
-------------+-----------------------------+----------------------------------------------------------
txt_id | bigint | not null default nextval(('gen_txt_id'::text)::regclass)
user_id | integer | not null
txt | text | not null
fti_txt | tsvector | 
last_update | timestamp without time zone | default now()
format | textformat | default 'wiki'::textformat
Indexes:
"texts_pkey" PRIMARY KEY, btree (txt_id)
"texts_txt_id_key" UNIQUE CONSTRAINT, btree (txt_id)
"fti_texts_idx" gist (fti_txt)
"last_update_idx" btree (last_update)
"texts_uid_idx" btree (user_id)

Для реалізації завдання формування списку підказок по поточному рядку пошуку був написаний Action у вигляді окремого підключається дії. Ісходник protected/extensions/actions/SearchAutocompleteAction.php:

<?php

class SearchAutocompleteAction extends CAction
{
public $model;
public $attribute;
public $fts_field;

public function run()
{
// Ініціалізуємо змінні
$_uid = Yii::app()->user->id;
$_model = new $this->model;
$_tableName = $_model->tableName();

// Розбиваємо пошуковий запит на слова, відокремлюємо від нього останнє слово
// і зберігаємо окремо це слово і решті запит
$_query_array = explode(' ', Yii::app()->db->quoteValue($_GET['term']));
$_word = array_pop($_query_array);
$_preQuery = implode(' ', $_query_array);
$_suggestions = array();

/*
* Запит на отримання tsvector з потрібних нам записів. Набір записів повинен належати поточному користувачу
* і в нього входять тільки записи, що відповідають першій частині запиту (без останнього слова).
*/
$_sub_sql = "SELECT $this->fts_field FROM $_tableName WHERE user_id="$_uid"";
if (count($_query_array) > 0)
$_sub_sql .= " AND $this->fts_field @@ to_tsquery("russian", "$_preQuery")";

/*
* Остаточний запит, який повертає список слів для доповнення останнього слова запиту.
* Використовується функція ts_stat з tsearch2. Вона повертає список слів у записах, обраних підзапит вище,
* відсортований за спаданням частоти появи слів у текстах. Можна додати в сортування аттрибут ndoc, що описує
* кількість документів, де зустрічається слово.
*/
$_sql = "SELECT word AS $this->attribute FROM ts_stat('$_sub_sql') WHERE word LIKE '$_word%' ORDER BY nentry DESC LIMIT 15;";

foreach(Yii::app()->db->createCommand($_sql)->query() as $_m)
$_suggestions[] = count($_query_array) > 0 ? $_preQuery.' '.$_m[$this->attribute] : $_m[$this->attribute];

echo CJSON::encode($_suggestions);
}
}

Для розбору алгоритму дій наводжу приклад SQL запиту по рядку пошуку «привіт хаб», формованого Action-ом:

SELECT 
word AS txt 
FROM 
ts_stat('SELECT fti_txt texts FROM WHERE user_id="1" AND fti_txt @@ to_tsquery("russian", "привіт")') 
WHERE 
word LIKE 'хаб%' 
ORDER BY nentry DESC 
LIMIT 15;

Суть роботи tsearch2 загалом полягає у формуванні типу запису tsvector в добавок до текстової, в нашому прикладі це поле fti_txt. В неї записуються слова тексту із зазначенням їх позицій і числа їх появи в тексті. З цього запису будується індекс (gin або gist) і в подальшому виконується пошук. Для налагодження та моніторингу стану індексу в tsearch2 є функція ts_stat. В якості параметра вона приймає текст запиту SQL, повертає набір полів типу tsvector. З цього набору статистика будується у вигляді списку слів з зазначенням кількості входжень (nentry) та кількості документів (записів) де слово зустрічається (ndoc).

В моєму прикладі якщо слово в пошуковому запиті одне — виконується пошук схожих на нього у всіх записах користувача. Якщо слів у запиті кілька — останнє слово вилучається з запиту, набір записів обмежується повнотекстовим пошуком по першій частині запиту (без останнього слова).

Підключення до проекту
Ця частина є Yii 1 специфічної, тут немає ніякої магії. Наводиться для цілісності нотатки. Всього буде два кроки. Крок перший — підключення Action-а до контролера, в моєму випадку DiaryController. Для цього в його метод actions() додаємо рядок:

public function actions()
{
return array(
...
'acsearch' => array(
'class' => 'application.extensions.actions.SearchAutocompleteAction',
'model' => 'Texts',
'attribute' => 'txt',
'fts_field' => 'fti_txt',
),
...
);
}

Тепер у відповідному view замінюємо старе текстове поле пошуку:

<?php echo CHtml::textField('sh', $search->sh, array('size' => 60,'maxlength' => 255)); ?>

на JQuery UI віджет:

<?php $this->widget('zii.widgets.jui.CJuiAutoComplete', array(
'attribute'=> 'sh',
'sourceUrl' => array('acsearch'),
'name' => 'sh',
'value' => $search->sh,
'options' => array(
'minLength' => '2',
),
'htmlOptions' => array(
'size' => 60,
'maxlength' => 255,
),
)); ?>

В результаті отримаємо щось схоже на картинку:

image

Недоліки
У всієї системи є один великий недолік — слова в полі типу tsvector записуються після стемминга. Простіше кажучи у більшості слів «відрізають» закінчення для обліку в пошуку різних форм. Подивіться на картинку вище і зверніть увагу на слово «формування». Таким чином дане рішення застосовується у проектах для особистого/внутрішнього використання. Без вирішення цієї проблеми показувати таке людям не можна. Можливо у когось знайдеться гідне рішення або хоча б думка. Ласкаво просимо в коментарі.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.