Vorwort: wieso ein Blog zu PHP, Solr und Lucene?

Wieso ein Blog zu PHP, Solr und Lucene?
Gegenstand und Ausgangspunkt all unserer Aktivitäten auf diesem Gebiet war ein Projekt um ein Nachrichtenportal und die Aufgabe, Recherchen und Analysen im Nachrichtenbestand von über 10 Million News performant zu handeln. Die MySQL Volltextsuche kam da schnell an Ihre grenzen, Oracle war keine Alternative.
Es reifte also die Frage, wie können andere (etwa die Internetsuchmaschiene google) immense Datenmengen spielend handeln?
Wir lösten den MySQL volltext mit Lucene ab. Der Performancegewinn war dramatisch. Suchen im Datenbestand, die vorher über 10 Sekunden dauerten, brauchen mittels Lucene und Solr nur selten mehr als 20ms!
Eine neue Welt tat sich auf, die es zu erobern galt und schnell fiel auf, dass deutschsprachige Seiten zum Thema Mangelware sind. Dies soll sich mit diesem Blog ein wenig ändern.

Sie haben Fragen zu Solr/Lucene/PHP? Schreiben sie uns einen Kommentar!

Montag, 13. Oktober 2014

Suchergebnis verbessern: zusammenhängende Worte besser berücksichtigen

Die Relevanzsortierung von Solr/Lucene ist von Haus aus schon sehr gut. Hin und wieder ergeben sich aber Potential, die Suchergebnisse von Solr weiter zu verbessern.
Das Suchergebnis ist regelmäßig (nicht immer) dann besser, wenn die Suchbegriffe in exakt der gleichen Reihenfolge auch im Text vorkommen.

In erster Linie geht es dabei also um die Verbesserung der Relevanzsortierung.

Beispiel die Suche nach: berliner currywurst
Ein valides Ergebnis wäre folgender Text: "Der Berliner Max fährt nach Hessen, um bei Best Worscht in Town eine Frankfurter Currywurst zu essen".
Basierend auf den Suchbegriff ist das Suchergebnis aber wenig relevant. Denn es geht hier um eine Frankfruter Currywurst, keine Berliner.
Relevanter ist folgender Text: "Der Frankfurter Moritz fährt nach Kreuzberg, um bei Curry 36 eine Berliner Currywurst zu essen."

Die Lösung besteht darin, Dokumente in Ihrer Sortierung zu Boosten, welche die Suchbegriffe in exakt der eingegebenen Reihenfolge und hintereinander aufweisen. Unabhängig davon, ob der Anwender eine phrasierte Suche gestartet hat oder nicht.

Hilfe kommt hier vom DisMax Suchhandler bzw. eDsiMax Query Parser.
Dort wird zum einem definiert, wie das Vorkommen der Suchbegriffe in einzelnen Feldern zu gewichten ist:

  <requestHandler name="/select" class="solr.SearchHandler" default="true">
[...]
       <str name="qf">
            PROVIDER^10 KATEGORIE^3 UEBSCHRIFT^5.5 TEXT^1.5

Zusätzlich kann man jedoch der "pf" (phrase Fields) Parameter kann genutzt werden, um die Dokumente in der Sortierung höher zu gewischten, bei denen die Suchbegriffe möglichst eng aneinander stehen.
  <requestHandler name="/select" class="solr.SearchHandler" default="true">
[...]
       <str name="qf">
            PROVIDER^10 KATEGORIE^3 UEBSCHRIFT^5.5 TEXT^1.5
       </str>
       <str name="pf">
          PROVIDER^10 UEBSCHRIFT^5.0 TEXT^1.5
       </str>

Abschließend den Server neu starten und die Anwendungen werden aktiv.

Montag, 6. Oktober 2014

"Meinten Sie ...?" mit Solr

Rechtschreibkorrektur mit Solr / Lucene

Es soll ja Leute geben, die Nutzen die google einzig deshalb, um die Rechtschreibung einzelner Wrote zu überprüfen. Die Funktion "Meinten Sie" ist dabei eine Hilfe.
Fakt ist: für die Ergonomie und Effizienz einer Suchmachschine ist es von Vorteil, wenn diese Rechtschreibfehler erkennt. Solr bietet hier das passende Feature unter der Bezeichnung "Spellchecking", oft auch umschrieben als DYM: "Did you mean...?".

