Поиск в MySQL. Часть 2 «Поиск с учетом русской морфологии»

Поиск с учетом русской морфологииПоиск в . Часть 2 «Поиск с учетом русской морфологии»

В этой статье описывается идея создания поиска в базе MySQL на основе индекса FULLTEXT с учетом русской морфологии. Никаких модулей для PHP или MySQL и других программ устанавливать на хостинг не нужно. И это важное преимущество данного алгоритма. Алгоритм подойдет для сайтов с малой и средней нагрузкой. Для крупных порталов, конечно, следует искать более скоростные и производительные решения (например, Sphinx). Мой вариант поиска работает значительно быстрее поиска на основе регулярных выражений и подойдет для большого количества web-проектов. Кстати, и не только web :)

Основан алгоритм на уже готовых решениях. Движок для  работы с морфологией – phpMorphy. Это морфологический модуль, написанный на PHP. Он основан на словарях проекта AOT. Оба продукта распространяются под лицензией LGPL.

Вкратце про проект phpMorphy.

Возможности:

  • Получение базовой формы слова
  • Получение всех словоформ слова
  • Получение грамматических характеристик для каждой словоформы
  • Предсказание ненайденных в словаре слов

Характеристики:

Анализ производится по словарю, размер словаря для русского языка ~4Mb. Скорость работы ~700 слов в секунду в нормальном режиме и ~1000 слов с загруженным в память словарем (без предсказания).

Подключение движка морфологии.

Для начала, конечно, нужно скачать модуль phpMorphy с официального сайта. В архиве находятся два каталога: dicts – словари АОТ, src – исходники phpMorphy. Разместите эти два каталога на сервере (желательно в общем каталоге phpMorphy). Далее для работы с модулем необходимо подключить в скрипте сайта файл src/common.php. Например так:

require_once(’phpmorphy/src/common.php’);

Библиотека подключена. Теперь вкратце о ее настройках. Порядок создания объекта:

$dict_bundle = new phpMorphy_FilesBundle(’phpmorphy/dicts’, ‘rus’); // создается объект словарей, //передается путь к каталогу со словарями. В данном случае используется русский словарь.

// Создаем объект словарей

$morphy = new phpMorphy($dict_bundle, array(’storage’ => PHPMORPHY_STORAGE_MEM, ‘with_gramtab’ => false, ‘predict_by_suffix’ => true, ‘predict_by_db’ => true));

Подробнее об опциях можно почитать в README к скрипту, а вот про ‘storage’ стоит упомянуть и тут. Это важная настройка.

phpMorphy поддерживает следующие варианты работы со словарями:

  • PHPMORPHY_STORAGE_FILE – использует файловые операции (fread, fseek) для доступа к словарям. Этот вариант самый медленных, но требует меньше всего памяти.
  • PHPMORPHY_STORAGE_SHM – загружается словари в общую память (используя расширение PHP shmop). Этот режим предпочтителен.
  • PHPMORPHY_STORAGE_MEM – загружает словари в память каждый раз, когда phpMorphy инициализируется. Этот режим используется, когда расширение shmop не включено. Скорость у PHPMORPHY_STORAGE_SHM и PHPMORPHY_STORAGE_MEM одинаковая.

Так как в начале статьи оговорено, что никаких дополнительных расширений мы не используется, в примере указано ’storage’ => PHPMORPHY_STORAGE_MEM. Если с такими настройками ваш поиск «вылетает» с ошибками о недостатке памяти, или злостный хостер ругается, что вы расходуете память больше положенного, включите’storage’ => PHPMORPHY_STORAGE_FILE.

Сразу хочу обратить ваше внимание, что алгоритм поиска будет правильно работать только при правильно настроенной локали (PHP setlocale) и кодировке в БД.

Изменения в таблицах БД.

Теперь поговорим об организации условий для поиска в БД. Напомню, что поиск у нас основан на индексах, что определяет высокую скорость выполнения поисковых запросов (подробнее в части 1).

Допустим, у нас есть некая таблица сообщений, по которой будет осуществляться поиск. В таблице есть поля: message_id, message_text,  message_date. Искать нужно по полю message_text. Для организации поиска с учетом морфологии необходимо создать еще одно поле. Назовем его message_words_index. И как раз для этого поля необходимо создать индекс вместе с полем message_text.

