Geschmeidige Suche mit Elasticsearch

„The noblest search is the search for excellence.“

Lyndon B. Johnson

Vergrößert sich der Datenbestand in der eigenen Anwendung merklich,  dann wird es Zeit über eine geeignete Suchlösung nachzudenken. Es gibt eine Menge frei verfügbarer Suchmaschinen, die den Vergleich mit kommerziellen Produkten nicht scheuen müssen. Viele zeigen sich in ihrem Funktionsumfang  sogar viel leistungsfähiger. Suchmaschinen liefern sehr schnell Ergebnisse, weil sie nur auf ihrem Index, einem aufbereiteten Extrakt der eigentlichen Daten arbeiten und nicht die gesamte Datenbasis durchsuchen müssen.

Wie einfach die Einbindung einer solchen Lösung ist,  werde ich hier mit einer Ahnensuche basierend auf Elasticsearch demonstrieren.

Elasticsearch ist neben Solr das führende Suchframework und bietet neben einer hohen Performance, Lastverteilung und Ausfallsicherheit eine REST API mit einer mächtigen Query DSL.

Obwohl für diesem Beitrag die Details der Elasticsearch Architektur unötig sind, hier ein paar Worte zu den Fachbegriffen.  Elasticsearch arbeitet mit einem Cluster, der aus mindesten einen einzelnen Node besteht. Ein Node ist eine einzelne Instanz von Elasticsearch und kümmert sich um Indexierung und Suche.  Der Index mit dem Elasticsearch arbeitet ist in mehrere Bruchstücke aufgeteilt.  Diese Bruchstücke werden Shard genannt und jeder von ihnen ist ein einzelner Apache Lucene Index. Damit Ausfallsicherheit und Lastverteilung gewährleistet sind gibt es immer mindestens ein Replica Shard zu einem Primary Shard. Gibt es Probleme mit dem Primary Shard, dann kann auf einen Replica Shard ausgewichen werden. Zusätzlich werden Suchen performanter, weil auch auf den Replicas gesucht wird.

Um Elasticsearch in eigene Anwendung zu integrieren, muss nur die aktuelle Version entpackt und der Server gestartet werden.

Nach dem ersten Start existiert noch kein Index und wir können einen ersten ersten eigenen Index erzeugen. Dazu reicht ein einfacher REST-Aufruf mit einem beliebigen REST-Client, wie z.B. CURL oder Postman. Für dieses Beispiel verwenden wir eine Index-Beschreibung, da wir uns keine Volltextsuche auf Geschlecht, URN und den Ortsnamen wünschen. Ohne eine explizite Index-Beschreibung erstellt Elasticsearch, anhand der eingefügten Dokumente, selbständig ein Mapping.

PUT /ancestors
{
  "mappings": {
    "_doc": {
      "properties": {
        "birth": {
          "properties": {
            "date": { "type": "date" },
            "place": { "type": "keyword" }
          }
        },
        "death": {
          "properties": {
            "date": { "type": "date" },
            "place": { "type": "keyword" }
          }
        },
        "firstname": {
          "type": "text",
          "fields": {
            "keyword": {
              "type": "keyword",
              "ignore_above": 256
            }
          }
        },
        "sex": { "type": "keyword" },
        "urn": { "type": "keyword" }
      }
    }
  }
}

Noch ist unser Index leer, daher wird die nächste Aufgabe sein, den Index mit Informationen zu den Ahnen zu füllen. Die Informationen werden über einen POST oder PUT Request an den Cluster übergeben. Im Sprachgebrauch von Elasticsearch handelt es sich um Dokumente, die als Liste von Key-Value Paaren im Json Format spezifiziert sind.

POST /ancestor/_doc/?pretty
{
  "firstname": "Dietrich",
  "lastname": "Kaiser",
  "birth": {
    "date": "1640",
    "place": "Oldenburg"
  },
  "sex": "male",
  "urn": "urn:gina:gedbas/12345"
}