Vorbetrachtung

Es gibt verschiedene Ansätze, eine Rechtschreibkorrektur über Solr zu realisieren. Vorab müssen zumindest 2 Dinge entschieden werden:
  1. Suchkomponente oder Dedizierter Requesthandler für die Rechtschreibprüfung und
  2. Wahl der Indexart

zu 1.) Kontakt zu Solr

Die Frage ist, über welchen Weg leitet man die Anfrage zur Rechtschreibprüfung an Solr?
Ein dedizierter Requesthandler nur für das Spellchecking ist dann sinnvoll, wenn die Rechtschreibkorrektur einen ganz besonderen Stellenwert einnimmt, wenn diese aufgrund der Last bspw. auf einer eigenen Instanz läuft oder Konfigurationen notwendig sind, die dem üblichen Request Handeler (bspw. /select ) entgegen stehen.
Üblicher weise reicht es aus, den klassischen Request Handler um die Spellchecking-Komponente zu erweitern. Dies hat den Vorteil, dass man mit nur einem Request die Suche durchführen und gleichzeitig einen Vorschalg zur Rechtschreibkorrektur von Solr erfragen kann.

zu 2.) Indexart

Spellchecking benötigt bei Solr eine Ansammlung an Worten, mittels derer die Rechtschreibprüfung durchgeführt wird. Dazu gibt es 3 Alternativen: Dateibasiert (FileBasedSpellChecker), mit einem separaten Spellchecking-Index (IndexBasedSpellChecker), oder basierend aufgrund der Informationen im aktuellen Index (DirectSolrSpellChecker).
Der große Nachteil beim Index basierten Spellchecker ist, dass der Index regelmäßig neu gebaut werden muss. Idealer Weise nach einer Optimierung des Solr-Index. Bei Systemen mit einer hohen Änderungsrate, gibt es jedoch oft einen commit, aber selten in optimize. Hier wird es schwer, den richtigen Zeitpunkt für den Neubau des Spellchecking-Index zu finden, denn ein Neubau nach jedem commit kostet sehr viel Performance.
Darüber hinaus gibt es noch den WordBreakSolrSpellChecker. Ein Spellcheck-Typ, der auf Basis geteilter Worte arbeitet und mit einem der übrigen Spellchecker kombiniert werden kann.

Im weiteren Verlauf konzentrieren wir uns auf folgende Kombination: DirectSolrSpellChecker und Standard Request Handler /select mit spellcheck component.

Konfiguration

schema.xml

 Der DirectSolrSpellChecker bezieht die Basis seiner Arbeit aus dem aktuellen Index und dort aus einem frei definierbaren Feld. In aller Regel sind die Informationen in Solr allerdings durch entsprechende Analysen, Stemming-Prozesse, etc. so verändert, dass sie für die Rechtschreibprüfung nicht mehr genutzt werden können.
Kurzum: es empfiehlt sich, für das "Meinten Sie..." Feature, einen eigenen Feldtyp zu erschaffen, der nur minimale Bearbeitungen eines Terms vornimmt. Wir nennen diesen Typ in unserem Beispiel: text_spell.

<fieldType name="text_spell" class="solr.TextField" positionIncrementGap="100">
    <analyzer type="index">
            <tokenizer class="solr.StandardTokenizerFactory"/>
            <filter class="solr.StopFilterFactory" ignoreCase="true"
                    words="stopwords-de.txt"/>
            <filter class="solr.StandardFilterFactory"/>
            <filter class="solr.LowerCaseFilterFactory" />
            <filter class="solr.RemoveDuplicatesTokenFilterFactory"/>
    </analyzer>
    <analyzer type="query">
            <tokenizer class="solr.StandardTokenizerFactory"/>
            <filter class="solr.SynonymFilterFactory" synonyms="synonyms-de.txt"
                        ignoreCase="true" expand="true"/>
            <filter class="solr.StopFilterFactory" ignoreCase="true"
                    words="stopwords-de.txt"/>
            <filter class="solr.StandardFilterFactory"/>
            <filter class="solr.LowerCaseFilterFactory" />
            <filter class="solr.RemoveDuplicatesTokenFilterFactory"/>
    </analyzer>