Пример создания такой таблицы:

CREATE TABLE `fn_messages` (`message_id` int(10) unsigned NOT NULL auto_increment,
`message_text` text NOT NULL,   `message_date` timestamp
NOT NULL default CURRENT_TIMESTAMP,
`message_words_index` text NOT NULL,
PRIMARY KEY  (`message_id`),
FULLTEXT KEY `fn_messages_words_index` (`message_text`,`message_words_index`)
) ENGINE=MyISAM;

Таблица готова к работе с полнотекстовым морфологическим поиском.

Внесение данных в таблицу.

При добавлении записей в таблицу, их нужно готовить для проведения поиска. Первые три поля заполняются как обычно, а в последнее вносятся слова сообщения в обработанной начальной форме. Что бы получить начальную форму слов, используется phpMorphy. Создадим функцию function Words2BaseForm($text), в которую передается исходный текст сообщения, а возвращает она в виде строки набор слов в начальных формах. Например:

Передано Получено
При реорганизации предприятия в мае менялись формы и условия контрактов. Когда набирали новых сотрудников с ними контракт не заключали, ждали пока новые формы не будут разработаны. Прошло 3 месяца. Как нам правильно заключить контракты с вновь принятыми людьми, т.к. задним числом мы заключить не можем (с введением новых контрактов изменились и условия оплаты)? БЫТЬ ВВЕДЕНИЕ ЖДАТЬ ЗАДНИЙ ИЗМЕНИТЬСЯ КОГДА ЧЕЛОВЕК МЕНЯТЬСЯ НАБИРАТЬ ОПЛАТА ФОРМА ПОКА РАЗРАБОТАТЬ СОТРУДНИК ЧИСЛО ВНОВЬ ЗАКЛЮЧИТЬ КОНТРАКТ КОНТРАКТОВЫЙ МЕСЯЦ МОЧЬ НОВЫЙ НОВОЕ ПРАВИЛЬНЫЙ ПРАВИЛЬНО ПРЕДПРИЯТИЕ УСЛОВИЕ ПРИНЯТЬ ПРИНЯТЫЙ ПРОЙТИ ПРОШЛЫЙ РЕОРГАНИЗАЦИЯ ЗКЛЮЧАТЬ

Содержимое столбца «Получено» будет помещено в поле message_words_index. Скачать функцию function Words2BaseForm($text) можно здесь.

Поиск данных.

При получении запроса на поиск для обработки фразы поиска используется другая функция, созданная для работы с phpMorphy: function Words2AllForms($text). Принимая разделенные пробелом слова для поиска, она возвращает эти слова во всех их морфологических формах. Таким образом, в полях message_text и message_words_index будут искаться все формы слов, а результаты выведутся по релевантности. Можно создавать индекс и искать только по полю message_words_index, но в этом случае вывод по релевантности будет нарушен.

Замечу, что создание дополнительного поля message_words_index необходимо еще и потому, что система phpMorphy может привести к начальной форме даже те слова, которых нет в словарях. Она это делает по анализу слова в соответствии с
правилами морфологии языка. А вот обратная система не сработает. Поэтому поиск по начальной форме даст более точные результаты.

Итак, передаем поисковый запрос в функцию function Words2AllForms($text), а возвращенный ей результат помещаем в запрос на выборку (вместе с изначальным условием для соблюдения релевантности). Скачать функцию function Words2AllForms($text) можно здесь.

Запрос имеет следующий вид:

SELECT _CALC_FOUND_ROWS * from messages WHERE MATCH (message_text, message_words_index) AGAINST (изначальный_текст, обработанный_текст) LIMIT 0, 10;

Подсветка найденных слов.

Не буду писать про организацию подсветки много слов. Скажу лишь, что она основана на словоформах, полученных из function Words2AllForms($text). Скачать PHP-код подсветки найденных слов можно здесь.

Заключение.

Протестировать данный алгоритм поиска можно на сайте ://kadrovik.by.

