The Hitchhiker's Guide to Full-Text Search

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"
                }
            }
        }
    }
...
> _Was haben wir gemacht?_ Zunächst ein neues Feld namens „full_text" definiert, welches in den Produktattributen so gar nicht vorkommt. Die Werte der relevanten Felder ```"name"```, ```"color"```, usw. kopieren wir nun mit ```"copy_to": "full_text"``` hinzu. Mit ```„store": „yes"``` speichern wir das Feld ab, um es später für Debugging verfügbar zu machen.

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"
        }
      }
...
> _Was haben wir gemacht?_ Für unser Suchfeld ```"full_text"``` haben wir zwei Unterfelder definiert, die wir unterschiedlich indexieren möchten. Dadurch können wir das gleiche Feld für verschiedene Suchanwendungsfälle verfügbar machen und da die Felder getrennt sind, später z.B. auch in den Ergebnissen unterschiedlich behandeln.

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"
                }
            }
...
Betrachten wir unsere Definition für die Analyse genauer. Zunächst sehen wir den Bereich der analysis wo wir unsere eigenen _Analyzer_ hinzufügen. Der ```"tokenizer": "standard"``` ist dafür zuständig die Daten aus dem Feld in Fragmente zu zerlegen. Dadurch erhalten wir z.B. aus dem Feld _„Granny Smith"_ die Token _[Granny, Smith]_. Praktisch, denn damit können wir Treffer landen, wenn der Kunde lediglich nach _„Granny"_ sucht. Diese Token werden wir dann aber mit _Token Filter_ weiter modifizieren, da uns das natürlich noch nicht ausreicht. Unsere gesetzten Filter ```"edge_n_gram"``` und ```"synonyms"``` kennt Elasticsearch erstmal nicht. Wir müssen sie noch spezifizieren. Der Filter ```"lowercase"```ist selbsterklärend, da er lediglich aus _[Granny, Smith]_, _[granny, smith]_ erzeugt. Nun, kann ich mich erinnern, dass wir vor hatten eine _Search-as-you-type_ Suche anzubieten, was uns momentan aber noch nicht gelingen würde. Wir müssen Token wie _„granny"_ noch weiter zerlegen, um Treffer auf Teile davon zu erhalten. Dafür stellt Elasticsearch den _EdgeNGram_ Tokenizer bereit. Nun definieren wir einen custom filter ```"edge_ngram_front"``` und konfigurieren ihn nach unserem Geschmack.
...
        "analysis": {
            "filter": {
                "edge_ngram_front": {
                    "min_gram": "3",
                    "side": "front",
                    "type": "edgeNGram",
                    "max_gram": "10"
                }
            },
...
> _Was haben wir gemacht?_ Unter analysis konfigurieren wir unsere Token Filter, die wir in den benutzerdefinierten Analyzern anwenden. Wir möchten, dass unser ```"edgeNGram"``` von vorne beginnt (```„side: front"```), mindestes 3 Zeichen besitzt(```"min_gram": "3"```) und aus maximal 10 Zeichen besteht (```"max_gram": "10"```). Der User muss also mindestens 3 Zeichen eingeben, damit ich relevante Ergebnisse ausliefern kann.

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"
}
Wie sieht unser Token _„granny"_ nun eigentlich aus? Nach der Indexierung befinden sich folgende N-Gramme im Inverted Index _[gra, gran, grann, granny]_. Sucht der Kunde nun nach _„Gra"_ können wir ihm bereits jetzt einen grünen Apfel präsentieren, vorausgesetzt meine Query ist dementsprechend ausgelegt. Aber dazu später mehr. Was hat es jetzt noch mit dem Feld synonyms auf sich? Wenn wir davon ausgehen, dass unsere Kunden ausschließlich danach suchen werden, was in den bisher indexierten Feldern hinterlegt ist, könnten wir die Analyse doch eigentlich abschließen, richtig? Dennoch möchten wir unser, zugegebenermaßen simples, Beispiel hier auch mit etwas mehr Realitätsnähe würzen. Wir wissen, dass z.B. die Karotte regionalspezifisch unterschiedlich bezeichnet wird. Wenn ein Kunde nun nach Mohrrübe sucht, mein Produktattribut _„product_type"_ hat aber lediglich _„Karotte"_ zu bieten hat, bleibt meine Ergebnisliste leer, obwohl ich eigentlich das passende Produkt im Sortiment habe. 😕 Dem kann ich aber Abhilfe schaffen indem ich bekannte Synonyme dafür definiere.
...
        "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"
                    ]
                }
            }
