Startseite >> Ajax Suggest mit PHP und Lucene

Ajax Suggest mit PHP und Lucene

Rubrik: PHP, zu den Kommentaren

Eine der beliebtesten Ajax Anwendungen ist sicher die Suggest- oder Autocomplete-Funktion. Dabei werden dem Benutzer während der Eingabe in ein Input-Feld Vorschläge zur Vervollständigung der Eingabedaten unterbreitet.

Technisch umgesetzt wird dies, indem per Javascript/Ajax nach der Eingabe jedes weiteren Buchstabens in das Eingebefeld eine Anfrage mit den bisher eingegebenen Daten an den Server geschickt wird und dieser eine Liste mit passenden Elementen zurücksendet, die dem Benutzer dann zur Auswahl angezeigt werden.

Die vorgeschlagenen Daten ermittelt der Server bei einer Webanwendung aus einer Datenbank. Bei einer viel besuchten Seite sowie dem breiten Einsatz dieser Methode kann so die Belastung für den Datanbankserver nicht ganz vernachlässigt werden, obgleich sie meistens sehr gering ist. Ein weiteres Problem ist die evtl. mangelhafte Suchmöglichkeit des Datenbankservers. So bietet MySQL eine Volltextsuche nur bei dem unzureichenden Tabellenformat MyISAM an, InnoDB beherrscht leider keine Volltextsuche, hier kann lediglich mit LIKE %...% oder ähnlichem gesucht werden.

In diesem Tutorial möchte ich die Umsetzung der Serverseite mit einem echten Suchindex, undzwar der PHP-Implementierung der Lucene-Such-Engine aus dem Zend Framework, vorstellen.

Kurz umrissen sieht diese Lösung folgendermaßen aus. Mittels Lucene wird ein Suchindex auf dem Webserver erzeugt. Hierfür werden die eigentlich zu durchsuchenden Daten aus der Datenbank verwendet. Anstelle der Datenbankanfrage geht jede Suche an den Suchindex, was die Datenbankzugriffe komplett einspart. Die Benutzung des Lucene-Suchindex ist deutlich schneller als die Benutzung der Datenbank und es können flexiblere Suchanfragen formuliert werden.

Zend_Search_Lucene

Zend_Search_Lucene ist eine Komponente aus dem Zend Framework - eine Klassenbibliothek für PHP. Diese Komponente setzt die Lucene-Suche (ursprünglich ein in Java entwickeltes Projekt, inzwischen in viele andere Sprachen portiert) in PHP um. Zend_Search_Lucene ist erst ab der kommenden Zend Framework-Version 1.5 wirklich brauchbar, da erst jetzt die für eine echte Suche essentiellen Platzhalter wie * und ? unterstützt werden (siehe Doku).

Index-Erzeugung mit Zend_Search_Lucene

Bevor wir Zend_Search_Lucene für unsere Ajax-Anwendung benutzen können, muss der Suchindex erzeugt werden. Hierzu werden die Daten aus der Datenbank benutzt. Ein Datensatz aus der Datenbank entspricht im Suchindex einem Dokument. Ein Dokument besteht wiederum aus Feldobjekten. Diese Feldobjekte können verschiedene Typen haben. Je nach Typ wird das Feld vom Suchindex anders behandelt. Die Unterscheidung besteht darin, ob die Daten in den Feldern gespeichert, indexiert, in Tokens aufgeteilt und ob diese binär sind. Es gibt 5 Typen: Keyword, UnIndexed, Binary, Text und Unstored. Für unserer Ajax-Suggest Funktion interessieren uns lediglich Keyword, UnIndexed und Text. Keywords werden im Index gespeichert und indiziert, UnIndexed wird nur gespeichert, aber nicht für die eigentliche Suche verwendet und Text wird gespeichert, indiziert und vorher in einzelne Tokens aufgeteilt. Das Skript zur Erzeugung eines Suchindexes kann folgendermaßen aussehen, beispielhaft für eine Flughafensuche:

