Введение


Когда-то давно я написал для себя ежедневник для ведения дел, заметок и фиксации движения по разным задачам. Сделан он был изначально на связке 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(' ', trim(Yii::app()->db->quoteValue($_GET['term']), " '\t\n\r\0\x0B"));
        $_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 FROM texts 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 записываются после стемминга. Проще говоря у большинства слов «отрезаются» окончания для учета в поиске их различных форм. Посмотрите на картинку выше и обратите внимание на слово «формирован». Таким образом данное решение применимо в проектах для личного/внутреннего использования. Без решения данной проблемы показывать такое людям нельзя. Возможно у кого-нибудь найдется достойное решение или хотя бы мысль. Добро пожаловать в комментарии.
Поделиться с друзьями
-->

Комментарии (23)


  1. oxidmod
    17.08.2016 10:10

    еластик серч/сфинкс?


    1. ischerbin
      17.08.2016 10:43

      tsearch2 встроен в базовод, не надо ничего отдельно ставить, настраивать и запускать. Но в любом случае на вкус и цвет…


      1. oxidmod
        17.08.2016 10:55

        вы же спрашивали какбы подфиксать окончания? это был ответ


        1. ischerbin
          17.08.2016 10:59
          +1

          Это как ответить на вопрос
          — Как пропатчить KDE под FreeBSD?
          — Ставь винду!
          Вроде бы и ответ и даже должно сработать, но чего то все-таки не хватает.


          1. oxidmod
            17.08.2016 11:11
            -4

            >> Возможно у кого-нибудь найдется достойное решение или хотя бы мысль. Добро пожаловать в комментарии.

            нужно было написать тогда: Возможно у кого-нибудь найдется достойное решение или хотя бы мысль. Добро пожаловать в комментарии. (но так чтобы особо не напрягаться)

            на этом позвольте откланяться


          1. AxisPod
            17.08.2016 14:19

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


            1. ischerbin
              17.08.2016 15:10

              Если не лезть в дебри того, что такое «семантический поиск» и какой софт умеет его выполнять, но говорить исключительно о tsearch2 vs sphinx и ему подобных то с чего Вы взяли, что первый ничего не умеет? Желательно для начала бегло почитать что пишут, например здесь.


  1. Shtucer
    17.08.2016 11:17

    Я, конечно, ничего не понимаю в этих ваших полнотекстовых поисках, поэтому у меня вопрос на вопрос: зачем вы выбираете fts_txt вместо text? В автокомплит подавать кусок живого текста из документа, а не индекс по нему. Это несколько усложнит запрос, но решит проблему с окончаниями. Или так не принято в полнотекстовом поиске делать?


    1. ischerbin
      17.08.2016 11:22

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

      ERROR:  ts_stat query must return one tsvector column
      


      1. Shtucer
        17.08.2016 11:37

        Ну так и пройти немного дальше и использовать полученный tsvector для извлечения информации из текста что-то мешает? Иначе это не было полнотекстовым поиском, а всего лишь стеммером. Ну, так мне кажется. По крайней мере в документации описан довольно богатый арсенал функций, и даже есть раздел «Simple Search Engine», который начинается со слов: Building a search engine involves only a few improvements upon the rudimentary vector searches described in the last section. А в этом самом last section и рассматривался ваш случай — ВЫБРАТЬ tsvector.


  1. Melkij
    17.08.2016 11:51
    +3

    Использовать Yii и так грубо допускать SQL-инъекции? Вроде бы за такое уже больно били по рукам до появления Yii.


    1. ischerbin
      17.08.2016 12:03
      +1

      Спасибо! Заменил строку:

      $_query_array = explode(' ', $_GET['term']);
      

      на:

      $_query_array = explode(' ', Yii::app()->db->quoteValue($_GET['term']));
      


      1. shushu
        17.08.2016 13:35

        А разве

        Yii::app()->db->quoteValue
        
        открывающую и закрывающую кавычку не добавляет?


        1. ischerbin
          17.08.2016 14:54

          Добавил trim и в этот раз без спешки проверил. Теперь работает как положено.


  1. bobelev
    17.08.2016 17:46

    А что не так со словом "формирован"? Ввели "фо" — дополнился "формирован", всё же правильно, разве нет?


    1. ischerbin
      17.08.2016 17:49

      Согласен, пример не самый удачный. Для более наглядной демонстрации результата стемминга привожу топ 10 слов из всей базы:

      tasks=# SELECT * FROM ts_stat('SELECT fti_txt FROM texts') ORDER BY nentry DESC, ndoc DESC LIMIT 10;
        word  | ndoc | nentry 
      --------+------+--------
       сдела  | 1286 |   1507
       сервер |  628 |    830
       сегодн |  667 |    725
       дан    |  573 |    723
       эт     |  579 |    689
       нов    |  525 |    629
       дела   |  524 |    595
       работ  |  522 |    589
       файл   |  382 |    544
       1      |  256 |    538
      (10 rows)
      


  1. cmdx
    17.08.2016 23:45
    +4

    Давайте по порядку.

    У всей системы есть один крупный недостаток...

    Откуда, позвольте поинтересоваться, вы откопали tsearch2? Его нужно закопать обратно и больше не трогать. Этот модуль используется только для обратной совместимости. Начиная с версии 8.3. в PostgreSQL есть встроенный полнотекстовый поиск:
    The tsearch2 module provides backwards-compatible text search functionality for applications that used tsearch2 before text searching was integrated into core PostgreSQL in release 8.3.
    … слова в поле типа tsvector записываются после стемминга

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

    SELECT
      token
    , dictionary
    , lexemes
      FROM ts_debug('Проверяю ксерокс searches ктулху')
      WHERE lexemes IS NOT NULL
    ;
    


    Должно получиться что-то вроде:

         token   |  dictionary     |  lexemes
       ----------+-----------------+-------------
        Проверяю | dict_ispell_rus | {проверять}
        ксерокс  | thesaurus_tpro  | {xerox}
        searches | dict_ispell_eng | {search}
        ктулху   | russian_stem    | {ктулх}
    


    P.S. А если еще синонимы настроить, то можно получить такой бонус:
    SELECT to_tsvector('клапан запорный корозийностойкий') @@ plainto_tsquery('вентиль нержавеющий'); -- TRUE
    


    1. ischerbin
      19.08.2016 04:31

      Хабр — сила! Спасибо!


  1. paradoxfm
    17.08.2016 23:45
    +2

    Тут стоит посмотреть в сторону pg_trgm
    https://postgrespro.ru/docs/postgrespro/9.5/pgtrgm


  1. shk1r
    18.08.2016 14:53

    Позвольте спросить, что выдаст запрос с частью слова, например, «обраб»(обработка)? Необходимо обязательно минимум одно полное слово для FTS?


    1. cmdx
      18.08.2016 19:24
      +1

      Для поиска по части слова лучше действительно использовать pg_trgm как советует paradoxfm

      SELECT title FROM company WHERE LOWER(title) ~ 'дизель' ORDER BY 1;
      
      АВТОДИЗЕЛЬ ПЛЮС
      АВТОДИЗЕЛЬЗАПЧАСТЬ
      Автодизель Актобе
      Дизель-Резерв
      ДизельГрупп
      ДизельДеталь
      ...
      Ярдизель Сервис
      


      Но для этого желательно правильно настроить индексы:
      CREATE INDEX ON company USING gin (LOWER(title) gin_trgm_ops);
      EXPLAIN SELECT title FROM company WHERE LOWER(title) ~ 'дизель' ORDER BY 1;
      
      ...
      Bitmap Index Scan on company_lower_title_trgm
        Index Cond: (lower(title) ~ 'дизель'::text)
      


    1. cmdx
      18.08.2016 19:32
      +1

      @shk1r: Необходимо обязательно минимум одно полное слово для FTS?


      Теоретически, вы можете искать по части слова используя родной полнотекстовый поиск:
      SELECT title FROM company WHERE tsv @@ to_tsquery('дизельн:*');
      
      Балтийская дизельная компания
      Дизельные Технологии
      ...
      Саратовдизельаппарат
      Сибирский дизельный центр
      


      Я предпочитаю для поиска создавать отдельное поле, например
      tsv::TSVECTOR

      и делать индекс по нему
      ...
      Bitmap Index Scan on company_tsv  (cost=0.00..496.22 rows=28 width=0)
        Index Cond: (tsv @@ to_tsquery('дизельн:*'::text))
      


      1. shk1r
        19.08.2016 12:12

        Спасибо большое за ответы