Der Request liefert uns die folgende Antwort, die uns signalisiert, dass wir erfolgreich einen ersten Ahnen in den Index eingefügt haben.

{
    "_index": "ancestors",
    "_type": "_doc",
    "_id": "1",
    "_version": 1,
    "result": "created",
    "_shards": {
        "total": 2,
        "successful": 1,
        "failed": 0
    },
    "_seq_no": 0,
    "_primary_term": 1
}

Alle Ahnen aus der GEDCOM Datei einzeln in den Index einfügen wäre zwar möglich, aber einfacher ist es mit einem Bulk Request. Ein Bulk  Request besteht aus einer Liste von Indexänderungen, also nicht nur das Einfügen von Dokumenten, sondern auch Ändern und Löschen von Dokumenten kann mit einem Request vielfach ausgeführt werden. Eine Indexänderung besteht jeweils aus einer Zeile mit der jeweiligen Aktion und eine weitere Zeile mit den notwendigen Daten zum Dokument. Einzige Ausnahme ist hier das Löschen von Dokumenten, hier wird verständlicherweise keine zweite Zeile benötigt.

PUT /_bulk
{ "index": { "_index": "ancestors", "_type": "_doc" }}
{ "sex": "♂", "firstname": "Johann Hinrich", "lastname": "Helweg"...
{ "index": { "_index": "ancestors", "_type": "_doc" } }
{ "sex": "♀","firstname":"Mette Margrete", "lastname": "Rathkamp"...

Für unseren Beitrag besteht der Inhalt des Bulk Request nur aus index Aktionen zum Einfügen von Dokumenten und darauf folgen die Daten zu der jeweiligen Person. Wir verwenden die JSON Darstellung für Personen, die im Beitrag REST in Peace vorgestellt wurde. Da es sich bei dem Inhalt des Bulk Request nicht um eine korrekte JSON Datenstruktur handelt, muss der Content-Type Header application/x-ndjson angegeben werden.

Jetzt sind wir in der Lage, ganze Busladungen von Personen, in Sekundenschnelle in den Index zu befördern.

Ein Beispiel zur Integration von Elasticsearch wäre aber nicht vollständig, ohne eine Suche auf dem Index auszuführen. Wie nicht anders zu erwarten, wird ein POST Request mit der Suchanfrage an den Server gesendet.

POST /ancestors/_search?pretty	 	 
{
  "query": {
    "match" : { "lastname" : "Köhnsen" }
  },
  "aggs" : {
    "birth.place" : {
      "terms" : { "field" : "birth.place" }
    }
  },
  "sort": { "birth.date": { "order": "desc" } }
}

Im obigen Beispiel suchen wir nach Personen, die den Nachnamen Köhnsen besitzen und erhalten als Resultat eine Liste von 12 Personen. Die Ergebnisliste wird uns absteigend sortiert zurückgegeben.

Mit zusätzlichen Filtern können wir die Suche einschränken und durch weitere Queries die Suche verfeinern.

POST /ancestors/_search?pretty	 	 
{
  "query": {
  	"bool": {
  	  "must": { "match_phrase": { "name": "Köhnsen" }},   
     "filter": { "range": { "birth.date": { "lt": "1900-01-01" }}}
  	}
  },
  "aggs" : { "sex" : { "terms" : { "field" : "sex" }}},
  "sort": { "birth.date": { "order": "asc" } }
}

Im obigen Beispiel suchen wir wie zuvor, jedoch filtern wir die nach 1899 geborenen heraus. Außerdem möchten wir eine Zusammenfassen erhalten, wie viele der gefundenen Personen, welchem Geschlecht angehören.

Die Beispiele hier zeigen nur einen winzigen Ausschnitt der Möglichkeiten die Elasticsearch den Entwickler bietet. Ich wünsche jedem Interessierten viel Spaß bei der eigenen Entdeckungsreise durch die vielfältigen Features von Elasticsearch. Wer hier etwas nicht findet, der sucht nicht richtig.