Морфологический поиск на русском языке на 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. Данный метод можно применить к любым разделам вашего сайта и сблизить посетителя и вас, просто выдав пользователю информацию, которую он искал.

Комментировать
Обновить проверочный код