Wann immer größere Datenbestände effektiv durchsucht werden müssen, stößt man mit relationalen Datenbanksystemen schnell an die Grenzen. Spätestens wenn bestimmte Begriffe stärker gewichtet oder auf andere umgeleitet werden müssen, wird die Formulierung einer entsprechenden Abfrage eine eigene, nicht triviale Wissenschaft. Meistens gestalten sich diese Abfragen komplex, sodass sie bei einem großen Datenbestand in der Folge nicht performant sind.
Dokumentenbasierte Indexe eignen sich besser für das Durchsuchen großer Datenbestände. Sie bieten spezielle Werkzeuge, um eine Treffermenge zu definieren und auch, um sie nach eigenen Regeln zu wichten. In diesem Artikel möchte ich eine Umsetzungsmöglichkeit mit Solr zeigen.
Was ist Solr?
Solr ist ein Apache-Projekt und bildet einen Java-Server um den ebenfalls von Apache entwickelten Suchindex Lucene. Sowohl Solr als auch Lucene sind Open Source Projekte und unter den Apache Lizenzen veröffentlicht. Damit lässt sich das Gespann problemlos in Enterprise-Projekten einsetzen.
Lucene stellt das technische das Herz dar und kümmert sich um den Aufbau der Daten, sowie die Ausführung und Optimierung von Abfragen. Ein solcher Index lässt sich auch mit anderen Skripten abfragen oder erzeugen.
Zend_Lucene z.B. kann Lucene-Indexe auf Dateiebene schreiben und durchsuchen. Da dies auf PHP-Ebene geschieht, ist das jedoch nicht so performant, wie eine Abfrage über Solr. Beim Experimentieren mit Zend_Lucene bin ich bei größeren Datenmengen schnell in die üblichen Limits bei PHP-Anwendungen gelaufen. So eignet sich die Zend-Lösung für kleinere Projekte, die schnell in einen funktionalen Zustand gelangen müssen. Mit entsprechendem Budgetrahmen zahlt sich der höhere Aufwand für das Aufsetzen und Pflegen eines Solr-Servers im Enterprise-Bereich durch eine bessere Performance aus.
Solr kapselt die Anfragen über eine einfache REST-Schnittstelle und kommuniziert intern mit Lucene. Nach außen bedient sich der Programmierer der einheitlichen Schnittstelle und hat den Zugriff so schnell im Griff.
Da ein eigener Server-Daemon zur Verfügung steht, kann dieser – losgelöst von PHP-Restriktionen – schneller auf die zugrunde liegenden Lucene-Indexe zugreifen. Die Beschreibung „einfache REST-Schnittstelle“ bezieht sich auf deren Bedienbarkeit. Der möglichen Komplexität bei Abfragen tut das keinen Abbruch, wie wir später noch sehen werden.
Seit Version 3.0 wurde die Entwicklung von Lucene und Solr zusammengelegt und seit Version 3.4.0 sind die Versionsnummern beider Programme zusätzlich synchron.
Das so verzahnte Solr und Lucene-Gespann werde ich im Folgenden der begrifflichen Einfachheit halber generell Solr nennen. Aus Sicht des Programmieres spielt es keine Rolle, ob die Aufgabe letztlich durch Solr oder Lucene erledigt wird.
Die Installation
Es existieren bereits aussagekräftige Anleitungen, die hier exemplarisch verlinkt sind. Als Java-Applikation muss Solr in einem Java Servlet Container installiert werden. Dies kann beispielsweise unter Jetty oder Tomcat geschehen. Besonders zu erwähnen ist, dass der Servlet-Container auf UTF8 eingestellt sein sollte wenn Solr im mehrsprachigen Bereich verwendet werden soll. Hinweise zur Aktivierung im jeweiligen Java-Container und auch zu dessen Installation finden sich auf den verlinkten Seiten.
Überblick
Um einen Index nutzen zu können, müssen folgende Schritte durchlaufen werden, die wir in diesem Artikel betrachten:
- einmalige Definiton des Index (Feldtypen, Felder, Analyzer, …)
- Befüllen des Index mit Daten
- Abfrage des Index per Query
Einen Index definieren: schema.xml
Was bei einem MySQL-Server die Definition einer Tabelle ist, ist bei Solr/Lucene die Definition eines Index. Dazu wird im Solr Homeverzeicnis (/usr/share/tomcat6/solr/) ein Unterordner mit dem Namen des Index angelegt, in unserem Fall productIndex. Darin befindet sich der Unterordner conf, der die Definitionsdateien im menschenlesbaren XML-Format enthält. Die Datei schema.xml definiert die einzelnen Felder und Feldtypen, die ein Dokument besitzen kann. Bei der Installation ist u.a. auch ein Index example angelegt worden. Dieser enthält sehr viele Beispiele und mag im ersten Moment verwirren. Dennoch eignet er sich gut als erste Basis, weshalb wir alle Daten aus example/conf/ nach productIndex/conf/ kopieren. Die Datei productIndex/conf/schema.xml ersetzen wir anschließend durch ein überschaubareres Beispiel:
schema.xml:
<?xml version="1.0" encoding="UTF-8" ?> <schema name="productIndex" version="1.2"> <types> <fieldType name="string" class="solr.StrField" sortMissingLast="true" omitNorms="true"/> <fieldType name="text" class="solr.TextField" positionIncrementGap="3"> <analyzer> <tokenizer class="solr.WhitespaceTokenizerFactory"/> <filter class="solr.WordDelimiterFilterFactory" splitOnNumerics="0" generateWordParts="1" generateNumberParts="1" catenateWords="1" catenateNumbers="1" catenateAll="0" splitOnCaseChange="1"/> <filter class="solr.LowerCaseFilterFactory"/> <filter class="solr.EdgeNGramFilterFactory" minGramSize="1" maxGramSize="25"/> </analyzer> </fieldType> </types> <fields> <field name="id" type="string" indexed="true" stored="true" required="true"/> <field name="title" type="text" indexed="true" stored="true" required="true"/> <field name="keywords" type="text" indexed="true" stored="false"/> <field name="language" type="string" indexed="true" stored="false" required="true"/> </fields> <uniqueKey>id</uniqueKey> <defaultSearchField>title</defaultSearchField> <solrQueryParser defaultOperator="OR"/> </schema>
Im Knoten fields werden entsprechende Felder beschrieben. Unser Produktindex soll die Felder id und language vom Typ string und die Felder title und keywords vom Typ text bekommen. Das Feldattribut indexed bestimmt, ob die im Feldtyp angegebenen Filter und Tokenizer beim Speichern des Dokuments auf den Feldwert angewandt und die so extrahierten, zusätzlichen Informationen im Index gespeichert werden sollen.
Über das Attribut stored wird gesteuert, ob das Originaldatum mit im Index abgespeichert werden soll. Ist es auf true gesetzt und wird ein Dokument bei der späteren Abfrage als Treffer erkannt, so wird der Originalwert mit der Trefferliste zurückgegeben. So kann man beispielsweise aus der Antwort eine Übersicht in seiner Applikation generieren, ohne für jeden Treffer erneute Anfragen an die Datenbank auszulösen, weil noch weitere Informationen ermittelt werden müssen. Hier ist der zusätzlich im Index verbrauchte Speicherplatz gegen den Mehraufwand weiterer Abfragen an andere Speichermechnismen abzuwägen, die die weiteren Informationen bereitsstellen. Mit einem gesunden Augenmaß für die spätere Ausgabe findet man in der Regel einen guten Mix aus nur indexierten und indexierten und gespeicherten Daten.
Das Feld keywords soll zwar zur Trefferermittlung herangezogen aber nicht ausgegeben werden. Deshalb wird es indexiert aber der Originalwert nicht gespeichert.
Das Attribut required gibt an, ob dieses Feld beim Speichern eines Dokuments vorhanden sein muss oder ob es optional ist. Ist es erforderlich aber nicht im einzutragenden Dokument vorhanden, schlägt der Speicherversuch fehl. Über dieses Attribut lässt sich somit eine gewisse Datenintegrität erzwingen. Im obigen Beispiel ist das Feld keywords optional, während alle anderen Felder vorhanden sein müssen.
Der Knoten defaultSearchField definiert, in welchem Feld Solr standardmäßig suchen soll, sofern in der Anfrage nichts abweichendes angegeben wurde. Selbiges gilt für den defaultOperator des Query Parsers. Wird ein Suchbegriff in mehrere Einzeltokens aufgeteilt oder sind mehrere Suchbegriffe bei der Abfrage vorhanden, wird hier der Standardoperator eingestellt, mit dem die Einzelbegriffe verknüpft werden: hier also or. Wieder gilt: sofern in der Abfrage nichts anderes angegeben wurde.
Im oberen Knoten types sind die referenzierten Feldtypen definiert. Während bei relationalen Datenbanken die Definiton eines Typs nicht verändert werden kann, eröffnet Solr uns hier alle Möglichkeiten eigene Typen zu beschreiben und diese beliebig mit Filtern und Tokenizern zu versehen. Beide wirken sich auf die letztlich gespeicherten Daten aus. Mit ihrer Hilfe können die eingehenden Daten normalisiert werden. Werden die gleichen Umformungen auf den bei einer Abfrage eingehenden Suchstring angewandt, so ist die Vergleichbarkeit gewährleistet und die Qualität der Trefferergebnisse hoch. Im Knoten analyzer kann man über das Attribut type zwischen index (bezieht sich auf die einzutragenden Daten beim Speichern) und query (bezieht sich auf den Suchstring bei der Abfrage) unterscheiden.
Lässt man das Attribut weg, werden die angegebenen Filter und Tokenizer auf beides angewandt.
Manchmal möchte man den Query von sogenannten Stopwords (der, die das, ein, eine, …) befreien, die für die Suche nicht relevant sind. Oder man möchte bestimmte Begriffe auf Synonyme mappen, die im Datenbestand vorkommen (Tempo => Taschentuch). Dies erlaubt Solr über einfache Textdateien, die im entsprechenden Analyzer anzugeben sind:
<analyzer type="index"> ... </analyzer> <analyzer type="query"> ... <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt"/> <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt"/> </analyzer>
In der Datei stopwords.txt wird in jeder Zeile ein Wort notiert; in synonyms.txt ist der Aufbau Wort | Synonym.
Die Filter werden bei diesem Beispiel nur beim eingehenden Such-Query angewandt. Angenommen in stopwords.txt stehen die Begriffe „ein, mit“ und in synonyms.txt „Auto|KFZ“. Wird der Query „Ein Auto mit 4 Türen“ an Solr gesendet, so werden die Stopwords entfernt und die Synonyme ersetzt. Intern entsteht so der Query „KFZ 4 Türen“ welcher schließlich im Datenbestand gesucht wird.
Einen Index definieren: solrconfig.xml
Während in der Datei schema.xml der grundsätzliche Aufbau eines Index definiert ist, können in der Datei solrconfig.xml zusätzliche Parameter notiert werden, die die Standardeinstellungen pro Index überschreiben. Der für uns in der Anwendung besonders interessanter Teil ist die Möglichkeit eigene Request-Handler zu definieren. Dieser nimmt die späteren Abfragen entgegen, wendet die definierten Regeln auf den eingehen Suchstring an und sucht mit den daraus entstandenen, normalisierten Daten im Index.
Ähnlich einem View in MySQL können darüber Sichten auf bestehende Indexe angelegt werden. Der Handler-Typ wird über das Attribut defType gewählt. Der Standardhandler kann mit Wildcards an beliebiger Stelle umgehen, unterstützt dafür die fein granulierte Wichtung von Suchtreffern nicht. Besonders im Enterprisebereich sind die Anforderung an die Reihenfolge der Suchtreffer oft mit speziellen Anforderungen behaftet, die sich besser über Dismax-Handler abbilden lassen.
Diese bieten eine Fülle weiterer Feinjustierungsmöglichkeiten bei der Bewertung und Wichtung von Suchbegriff und Trefferdokumenten.
solrconfig.xml
<config> ... <requestHandler name="productsDE" class="solr.SearchHandler"> <lst name="defaults"> <str name="defType">dismax</str> <str name="echoParams">explicit</str> <str name="qf">keywords^1.5 title^1</str> <str name="pf">id title</str> </lst> <lst name="appends"> <str name="fq">language:DE</str> </lst> </requestHandler> <requestHandler name="/admin/" class="org.apache.solr.handler.admin.AdminHandlers"/> ... </config>
Hier wird ein Dismax-Handler mit dem Namen productsDE definiert. echoParams=explicit gibt an, dass nur die explizit in den phrase fields (pf) angesprochenen Feldwerte zurückgegeben werden sollen und nicht alle. Bei den query fields (qf) werden die zu durchsuchenden Felder inklusiver ihrer Wichtung notiert. id und language werden somit nicht durchsucht. Die Treffer im Feld keywords werden um den Faktor 1,5 höher bewertet als die Treffer im Feld titel.
Mittels „append“ werden an jede Anfrage automatisch zusätzliche Bedingungen angehängt, die nicht im ursprünglichen Query enthalten sind. In diesem Beispiel wird jeweils gefordert, dass ein Filter-Query (fq) für das Feld language in jedem Fall den Wert DE enthält.
Schließlich wird der Admin-Request-Handler definiert. Ist er vorhanden, kann eine administrative Web-GUI von Solr auf productsIndex zugreifen. Dies ist in der Entwicklungsphase nützlich, da neben diverser Statistiken auch ein Debugging möglich ist. Die ermittelten Treffer werden nach ihrer Relevanz (ermittelter Score) absteigend sortiert. Es kommt vor, dass die Trefferliste nicht so ausfällt wie man es erwarte. In der Debug-Ausgabe wird die genaue Berechnung des Scores angezeigt, was hilft, um die Wichtung so lange zu korrigieren, bis das Ergebnis den Anforderungen entspricht.
Definition eines cores
Zu guter Letzt muss noch ein sogennnter Kern (core) in der zentralen Datei /usr/share/tomcat6/solr/solr.xml definiert werden. Die hier gelisteten Indexe können dann angesteuert werden.
solr.xml:
<solr persistent="true" sharedLib="lib"> <cores> <core name="ProductIndex" instanceDir="productIndex"> <property name="dataDir" value="/data/productIndex"/> </core> </cores> </solr>
Nach einem Neustart Solrs kann die Admin-GUI im Browser üblicherweise unter http://localhost:8080/productIndex/admin aufgerufen werden. Noch befinden sind keine Daten im Index – jedoch kann so überprüft werden, ob der Index ordnungsgemäß eingerichtet wurde und Solr mit der erstellten Konfiguration einverstanden ist.
Befüllen des Index
Solr stellt eine REST-Schnittstelle zur Verfügung, die über entsprechende Kommandos befeuert wird. Anstatt das Rad neu zu erfinden, bediene ich mich gerne der PHP-Klasse solr-php-client. Diese bietet für den Eintrag und das Abholen von Dokumenten entsprechende Methoden, generiert die jeweils notwendige Abfrage an Solr, führt diese aus und liefert ein Response-Objekt. Der Datentransport zu und von Solr ist damit vollständig in der Klasse gekapselt. In der eigenen Applikation kann die Rückgabe bequem ausgewertet werden.
Hier ein einfaches Beispiel für den Aufbau eines Index. Die Daten werden aus einer angenommenen Datenbank ausgelesen, können aber auch aus jeder andern Quelle kommen. (Hinweis: Solr beherrscht auch den direkten Datendurchgriff auf MySQL, so dass der Index nicht manuell befüllt werden muss):
buildIndex.php:
$solr = new Apache_Solr_Service("localhost", 8080, "productIndex"); $res = $db->query("SELECT id, keywords, titel, ..."); while ($row = $db->fetch($res, MYSQL_ASSOC)) { // neues Dokument anlegen $doc = new Apache_Solr_Document(); // Felder befüllen $doc->addField("id", $row["id"]); $doc->addField("keywords", $row["keywords"]); $doc->addField("title", $row["title"]); $doc->addField("language", $row["language"]); //im Solr-Index speichern $response = $solr->addDocument($doc); if ($response->getHttpStatus() != 200) { echo "Fehler beim Eintragen!"; } } $solr->commit(); $solr->optimize();
Abfragen des Index
Solr bietet bereits von Haus aus zahlreiche Rückgabeformate: xml, json, python, ruby, php und phps (serialisiertes PHP-Array). Bei der Anfrage bestimmt der Parameter wt (writer-type) in welchem Format Solr die Rückgabe liefern soll. Ist nichts angegeben greift die Definition des Default-Writers der solrconfig.xml. Ist auch hier nichts angegeben wird die Antwort als XML geliefert. Dies bietet uns Flexibilität, da das Befüllen des Index und seine Abfragen nicht notwendigerweise die gleiche Technik nutzen müssen.
Es ist denkbar, dass unser Produktindex durch PHP aufgebaut und aktualisiert wird und eine Suchübersicht ebenfalls per PHP realisiert ist. In einer AutoSuggest-Box können wir den Index aber ebensogut per AJAX abfragen, lassen uns ein JSON-Response-Objekt liefern und werten dieses direkt clientseitig aus.
Jetzt zahlt sich die Planung der Request-Handler aus. Da die spezifischen Filter dort abgelegt sind, muss bei der Abfrage lediglich der richtige Handler angesprochen werden. Das teilweise etwas umständliche programmatisches Hinzufügen von vielen Parametern entfällt; lediglich der Suchbegriff und bei Bedarf der Rückgabetyp muss übergeben werden. Zusätzlich ist die Wartung des Handlers dadurch einfacher zu bewerkstelligen; schließlich muss nur die zentrale Definition angepasst werden und nicht sämtliche programmatische Abfragen auf der Clientseite.
Ein weiterer Vorteil ist, dass Änderungen an den Request-Handlern den zugrunde liegenden physikalischen Index nicht ändern. Dadurch gehen die vorhandenen Daten nicht verloren.
Ein Abfragequery kann z.B. so aussehen: http://localhost:8080/productIndex/select?qt=productsDE&q=blau&wt=json
Die Rückgabe erfolgt, wie angefordert, als JSON-Objekt:
{"responseHeader":{"status":0,"QTime":2,"params":{"q":"blau","qt":"productsDE","wt":"json"}}, "response":{"numFound":2,"start":0,"docs":[{"id":"2","title":"Blaues KFZ mit Spoiler 45 PS"},{"id":"1","title":"Blaues Pferd"}]}}
Nun ist allerdings unklar warum das blaue KFZ vor dem blauen Pferd gelistet wird. Wonach wird hier sortiert?
Die Treffer erhalten eine Bewertung in Punkten (Score) und werden danach absteigend sortiert.
Mit Hilfe der Admin-Oberfläche kann die Berechnung des Scores nachvollzogen werden. Dazu muss der Haken bei Debug: enable gesetzt werden.
Nun wird ersichtlich, dass das blaue KFZ auch im Feld keywords den Wert blau eingetragen hat.
Durch die höhere Wichtung (Faktor 1,5) ergibt sich so bereits ein höherer Score für dieses Dokument (selbst wenn es im Feld title keinen Treffer gäbe).
Zusätzlich gibt es im Feld title einen zweiten Treffer, was den Score weiter in die Höhe treibt.
Folgerichtig erscheint dieser Treffer weiter oben.
Solr bietet zahlreiche Filter und Tokenizer, mit deren Hilfe die Anforderungen sehr fein granuliert umgesetzt werden können.
Es ist auch denkbar den Suchstring zuerst kontextbezogen zu parsen, um anschließend unterschiedliche Request-Handler abzufragen. Stellt man beispielsweise fest, dass nach Kleidung gesucht wird, bemüht man den entsprechenden Handler, der mit seinen Suchfiltern auf diesen Bereich spezialisiert ist. Den gedanklichen Spielarten sind hier kaum Grenzen gesetzt.
Innerhalb der Request-Handler kann eine beliebige Kombination von Filtern und Tokenizern zusammengestellt werden. Und selbst wenn das einmal nicht ausreichen sollte, kann man Solr mit selbstgeschriebenen Filtern erweitern. Diese müssen in Java geschrieben sein.
Zusammenfassung
Auch wenn die Flexibilität in diesem kleinen Beispiel nur an der ein oder anderen Stelle aufblitzen konnte – Solr ist gepaart mit der unverschämt guten Antwortzeit ein sehr mächtiges Werkzeug. Selbst mit Millionen indexierten Dokumenten bewegt sich die Antwortzeit im Millisekundenbereich. Die hier geschilderten Features sind nur ein kleiner Ausschnitt – Solr bietet noch mehr: Replikationsmechanismen von Haus aus, Anbindung von Datenbanken als Datenquelle, Scannen von Word-/Office-Dokumenten per TIKA, Speichern von GEO-Daten und Entfernungsberechnung (ab Solr 4.0), usw…
Ich hoffe, ich konnte Ihnen etwas Appetit machen. Eine Beschäftigung mit diesem Thema lohnt sich aus meiner Sicht in jedem Fall. Vielleicht teilen Sie meine Begeisterung bald?
Mit herzlichem Gruß,
Daniel Schlichtholz
Schreibe einen Kommentar