Сама идея поиска «распространяется» под лицензией BSD и принадлежит мне (Валерию Леонтьеву). При перепечатке статьи ссылка на эту страницу обязательна. Обращаю внимание, что использованные в алгоритме библиотеки распространяются по лицензии LGPL.

Ссылки.

Продолжение! Внимание, при использовании описанной технологии обязательно прочитайте продолжение статьи. Правки в этот материал решено не вносить. Поиск в MySQL. Часть 3 «FULLTEXT IN BOOLEAN MODE»

Поиск в MySQL. Часть 2 «Поиск с учетом русской морфологии»: 20 комментариев

  1. Подсветка слов работает для всего текста хранимого в поле `message_text` А что есля я хочу выводить подсвечиваемые слова прямо в списке результатов поиска? Т.е подсвечиваемое влово + или — три слова около него.

  2. Игорь, этот алгоритм у меня не реализован, потому что поиск использовался там, где найденные сообщения выводились сразу полностью.

  3. Здравствуйте. А как быть, если текст в UTF-8? Конвертирую его с помощью iconv’а в цп1251, слова мне назад не отдаются…

  4. Честно говоря, я не пробовал эту библиотеку с UTF-8. Так что все, что могу ответить — надо пробовать и разбираться. Если PHP работает в UTF, есть подозрение, что он не сможет нормально работать с самим файлом морфологии. Возможно, проблема в этом.
    Если Вы разберетесь, просьба тут отписать.

  5. Возникла аналогичная проблема с UTF-8. Хотелось бы узнать, кто как решал, если решал.

  6. Пробовал поиск с использованием phpMorphy, он не работает с UTF-8, но при помощи iconv я менял кодировку строки в cp1251, извлекал корень слова, и дальше конвертировал обратно в UTF-8. Но столкнулся с проблемой при выборке из БД, в базе текст также в UTF, и почему то конструкция приведения текста в верхний регистр отказывается работать :(

  7. Офигенный модуль! Разработчику респект!:-) Ему бы еще пару функций контент-анализа…
    Как только прикручу к движку сайта — поставлю ссылку! Только одно непонятно: почему у автора в блоге поиск от Google?

  8. Спасибо :)

    Потому, что лень к вордпресу прикручивать свой поиск. Проще поставить гугл. Да и гугл лучше ищет, чем этот двиг на базе PHPMORPHY. И Яндекс.Сервер тоже лучше ищет. Но у обоих есть очевидные недостатки. Так что описанный выше алгоритм работает (пока) на других сайтах, например http://kadrovik.by. Там его можно вдоволь потестить, если есть желание.

  9. У мну запрос не проходит:-(
    SELECT SQL_CALC_FOUND_ROWS * from messages WHERE MATCH (message_text, message_words_index) AGAINST (изначальный_текст, обработанный_текст) LIMIT 0, 10;
    Так вот, «обработанный_текст» сервер в упор воспринимать не хочет. Без него всё работает, но это же не совсем правильно?… Кстати, Words2AllForms возвращает массив. Пробовал и массив в запрос вставлять, и строку. Никак:-( Help! Версия сервера: 5.0.45

  10. Разобрался. Поправка в запрос:
    SELECT SQL_CALC_FOUND_ROWS * from messages WHERE MATCH (message_text, message_words_index) AGAINST (изначальный_текст[ПРОБЕЛ]обработанный_текст_в_текстовом_формате) LIMIT 0, 10;
    То есть, массив, полученный от Words2AllForms, нужно привести к строке с пробелами. Я правильно всё понял?

  11. Подскажите а почему мне Words2AllForms — ничего не возвращает.. ???
    <?
    $text = $_POST['text'];
    if($text»)
    {
    echo «Все формы «;

    echo Words2AllForms($text);

    echo «Слова «;
    echo Words2BaseForm($text);

    ?>

    Вот результат

    Все формы
    Array

    Слова
    ЭЛЕМЕНТ ФУНКЦИЯ ТОЛЬКО ВЕДЬ ТАКЖЕ СИЛЬНЫЙ СИЛЬНО ПРОИЗВОДИТЬ ПОЛУЧАТЬ НАПОМИНАТЬ ИЗВЛЕКАТЬ ВОЗВРАЩАТЬ ПЕРВЫЙ ПЕРВОЕ НАЧАЛЬНЫЙ МАССИВ КОНЕЧНЫЙ ИЗВЛЕЧЕНИЕ ДОВОЛЬНО ДОВОЛЬНЫЙ ВСТРЯСКА ВСЕГО ВЕСЬ

  12. и вообще почему вы решили, что getAllForms можно скормить массив — по моему он только с одним словом работает…

    mixed phpMorphy::getAllForms($word, $type = self::NORMAL)
    Возвращает список всех форм (в виде массива) для СЛОВА. Если $word отождествляется с формами разных слов, словоформы для каждого слова сливаются в один массив.

    ?????????????

  13. Вот так переписал вашу функцию…. работает и возвращает уже готовую строку всех словоформ поискового запроса склеенных в строку… и сразу пишем её в БД


    /**
    * Возвращает все словоформы слов поискового запроса
    *
    * @param string $text
    * @return array
    */
    function Words2AllForms($text)
    {
    require_once('phpmorphy/src/common.php');

    // set some options
    $opts = array(
    // storage type, follow types supported
    // PHPMORPHY_STORAGE_FILE - use file operations(fread, fseek) for dictionary access, this is very slow...
    // PHPMORPHY_STORAGE_SHM - load dictionary in shared memory(using shmop php extension), this is preferred mode
    // PHPMORPHY_STORAGE_MEM - load dict to memory each time when phpMorphy intialized, this useful when shmop ext. not activated. Speed same as for PHPMORPHY_STORAGE_SHM type
    'storage' => PHPMORPHY_STORAGE_MEM,
    // Extend graminfo for getAllFormsWithGramInfo method call
    'with_gramtab' => false,
    // Enable prediction by suffix
    'predict_by_suffix' => true,
    // Enable prediction by prefix
    'predict_by_db' => true
    );

    $dir = 'phpmorphy/dicts';

    // Create descriptor for dictionary located in $dir directory with russian language
    $dict_bundle = new phpMorphy_FilesBundle($dir, 'rus');

    // Create phpMorphy instance
    $morphy = new phpMorphy($dict_bundle, $opts);

    // All words in dictionary in UPPER CASE, so don`t forget set proper locale
    // Supported dicts and locales:
    // *------------------------------*
    // | Dict. language | Locale name |
    // |------------------------------|
    // | Russian | cp1251 |
    // |------------------------------|
    // | English | cp1250 |
    // |------------------------------|
    // | German | cp1252 |
    // *------------------------------*
    // $codepage = $morphy->getCodepage();
    // setlocale(LC_CTYPE, array('ru_RU.CP1251', 'Russian_Russia.1251'));

    $words = preg_split('/ /', $text, -1, PREG_SPLIT_NO_EMPTY);

    foreach ( $words as $v )
    if ( strlen($v) > 3 )
    {
    $result = $morphy->getAllForms(strtoupper($v));
    $full.= implode(" ", $result);
    }

    return $full;
    }

  14. команду $full.= implode(» «, $result); нужно заменить на

    $full.= @implode(» «, $result);

    так как при попадании в тексте английских слов — ругается благим матом потому, что словарь только русский….

  15. Notice: iconv_strlen() [function.iconv-strlen]: Detected an illegal character in input string in …\src\unicode.php on line 142
    Странно все и как-то мутно, ставишь заглушку, выдает то что было

  16. модифицировал
    $bulk_words = array();
    foreach ( $words as $v )
    if ( strlen($v) > 3 )
    $bulk_words[] = mb_convert_encoding(strtoupper($v), «UTF8», «CP1251»);//конвертим в utf-8

    при выводе обратно, конечно через ж…, но работает

  17. Спасибо за информацию, очень помогла.

    Использую базу с кодировкой UTF-8, и для того чтобы всё заработало пришлось внести некоторые изменения:
    1. Заменить в morphy.functions.php все strtoupper на mb_strtoupper($v, ‘UTF-8’)
    2. Исправить функцию Words2AllForms как указано выше.

    Но это мелочи, в остальном всё отлично прижилось :)

  18. И в запросе поиска AGAINST (изначальный_текст, обработанный_текст) , как писали выше, заменить запятую на пробел.

Добавить комментарий