...
Damit ist unsere Indexdefinition nun vollständig. Senden wir diese jetzt mit einer PUT Anfrage an Elasticsearch, erhalten wir hoffentlich eine positive Antwort, dass unser Index bereit steht.
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
                        }
                    }
                }
            }
        }
    }
}
> _Was haben wir gemacht?_ Mit der vollständigen Definition haben wir nun einen neuen, leeren Index in Elasticsearch angelegt. Eine Abfrage mit ```GET veggie-shop.test:9200/_cat/indices?v``` bestätigt uns das auch.

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"
    }
}
Da wir aber mit unserem Beispiel arbeiten, haben wir noch keine Dokumente in Elasticsearch. Deshalb sollten wir uns die Zeit nehmen und ein passendes Gemüse- und Obstkörbchen zusammenstellen, womit wir unsere bisherigen Gedanken und Erkenntnisse auch konkret überprüfen können. 🧺 🍎 🥕 Da in den Synonymen von Mohrrüben die Rede ist, sollten wir uns ein paar Karotten in den Index packen und wegen dem Granny Smith Beispiel legen wir auch noch Äpfel dazu. Da wir uns eine Suche vorstellen, die auch mit mehreren Suchbegriffen zurecht kommen soll, geben wir dem Obst und Gemüse unterschiedliche Farben und Namen. Nun wissen wir, welche Produkte wir benötigen und müssen sie noch indexieren. Um mehrere Dokumente in einem Elasticsearch Index anzulegen gibt es den bulk Endpoint der API, den wir uns dafür zu nutze machen.
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"}
> _Was haben wir gemacht?_ An der URL erkennt man, dass wir die Anfrage an unseren neuen Index ```/groceries``` und auch gleich an unseren im Mapping hinterlegten Dokumententypen ```/veggies``` senden. Jedes Dokument wird durch eine neue Zeile definiert, welcher der Befehl ```{"index": {} }```, der beschreibt was zu tun ist, vorangeht. *Achtung! Bei Postman muss nach dem letzen Dokument noch eine Leerzeile hinzugefügt werden, damit alle Dokumente angelegt werden.* Nehmen wir jetzt noch einmal die Abfrage ```GET veggie-shop.test:9200/_cat/indices?v``` zur Hand, können wir sehen, dass der ```docs.count``` nun auf 4 angewachsen ist und wir endlich bereit für die echte Suchabfrage sind.

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"
            ]
        }
    }
}
> _Was haben wir gemacht?_ Zunächst möchten wir bei userer Search-as-you-type Suche nur die ersten fünf Ergebnisse anzeigen und schränken mit ```"from"``` und ```"size"``` die Ergebnismenge ein. Ein ```"min_score"``` legt fest wie hoch ein Dokumentenscore über die Abfrage sein muss um noch in den Ergebnissen gelistet zu werden. Jetzt zur eigentlichen Datenabfrage. Das Feld ```"query"``` innerhalb der ```"multi_match"``` Abfrage stellt die konkrete Eingabe des Nutzers dar. Im Array ```"fields"``` hinterlegen wir unsere beiden indexierten Feldfilter für Synonyme und 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"
            ]
        }
    }
}
*Suche mit mehreren Suchbegriffen* Volltext bedeutet aber auch die Kombination mehrerer Suchwörter. Blöderweise gibt uns die Abfrage auf _„roter Apfel"_ alle Äpfel, sowie auch die rote Karotte. Eigentlich logisch, da die Suchbegriffe _[roter, apfel]_ getrennt betrachtet werden und _„rot"_ eben auch im _„full_text"_ Feld der roten Karotte enthalten ist. Also müssen wir unsere Abfrage erweitern, indem wir den Paramater ```"operator"``` mit dem Wert ```"and"``` hinzufügen. Damit weisen wir Elasticsearch an, uns nur noch Ergebnisse auszugeben, in welchen *alle Suchbegriffe* enthalten sind.
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"
        }
    }
}
Schicken wir jetzt unsere Anfrage an Elasticsearch, bekommen wir nur noch den roten Apfel präsentiert. 😅

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"]
}
Damit haben wir die wichtigsten Schritte erledigt, um uns eine Basis Volltexsuche zu erstellen. Natürlich ist eine gute Suche bei komplexeren Produktdaten und größerer Datenmenge nicht so schnell und einfach erzeugt. Aber mit den gewonnenen Erkenntnissen ist es vielleicht einfacher geworden einen guten Ansatz zu finden und die Hürde erscheint etwas kleiner.

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"
            ],
...
*Fuzziness ja/nein?* Weil Nutzer sich auch mal vertippen, kann eine einfache fuzziness auch Vorteile bringen, da wir dadurch bei Eingabe von _„Grnny Simth"_ ein passendes Suchergebnis erhalten. Jedoch muss man vorsichtig sein. Fuzziness und N-Gramme können auch schnell unerwartete Ergebnisse liefern. Da muss man sich dann tiefer in die Materie buddeln und z.B. die Query grundlegend erweitern, indem die einfache ```"multi_match"``` durch eine mehrstufige ```query:bool:must/should:multi_match``` Abfrage abgelöst wird und dadurch die Felder unterschiedlich behandelt werden können.
...
        "multi_match": {
            "query": "Grnny Simth",
            "fields": [
                "full_text.synonyms^2",
                "full_text.prefix"
            ],
            "fuzziness": "AUTO",
            "operator": "and"
        }
...
*Function score und Script score* Desweiteren kann mit ```"function_score"``` und ```"script_score"``` noch tieferer Einfluss auf die Berechnung von Ergebnissen genommen werden, um z.B. Verkaufsränge zu berücksichtigen. Das setzt natürlich voraus, dass man dafür relevante Daten gesammelt und indexiert hat. Oder man definiert ```functions``` die nach Treffern aus Filtern angewendet werden, wenn man z.B. bestimmte Wörter immer höher bewerten möchte.
{
...
    "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)

Du willst bei uns mitmachen?
Zu den Stellen