Volltextsuche mit ElasticSearch

Wenn es um die Volltextsuche in großen Datenmengen geht, führte lange kein Weg an Apache Solr vorbei. Seit 2010 steht mit ElasticSearch ein weiteres ebenfalls auf Lucene aufbauendes Projekt zur Verfügung, das sich zunehmend als Alternative zum etablierten Solr positioniert.

In Pocket speichern vorlesen Druckansicht 2 Kommentare lesen
Lesezeit: 16 Min.
Von
  • Oliver Fischer
Inhaltsverzeichnis

Wenn es um die Volltextsuche in großen Datenmengen geht, führte lange kein Weg an Apache Solr vorbei. Seit 2010 steht mit ElasticSearch ein weiteres ebenfalls auf Lucene aufbauendes Projekt zur Verfügung, das sich zunehmend als Alternative zum etablierten Solr positioniert und immer häufiger zum Einsatz kommt.

Dass ElasticSearch erfolgreich ist, liegt an Features wie einfachem Clustering zur Umsetzung von Hochverfügbarkeit und Lastverteilung, einer vollständig REST- und Java-API sowie an der Tatsache, dass es schemalos und dokumentenorientiert arbeitet. Zudem finden Änderungen auch unter hoher Änderungslast schnell Eingang in Suchergebnisse, weshalb die ElasticSearch-Entwickler auch von Suche in Echtzeit sprechen. Der vielleicht wichtigste Aspekt ist jedoch die einfache Handhabung von ElasticSearch: Mehr als ein Java Development Kit, cURL und ein Editor wird nicht benötigt, um innerhalb von ein paar Minuten eine ElasticSearch-Instanz zum Laufen zu bringen und die ersten Dokumenten zu indizieren.

ElasticSearch folgt, wie viele Vertreter der NoSQL-Systeme, einem dokumentenzentrierten Ansatz im Umgang mit Daten. Für die spätere Suche werden zu indizierenden Daten als JSON-Dokumente an ElasticSearch übergeben und gespeichert.

Intern legt ElasticSearch die Dokumente in Indizes ab. Ein Index stellt dabei lediglich einen logischen Namensraum dar, in dem beliebig viele Dokumente unterschiedlichen Typs abgelegt werden. Beispielsweise könnten alle Dokumente mit Adressen in einem Index addresses abgelegt und zusammengefasst werden, unabhängig ob sie deutsche oder japanische Adressen enthalten. Die physische Speicherung erfolgt dabei in mehreren als Primary Shards bezeichneten Lucene-Instanzen. Das Aufteilen eines Index dient der Lastverteilung, da sich so lang laufende oder komplexe Suchanfragen durch mehrere Instanzen parallel bearbeiten lassen. Die Shards eines Index können darüber hinaus auf unterschiedliche Knoten eines Clusters verteilt werden, um so die Last auch physisch zu verteilen. Um Datenverlust im Falle des Ausfalls eines Knotens zu verhindern, lassen sich von jedem Primary Shard mehrere als Replica Shards bezeichnete Kopien auf anderen Clusterknoten anlegen. Dieses Feature ist zur Optimierung von Suchanfragen nützlich, da das Verteilen von Shards ebenso wie das Speichern von Dokumenten und Ausführen von Suchanfragen so beeinflussbar ist.

Von Haus aus ist ElasticSearch als Clusteransatz nach dem Master-Slave-Muster konzipiert. Jeder Knoten eines ElasticSearch-Clusters kann dabei jede der beiden Rollen einnehmen. Beim Start führt jeder Knoten einen Discovery-Prozess aus und sucht via Uni- oder Multicast nach anderen Knoten im gleichen Netzwerk. Ob ein anderer Knoten zum gleichen Cluster gehört, wird dabei über den für den Knoten konfigurierten Clusternamen, eine einfache String-Eigenschaft, entschieden. So ist es möglich, mehrere Cluster parallel im gleichen Netzwerk zu betreiben. Anschließend handeln die Knoten untereinander den Masterknoten aus, dem dann die Koordination des Clusters obliegt. Fällt er aus oder ist er eine zeitlang nicht erreichbar, bestimmen die verbliebenen Knoten einen neuen Master.

Um ElasticSearch zu installieren, ist lediglich die jeweils aktuelle Distribution herunterzuladen und zu entpacken. Anschließend lässt sie sich sofort mit

