Zend Framework: пример реализации поиска с помощью Zend_Search_Lucene

Задача: организовать полноценный, быстрый и релевантный поиск по сайту.

Решение: использовать php-имплементацию поискового движка «Люсин», или по-нашему Java Lucene, — Zend_Search_Lucene. Этот вариант хорош тем, что запросто заведется даже на виртуальном хостинге, где нет возможности ставить свои приложения на сервер.

Конечно, можно решить эту задачу и с помощью Sphinx, но зачем, если мы все так привыкли к инструментам Zend Framework?) К тому же во многих тестах решение на php работает не сильно медленней sphinx. Есть еще вариант: реализовать поиск силами SQL, но в этом случае при больших размерах баз данных и небольшом объеме оперативной памяти, выделенном процессу mysqld, мы рискуем напороться на медленную скорость прохождения запросов.

1. Настраиваем приложение

Начнем с создания простой среды для работы приложения. Здесь всё стандартно, стоит просто вспомнить оригинальный Zend Framework Quick Start или еще какой-нибудь мануал. Теперь добавим наш модуль, который будет отвечать за поиск. Здесь тоже всё стандартно, не забудем также положить файл Bootstrap.php в корень модуля. Это необходимо для работы системы автозагрузки классов. У файла /application/modules/search/Bootstrap.php следующее содержимое:

<?php
class Search_Bootstrap extends Zend_Application_Module_Bootstrap
{

}

В итоге получаем такую структуру приложения:

2. Создаем поисковой индекс

В основе у нас будет простая таблица со словами:

CREATE TABLE IF NOT EXISTS `ru_words` (
  `id` int(11) NOT NULL auto_increment,
  `name` varchar(50) NOT NULL,
  PRIMARY KEY  (`id`),
) DEFAULT CHARSET=utf8;

INSERT INTO `ru_words` (`id`, `name` ) VALUES
(1, 'абрикос'),
(2, 'египет'),
(3, 'абзац'),
(4, 'зенд');

Создадим файл /application/modules/search/models/Search.php:

<?php
class Search_Model_Search extends Zend_Db_Table_Abstract {

	protected $_searchIndexPath;	//путь для папки с файлами поискового индекса
	protected $_name = 'ru_words';	//имя таблицы в БД, для которой создаем поисковой индекс

	public function __construct() {
		$this->_searchIndexPath = APPLICATION_PATH . '/data/search-index';
		set_time_limit(900);
		Zend_Search_Lucene_Analysis_Analyzer::setDefault(
			new Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8_CaseInsensitive());

	}

	/**
	 * Создает новый поисковой индекс
	 */
	public function updateIndex() {
		//удаляем существующий индекс, в большинстве случае эта операция с последующий созданием нового индекса работает гораздо быстрее
		$this->recursive_remove_directory($this->_searchIndexPath, TRUE);

	  	try {
	  		$index = Zend_Search_Lucene::create($this->_searchIndexPath);
	  	} catch (Zend_Search_Lucene_Exception $e) {
	  	 echo "<p class=\"ui-bad-message\">Не удалось создать поисковой индекс: {$e->getMessage()}</p>";
	  	}

		try {
			//выбираем все слова из БД
			$words = $this->fetchAll($this->select());
			$i = 0;
			foreach ($words as $w) {
				$doc = new Zend_Search_Lucene_Document();
				$doc->addField(Zend_Search_Lucene_Field::Keyword('word_id', $w['id']));
				$doc->addField(Zend_Search_Lucene_Field::Text('name', $w['name'], 'UTF-8'));
				$index->addDocument($doc);
				$i++;
			}
		} catch (Zend_Search_Lucene_Exception $e) {
    		echo "<p class=\"ui-bad-message\">Ошибки индексации: {$e->getMessage()}</p>";
    	}

    	//let's clean up some
    	$index->optimize();

    	echo "<p class=\"ui-good-message\">
    			Поисковой индекс слов заново создан. Слов добавлено: {$i}. <br />
    			Индекс оптимизирован.</p>";
	}

	/**
	 * recursive_remove_directory( directory to delete, empty )
	 * expects path to directory and optional TRUE / FALSE to empty
	 *
	 * @param $directory
	 * @param $empty TRUE - just empty directory
	 */
	function recursive_remove_directory($directory, $empty=FALSE)
	{
		if(substr($directory,-1) == '/')
		{
			$directory = substr($directory,0,-1);
		}
		if(!file_exists($directory) || !is_dir($directory))
		{
			return FALSE;
		}elseif(is_readable($directory))
		{
			$handle = opendir($directory);
			while (FALSE !== ($item = readdir($handle)))
			{
				if($item != '.' && $item != '..')
				{
					$path = $directory.'/'.$item;
					if(is_dir($path))
					{
						$this->recursive_remove_directory($path);
					}else{
						unlink($path);
					}
				}
			}
			closedir($handle);
			if($empty == FALSE)
			{
				if(!rmdir($directory))
				{
					return FALSE;
				}
			}
		}
		return TRUE;
	}
}