</fieldType>


Zusätzlich definieren wir ein neues Feld (SPELL_TEXT), dem wir diesen Feldtyp zuweisen.
<field name="SPELL_TEXT" type="text_spell" indexed="true" stored="false"/>

Abschließend muss dieses Feld noch mit informationen gefüllt werden. Dazu kopieren wir einfach die Werte aus dem Standard-Volltextfeld eines jeden Dokumentes. Der für die schema.xml notwendige Eintrag sieht dazu wie folgt aus:

<copyField source="TEXT" dest="SPELL_TEXT" />


solrconfig.xml

In der solrconfig.xml muss zunächst der Request Handler /select mit der Spellcheck-Komponenten verknüpft werden. Dies passiert durch eintragen eines XML Feldes:
 </requestHandler>
   [...]
       <arr name="last-components">
           <str>spellcheck</str>
        </arr>
  </requestHandler>

Im nächsten Schritt muss die Komponente mit dem Namen "spellcheck" definiert werden. Ebenfalls in der solrconfig.xml, nur außerhalb des Request-Handlers, als eigener Abschnitt, als eigenen searchComponent.

 <searchComponent name="spellcheck" class="solr.SpellCheckComponent">
    <str name="queryAnalyzerFieldType">text_spell</str>
    <lst name="spellchecker">
      <str name="name">default</str>
      <str name="classname">solr.DirectSolrSpellChecker</str>
      <str name="field">SPELL_TEXT</str>
      <str name="spellcheckIndexDir">spellchecker</str>
      <float name="accuracy">0.65</float>

      <str name="comparatorClass">freq</str>
      <float name="maxQueryFrequency">0.0001</float>
      <float name="thresholdTokenFrequency">0.0002</float>
      <int name="maxInspections">5</int>
      <int name="maxEdits">2</int>
      <str name="distanceMeasure">internal</str>
    </lst>
 </searchComponent>


Hier werden wichtige Einstellungen zum Verhalten des Spellcheckers gemacht.
Insbesondere wird hier der Spellchecker mit dem dafür vorgesehenen Feldtyp und dem dafür eingerichteten Feld (SPELL_TEXT) verknüpft.

  • <float name="accuracy">0.65</float> 
  • 65% des Wortes muss mit dem Vorschlag überein stimmen, damit es in die Kandidatenliste für Vorschläge kommt.
  • <float name="maxQueryFrequency">0.0001</float> 
  • Maximale Prozentzahl (0.01 = 1%) an Dokumenten in denen ein Wort auftauchen darf, damit es als "zu korrigieren" Kandidat angesehen wird
  • <float name="thresholdTokenFrequency">0.0002</float> 
  •  Minimale Prozentzahl an Dokumenten, in denen Vorschläge auftauchen müssen, damit sie als Vorschläge akzeptiert werden.

Nach der Konfiguration ganz wichtig: Solr neu starten und den Index einmalig neu aufbauen, damit das Feld SPELL_TEXT gefüllt wird.

Abfrage mit Spellchecking

 Nachdem alles konfiguriert ist, kann nun eine Solr Abfrage genutzt werden, um die Informationen zum Spellchecking zu erhalten. Grundsätzlich ist dazu nur ein weiterer Paremeter notwendig:spellcheck=true
http://localhost:8088/solr/select/?q=brelin&spellcheck=true
Neben der bekannten Ergebnisliste (mit oder ohne Treffer)  liefert Solr neu einen eigenen Block für die Rechtschreibprüfung, dessen Ausgabe man verarbeiten kann:
"responseHeader":{
    "status":0,
    "QTime":86,
    "params":{
      "spellcheck":"true",
      "indent":"true",
      "q":"brelin",
      "wt":"json",
      "rows":"1"}},
  "response":{"numFound":0,"start":0,"docs":[]
  },
  "spellcheck":{
    "suggestions":[
      "brelin",{
        "numFound":1,
        "startOffset":0,
        "endOffset":6,
        "suggestion":["berlin"]},
      "collation","berlin"]}}