./bin/elasticsearch -f

starten. Durch die Option -f bleibt ElasticSearch im Vordergrund und gibt seine Log-Meldungen auf der Konsole aus.

Für den ElasticSearch-Server gilt ein Zero-Configuration-Ansatz, das heißt, dass für alle möglichen Einstellungen sinnvolle Standardwerte vorgegeben sind. Passen sie nicht, lassen sie sich über die im YAML-Format geschriebene und standardmäßig leere Konfigurationsdatei config/elasticsearch.yml an die eigenen Anforderungen anpassen. Auf jeden Fall sollte der Clustername der ElasticSearch-Instanz, der standardmäßig auf elasticsearch voreingestellt ist, über den Parameter cluster.name auf einen selbstgewählten Namen geändert werden. Sonst besteht die Gefahr, dass eine andere frisch gestartet Instanz im gleichen Netz dem eigenen Cluster beitritt und Daten und Suchanfragen entgegennimmt. Auch darüber hinaus ist ein Blick in die Konfigurationsdatei lohnenswert, da sie ausführlich dokumentiert ist und einen guten Einblick über mögliche Einstellungen des Servers gibt. Nach dem Start lauscht ElasticSearch auf Port 9200.

Läuft ElasticSearch, lassen sich JSON-Dokumente mit einem HTTP-POST- oder HTTP-PUT-Befehl zur Indizierung übergeben. Um beispielweise eine deutsche Post-Adresse als Dokumententyp man im Index addresses zu indizieren, reicht der unten zu sehende cURL-Befehl. Jede Ressource in ElasticSearch lässt sich über eine REST-konforme URL angesprechen. So setzt sich die URL für ein in ElasticSearch abgelegtes Dokument dabei nach folgendem Schema zusammen:

http://hostname:port/indexname/dokumententyp/dokumentenid
curl -X POST http://localhost:9200/addresses/german -d '{
"empfaenger": "Otto Normalverbraucher",
"strasse": "Hauptstraße",
"hausnummer" : "1",
"stadt" : "Katzenelnbogen",
"postleitzahl" : 56368,
"land" : "Deutschland"
}'

{
"ok" : true,
"_index" : "addresses",
"_type" : "german",
"_id" : "PNsR08kgT-uJ_amlS4GGpA",
"_version": 1
}

ElasticSearch quittiert den Aufruf mit dem im Beispiel aufgeführten Dokument, aus dem sich der Erfolg der Operation, der Name des verwendeten Index und Dokumententyps sowie ID und Version des Dokuments entnehmen lässt.

Da das Dokument mit POST und ohne Angabe einer ID übergeben wurde, erzeugt ElasticSearch selbst eine. Alternativ lässt sich die Dokumenten-ID auch selbst vergeben, wofür das Dokument aber mit PUT an ElasticSearch zu übermitteln ist, wie folgender Codeausschnitt zeigt:

curl -X PUT http://localhost:9200/addresses/german/1234 -d '{
"empfaenger": "Otto Normalverbraucher",
"strasse": "Hauptstraße",
"hausnummer" : "1",
"stadt" : "Katzenelnbogen",
"postleitzahl" : 56368,
"land" : "Deutschland"
}'
{
"ok" : true,
"_index" : "addresses",
"_type" : "german",
"_id" : "1234",
"_version" : 1
}

Unter Angabe der ID kann ein Dokument durch ein erneutes PUT überschrieben, durch ein GET gelesen, dessen Existenz mit HEAD abgefragt und durch DELETE gelöscht werden.

curl -X PUT http://localhost:9200/addresses/german/1234 -d '{
"empfaenger": "Otto Normalverbraucher",
"strasse": "Hauptstraße",
"hausnummer" : "19",
"stadt" : "Katzenelnbogen",
"postleitzahl" : 56368,
"land" : "Deutschland"
}'