В конструкторе инициализируем свойства класса, выбираем режим анализатора текста. По умолчанию используется ASCII-анализатор, который для текстов на русском не подходит, поэтому изменяем его на регистронезависимый анализатор с поддержкой utf-8.

Стоит отметить решение с удалением предыдущего индекса. Создание нового индекса для 2500 записей занимает 40-60 секунд, если же использовать алгоритмы обновления индекса, то такая операция займет 8-10 минут.

Теперь создадим файл /application/modules/search/controllers/IndexController.php:

<?php

class Search_IndexController extends Zend_Controller_Action {

	public function updateIndexAction() {
		$this->_helper->layout()->disableLayout();
		$this->_helper->viewRenderer->setNoRender(true);

		$model = new Search_Model_Search();
		$model->updateIndex();
	}
}

Здесь мы просто создаем action для вызова функции создания поискового индекса. Обратиться к ней мы всегда с можем по адресу http://localhost/search/index/update-index

3. Осуществляем поиск

Добавим нашей модели (/application/modules/search/models/Search.php) функцию поиска:

	/**
	 * Search by query
	 *
	 * @param $query search query
	 * @return array Zend_Search_Lucene_Search_QueryHit
	 */
	public function search($query) {
		try{
			$index = Zend_Search_Lucene::open($this->_searchIndexPath.'/words');
		} catch (Zend_Search_Lucene_Exception $e) {
			echo "Ошибка:{$e->getMessage()}";
		}

		$userQuery = Zend_Search_Lucene_Search_QueryParser::parse($query);

		return $index->find($userQuery);
	}

Теперь добавим её вызов в соответствующем котроллере ( /application/modules/search/controllers/IndexController.php):

	public function searchAction() {
		$model = new Search_Model_Search();
		$this->view->query = $this->_getParam('query');
		$this->view->hits = $model->search($this->view->query);
	}

И соответствующий вид по адресу /application/modeuls/view/scripts/search.phtml:

<?php

if (empty($this->hits)){
	echo "Ничего не найдено.";
} else {
	echo "<ul>";
	foreach($this->hits as $hit){
		echo "<li>{$hit->name}</li>";
	}
	echo "</ul>";
}

4. Смотрим, как работает

Заходим по ссылке http://localhost/search/index/search/?query=зенд и видим, что у нас есть в индексе на этот счет.

Вот и сказу конец. Как видно, всё довольно прозрачно, просто стоит потратить время на официальный мануал для компонента (он, кстати, есть на русском, но английском он полнее).

В конце приведу цифры сравнения простого поиска с помощью SQL и Zend_Search_Lucene. В таблице с 2500 записями, в каждой из которых по 6 полей, поиск с SQL занимает 130-350 мс (такой разброс во времени обуславливается кешированием запросов) на VPS с 400 МГц и 256 ОЗУ. На той же конфигурации запросы через Zend_Search_Lucene занимают 145-200 мс. Такие сравнения, конечно, не показатель для больших проектов, но дают представления об общей картине в рамках средних и небольших проектов.

Скачать архив с тестовым приложением

<?php
class Search_Model_Search extends Zend_Db_Table_Abstract { 

protected $_searchIndexPath;    //path to initial data folder
protected $_name = ‘ru_words’;                //database adapter

public function __construct() {
$this->_searchIndexPath = APPLICATION_PATH . ‘/data/search-index’;
set_time_limit(900);
Zend_Search_Lucene_Analysis_Analyzer::setDefault(
new Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8_CaseInsensitive());

}

/**
* Создает новый поисковой индекс
*/
public function updateIndex() {
//удаляем существующий индекс, в большинстве случае эта операция с последующий созданием нового индекса работает гораздо быстрее
$this->recursive_remove_directory($searchIndexPath, TRUE);

try {
$index = Zend_Search_Lucene::create($searchIndexPath);
} catch (Zend_Search_Lucene_Exception $e) {
echo «<p class=\»ui-bad-message\»>Не удалось создать поисковой индекс: {$e->getMessage()}</p>»;
}

try {
//выбираем все слова из БД
$words = $this->fetchAll($this->select());

  • гость

    Есть некоторые поправки которые сделал у себя, используя Ваш код.

    1) в функции updateIndex() использовал $this->_searchIndexPath в место $searchIndexPath;
    2) при выборе всех слов из таблицы указал класс таблицы из которой делаем выборку;
    3) внутри функции recursive_remove_directory($directory, $empty=FALSE) при вызове её же указал $this->recursive_remove_directory($path), так как выдавало ошибку.

    В остальном отличный пример. Спасибо большое.

  • гость

    Есть некоторые поправки которые сделал у себя, используя Ваш код.

    1) в функции updateIndex() использовал $this->_searchIndexPath в место $searchIndexPath;
    2) при выборе всех слов из таблицы указал класс таблицы из которой делаем выборку;
    3) внутри функции recursive_remove_directory($directory, $empty=FALSE) при вызове её же указал $this->recursive_remove_directory($path), так как выдавало ошибку.

    В остальном отличный пример. Спасибо большое.

  • Пользуйтесь на здоровье и спасибо за поправки.)

  • Пользуйтесь на здоровье и спасибо за поправки.)