<?php
// Klasse für Datenbankzugriffe
require_once('lib/DB.inc.php');
// Zend_Search_Lucen-Klasse laden
require_once('Zend/Search/Lucene.php');

setlocale(LC_ALL'de_DE.UTF-8');

// dieser Analyzer beachtet auch Zahlen, der Standard-Analyzer nicht
Zend_Search_Lucene_Analysis_Analyzer::setDefault(
    new 
Zend_Search_Lucene_Analysis_Analyzer_Common_TextNum_CaseInsensitive()
);

// Flughäfen selektieren
$airports = array();
$sql "SELECT `id`, `code`, `name`, `country`, `popularity`
        FROM `Airports`"
;
$stmt $dbh->execute($sql);
while(list(
$id$code$name$country$popularity) = $stmt->fetch_row()){
    
$airports[$id] = array(
        
'code' => $code,
        
'name' => $name,
        
'country' => $country,
        
'popularity' => $popularity,
    );
}

// den Such-Index erzeugen
$index Zend_Search_Lucene::create("/var/www/index/airports");
foreach(
$airports as $id => $airport){
    
$document = new Zend_Search_Lucene_Document();
    
    
$document->addField(Zend_Search_Lucene_Field::Text(
                
'code_airport',
                
$airport['code_airport']));
    
$document->addField(Zend_Search_Lucene_Field::Text(
                
'name',
                
iconv('UTF-8''ASCII//TRANSLIT'$airport['name'])));
    
$document->addField(Zend_Search_Lucene_Field::Text(
                
'country',
                
iconv('UTF-8''ASCII//TRANSLIT'$airport['country'])));
    
$document->addField(Zend_Search_Lucene_Field::UnIndexed(
                
'_name',
                
$airport['name']));
    
$document->addField(Zend_Search_Lucene_Field::UnIndexed(
                
'_country',
                
$airport['country']));
    
$document->addField(Zend_Search_Lucene_Field::UnIndexed(
                
'id',
                
$id));
    
$document->addField(Zend_Search_Lucene_Field::UnIndexed(
                
'popularity',
                
$airport['popularity']));
    
    
$index->addDocument($document);
}
$index->optimize();
?>

Die Flughafendaten, bestehend aus id, Flughafen-Code, Name, Land und einer Popularität (wird später zum sortieren verwendet), werden aus der Datenbank selektiert. Aus diesen Daten wird dann der Index erzeugt. Für die Suche werden der Code, der Name und das Land verwendet, weshalb diese Daten den Feldtyp Text bekommen. Die Popularität dient lediglich der Sortierung der Trefferliste, es reicht also aus, diese als Feldtyp UnIndexed in den Index zu speichern. Um Probleme mit Zeichensätzen zu vermeiden, werden die zu durchsuchenden Daten vor dem Speichern von UTF-8 (das kommt aus der Datenbank) nach ASCII//TRANSLIT konvertiert. Um aber die Originaldaten aus dem Index zu bekommen, wird zusätzlich die UTF-8 Version als UnIndexed mit in den Index gespeichert. Hierbei ist zu beachten, dass die Suchanfragen dann ebenfalls nach ASCII//TRANSLIT konvertiert werden, bevor die Anfrage gestellt wird.

Ajax-Suchanfragen mit Zend_Search_Lucene

An dieser Stelle werde ich nicht weiter auf die Javascript-Implementierung auf der Client-Seite eingehen, das Skript auf der Serverseite soll im Vordergrund stehen. Wie bereits erwähnt werden die Suchanfragen vor der Verwendung in ASCII//TRANSLIT konvertiert. Die Ergebnisse der Anfrage werden zusammengefasst und an den Client zurück gesendet.

<?php
// Lucene-Suche mit dem Zend-Framework
require_once('Zend/Search/Lucene.php');
// Header an den Clienten senden
header("Expires: Mon, 26 Jul 1997 05:00:00 GMT" ); 
header("Last-Modified: " gmdate"D, d M Y H:i:s" ) . "GMT" ); 
header("Cache-Control: no-cache, must-revalidate" ); 
header("Pragma: no-cache" );
header("Content-Type: text/plain; charset=utf-8");

