Suchen mit Zend_Search_Lucene

Eine Suche in einer MySQL-Datenbank ist arg begrenzt. Es gibt nur 2 verschiedene Suchvarianten:

Beide Möglichkeiten können mit dem richtigen Index auch zu ganz guten Ergebnissen führen. Allerdings wird es bei größeren Datenmengen nicht mehr so performant. Alternativ kann man Lucene einsetzen.

Das Zend Framework liefert eine eigene Adaption der Apache Lucene Engine in PHP: Zend_Search_Lucene.

Index aufbauen mit Zend_Search_Lucene

Der Index bei Lucene beinhaltet alle Indexinformationen in einem binären Format. Das Format ist mit dem von Apache Lucene identisch und kann von beiden Engines genutzt und erzeugt werden.
Zum Aufbau des Indexes werden Dokumente in dem Index angelegt. Jedes Dokument besteht aus Feldern. Es gibt 5 verschiedene Feldtypen:

  • Keyword (Stichwort) Felder werden gespeichert und indiziert, was bedeutet, dass sie sowohl durchsucht als auch in Suchergebnissen angezeigt werden können. Sie werden nicht in einzelne Worte (Tokens) zerteilt. Datenbankfelder für Aufzählungen lassen sich normalerweise leicht in Keyword Felder für Zend_Search_Lucene überführen.
  • UnIndexed (unindizierte) Felder sind nicht durchsuchbar, werden aber bei Suchtreffern zurückgegeben. Datenbank Zeitstempel, Primärschlüssel, Pfade des Dateisystems und andere externe Identifikatoren sind gute Kandidaten für UnIndexed Felder.
  • Binary (binäre) Felder werden nicht in Token aufgeteilt und indiziert, aber für die Rückgabe bei Suchtreffern gespeichert. Sie können für die Speicherung aller Daten, die als binäre Zeichenkette kodiert sind, verwendet werden, wie z.B. eine Grafiksymbol.
  • Text Felder werden gespeichert, indiziert und in Token aufgeteilt. Text Felder sind geeignet für die Speicherung von Informationen wie Themen und Überschriften, die sowohl durchsuchbar sein müssen, als auch in Suchergebnissen zurückgegeben werden müssen.
  • UnStored (nicht gespeicherte) Felder werden in Token aufgeteilt und indiziert, aber nicht im Index gespeichert. Umfangreiche Texte werden am besten durch diesen Feldtyp indiziert. Gespeicherte Daten benötigen einen größeren Index auf der Festplatte, wenn du also Daten nur durchsuchbar aber nicht wieder ausgegeben haben musst, verwende ein UnStored Feld. UnStored Felder sind geeignet, wenn ein Zend_Search_Lucene Index in Kombination mit einer relationalen Datenbank verwendet wird. Du kannst große Datenfelder mit UnStored Feldern für die Suche indizieren und sie aus der relationalen Datenbank durch die Verwendung eines separaten Feldes mithilfe eines Identifikators zurückholen.

(Quelle: framework.zend.com)

Zum Aufbau des Indexes erstelle ich mir ein PHP-Skript, welches periodisch den Index komplett aktualisiert.

Hier die /cronjobs/Bootstrap.php:

<?php

// define base path
define('BASE_PATH', realpath(dirname(__FILE__) . '/../'));

// define application path
define('APPLICATION_PATH', BASE_PATH . '/application');

// define application environment
define('APPLICATION_ENV', 'development');

// set include path for zend library and models
set_include_path(
	BASE_PATH . '/library' . PATH_SEPARATOR . APPLICATION_PATH . '/models'
);

// create application, bootstrap, and run
require_once 'Zend/Application.php';

$application = new Zend_Application(
	APPLICATION_ENV,
	APPLICATION_PATH . '/configs/application.ini'
);

$application -> bootstrap();

Hier die /cronjobs/renew-index.php:

<?php
/**
 * This script renews the lucene search index
 */
ini_set('memory_limit', '512M');

$fltStartTime = microtime(true);

require_once 'Bootstrap.php';

$strIndex = APPLICATION_PATH . '/data/index';

//	clear all files in index folder
foreach (glob($strIndex . DIRECTORY_SEPARATOR . '*') as $file)
{
	unlink($file);
}

try
{
	echo 'opening index...';
	$index = Zend_Search_Lucene::open($strIndex);
	echo 'done' . PHP_EOL;
}
catch (Zend_Search_Exception $e)
{
	echo 'failed, index does not exist' . PHP_EOL;
	echo 'try creating...';
	$index = Zend_Search_Lucene::create($strIndex);
	echo 'done' . PHP_EOL;
}

$objSystems = new Default_Model_Mapper_SystemsMapper();
$objSystemsRowset = $objSystems -> fetchAll();

foreach ($objSystemsRowset as $objSystem)
{
	set_time_limit(0);

	$strId = $objSystem -> id; // technical id of the pc
	$strComputerName = $objSystem -> name; // name of pc
	$strFolderStructure = $objSystem -> folder_structure; // dirtree of system

	echo 'indexing ' . $strId . '...';

	$doc = new Zend_Search_Lucene_Document();
	//	never use the field identifier id and score, they are reserved by lucene
	$doc -> addField(Zend_Search_Lucene_Field::keyword('pcid', $strId, 'utf-8'));
	$doc -> addField(Zend_Search_Lucene_Field::keyword('computername', $strComputerName, 'utf-8'));
	$doc -> addField(Zend_Search_Lucene_Field::unStored('contents', $strFolderStructure, 'utf-8'));

	echo 'added to index' . PHP_EOL;
	$index -> addDocument($doc);
}