Zusätzliche Parameter

Je nach Anwendungsfall kann man die Abfrage mit aktivem Spellchecking durch weitere Parameter anpassen. Diese können direkt an die abfragende URL angebunden werden oder Teil der solrconfig.xml sein. Es gibt eine ganze Reihe von Parametern. Zwei möchte ich hier ansprechen.
  • collation

    Collation (im oberen Beispiel bereits aktiv) schlägt eine alternative Suchphrase vor. Das ist vor allem interessant, wenn die Suchanfrage aus mehreren Wörtern besteht. Letztlich kann man die Ausgabe von Collation direkt dazu nutzen, eine "Meinten Sie...?" Ausgabe zu erzeugen.
    Beispiel: "hmaburger essen in brelin" bringt folgende Spellchecking-Ausgabe
      "spellcheck":{
        "suggestions":[
          "hmaburger",{
            "numFound":1,
            "startOffset":0,
            "endOffset":9,
            "suggestion":["hamburger"]},
          "brelin",{
            "numFound":1,
            "startOffset":19,
            "endOffset":25,
            "suggestion":["berlin"]},
          "collation","hamburger essen in berlin"]}}
    
    
  • onlyMorePopular

    Solr hat meist mehrere Begriffe zur Auswahl, die als potentieller Vorschlag für eine Rechtschreibkorrektur passen könnten. Mit dem Parameter "onlyMorePopular" werden nur diejenigen Begriff als Vorschlag berücksichtigt, die mehr Treffer in einer Suche bringen würden, als der aktuelle Suchbegriff.

Donnerstag, 18. September 2014

Suchergebnis verbessern: Teile einese Textes unterschiedlich gewichten

Seit google wissen wir: kein Mensch möchte bei einer Suche/Volltextsuche auf die 2. Ergebnisseite blättern.
So etwa ist die Vorgabe durch den Anwender.
Der Knackpunkt neben validen Suchergebnissen ist also eine möglichst gute Relevanzsortierung.
Glücklicher Weise funktioniert die Relevanzsortierung bei Solr schon sehr gut. Dennoch gibt es je nach Dokumentenart und -inhalt Optimierungspotential.

In unserem Fall werden informelle Volltexte verarbeitet: Nachrichten. Diese haben die Natur, dass in den ersten Zeilen meist Zusammenfassungen, Kontextinformationen oder expemplarische Informationen zur Nachricht selber stehen.
Die ersten Zeilen eines Dokumentes sind also meist besonders inhaltsreich und relevant
Aus diesem Grund bossten wir die ersten N Zeichen eines jeden Dokumentes besonders. Oder anders Gesagt: Teile des eines Textes werden unterschiedlich gewichtet.

Das boosten bzw. höhere Gewichten einzelner Textabschnitte oder Zeilen in Solr funktioniert über einen Trick. Beim Import der Daten aus unserer MySQL Tabelle mittels Data-Import-Handler (DIH) erzeugen wir eine virtuelle Spalte. Diese zusätzliche Spalte befüllen wir mit den ersten N Zeichen des eigentlichen Volltextes. In der data-config.xml des DIH sieht das dann exemplarisch wie folgt aus:

<entity name="tabelle_news" pk="LFNR" 
        query="SELECT *,
                SUBSTRING(TEXT,1,500-CHAR_LENGTH(SUBSTRING_INDEX(SUBSTRING(TEXT,1,500),' ',-1))) 
                AS TEXT_SNIPPET
                FROM tabelle_news
                WHERE '${dataimporter.request.clean}' != 'false'
                           OR LAST_MODIFIED &gt;= '${dataimporter.last_index_time}' - INTERVAL 5 MINUTE">
</entity>
In der Konfiguration des Data-Import-Handler oben wird unmittelbar während des Imports ein maximal 500 Zeichen langer Text Schnipsel aus den ersten Zeichen des Dokumenten-Textes gennerriert und als neues Feld an Solr weiter gereicht. Wichtig ist, nicht blind nach 500 Zeichen abzuschneiden, sondern bereits am Ende des letzten Wortes innerhalb der 500 Zeichen!

