The Hitchhiker's Guide to Full-Text Search
Vorwort
Die Anforderungen an eine Elasticsearch Volltextsuche können, je nach Anwendungsfall, sehr unterschiedlich sein. Deshalb möchte ich für diesen Blogartikel keine Allgemeingültigkeit heraufbeschwören. Ich möchte meine Gedanken und Erkenntnisse, die ich bei der Implementierung einer solchen Suche gemacht habe, mitteilen, auf dass es vielleicht für den Einen oder Anderen die Einstiegshürde etwas senken möge. Die hier beschriebenen Snippets wurden für Elasticsearch 5.4 erstellt 🙊
Hilfreiches Tool für alle die nicht mit cURL arbeiten möchten, oder noch kein anderes API Werkzeug nutzen: Postman
1. Kapitel: Die Daten
Um über eine Suche etwas finden zu können, brauchen wir zunächst Daten, also Informationen über Dinge. Grundsätzlich kann es sich dabei, je nach Projekt, um verschiedene Dinge handeln, wie z.B. Produkte, Nutzerinformationen, Orte usw. Daher ist es wichtig, dass wir uns im Vorfeld Gedanken darüber machen, welche Art Information wir vorliegen haben und wie ein Nutzer diese Information durchsuchen könnte. Bevor wir also mit Elasticsearch anfangen, analysieren wir selbst erst einmal unsere Daten:
- Um welche Art von Daten handelt es sich?
- Kenne ich bereits gängige Suchabfragen von Kunden?
- Welche Ergebnisse möchte ich dem Nutzer in der Suche anzeigen?
Diese Fragen sind beim Erstellen des Suchindex und qualitativen Bewertung der späteren Ergebnisse recht wichtig. Es kann nämlich sein, dass das mathematisch beste Ergebnis nicht jenes ist, welches ich dem Nutzer aus kaufmännischer Sicht an erster Stelle präsentieren möchte. In einem Supermarkt ist ein vergleichbar günstigeres Produkt z.B. auch nicht auf Sichthöhe im Regal eingeordnet, sondern weiter unten.
Im Weiteren lässt sich auch planen, weitere Metriken (z.B. aus Verkaufsanalysen) mit einzubeziehen. Indem Werte für Bestseller o.ä. indexiert werden, können bestimmte Ergebnisse stärker gewichtet werden.
Aber werden wir jetzt mal ein wenig konkreter.
Produktbezogene Sicht der Daten
Meistens fängt man ja nicht auf einer grünen Wiese an, sondern hat bereits Daten in irgendeiner Form vorliegen. Der Einfachheit nehmen wir an, dass Produktinformationen bereits in Elasticsearch für die Katalogdarstellung eines Shops vorliegen. Diese Daten sind meist in einer produktbezogenen Sicht strukturiert und setzen sich aus typischen Attributen zusammen:name, sku, color, price, size, product_typ, description,…
Das eignet sich super, um Filter anzuwenden um z.B. nur Produkte einer bestimmten Farbe anzuzeigen. Für eine Suche eignet sich diese Struktur meiner Meinung nach nur bedingt. Zwar wird die Suche nach „rot" sicher auch Ergebnisse liefern, aber wird ein Kunde lediglich danach suchen? Suchanfragen im Alltag setzen sich meistens eher aus zwei oder drei Begriffen zusammen. Ein Kunde wird wahrscheinlich eher nach „rote Rüben" suchen. Nun kommt die Krux. Die Suche mit mehreren Begriffen über mehrere Felder wird sowohl in der Konfiguration der Analyzer, als auch in der Query schnell komplex werden, wenn die Ergebnisse qualitativ dem entsprechen sollen was man erwartet. Denn unser Begriff „rote" wird auch bei anderen roten Produkten gefunden werden, wie auch Rüben bei Rüben anderer Farbe Treffer landen wird. Die vielen Felder machen es mir, bei der Entwicklung der Datenanalyse und der Suchabfrage, schwer den Überblick zu behalten. 👀
Besser wäre doch, wenn wir nur ein Feld hätten, das suchrelevante Attribute zusammenfasst und passen dafür unsere Analyzer und Query an. Das müsste auch das Debugging übersichtlicher machen.
Anwendungsbezogene Sichtweise der Daten
Mit dieser Sichtweise möchte ich mir bewusst machen, dass ich die Produktinformationen, für meine Anwendung aufbereiten und alle relevante Daten in einem Feld aggregieren könnte, um eine einfachere Struktur zu schaffen.
Die Anwendung ist in unserem Fall natürlich die Suchanfrage eines Anwenders, der passende Ergebnisse erwartet. Kenne ich meine Produkte und meine Nutzer ein wenig, werde ich relativ schnell herausfinden, welche Attribute aus den Produktdaten für die Suche relevant sein könnten. Diese verwende ich um mir mit Hilfe des Elasticsearch Mappings ein neues Feld zu erzeugen, in welchem ich dann die Werte der Attribute kopiere. Dieses Feld wird durch den Analyzer im inverted Index von Elasticsearch, also dem Lucene Unterbau, angelegt und ist kein Feld, das zur Anzeige genutzt wird. Es taucht damit auch nicht in der "_source"
auf, sondern dient lediglich dazu darauf Suchanfragen anzuwenden. Damit ich aber ein wenig mehr Kontrolle im Debugging habe, werde ich das Feld zusätzlich, für die spätere Ausgabe über eine Suchabfrage abspeichern. Bei größeren Datenmengen, erhalte ich dadurch die Möglichkeit mir zu jederzeit anzusehen welche Werte in meinem neuen Feld, das ich zur Suche verwende, enthalten sind.
So, genug der theoretischen Gedanken. Um auszuprobieren ob unsere Ideen auch in der Praxis bestehen können, packen wir im nächsten Kapitel die Elasticsearch API an und bauen uns einen neuen Suchindex auf. 💪
2. Kapitel: Das Mapping und der Analyzer
Da es für den Index einiges vorzubereiten gibt, wird dieses Kapitel wird wohl etwas länger werden, also erstmal durchatmen. 💨
Mit den bisher zusammengetragenen Gedanken machen wir uns nun mal ans Konfigurieren des Elasticsearch Indexes. Dafür definieren wir zunächst ein Mapping mit Dokumenttypen "veggies"
(Ab Elastic 6.x werden mapping types abgelöst), in dem wir festlegen welche Felder wir für die Suche benötigen. Dieses Mapping beinhaltet zunächst nur die relevanten Produktattribute, die wir dem Nutzer anzeigen möchten und ein Feld, welches wir ausschließlich für die Suche verwenden werden. Dieses Feld aggregiert die Werte aus den Produktattributen.
Unser stark vereinfachtes mapping sieht dann wie folgt aus:
// Die vollständige Indexdefinition erhaltet Ihr gegen Ende von Kapitel 2. ... "mappings": { "veggies": { "_source": { "enabled": true }, "_all": { "enabled": false }, "properties": { "color": { "copy_to": "full_text", "type": "text" }, "name": { "copy_to": "full_text", "type": "text" }, "product_type": { "copy_to": "full_text", "type": "text" }, "full_text": { "type": "text", "store": "yes" } } } } ...
Das Feld das wir erzeugt haben muss erst noch analysiert werden, so dass die Suche auch Treffer landen kann. Die Analyse wird, je nach Definition, die im Feld enthaltenen Daten aufschlüsseln und ermöglicht uns, Dokumente auszugeben, bei welchen die Suchanfrage zu einem aus der Analyse entstandenen Fragment passt. Schauen wir uns das gleich mal für unser Beispiel an.
Mit unseren Gedanken der Produktdaten und der anwendungsbezogenen Sichtweise im Hinterkopf, fallen mir zwei recht brauchbare Analysetypen ein. Zum einen möchten wir so etwas wie eine Search-as-you-type Suche anbieten. Also bereits Ergebnisse anzeigen, während der Nutzer noch dabei ist seine Eingabe zu tätigen. Zum Anderen stelle ich mir vor, dass es für den Anwender recht komfortabel ist, wenn er Treffer auch für Synonyme der Suchwörter erhält. Für erstes möchte ich ein "prefix" und für letzteres ein "synonym" Feld erzeugen, auf welche ich später meine Suchanfrage anwende.
Unser angepasstes mapping sieht nun wie folgt aus:
... "full_text": { "type": "text", "store": "yes", "fields": { "prefix": { "type": "text", "analyzer": "full_text_prefix" }, "synonyms": { "type": "text", "analyzer": "full_text_synonyms" } } ...
Sehen wir uns nun unsere Definition eines Subfeldes an, bemerken wir, dass dort ein benutzerdefinierter Analyzer zum Einsatz kommt. Das bedeutet, damit unsere spätere Indexierung funktioniert, sollten wir uns schleunigst darum kümmern, zu hinterlegen wie dieser Analyzer zu arbeiten hat.
{ "settings": { "analysis": { "analyzer": { "full_text_prefix": { "filter": [ "lowercase", "edge_ngram_front" ], "char_filter": "html_strip", "tokenizer": "standard" }, "full_text_synonyms": { "filter": [ "lowercase", "synonyms" ], "char_filter": "html_strip", "tokenizer": "standard" } } ...
... "analysis": { "filter": { "edge_ngram_front": { "min_gram": "3", "side": "front", "type": "edgeNGram", "max_gram": "10" } }, ...
Beim Entwickeln der benutzerdefinierten Analyzer kann folgende Abfrage sehr hilfreich sein, bei welcher man sowohl das indexierte Ergebnis der Produktdaten, als auch von Suchanfragen testen kann. Allerdings muss dazu der Index angelegt worden sein, was wir in ein paar Minuten erledigen werden.
GET veggie-shop.test:9200/groceries/_analyze { "analyzer": "full_text_prefix", "text": "Granny Smith" }
... "analysis": { "filter": { "edge_ngram_front": { "min_gram": "3", "side": "front", "type": "edgeNGram", "max_gram": "10" }, "synonyms": { "type": "synonym", "synonyms": [ "karotte, mohrrübe, möhre, rübe, rübli" ] } } ...
PUT veggie-shop.test:9200/groceries { "settings": { "number_of_shards": 1, "number_of_replicas": 1, "analysis": { "filter": { "edge_ngram_front": { "min_gram": "3", "side": "front", "type": "edgeNGram", "max_gram": "10" }, "synonyms": { "type": "synonym", "synonyms": [ "karotte, mohrrübe, möhre, rübe, rübli" ] } }, "analyzer": { "full_text_prefix": { "filter": [ "lowercase", "edge_ngram_front" ], "char_filter": "html_strip", "tokenizer": "standard" }, "full_text_synonyms": { "filter": [ "lowercase", "synonyms" ], "char_filter": "html_strip", "tokenizer": "standard" } } } }, "mappings": { "veggies": { "_source": { "enabled": true }, "_all": { "enabled": false }, "properties": { "color": { "copy_to": "full_text", "type": "text" }, "name": { "copy_to": "full_text", "type": "text" }, "product_type": { "copy_to": "full_text", "type": "text" }, "full_text": { "type": "text", "store": "yes", "fields": { "prefix": { "type": "text", "analyzer": "full_text_prefix", "fielddata": true }, "synonyms": { "type": "text", "analyzer": "full_text_synonyms", "fielddata": true } } } } } } }
In meiner Einführung ging ich ja davon aus, dass wir schon einen existierenden Index mit Produktdaten zur Verfügung haben. Da würde es nun ausreichen, einen Reindex mit dem alten zum neuen Index durchzuführen.
POST veggie-shop.test:9200/_reindex { "source": { "index": "groceries_old" }, "dest": { "index": "groceries_new" } }
POST veggie-shop.test:9200/groceries/veggies/_bulk {"index": {} } {"color": "gelb","name": "Adelaide","product_type": "Karotte"} {"index": {} } {"color": "rot","name": "Berlicum","product_type": "Karotte"} {"index": {} } {"color": "grün","name": "Granny Smith","product_type": "Apfel"} {"index": {} } {"color": "rot","name": "Elstar","product_type": "Apfel"}
Kapitel 3: Die Volltextsuche
Gratulation 🎉! Wenn Du bis hier durchgehalten hast, ist es nicht mehr weit, bis wir endlich Ergebnisse sehen und der eigentliche Spaß beginnen kann.Grundsätzlich erhält man aus Elasticsearch Dokumente über den _search
Endpoint der API, mit einer Datenabfrage query
.
Da wir unser Datenfeld "full_text"
mit zwei Subfeldern indexiert haben, bietet sich eine Multi-Match Query an, die Suchwörter über mehrere Felder anwendet. Schauen wir uns das also mal an.
// Die vollständige Query erhaltet Ihr etwas weiter unten. { "from": 0, "size": 5, "min_score": 0.3, "query": { "multi_match": { "query": "Apfel", "fields": [ "full_text.synonyms", "full_text.prefix" ] } } }
Damit ist die Basis Datenabfrage geschaffen und wir erhalten alle 🍎 mit:
GET veggie-shop.test:9200/groceries/veggies/_search { "from": 0, "size": 5, "min_score": 0.3, "query": { "multi_match": { "query": "Apfel", "fields": [ "full_text.synonyms", "full_text.prefix" ] } } }
GET veggie-shop.test:9200/groceries/veggies/_search { "from": 0, "size": 5, "min_score": 0.3, "query": { "multi_match": { "query": "roter apfel", "fields": [ "full_text.synonyms", "full_text.prefix" ], "operator": "and" } } }
Search-as-you-type
Jetzt testen wir mal eben unseren Search-as-you-type Modus. Wir erinnern uns, dass dafür unser Feld prefix zuständig ist. Da wir wissen, dass bereits N-Gramme der Felder indexiert sind, bekommen wir kein Herzklopfen beim Eingeben des Suchwortes „Gran" und erwarten entspannt als Ergebnis den „Granny Smith" Apfel. Gleiches Ergebnis erhalten wir auch für „smi", da das ebenfalls als Fragment des Tokens „smith" indexiert wurde. Nebenbei bemerkt ist auch in unserer Suche zuvor, nach „roter Apfel", bereits ein N-Gramm zum tragen gekommen, da der Suchbegriff „roter" ohne das Fragment „rot", nichts passendes finden würde. Da nur das exakte Wort auf unseren, in den Produktattributen hinterlegten, Farbwert passt.
GET veggie-shop.test:9200/groceries/veggies/_search { "from": 0, "size": 5, "min_score": 0.3, "query": { "multi_match": { "query": "Gran", "fields": [ "full_text.synonyms", "full_text.prefix" ], "operator": "and" } } }
Synonyme
Nun fehlt uns noch zu überprüfen, ob unsere Synonyme funktionieren. Also schicken wir mal munter das Wort „Rübe" los und siehe da, uns werden alle 🥕 präsentiert. Kombinieren wir das noch mit einer Anfrage, bestehend aus mehreren Suchbegriffen, die sowohl die Felder synonyms
als auch prefix
berücksichtigen müssen. Dass sich unsere Gedanken und die Vorarbeit ausgezahlt haben erkennen wir, da die Suche nach „gelbe Rüben", uns nur noch eine gelbe Karotte ausspuckt. 💯
GET veggie-shop.test:9200/groceries/veggies/_search { "from": 0, "size": 5, "min_score": 0.3, "query": { "multi_match": { "query": "gelbe Rüben", "fields": [ "full_text.synonyms", "full_text.prefix" ], "operator": "and" } } }
Analyse der eingegebenen Suchbegriffe
Wichtig ist auch zu erwähnen, dass auf die Suchwörter grundsätzlich die zum Feld passenden Analyzer angewendet werden. Das bedeutet „gelbe Rübe" wird für das Feld synonyms mit dem Analyzer "synonyms"
und für "prefix"
mit dem Analyzer "prefix"
verarbeitet. Je nach Komplexität der Felder, kann das auch zu unerwarteten Ergebnissen führen. In diesem Fall bietet es sich an, einen weiteren Analyzer nur für die Suchabfrage zu erstellen, analog zu unserem bisher verwendeten Prinzip. In der Suchabfrage können wir dann diesen Analyzer für die Auswertung der Suchwörter benutzen.
Beim beurteilen, wie unsere Query angewendet wird, ist folgende Abfrage sehr hilfreich, da wir mögliche Fehler in unserer Annahme erkennen können.GET veggie-shop.test:9200/groceries/veggies/_validate/query?explain
Zum Debuggen kann es auch praktisch sein zu sehen, was im Feld "full_text"
indexiert wurde.
Als Suchergebnisse erhalten wir aber grundsätzlich nur alle Felder, die in der "_source"
enthalten sind, also das, was wir explizit mit der Dokumentenstruktur über die "_bulk API
" geschrieben haben. Da wir aber im Mapping bei unserem Feld angegeben haben, dass wir es auch speichern möchten, ist eine Ausgabe über den Parameter "stored_fields"
möglich. Damit werden uns nur noch Felder angezeigt, die ausdrücklich mit dem Parameter definiert wurden.
{ ... "query": { "multi_match": { ... } }, "stored_fields": ["full_text"] }
Für weitere Verbesserungen gibt es Mechanismen, die bei der Anwendung hilfreich sein können. Hier verlassen wir aber unseren bisher recht einfachen Einstieg in die Volltextsuche und deshalb werden die folgenden Themen nur angerissen.
Field boosting gefällig?
Wir können weitere Analysefilter definieren und einzelne Felder stärker gewichten. Im Folgenden wird das Feld ``synonyms``` mit dem Faktor 2 berechnet, da wir Ergebnisse die einen direkten Treffer liefern höher bewerten möchten, als jene eines N-Gramms. Das könnte bei großen Datenmengen hilfreich sein, bei welchen N-Gramme auch mal bei nicht ganz passenden Produkten erzeugt werden. Da das Feld nun ein höheres Scoring erhält, werden die relevanteren Ergebnisse zuerst angezeigt.
... "fields": [ "full_text.synonyms^2", "full_text.prefix" ], ...
... "multi_match": { "query": "Grnny Simth", "fields": [ "full_text.synonyms^2", "full_text.prefix" ], "fuzziness": "AUTO", "operator": "and" } ...
{ ... "query": { "function_score": { "query": { .... }, "script_score": { "script": { "lang": "painless", "inline": "_score * doc['sell_ranking'].value" } } } } }
Schlusswort
Nun haben wir einen Ansatz ausprobiert und eine Herangehensweise kennengelernt mit der man relativ einfach mit der Elasticsearch API arbeiten kann. Mit einem guten Werkzeug zur Hand, macht es mehr Spaß sich tiefer in die Materie einzuarbeiten, da man die Auswirkungen schneller überprüfen kann. Nimmt man dazu noch die offizielle Doku zur Hand, bin ich mir sicher, dass man gute Ergebnisse erzielen kann.TLDR; 😅 Mir ist wichtig mitzugeben, dass die technische Sichtweise nur eine Seite der Medaille ist. Ebenso wichtig ist es, sich Gedanken über die zur Verfügung stehenden Daten zu machen, wie man sie aufbereitet und Ergebnisse qualitativ bewerten muss. Das möchte ich mit anwendungsbezogener Sichtweise auf die Daten ausdrücken.
Viel Spaß beim ausprobieren! ✊
(Januar, 2020 - Johannes Müller)
Weitere Blog-Artikel
Composable Commerce und Shopware: Ein Interview mit unserem Entwickler Niklas
Niklas, einer unserer Entwickler, erzählt uns von Shopware Frontends, Composable Commerce und seiner Arbeit bei Mothership.
Recap zur Shopware Unconference 2024
Andreas, Don Bosco und Niklas teilen ihre Erfahrungen von der Shopware Unconference 2024 in Köln.
Unser Co-Founder Don Bosco van Hoi im Interview
Unser Co-Geschäftsführer Bosco steht und Rede und Antwort rund um Mothership, Shopware und E-Commerce.