curl -X GET http://localhost:9200/addresses/german/1234?pretty
{
"_index" : "addresses",
"_type" : "german",
"_id" : "1234",
"_version" : 4,
"exists" : true, "_source" : {
"empfaenger": "Otto Normalverbraucher",
"strasse": "Hauptstraße",
"hausnummer" : "19",
"stadt" : "Katzenelnbogen",
"postleitzahl" : 56368,
"land" : "Deutschland"
}

curl -X DELETE http://localhost:9200/addresses/german/1234?pretty
{
"ok" : true,
"found" : true,
"_index" : "addresses",
"_type" : "german",
"_id" : "1234",
"_version" : 5
}

Da jedes Dokument einen eindeutigen Bezeichner hat, lässt sich ElasticSearch je nach Anwendungsfall auch als Key-Value-Store nutzen.

Einer der Vorzüge von ElasticSearch ist seine Schemafreiheit. Das bedeutet, dass keine explizite und ausformulierte Beschreibung der zu verarbeitenden Dokumente in Hinsicht auf Datentypen und weitere Informationen angegeben werden muss, sondern sich die Daten direkt an das jeweilige System übergeben lassen. Besonders beim Verarbeiten semistrukturierter Daten kann sich diese Eigenschaft als nützlich erweisen. ElasticSearch arbeitet jedoch nicht vollkommen ohne Schema, sondern verfügt über die Fähigkeit, eins per Heuristik über die übergebenen Daten abzuleiten. Ein aus Zeichen bestehendes Feld wird als String erkannt, Datumsangaben erfolgen meist in ein paar Grundformaten und Zahlen deuten auf einen numerischen Wert hin. Der folgende Codeausschnitt zeigt das von ElasticSearch für Adressen abgeleitete Schema. In ihm werden die Postleitzahl als Long-Wert und die Hausnummer, obwohl auch nur aus Zahlen bestehend, als String betrachtet, da sie in Anführungszeichen eingefasst wurde.

curl -X GET http://localhost:9200/addresses/german/_mapping?pretty
{
"german" : {
"properties" : {
"empfaenger" : { "type" : "string" },
"hausnummer" : { "type" : "string" },
"land" : { "type" : "string" },
"postleitzahl" : { "type" : "long" },
"stadt" : { "type" : "string" },
"strasse" : { "type" : "string" }
}
}
}

Statt ElasticSearch ein Schema ableiten zu lassen, kann der Nutzer auch selbst eins erstellen. Dies erlaubt vergleichsweise mehr Kontrolle darüber, wie ElasticSearch mit den übergebenen Dokumenten umgeht. Die Spannweite der unterstützten Datentypen reicht von mehreren Zahlenformaten über Datums- und Zeitangaben bis hin zu Geodaten. Im vorliegenden Beispiel wäre es möglich, die Postleitzahl als String zu definieren, um auch Kombinationen wie 03476 richtig zu behandeln, die Hausnummer jedoch als Zahl, um sie später für Vergleiche nutzen zu können.

Ein eigenes Mapping erlaubt jedoch nicht nur, die verwendeten Datentypen selbst zu bestimmen, sondern auch wie die Daten durch Einstellungen und darunter liegend durch Lucene verarbeitet werden. Nach Begriffen in der exakten Schreibweise zu suchen, ist nur selten sinnvoll. Beispielsweise wäre es wünschenswert, den Beispielempfänger Otto Normalverbraucher auch bei unvollständiger Eingabe und unabhängig von Groß- und Kleinschreibung zu finden. Die hierfür notwendige Vorverarbeitung übernehmen Analyzer, die Eingangstexte in einzelne Token aufspalten und sie vor dem eigentlichen Speichern in den Indizes von ElasticSearch filtern und transformieren können. Mit Analyzern lassen sich zum Beispiel Texte in einzelne Wörter zerteilen, diese in Kleinbuchstaben umwandeln und Stoppwörter wie "der", "die" und "das" herausfiltern, ehe die Token gespeichert werden.

So ließe sich der Beispielempfänger sowohl bei der Suche nach "otto" als auch nach "normalverbraucher" finden. ElasticSearch stellt eine Reihe solcher Werkzeuge bereit und erlaubt zudem die Definition eigener Varianten. Jeder Analyzer setzt sich aus einem Tokenizer und einem oder mehreren nachgeschalteten Filtern zusammen. Der Tokenizer spaltet die Eingabe in einzelne Elemente auf, und die Filter transformieren diese Token, bevor sie im Index abgelegt werden. Ohne explizite Angabe eines Analyzers kommt ein Standard-Analyzer zum Einsatz.

curl -X PUT http://localhost:9200/addresses -d '{

"settings": {
"analysis" : {
"analyzer" : {
"3gram" : {
"tokenizer" : "engram",
"filter" : ["lowercase"]
}
},
"tokenizer" : {
"engram" : {
"type" : "NGram",
"min_gram" : 3,
"max_gram" : 10
}
}
}
},
"mappings": {
"german": {
"_all": { "enabled": false },
"_source" : { "enabled" : true },

"properties" : {
"empfaenger" : {
"type" : "string",

"index_analyzer" : "3gram",
"search_analyzer" : "standard"
},
"hausnummer" : { "type" : "string" },
"land" : { "type" : "string" },
"postleitzahl" : { "type" : "long" },
"stadt" : { "type" : "string", "store" : "yes" },
"strasse" : { "type" : "string" }
}
}
}
}