echo 'optimizing index' . PHP_EOL;
$index -> optimize();

echo 'docs in index           : ' . $index -> count() . PHP_EOL;
echo ' undeleted docs in index: ' . $index -> numDocs() . PHP_EOL;

$fltTimeEnd = microtime(true);
echo 'it took ' . round(($fltTimeEnd - $fltStartTime), 2) . 's' . PHP_EOL;

echo '[DONE]';
exit(0);

Damit habe ich bei 25.000 PCs einen 36 MB großen Index in /application/data/index angelegt. Wichtig: beim Öffnen oder Anlegen eines Index muss man immer ein Verzeichnis angeben und keine Datei.

Suchen mit Lucene

Für die Nutzung der Suche habe ich folgende Such-Action in einem Controller angelegt:

/**
 * simple search form
 */
public function searchAction()
{
	$doSearch = false;
	$doPagination = false;
	$objSearchForm = new Machines_Form_MachineSearch();
	$pageRequestHash = null;

	//	session store
	$objSessionSearchResult = new Zend_Session_Namespace('search');

	//	check requesting method:
	//		POST is new search
	//		GET is fresh display or paging through the results
	if ($this -> getRequest() -> isPost())
	{
		$strQuery = $this -> getRequest() -> getParam('q');
		$objSearchForm -> populate(array(
			'q' => $strQuery
		));

		$pageRequestHash = base64_encode($strQuery);
		$this -> view -> requestHash = $pageRequestHash;
		$doSearch = true;

		//	clear session store
		$objSessionSearchResult -> unsetAll();
	}
	else
	{
		$pageRequestHash = $this -> getRequest() -> getParam('hash');
		if (null !== $pageRequestHash)
		{
			$doPagination = true;
			$objSearchForm -> populate(array(
				'q' => base64_decode($pageRequestHash)
			));
			$this -> view -> requestHash = $pageRequestHash;
		}
		else
		{
			//	clear session store
			$objSessionSearchResult -> unsetAll();
		}
	}

	if ($doSearch === true)
	{
		$strIndex = APPLICATION_PATH . '/data/index';
		$objIndex = Zend_Search_Lucene::open($strIndex);

		$objQuery = Zend_Search_Lucene_Search_QueryParser::parse($strQuery);
		try
		{
			Application_Utilities::log('lucene simple find: ' . $strQuery, Zend_Log::DEBUG);

			$arrQueryHits = $objIndex -> find($objQuery);
			Application_Utilities::log('lucene search finished: ' . $strQuery, Zend_Log::DEBUG);

			$fltMaxScore = null;
			$arrResultHits = array();

			foreach ($arrQueryHits as $objQueryHit)
			{
				if (null === $fltMaxScore)
				{
					$fltMaxScore = $objQueryHit -> score;
				}

				$arrResultHits[ $objQueryHit -> lanid . $objQueryHit -> machineid ] = array(
					'id' => $objQueryHit -> id,
					'score' => $objQueryHit -> score,
					'pcid' => $objQueryHit -> pcid,
					'computername' => $objQueryHit -> computername
				);
			}

			//	store the search query result in the session
			$objSessionSearchResult -> arrResultHits = $arrResultHits;
		}
		catch (Exception $e)
		{
			$this -> addErrorMessage($e -> getMessage(), true);
		}
	}

	if (($doSearch === true
		|| $doPagination === true)
		&& is_array($objSessionSearchResult -> arrResultHits))
	{
		//	create a paginator with adapter
		$paginator = new Zend_Paginator(
			new Zend_Paginator_Adapter_Array($objSessionSearchResult -> arrResultHits)
		);
		$paginator -> setCacheEnabled(true);

		//	loading application settings
		$bootstrap = $this -> getInvokeArg('bootstrap');

		//	configure paginator
		$paginator -> setItemCountPerPage( $this -> _getParam('items', $bootstrap -> getOption('app.pagination.items_per_page', 20)) )
			-> setPageRange( $bootstrap -> getOption('app.pagination.page_range', 5) )
			-> setCurrentPageNumber($this -> _getParam('page', 1));

		//	send the objects data to the view
		$this -> view -> paginator = $paginator;
		$this -> view -> entries = $paginator -> getCurrentItems();
	}

	$this -> view -> objForm = $objSearchForm;
}

Das verwendete Formular beinhaltet lediglich ein Textfeld mit Namen „q“ und einen Submit-Button.

Grundsätzlich fungiert die Suche als Einmal-Suche bei Absetzen des Suchbegriffes und bei der Ergebnisnavigation wird auf die Resultate aus der Session zurückgegriffen. Dies steigert die Performance, da nicht bei jedem Aufruf der Ergebnislisten eine neue Suche ausgeführt werden muss.
Nachteil ist, dass ich durch „veraltete“ Ergebnisse navigiere. In zeitkritischen Systemen wäre dieses Vorgehen nicht sinnvoll. In meinem Beispiel geht das aber ganz gut.
Die Suche selber benötigt weniger als eine halbe Sekunde, um Ergebnisse zu einer beliebigen Anfrage herauszufinden.

Die Anfrage kann durch meine Nutzung den kompletten Umfang der Abfrage-API von Lucene nutzen.

Die verwendete Konfiguration entspricht der aus meinen vorherigen Beiträgen.

Ich hoffe, ihr könnt mir mal über euren Einsatz von Lucene berichten.

Ein Gedanke zu „Suchen mit Zend_Search_Lucene

Kommentare sind geschlossen.