Морфологический поиск на русском языке на PHP и MySQL
Важной функцией на сайте является поиск по разделу или по всему сайту. Это позволяет пользователю быстро найти необходимую информацию. Чаще всего этот вопрос решается с помощью полнотекстового поиска или поиска по базе через выражение LIKE. Полнотекстовый поиск удобен тем, что перекладывает вопрос ранжирования страниц по релевантности на СУБД, позволяет искать фразы.
Но вместе с тем накладывает некоторые ограничения, в частности, заставляет использовать таблицы типа MyISAM, не осуществляет поиск слов, короче 4-ех символов, создает дополнительные индексы. Поиск через выражение LIKE менее удобен для разработчика, так как работает медленнее, осложняет поиск фраз. Общим недостатком обоих подходов является невозможность морфологического поиска. Например, в каталоге товаров присутствет товар "Букет из желтых роз", при поиске по фразе "красные розы" пользователь не увидит этот товар в результатах. А хотелось бы, чтобы машина хоть немного понимала естественный язык. Решить эту проблему несложно на PHP и в этом нам поможет phpMorphy. phpMorphy позволяет решать следующие задачи:
- Лемматизация (получение нормальной формы слова)
- Получение всех форм слова
- Получение грамматической информации для слова (часть речи, падеж, спряжение и т.д.)
- Изменение формы слова в соответствии с заданными грамматическими характеристиками
- Изменение формы слова по заданному образцу
Поддерживаемые языки: Русский, Английский, Немецкий (AOT). Украинский, Эстонский (на основе ispell). Есть возможность добавить поддержку других языков при помощи myspell словаря. Словари можно взять здесь (http://sourceforge.net/projects/phpmorphy/files/phpmorphy-dictionaries/). Выбирайте в зависимости от версии библиотеки и кодировки.
Поддерживаются различные кодировки:
- все однобайтовые (windows-1251, iso-8859-* и т.п.)
- Unicode кодировки - utf-8, utf-16le/be, utf-32, ucs2, ucs4.
Следует учесть что использование этой библиотеки создаст некоторую дополнительную нагрузку на сервер при инициализации и работе.
Для начала скачайте архив библиотеки и распакуйте его, например, в папку "phpmorphy"
. По возможности в директории недоступной web серверу. Далее, скачайте словари с определенным языком и кодировкой и распакуйте их в папку "phpmorphy/dicts"
данной библиотеки. Для инициализации библиотеки подключите файл common.php
из папки "phpmorphy/src"
и выставите настройки:
require_once( 'phpmorphy/src/common.php'); // Укажите путь к каталогу со словарями $dir = 'phpmorphy/dicts'; // Укажите, для какого языка будем использовать словарь. // Язык указывается как ISO3166 код страны и ISO639 код языка, // разделенные символом подчеркивания (ru_RU, uk_UA, en_EN, de_DE и т.п.) $lang = 'ru_RU'; // Укажите опции $opts = array('storage' => PHPMORPHY_STORAGE_FILE,); // создаем экземпляр класса phpMorphy // обратите внимание: все функции phpMorphy являются throwable т.е. // могут возбуждать исключения типа phpMorphy_Exception (конструктор тоже) try { $morphy = new phpMorphy($dir, $lang, $opts); } catch(phpMorphy_Exception $e) { die('Error occured while creating phpMorphy instance: ' . $e->getMessage()); } // далее под $morphy мы подразумеваем экземпляр класса phpMorphy
С возможными опциями настройки библиотеки можно ознакомиться здесь (http://phpmorphy.sourceforge.net/dokuwiki/manual).
Для поиска мы будем использовать следующий подход: слова в поисковой фразе будем приводить к их начальной форме, индексировать контент для поиска также, приводя отдельные слова к начальной форме, и в зависимости от частоты повторения и местонахождения слова — задавать ему вес, далее — искать по таблице индексов поисковую фразу и ранжировать результаты по весу.
Для начала проиндексируем контент, по которому будет происходить поиск. В нашем примере поиск происходит по цветам в каталоге товаров. У товара есть идентификатор, наименование, краткое и полное описание. Кодировка — UTF-8, collate - utf8_bin
. Создадим таблицу с товарами:
CREATE TABLE `product` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(255) CHARACTER SET utf8 NOT NULL, `short_text` varchar(255) CHARACTER SET utf8 DEFAULT NULL, `text` text CHARACTER SET utf8, PRIMARY KEY (`id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
и таблицу, в которой будут храниться индексы:
CREATE TABLE `prod2search` ( `word` varchar(32) COLLATE utf8_bin NOT NULL, `prod_id` int(11) NOT NULL, `weight` tinyint(1) NOT NULL, PRIMARY KEY (`word`,`prod_id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
здесь `word`
- ключевое слово, `prod_id`
- идентификатор товара, `weight`
- вес слова. Каждая запись характеризуется уникальной парой слово-товар.
Далее возьмем из базы все записи о товарах:
SELECT * FROM `product`
При работе с библиотекой PhpMorphy cледует помнить, что термины в словарях хранятся в верхнем регистре, для удобства переведем контент в верхний регистр. Также возможно появление слов с буквой "ё"
, collate utf8_bin
гарантирует нам отсутствие ошибок, связанных с уникальностью ключей в таблице `prod2search`
, так как буквы "е"
и "ё"
не будут считаться одним символом, однако будет неправильным если слова "ЗЕЛЕНЫЙ"
и "ЗЕЛЁНЫЙ"
будут индексироваться отдельно. Поэтому мы заменяем букву "ё"
на "е"
. В подробном описании могут содержаться html-теги, их мы не будем индексировать и от них следует избавиться.
Здесь приведем скрипт, индексирующий товары, а далее дадим пояснения.
//Инициализируем PhpMorphy, код инициализации приведен выше //Массив $res содержит результат запроса выше — все записи о товарах for($i = 0; $i < count ( $res ); $i ++) { $res [$i] ['name'] = mb_strtoupper ( str_ireplace ( "ё", "е", $res [$i] ['name'] ), "UTF-8" ); $res [$i] ['short_text'] = mb_strtoupper ( str_ireplace ( "ё", "е", $res [$i] ['short_text'] ), "UTF-8" ); $res [$i] ['text'] = mb_strtoupper ( str_ireplace ( "ё", "е", strip_tags ( $res [$i] ['text'] ) ), "UTF-8" ); $word = array (); preg_match_all ( '/([a-zа-яё]+)/ui', $res [$i] ['name'], $word_pma ); $words = $morphy->lemmatize ( $word_pma [1] ); foreach ( $words as $k => $w ) { //Если не удалось получить начальную форму if (! $w)$w [0] = $k; //Индексируем только слова, длиннее двух символов if (mb_strlen ( $w [0], "UTF-8" ) > 2) { if (! isset ( $word [$w [0]] )) $word [$w [0]] = 0; //Выставляем вес слова $word [$w [0]] += 3; } } preg_match_all ( '/([a-zа-яё]+)/ui', $res [$i] ['short_text'], $word_pma ); $words = $morphy->lemmatize ( $word_pma [1] ); foreach ( $words as $k => $w ) { if (! $w) $w [0] = $k; if (mb_strlen ( $w [0], "UTF-8" ) > 2) { if (! isset ( $word [$w [0]] )) $word [$w [0]] = 0; $word [$w [0]] += 2; } } preg_match_all ( '/([a-zа-яё]+)/ui', $res [$i] ['text'], $word_pma ); $words = $morphy->lemmatize ( $word_pma [1] ); foreach ( $words as $k => $w ) { if (! $w) $w [0] = $k; if (mb_strlen ( $w [0], "UTF-8" ) > 2) { if (! isset ( $word [$w [0]] )) $word [$w [0]] = 0; $word [$w [0]] ++; } } foreach ( $word as $wd => $w ) { mysql_query ( "INSERT INTO `prod2search` SET `word` = '".$wd."', `prod_id` = ".$res[$i]['id'].", `weight` = ".$w ); } }
Итак, строка:
preg_match_all ( '/([a-zа-яё]+)/ui', $res [$i] ['name'], $word_pma );
Данное регулярное выражение разбивает контент на отдельные слова. Для кириллицы используется модификатор "u"
. Следует помнить, что буква "ё"
не попадает в диапазон "а-я"
. Регулярное выражение корректно обработает такую строку:
"Текст-текст. Текст _текст_, 123текст123."
Массив $word_pma[1]
содержит список отдельных слов.
В строке:
$words = $morphy->lemmatize ( $word_pma [1] );
под $morphy
мы подразумеваем экземпляр класса phpMorphy
, $morphy->lemmatize()
приводит слово в строке или массив слов к начальной форме и возвращает массив вида "Исходное слово" => Массив со списком слов в начальной форме
. Метод lemmatize()
по умолчанию сравнивает слово по словарям, при отрицательном результате пытается образовать начальную форму по внутренним правилам, если и это не удается вместо массива возвращается FALSE
. В нашем примере мы использовали только русский словарь, но выбирали также и английские слова. Поэтому при невозможности образовать начальную форму мы индексировали входящее слово:
foreach ( $words as $k => $w ) {if (! $w) $w [0] = $k;...}
Блок:
preg_match_all ( '/([a-zа-яё]+)/ui', $res [$i] ['text'], $word_pma ); $words = $morphy->lemmatize ( $word_pma [1] ); foreach ( $words as $k => $w ) { if (! $w)$w [0] = $k; if (mb_strlen ( $w [0], $this->conf ['opt'] ['charset'] ) > 2) { if (! isset ( $word [$w [0]] )) $word [$w [0]] = 0; $word [$w [0]] ++; } }
повторяется в коде несколько раз потому что, нам необходимо выставить разный вес словам в зависимости от их местонахождения. Словам в заголовке мы задаем вес 3, словам в кратком описании — 2, в полном — 1. При повторе слова увеличиваем его вес.
В итоге у нас получается массив $word
для одного товара, который имеет вид "Слово" => "Вес"
. Эти данные заносим в таблицу `prod2search`
:
mysql_query("INSERT INTO `prod2search` SET `word` = '".$wd."', `prod_id` = ".$res[$i]['id'].", `weight` = ".$w);
Скрипт еще возможно улучшить, например, запретить индексирование определенных предлогов; индексировать определенные части речи, например, только существительные и прилагательные; исключать поиск по словам, которые присутствуют более чем в половине записей, к примеру слово "описание"
входит в полное описание каждого товара и поиск по этому слову и никак не характеризует конкретный товар.
Данный скрипт позволяет с нуля проиндексировать весь контент, и служит лишь дополнительной утилитой. В реальном приложении необходимо индексировать текстовый контент только при добавлении или редактировании конкретной записи. Для индексации контента воспользуемся приведенным выше скриптом, но только не будем использовать цикл и будем работать с одной записью. При редактировании, до внесения индексов в таблицу `prod2search`
или при удалении товара - удалять неактуальные записи:
mysql_query("DELETE FROM `prod2search` WHERE `prod_id` = ".$prod_id);
где $prod_id
— идентификатор редактируемого или удаляемого товара.
Можно произвести некоторую оптимизацию. Допустим, кроме наименования, краткого и полного описания товар обладает еще несколькими характеристиками, и при редактировании пользователь меняет только цену. В этом случае индексированный контент не изменился и не требуется его повторная индексация. Этого можно избежать сравнив хеши индексируемого контента до и после редактирования товара. Сольем индексируемые поля в одну строку и получим ее хеш через md5($str)
, далее вставим полученное значение в форму и передав данные на сервер сравним хеш до и после редактирования. Если хеши совпадут, значит контент не менялся и не требуется его переиндексация.
После того как проиндексирован весь контент и таблица `prod2search`
заполнена данными можно приступать к реализации поиска.
//Инициализируем PhpMorphy, код инициализации приведен выше //Переменная $search содержит обработанную поисковую фразу //Если фраза коротка или пуста следует прекратить поиск и уведомить пользователя об этом //Разбиваем поисковую фразу на отдельные слова preg_match_all ( '/([a-zа-яё]+)/ui', mb_strtoupper ( $search, "UTF-8" ), $words_pma ); //Приводим слова из поисковой фразы к начальной форме, аналогично как это //происходило в предыдущем скрипте индексации содержимого $words = $morphy->lemmatize ( $words_pma [1] ); $sql = array (); $result = array (); foreach ( $words as $k => $w ) { if (! $w)$w [0] = $k; if (mb_strlen ( $w [0], "UTF-8" ) > 2) { //Функция quote_smart() подготавливает строки для вставки их в //запрос, обрамляет кавычками и т.д. array_push ( $sql, quote_smart( $w [0] ) ); } } //Если после разбора поисковой фразы мы не смогли сформировать слова для поиска if (! count ( $sql )) { die('по указаной фразе ничего не найдено'); } mysql_query ( "SELECT `p`.*, `s`.`prod_id`, SUM(`weight`) AS `weight_sum`, COUNT(`s`.`prod_id`) AS `num` FROM `prod2search` AS `s` INNER JOIN `product` AS `p` ON `p`.`id` = `s`.`prod_id` WHERE `s`.`word` IN (" . implode ( ",", $sql ) . ") GROUP BY `s`.`prod_id` HAVING `num` = ".count ( $sql )." ORDER BY `weight_sum` DESC");
Разберем запрос. Мы находим записи в таблице `prod2search`
, в которых слова из списка слов в поисковой фразе WHERE `s`.`word` IN (" . implode ( ",", $sql ) . ")
. При поиске по нескольким словам, несколько слов относятся к одному и тому же товару, чтобы товары в результате не дублировались мы группируем результат по товару GROUP BY `s`.`prod_id`
. В этом же случае мы суммируем вес найденных слов для одного товара SUM(`weight`) AS `weight_sum`
. Например, мы ищем «красные розы»
- у товара, у которого оба слова встречаются в описании, вес будет больше, чем у товара с одним словом. Результаты выборки мы ранжируем по релевантности, то есть весу ORDER BY `weight_sum` DESC
. Для того чтобы при поиске нескольких слов в результате мы получили только те товары, в описании которых встречаются ВСЕ попавшие в запрос слова, мы учитываем сколько слов для одного товара найдено COUNT(`s`.`prod_id`) AS `num`
и ставим условие HAVING `num` = ".count ( $sql )."
. В данном случае реализуется логическое «И», соединяющие слова в поисковой фразе, для логического «ИЛИ» условие следует упустить. В итоге мы получаем список идентификаторов товаров, которые подходят под критерий поиска. Для наглядности записи с товарами мы берем вложенным запросом, однако на практике, возможно, лучшим решением будет взять список товаров отдельным запросом по их идентификаторам. Вариант запроса с логическим «ИЛИ» и лимитами будет работать значительно быстрее.
Таким образом, на примере каталога цветов мы реализовали морфологический поиск на русском языке с использованием PHP, MySQL и PhpMorphy. Данный метод можно применить к любым разделам вашего сайта и сблизить посетителя и вас, просто выдав пользователю информацию, которую он искал.