Das obige Beispiel zeigt die manuelle Erzeugung eines Indexes mit einem selbst erstellten Mapping für deutsche Adressen. Es unterscheidet sich vom von ElasticSearch erstellten Schema zum einen durch die abweichenden Datentypen für Hausnummer und Postleitzahl. Zum anderen werden für das Feld "empfaenger" zwei Analyzer konfiguriert. "index_analyzer" legt den zur Indizierung verwendeten Analyzer fest, in diesem Fall den zuvor konfigurierten 3gram. Für Suchanfragen im Feld selbst kommt der erwähnte Standard-Analyzer zum Einsatz. Bei Bedarf lässt sich ein Index wie folgt löschen:

curl -X DELETE localhost:9200/adresses

Über die Mapping-Einstellungen lässt sich nicht nur auf Schema und Indizierung Einfluss nehmen, sondern unter anderem das Speichern von Daten steuern oder festlegen, mit welcher Gewichtung Felder zur Bewertung von Suchergebnissen beitragen.

Die Hauptaufgabe einer Suchmaschine besteht im Suchen und Finden von Daten. Je einfacher und präziser sich Anfragen stellen lassen, desto leichter lässt sie sich lösen. ElasticSearch stellt für Suchanfragen eine umfangreiche Query-DSL zur Verfügung, die sowohl einfache Term-, als auch komplexe, mit boolschen Operatoren verbundene Anfragen ermöglicht.

Die einfachste Anfrage, die sich an ElasticSearch stellen lässt, ist die nach einem konkreten Term, der in der festgelegten Schreibweise in einem Feld vorhanden sein muss:

curl -X GET http://localhost:9200/addresses/german/_search -d '{
"query" : {
"term" : {
"stadt" : "katzenelnbogen"
}
}'

{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"failed" : 0
},
"hits" : {
"total" : 1,
"max_score" : 0.30685282,
"hits" : [ {
"_index" : "addresses",
"_type" : "german",
"_id" : "1234",
"_score" : 0.30685282, "_source" : {
"empfaenger" : "Otto Normalverbraucher",
"strasse" : "Hauptstraße",
"hausnummer" : "19",
"stadt" : "Katzenelnbogen",
"postleitzahl" : 56368,
"land" : "Deutschland"
}
} ]
}
}

Der Codeausschnitt zeigt die Term-Anfrage nach allen Adressen aus der Stadt Katzenelnbogen. ElasticSearch beantwortet sie mit einer Reihe von Header-Informationen zur Suche und allen zu der Anfrage passenden Dokumenten.

Der eigentliche Nutzen von Volltextsuchmaschinen wie ElasticSearch ist jedoch die Fähigkeit, nach Begriffen in großen, eventuell unstrukturierten Datenbeständen suchen zu können und dabei auch mit einer gewissen Unschärfe der Suchanfragen umgehen zu können. So sollen sich Personen auch bei abweichender oder falscher Schreibweise ihres Namens finden lassen. Solche Anfragen können mit der match-Query gestellt werden, wie folgendes Beispiel zeigt.

curl -X GET http://localhost:9200/addresses/german/_search?pretty -d '{
"fields" : ["empfaenger", "stadt"],
"query" : {
"match" : {
"empfaenger" : "Otto Normalbraucher"
}
}
}