setlocale(LC_ALL'de_DE.UTF-8');

// den Wert aus dem $_GET-Array evtl. Tests unterziehen
/ ...

// den Suchstring für die Suche vorbereiten
$search urldecode($_GET['search']);
// in ASCII-Zeichen umwandeln
$search iconv('UTF-8''ASCII//TRANSLIT'$search);
// nur Buchstaben und Zahlen,
// alles andere wird durch Leerzeichen ersetzt
$search preg_replace('/[^a-zA-Z0-9]/'' '$search);
// Wenn keine Buchstaben oder Zahlen enthalten sind,
// kann gleich abgebrochen werden
if(!preg_match('/[a-zA-Z0-9]/'$search)){
    exit;
}
// mehrere Leerzeichen hintereinander zusammenfassen
$search preg_replace('/\ \ */'' '$search);
// Whitespace am Anfang und am Ende entfernen
$search trim($search);
// vor alle Wortteile + und hinter alle Wortteile *
$search str_replace(' ''* +'$search);
$search "+{$search}*";

try{
    
// Index aufrufen
    
$index Zend_Search_Lucene::open("/var/www/index/airports");
    
// Suche ausführen und Ergebnisse umgekehrt nach popularity sortieren
    
$hits $index->find($search'popularity'SORT_NUMERICSORT_DESC);
    
// maximal 10 Ergebnisse zurück liefern
    
$counter 0;
    foreach(
$hits as $hit){
        
$counter++;
        echo 
"{$hit->_name} / {$hit->code_airport} ({$hit->_state})\n";
        if(
$counter == 10){
            break;
        }
    }
}catch(
Exception $e){
    
// Fehlerbehandlung
}

Der Suchstring, den die Ajax-Suggest Suche übermittelt, wird intensiv vorbereitet, sodass kein "Blödsinn" in die Suche geworfen wird. Also erstmal den Wert aus dem $_GET-Array dekodieren, nach ASCII//TRANSLIT konvertieren, alles außer Buchstaben und Zahlen durch Leerzeichen ersetzen, da andere Zeichen von der Suche eh nicht beachtet werden bzw. diese nur stören, mehrere Leerzeichen hintereinander durch ein einzelnes ersetzen und am Ende noch trimmen. Für die Suche wird dann vor jedes Einzelwort noch das Plus-Zeichen (+) und hinter jedes Wort noch der Stern (*) gesetzt. Dies ist eine spezielle Funktion der Suche. Das Plus bedeutet, dass dieses Wort im Treffer vorhanden sein muss und der Stern, dass das Suchwort nur ein Teilwort sein kann. Die Suche nach Berl* findet also auch Berlin. Danach wird dieser vorbereitete Suchstring an den Suchindex gesendet und die Treffer nach der Popularität sortiert. Zu beachten ist, dass die Original UTF-8 Felder aus dem Index selektiert werden. Die Ergebnismenge wird hier auf 10 beschränkt und als einfacher Text zurück an den Clienten gesendet, in der Form: Berlin / BER (Deutschland). An dieser Stelle kann man natürlich auch XML oder JSON verwenden.

Weitere Anwendungen von Zend_Search_Lucene

Die hier vorgestellte Anwendung ist nicht das Haupteinsatzgebiet von Zend_Search_Lucene. Diese Art Suchindex ist eher für große Datenbestände gedacht. Beispielsweise könnte man die Suche in einem großen Forum, anderen großen Datenbeständen oder auch statischen HTML-Seiten implementieren. Zum Indexieren von HTML-Seiten bietet Zend_Search_Lucene sogar spezielle Funktionen. Des Weiteren bin ich nicht auf die ausgefeilteren Such-Möglichkeiten eingegegangen sowie die Sortierung der Suchergebnisse anhand der Relevanz entsprechend des Suchstrings. Weitere Details sind der recht ordentlichen Dokumentation zu entnehmen.

Nachteile von Zend_Search_Lucene