Dieses Feld sollte natürlich in der Schema-Datei (schema.xml) entsprechend vorhanden sein, idealer Weise vom gleichen Typ, wie das Volltextfeld.

Wichtig ist, den Textschnipsel auch bei der Suche zu würdigen.
Dazu wird die Datei (solrconfig.xml) entsprechend angepasst.

In unserem Fall kommt der edismax SearchHandler zum Einsatz. Hier kann man explizit angeben, in welchen Feldern er suchen soll und wie Stark die einzelnen Felder gewichtet werden sollen. Exemplarisch könnte der Eintrag wie folgt aussehen:

<requestHandler name="/select" class="solr.SearchHandler" default="true">
     <lst name="defaults">
       <str name="defType">edismax</str>
       <float name="tie">0.01</float>
       <bool name="tv">true</bool>
       <str name="qf">
          
AGENTUR^10 UEBSCHRIFT^5.5 TEXT^1.5 THEMA TEXT-EXAKT^7 TEXT_SNIPPET
       </str>
       <str name="q.alt">*:*</str>
       <str name="pf">
          AGENTUR^10 UEBSCHRIFT^5.0 TEXT^1.5 TEXT-EXAKT^7 TEXT_SNIPPET
       </str>

[...]
Startet ein Anwender eine Suche, so sucht Solr die Informationen in verschiedenen Feldern der Dokumente und gewichtet das Ergebnis abhängig davon, in welchem Feld der gesuchte Begriff auftaucht.
Wichtig ist, dass hier das Feld TEXT_SNIPPET überhaupt erwähnt wird, damit es in der Relevanzberechnung berücksichtigt wird. Je nach Anwendungsfall kann man diesen Bereich natürlich auch noch boosten.
In jedem Fall aber wird nun der erste Teil eines Volltextes/Dokumentes stärker berücksichtigt/gewichtet als der Rest des Dokumentes/Textes.

Montag, 13. Januar 2014

mongoDB Data Import Handler für Solr

Wie bereits im vorhergehenden Post beschrieben ist derzeit keine Software im Netz verfügbar, die zum einen gepflegt wird und zum anderen performant große Datenmengen einer mongoDB in Solr indexiert.
Die Lösung für uns bestand darin, selber ein kleines Tool zu schreiben, dessen Aufgabe initial darin bestand, eine mongoDB komplett nach Solr zu importieren.

Ich habe es unter github abgelegt, für jedermann zugänglich: mongoSolrImporter


Der klassische Data Import Handler von Solr ist in Solr selber integriert. Der mongoSolrImporter arbeitet im Gegenzug dazu autark als externe PHP Skript.
In einer Konfigdatei werden dazum Solr Server, mongoDB Host, das Field-Mappiung und die mongoDB Query angegeben. Die Konfigurationsdatei wird dann dem Skript übergeben

user@host> php mongoSolrImporter.php -c mongoSolrImporter.ini

..und schon kanns losgehen.
Die Daten werden Blockweise in Solr indexiert. Dadurch wird im Vergleich zum mongo-connetor eine deutliche Performancesteigerung. der mongoSolrImporter verarbeitet pro Sekunde 2500 Dateien, wenn man auf einem Linuxsystem arbeitet über 6500 Dateien pro Sekunden, während der mongo-connector bei gleichem Datenbestand auf der gleichen Hardware etwa 33 Dokumente pro Sekunde verarbeitet. Der Geschwindigkeitsgewinn liegt folglich beim Faktor 200.

Zudem basieren die Daten auf einer Query, nicht auf dem oplog der mongoDB. Sind also auch bei abgelaufenem oplog noch greifbar.

Die Beschränkung liegt allerdings in der bisher mangelnden Unterstützung von mongoDB Datentypen. Initial ist das Tool so designt, dass mongoDB Daten verarbeitet werden, die ursprünglich aus einer relationalen Datenbank kommen. Die also keine Arrays oder embedded documents haben.

Dienstag, 7. Januar 2014

Synonyme und explizites Mapping

Wir haben ein paar Erkenntnisse aus einer Problemstellung mit Synonymen gewinnen können, die im Folgenden an einem fiktivem Beispiel erklärt werden sollen.