{
"took" : 2,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
failed" : 0
},
"hits" : {
"total" : 1,
"max_score" : 0.003516253,
"hits" : [ {
"_index" : "addresses",
"_type" : "german",
"_id" : "1234",
"_score" : 0.003516253,
"fields" : {
"empfaenger" : "Otto Normalverbraucher",
"stadt" : "Katzenelnbogen"
}
} ]
}
}

Aufgrund der gewählten Analyzer-Konfiguration im Index-Mapping, kann ElasticSearch "Otto Normalverbraucher" finden, obwohl der Name in der Suchabfrage abweicht.

Ein besonderes Feature von ElasticSearch sind die Percolate-Queries. Dabei handelt es sich um gespeicherte Anfragen, die eine Umkehr der Suche erlauben. Statt zu fragen, welches Dokument zu einer Anfrage passt, lässt sich herausfinden, welche Anfragen zu einem Dokument registriert wurden. So lassen sich leicht Benachrichtigungen bei neuen Suchergebnissen realisieren.

Neben seinen Suchfähigkeiten und seiner Einfachheit verfügt ElasticSearch über weitere interessante Funktionen. Beim Indizieren bestimmt ElasticSearch den Shard, in dem ein Dokument abgelegt wird, über einen aus der Dokumenten-ID bestimmten Hash-Wert. Abhängig von diesem wird das Dokument an einen entsprechenden Shard geroutet. Soll das Routing nicht auf der ID beruhen, lassen sich andere Werte als Routing-Werte ermitteln. Dadurch können Daten nicht nur gezielt im Cluster verteilt, sondern auch das Ausführen von Suchen optimiert werden, da sich auch Routing-Werte vorgeben lassen. Scripting bezeichnet das Ausführen eigener Skripte, unter anderem während der Suche, um beispielsweise zusätzliche Felder zu berechnen oder Suchergebnisse zu manipulieren. ElasticSearch unterstützt hierfür die MVFLEX Expression Language (MVEL), JavaScript und Java.

Hilfreich sind darüber hinaus die Möglichkeiten, die Lebensdauer von Dokumenten in einem ElasticSearch-Cluster durch die Angabe eines TTL-Wertes (Time to live) zu begrenzen oder externe Systeme als Datenquellen integrieren zu können. Für letzteres steht das River-Subsystem zur Verfügung, über das unter anderem Daten aus CouchDB, RabbitMQ oder via Java Database Connectivity (JDBC) automatisch importiert werden können. Auch dem Bedarf nach Mandantenfähigkeit kann ElasticSearch durch die getrennten Speicherung in unterschiedlichen Indexen gerecht werden.

Für die Integration zusätzlicher Funktionen steht eine Plug-in-Schnittstelle zur Verfügung, für die eine Vielzahl fertiger Plug-ins erhältlich ist. Unter anderem lassen sich so Groovy oder Clojure als weitere Sprachen zum Schreiben von Skripten hinzufügen oder es können weitere Analyzer zur Anwendung kommen. Da ElasticSearch selbst über kein Webfrontend verfügt, existieren auch hierfür Plug-ins wie ElasticSearch Head. Externe Datenquellen wie der Java Message Service (JMS), Dateisysteme oder LDAP können ebenso als mögliche River über Plug-ins angebunden werden.

ElasticSearch stellt als Volltextsuchmaschine eine ebenbürtige Alternative zu etablierten Techniken wie Solr dar, punktet mit vielen innovativen Ideen und überzeugt durch eine ansprechende Architektur. Besonders wenn es um Echtzeit-Suche und den Einsatz in dynamischen, Cloud-ähnlichen Umgebungen geht, ist der Einsatz von ElasticSearch sinnvoll. Dabei erweist es sich als überaus anpassungsfähig, da aus Produktionssicht gefährliche Operationen wie das Löschen von Indizes via REST abschaltbar sind. Die Ausrichtung auf JSON begünstigt darüber hinaus die Integration in heterogene Projekte. Für ein so vergleichsweise junges System überzeugt ElasticSearch durch eine hohe Reife, die es unter anderem im Einsatz bei GitHub und SoundCloud unter Beweis stellt. Man darf gespannt sein, welche Ideen die Entwickler in den nächsten Versionen umsetzen.

Oliver Fischer
beschäftigt sich mit agiler Entwicklung und innovativen Systemen und Ideen, Letzteres am liebsten in Java. Er lebt und arbeitet in Berlin.
(jul)