Ja, leider gibt es auch einige Nachteile. Insbesondere ist hier die Trennung von der Datenbank zu nennen. Wenn die Suche auf Daten arbeitet, die sich ändern bzw. sich ständig erweitern (wie z.B. einem Forum), so muss der Index ständig aktualisiert werden, um auf dem neuesten Stand zu bleiben. Ansonsten werden von der Suche Dokumente evtl. nicht gefunden, obwohl diese in der Datenbank vorhanden sind. Hier hat die Datenbank ihre Vorteile, da neue Dokumente sowie Änderungen direkt in die Datenbank eingepflegt werden und somit von einer datanbankbasierten Suche auch gefunden werden.

Ein weiterer Nachteil hat sich für mich bei der Index-Erzeugung mit Zend_Search_Lucene ergeben, was aber evtl. auf den Preview-Status des Zend Frameworks zurückzuführen ist. Ich konnte einen Index nicht als ein normaler Benutzer auf dem Linux-Rechner erzeugen und dann über den Webserver darauf zugreifen. Für einen reibungslosen Ablauf muss der Index von dem Benutzer erzeugt werden, der ihn später auch benutzt (in meinem Fall www-data, da PHP als Modul unter diesem Benutzer ausgeführt wurde.)

Tipp: Ajax Suggest mit der Yahoo! User Interface Library

Um nicht selbst irgendwelche XMLHttpRequest-Anfragen programmieren zu müssen, rate ich zur Benutzung einer Javascript-Bibliothek. Ich selbst benutze YUI. Ob diese nun besser oder schlechter ist als die anderen zahlreichen JS-Libs kann ich nicht sagen, für meine Zwecke war diese aber vollkommen ausreichend. Die Suggest-Komponente heißt bei YUI AutoComplete. Die Unterstützung von JSON, XML oder einfachen Text-Daten ist bereits eingebaut. Eigentlich braucht man nur ein paar Zeilen JavaSrcipt und kann sofort loslegen (siehe YUI)

yigg

3 Kommentare zu: Ajax Suggest mit PHP und Lucene

  1. ben, 07. Oktober 2008, 20:00 Uhr

    $search = urldecode($_GET['search']);
    Ist überflüssig, die superglobals werden automatisch urldecode()d.

    Statt $index->optimize() würde ich index->commit() verwenden, ist schneller.

    Bei mir macht zend's lucene die grätsche, wenn ich von mehreren seitenaufrufen gleichzeitig in den index schreiben will. Wenn also z.b. zwei User gleichzeitig was ins Forum posten. Hast Du sowas auch schon erlebt?

    Danke für den Artikel, schönes Ding!

  2. Johannes, 07. Oktober 2008, 20:32 Uhr

    Hallo ben,

    ich benutze Zend_Search_Lucene nicht mehr. War mir für die Datenmengen, die ich (beruflich) zu verarbeiten habe, zu langsam. Gerade bei der Indexerzeugung, wenn ich einen komplett neuen Index aufsetze (mit mehreren 100.000 oder auch mehreren Millionen Dokumenten). Ich benutze jetzt Apache Solr (solltest du dir mal ansehen, wenn du dich für Suchindexe interessiert ;-)

    Zu deinem Problem kann ich nichts genaues sagen. Zend_Search_Lucene benutzt ja flock() um gleichzeitiges Schreiben auf den Index zu verhindern, das funktioniert auf manchen Dateisystemen nicht... Vielleicht gibt es damit Probleme?

  3. ben, 09. Oktober 2008, 05:28 Uhr

    Johannes!

    Zend's lucene hat mich nun schon seit Monaten geärgert, auf bugs wird überhaupt nicht reagiert, im irc weiß auch niemand Bescheid.

    Es ist jetzt 05:25 morgens. Seit gestern Nachmittag um 14 Uhr beschäftige ich mich nun mit Apache Solr. Ich bin noch nicht ganz fertig mit der Portierung, aber es macht schon einen guten Eindruck. Wie ja auch der Name Apache schon vermuten lässt :)

    Vielen Dank für den Tip!
    ben

Einen Kommentar schreiben






Captcha

Beiträge nach Rubrik
Amazon.de: Bestseller RSS