Dabei ist zu beachten: Wir erstellen Synonyme immer zur Index-Zeit, wie von SOLR empfohlen!

Problem
Nehmen wir einmal an, wir betreiben eine Webseite für Naturliebhaber und möchten, dass eine Suche nach Amsel oder Drossel oder Fink oder Starr Artikel findet, in denen die jeweils genannte Vogelart als Term vorkommt.
Wenn ich jedoch nach Vogel suche, möchte ich, dass nicht nur Artikel gefunden werden, in denen der Suchterm Vogel enthalten ist, sondern auch jene Artikel gefunden werden, in denen der Suchterm zwar nicht vorkommt, jedoch eine Vogelart benannt wird.
Bisher haben wir das in der Synonymdeklaration wie folgt gelöst:

vogel, amsel, drossel, fink, starr

Hier ergibt sich jedoch ein Problem mit der Präzision der Suchergebnisse:
So findet eine Suche nach Amsel per Definition auch Artikel, in denen es lediglich um Finken oder Drosseln etc. geht, ohne das eine Amsel erwähnt werden muss. SOLR verhält sich hier nämlich wie folgt:

Artikel A enthält als relevanten Begriff das Wort Amsel.
Mit obiger Synonymdeklaration wird dann während der Indexierung nicht nur das Wort Amsel mit in den Index aufgenommen, sondern ebenso alle o.g. Synonyme (Drossel, Fink, Starr, Vogel).

Ähnliches gilt für Artikel B, der nur das Wort Fink enthält, und sonst keinen anderen der genannten Begriffe. Auch würden die Terme Drossel, Amsel, Starr und Vogel zu diesem Artikel in den Index aufgenommen werden.

Die Folge: Eine Suche nach Amsel würde nicht nur den Artikel A zurückliefern, sondern auch den Artikel B, wenngleich in B der Begriff Amsel garnicht vorkommt. Gleiches gilt für den Suchbegriff Fink, der ebenfalls beide Artikel liefern würde.

Eine Suche nach dem Begriff Vogel würde gleichfalls beide Artikel liefern, was in diesem Fall auch richtig und gewollt wäre, den Mangel bei den beiden anderen Suchen aber nicht ausgleichen kann.

Was wir grundsätzlich wollen: Wenn man einen speziellen Suchbegriff eingibt, soll er spezielle Artikel finden, wenn wir einen Oberbegriff verwenden, soll ein breit gefächertes Ergebnis erscheinen.

Lösung
Zunächst einmal müssen wir entscheiden, welcher Begriff ein spezieller Begriff ist und welcher Begriff als Oberbegriff gilt. Diese Definition obliegt dem jeweiligen Datenbestand etc.
Im obigen Beispiel fällt die Einordnung leicht.
Amsel ist der Spezialbegriff, Vogel der Oberbegriff.

Die Lösung für unser Problem iat das sogenannte explizite Mapping.
Während das obige Beispiel für eine Synonymdeklaration unbestimmt ist und alles mit allem kombiniert, bietet das explizite Mapping die Möglichkeit, festzulegen, welche Begriffe wie zueinander gemappt werden sollen.
In der Sache ist das ganz einfach, wobei es einen interessanten Knackpunkt gibt:

amsel => vogel

Hier wird unidirektional der Begriff Amsel durch den Begriff Vogel ersetzt!
Es ist nicht so, dass bei dieser Deklaration der sowohl Amsel als auch Vogel nach der Indexierung im Index vorhanden wären.
Eine Suche nach Amsel würde keinen Treffer ergeben, weil alle Vorkommen des Worte Amsel durch das Wort Vogel ersetzt worden wären!

Die einfachste Lösung, die für uns gut funktioniert:

amsel => vogel, amsel
drossel => vogel, drossel
fink => vogel, fink
[...] 

Jetzt verhält sich die Suche wie gewünscht: Eine Suche nach Amsel liefert wirklich nur Amsel-Artikel zurück, während die Suche nach dem Oberbegriff Vogel auch Artikel mit allen anderen Vogelarten findet.

Limitierungen
Die Pflege entsprechender Synonyme kann sehr mühselig und aufwendig sein. Aber das ist bei Synonymen unserer Erfahrung nach immer der Fall.