Volltextsuche: Elasticsearch-spezifische Funktionen für komplexe Aufgaben

Bild

Hallo allerseits, mein Name ist Andrey und ich bin Entwickler. Vor langer Zeit - anscheinend letzten Freitag - hatte unser Team ein Projekt, bei dem nach den Zutaten gesucht werden musste, aus denen die Produkte bestehen. Sagen wir die Zusammensetzung der Wurst. Zu Beginn des Projekts war bei der Suche nicht viel erforderlich: Alle Rezepte anzuzeigen, in denen die gewünschte Zutat in einer bestimmten Menge enthalten ist; Wiederholen Sie dies für N Zutaten.

In Zukunft sollte die Anzahl der Produkte und Inhaltsstoffe jedoch erheblich erhöht werden, und die Suche sollte nicht nur dem zunehmenden Datenvolumen gerecht werden, sondern auch zusätzliche Optionen bieten - beispielsweise die automatische Erstellung einer Produktbeschreibung auf der Grundlage der vorherrschenden Inhaltsstoffe.

Anforderungen

  • Erstellen Sie eine Suche in Elacsticsearch mit einer Datenbank von mindestens 50.000 Dokumenten.
  • Bieten Sie eine schnelle Antwort auf Anfragen - weniger als 300 ms.
  • Um sicherzustellen, dass die Anfragen klein waren und der Dienst auch unter den Bedingungen des schlechtesten mobilen Internets verfügbar war.
  • Machen Sie die Suchlogik aus UX-Sicht so intuitiv wie möglich. Es war im Wesentlichen so, dass die Schnittstelle die Suchlogik widerspiegeln würde - und umgekehrt.
  • Minimieren Sie die Anzahl der Zwischenschichten zwischen Systemelementen, um eine höhere Leistung und weniger Abhängigkeiten zu erzielen.
  • Sie können jederzeit die Möglichkeit bieten, den Algorithmus durch neue Bedingungen zu ergänzen (z. B. automatische Generierung einer Produktbeschreibung).
  • Machen Sie die weitere Unterstützung für den Suchteil des Projekts so einfach und bequem wie möglich.

Wir beschlossen, uns nicht zu beeilen und einfach anzufangen.

Zunächst haben wir alle Inhaltsstoffe der Produktzusammensetzung in einer Datenbank gespeichert, nachdem wir zunächst 10.000 Einträge erhalten hatten. Leider hat das Durchsuchen der Datenbank selbst bei dieser Größe zu viel Zeit in Anspruch genommen, selbst unter Berücksichtigung der Verwendung von Joins und Indizes. Und in naher Zukunft sollte die Anzahl der Datensätze 50.000 überschritten haben. Darüber hinaus bestand der Kunde darauf, Elasticsearch (im Folgenden: ES) zu verwenden, da er auf dieses Tool stieß und anscheinend ein warmes Gefühl für ihn hatte. Wir haben vorher nicht mit ES gearbeitet, aber wir wussten um die Vorteile und stimmten dieser Wahl zu, da beispielsweise geplant war, dass wir häufig neue Einträge haben (nach verschiedenen Schätzungen von 50 bis 500 pro Tag), die notwendig wären sofort an den Benutzer weitergeben.

Wir haben uns entschieden, Interlayer auf Treiberebene aufzugeben und einfach REST-Anforderungen zu verwenden, da die Synchronisierung mit der Datenbank nur zum Zeitpunkt der Erstellung des Dokuments erfolgt und nicht mehr benötigt wird. Dies war ein weiterer Vorteil - bis zum direkten Senden von Suchanfragen von einem Browser an ES.

Wir haben den ersten Prototyp zusammengestellt, in dem wir die Struktur aus einer Datenbank (PostgreSQL) in ES-Dokumente übertragen haben:

{"mappings" : { "recipe" : { "_source" : { "enabled" : true }, "properties" : { "recipe_id" : {"type" : "integer"}, "recipe_name" : {"type" : "text"}, "ingredients" : { "type" : "nested", "properties": { "ingredient_id": "integer", "ingredient_name": "string", "manufacturer_id": "integer", "manufacturer_name": "string", "percent": "float" } } } } }} 

Basierend auf dieser Zuordnung erhalten wir ungefähr das folgende Dokument (wir können den Mitarbeiter aufgrund der NDA nicht aus dem Projekt anzeigen):

 { "recipe_id": 1, "recipe_name": "AAA & BBB", "ingredients": [ { "ingredient_id": 1, "ingredient_name": "AAA", "manufacturer_id": 3, "manufacturer_name": "Manufacturer 3", "percent": 1 }, { "ingredient_id": 2, "ingredient_name": "BBB", "manufacturer_id": 4, "manufacturer_name": "Manufacturer 4", "percent": 3 } ] } 

All dies wurde mit dem Elasticsearch PHP-Paket durchgeführt. Erweiterungen für Laravel (Elastiquent, Laravel Scout usw.) haben sich aus einem Grund entschieden, sie nicht zu verwenden: Der Kunde benötigte eine hohe Leistung, bis zu dem oben genannten Punkt, dass „300 ms für eine Anfrage viel sind“. Und alle Pakete für Laravel wirkten wie ein zusätzlicher Aufwand und wurden langsamer. Es hätte direkt auf Guzzle gemacht werden können, aber wir haben uns entschieden, nicht bis zum Äußersten zu gehen.

Zunächst wurde die einfachste Suche nach Rezepten direkt auf den Arrays durchgeführt. Ja, all dies wurde in Konfigurationsdateien übernommen, aber die Anfrage stellte sich trotzdem als zu groß heraus. Die Suche fand in den angehängten Dokumenten (den gleichen Bestandteilen) statt, in booleschen Ausdrücken mit "sollte" und "muss" gab es auch eine Richtlinie für die obligatorische Passage in den angehängten Dokumenten - als Ergebnis nahm die Anfrage von hundert Zeilen und ihr Volumen betrug drei Kilobyte.

Vergessen Sie nicht die Anforderungen an Geschwindigkeit und Größe der Antwort. Zu diesem Zeitpunkt wurden die Antworten in der API so formatiert, dass die Menge nützlicher Informationen erhöht wurde: Die Schlüssel in jedem JSON-Objekt wurden auf einen Buchstaben reduziert. Abfragen in ESs von wenigen Kilobyte wurden daher zu einem inakzeptablen Luxus.

In diesem Moment wurde uns klar, dass das Erstellen von riesigen Abfragen in Form von assoziativen Arrays in PHP eine Art heftige Sucht ist. Außerdem wurden die Controller völlig unlesbar, überzeugen Sie sich selbst:

 public function searchSimilar() { /*...*/ $conditions[] = [ "nested" => [ "path" => "ingredients", "score_mode" => "max", "query" => [ "bool" => [ "must" => [ ["term" => ["ingredients.ingredient_id" => $ingredient_id]], ["range" => ["ingredients.percent"=>[ "lte"=>$percent + 5, "gte"=>$percent - 5 ]]] ] ] ] ] ]; $parameters['body']['query']['bool']['should'][0]['bool']['should'] = $conditions; /*...*/ $equal_conditions[] = [ "nested" => [ "path" => "flavors", "query" => [ "bool" => [ "must" => [ ["term" => ["ingredients.percent" => $percent]] ] ] ] ] ]; $parameters['body']['query']['bool']['should'][1]['bool']['must'] = $equal_conditions; /*...*/ return $this->client->search($parameters); } 

Lyrischer Exkurs: Wenn es um verschachtelte Felder im Dokument ging, stellte sich heraus, dass wir eine Abfrage des Formulars nicht erfüllen können:

 "query": { "bool": { "nested": { "bool": { "should": [ ... ] } } } } 

Aus einem einfachen Grund können Sie in einem verschachtelten Filter keine Mehrfachsuche durchführen. Deshalb musste ich Folgendes tun:

 "query": { "bool": { "should": [ {"nested": { "path": "flavors", "score_mode": "max", "query": { "bool": { ... } } }} ] } } 

d.h. Zuerst wurde ein Array von Soll-Bedingungen deklariert, und innerhalb jeder Bedingung wurde eine Suche durch das verschachtelte Feld aufgerufen. Aus Sicht von Elasticsearch ist dies korrekter und logischer. Als Ergebnis haben wir selbst gesehen, dass dies logisch war, als wir zusätzliche Suchbegriffe hinzugefügt haben.

Und hier haben wir Google- Vorlagen entdeckt, die in ES integriert sind. Die Wahl fiel auf Moustache - eine ziemlich praktische logiklose Template-Engine. Es war möglich, den gesamten Anfragetext und alle übertragenen Daten praktisch unverändert darin abzulegen, wodurch die endgültige Anforderung die Form hatte:

 { "template": "template1", "params": params{} } 

Der Hauptteil der Vorlage erwies sich als eher bescheiden und lesbar - nur JSON und die Anweisungen von Moustache selbst. Die Vorlage wird in Elasticsearch selbst gespeichert und beim Namen aufgerufen.

 /* search_similar.mustache */ { "query": { "bool": { "should": [ {"bool": { "minimum_should_match": {{ minimumShouldMatch }}, "should": [ {{#ingredientsList}} // mustache         ingredientsList {{#ingredients}} //         ingredients {"nested": { "path": "ingredients", "score_mode": "max", "query": { "bool": { "must": [ {"term": {"ingredients.flavor_id": {{ id }} }}, {"range": {"ingredients.percent" : { "lte": {{ lte }}, "gte": {{ gte }} }}} ] } } }} {{^isLast}},{{/isLast}} //    {{/ingredients}} {{/ingredientsList}} ] }} ] } } } /*  */ { "template": "search_similar", "params": { "minimumShouldMatch": 1, "ingredientsList": { "ingredients": [ {"id": 1, "lte": 10, "gte": 5, "isLast": true } ] } } } 

Als Ergebnis haben wir bei der Ausgabe eine Vorlage erhalten, in die wir einfach eine Reihe der erforderlichen Zutaten übergeben haben. Logischerweise unterschied sich die Anforderung nicht wesentlich von den folgenden Bedingungen:

 SELECT * FROM ingredients LEFT JOIN recipes ON recipes.id = ingredient.recipe_id WHERE ingredients.id in (1,2,3) AND ingredients.id not in (4,5,6) AND ingredients.percent BETWEEN 10.0 AND 20.0 

aber er arbeitete schneller und es war eine fertige Basis für weitere Anfragen.

Hier brauchten wir zusätzlich zur prozentualen Suche mehrere weitere Arten von Operationen: eine Suche nach Namen unter den Zutaten, Gruppen und Namen von Rezepten; Suche nach Zutaten-ID unter Berücksichtigung der Toleranz des Inhalts im Rezept; die gleiche Abfrage, jedoch mit der Berechnung der Ergebnisse unter vier Bedingungen (wurde anschließend für eine andere Aufgabe wiederholt) sowie die endgültige Abfrage.

Die Anforderung erforderte die folgende Logik: Für jede Zutat gibt es fünf Tags, die sie mit einer beliebigen Gruppe verknüpfen. Konventionell sind Schweinefleisch und Rindfleisch Fleisch und Huhn und Pute Geflügel. Jedes der Tags befindet sich auf einer eigenen Ebene. Basierend auf diesen Tags konnten wir eine bedingte Beschreibung für das Rezept erstellen, mit der wir automatisch einen Suchbaum und / oder eine Beschreibung generieren konnten. Zum Beispiel Wurstfleisch und Milch mit Gewürzen, Leber und Soja, Hühnerhalal. Ein einzelnes Rezept kann mehrere Zutaten mit demselben Etikett enthalten. Dies erlaubte uns, die Etikettenkette nicht mit unseren Händen zu füllen - basierend auf der Zusammensetzung des Rezepts konnten wir es bereits klar beschreiben. Die Struktur des angehängten Dokuments hat sich ebenfalls geändert:

 { "ingredient_id": 1, "ingredient_name": "AAA", "manufacturer_id": 3, "manufacturer_name": "Manufacturer 3", "percent": 1, "level_1": 2, "level_2": 4, "level_3": 6, "level_4": 7, "level_5": 12 } 

Es bestand auch die Notwendigkeit, eine Suche nach der Bedingung der „Reinheit“ des Rezepts anzugeben. Zum Beispiel brauchten wir ein Rezept, bei dem es nur Rindfleisch, Salz und Pfeffer gab. Dann mussten wir Rezepte aussortieren, bei denen sich nur Rindfleisch auf der ersten Ebene und nur Gewürze auf der zweiten Ebene befanden (das erste Etikett für Gewürze war Null). Hier musste ich schummeln: Da Schnurrbart eine Vorlage ohne Logik ist, konnte von keiner Berechnung die Rede sein; Hier musste ein Teil des Skripts in der Anforderung in der ES-Skriptsprache - Painless - implementiert werden. Die Syntax ist so nah wie möglich an Java, daher gab es keine Schwierigkeiten. Als Ergebnis hatten wir eine Moustache-Vorlage, die JSON generierte, in der ein Teil der Berechnungen, nämlich Sortieren und Filtern, auf Painless implementiert wurde:

 "filter": [ {{#levelsList}} {{#levels}} {"script": { "script": " int total=0; for (ingredient in params._source.ingredients){ if ([0,{{tag}}].contains(ingredient.level_{{id}})) total+=1; } return (total==params._source.ingredients.length); " }} {{^isLast}},{{/isLast}} {{/levels}} {{/levelsList}} ] 

Im Folgenden wird der Hauptteil des Skripts zur besseren Lesbarkeit formatiert. Zeilenumbrüche können in Anforderungen nicht verwendet werden.

Zu diesem Zeitpunkt haben wir die Toleranz für den Inhalt der Zutat aufgehoben und einen Engpass festgestellt - wir könnten Rindfleischwurst nur in Betracht ziehen, weil diese Zutat dort gefunden wird. Dann fügten wir - alle in denselben schmerzlosen Skripten - Filter hinzu, unter der Bedingung, dass diese Zutat in der Komposition Vorrang haben sollte:

 "filter": [ {"script":{ "script": " double nest=0,rest=0; for (ingredient in params._source.ingredients){ if([{{#tags}}{{tagId}}{{^isLast}},{{/isLast}}{{/tags}}].contains(flavor.level_{{tags.0.levelId}})){ nest+= ingredient.percent; }else{ if (ingredient.percent>rest){rest = ingredient.percent} } } return(nest>=rest); " }} ] 

Wie Sie sehen, fehlten Elasticsearch viele Dinge für dieses Projekt, so dass sie aus „verfügbaren Mitteln“ zusammengesetzt werden mussten. Dies ist jedoch nicht überraschend - das Projekt ist untypisch genug für eine Maschine, die für die Volltextsuche verwendet wird.

In einer der Zwischenphasen des Projekts brauchten wir Folgendes: Zeigen Sie eine Liste aller verfügbaren Gruppen von Zutaten und die Anzahl der Positionen in jeder an. Hier zeigte sich das gleiche Problem wie in der vorherrschenden Abfrage: Aus 10.000 Rezepten wurden basierend auf dem Inhalt etwa 10 Gruppen generiert. Es stellte sich jedoch heraus, dass sich in diesen Gruppen insgesamt etwa 40.000 Rezepte befanden, die überhaupt nicht der Realität entsprachen. Dann begannen wir, nach parallelen Abfragen zu suchen.

Bei der ersten Anfrage haben wir eine Liste aller Gruppen erhalten, die sich auf der ersten Ebene ohne die Anzahl der Einträge befinden. Danach wurde eine Mehrfachanfrage generiert: Für jede Gruppe wurde eine Anfrage gestellt, um die tatsächliche Anzahl von Rezepten nach dem Prinzip des vorherrschenden Prozentsatzes zu erhalten. Alle diese Anfragen wurden in einer gesammelt und an Elasticsearch gesendet. Die Antwortzeit für die allgemeine Anforderung entsprach der Verarbeitungszeit der langsamsten Anforderung. Die Massenaggregation ermöglichte die Parallelisierung. Eine ähnliche Logik (nur durch Gruppieren nach Bedingungen in einer Abfrage) in SQL dauerte etwa 15-mal länger.

 /*   */ $params = config('elastic.params'); $params['body'] = config('elastic.top_list'); return (Elastic::getClient()->search($params))['aggregations']['tags']['buckets']; /*   */ 

Danach mussten wir bewerten:

  1. Wie viele Rezepte sind für die aktuelle Komposition verfügbar?
  2. Welche anderen Zutaten können wir der Zusammensetzung hinzufügen (manchmal haben wir die Zutat hinzugefügt und eine leere Probe erhalten);
  3. Welche Zutaten unter den ausgewählten können wir als die einzigen auf dieser Ebene markieren?

Basierend auf der Aufgabe haben wir die Logik der letzten für die Rezeptliste empfangenen Anforderung und die Logik des Erhaltens genauer Zahlen aus der Liste aller verfügbaren Gruppen kombiniert:

 /*  */ "aggs" : { //      "tags" :{ //    "terms" :{ "field" : "ingredients.level_{{ level }}", "order" : {"_term" : "asc"}, "exclude" : [ {{#exclude}}{{ id }},{{/exclude}} 0] }, "aggs": { "reverse_nested": {} } //    ,    } } /*   */ foreach ($not_only as $element) { $parameters['body'][] = config('elastic.params'); $parameters['body'][] = self::getParamsBody( $body, collect($only->all())->push($element), $max_level, 0, 0 ); } /*   */ $parameters['body'][] = config('elastic.params'); $parameters['body'][] = self::getParamsBody( $body, $only, $max_level, $from, $size') ); /*     */ $parameters['max_concurrent_searches'] = 1 + $not_only->count(); return (Elastic::getClient()->msearchTemplate($parameters))['responses']; 

Als Ergebnis haben wir eine Anfrage erhalten, die alle erforderlichen Rezepte und deren Gesamtzahl findet (sie wurde aus der Antwort ["Treffer"] ["Gesamt"] entnommen). Der Einfachheit halber wurde diese Anfrage an letzter Stelle in der Liste aufgezeichnet.

Zusätzlich haben wir durch Aggregation alle ID-Zutaten für das nächste Level erhalten. Für jede der Zutaten, die nicht als "eindeutig" gekennzeichnet waren, haben wir eine Abfrage erstellt, in der wir sie entsprechend gekennzeichnet haben, und dann einfach die Anzahl der gefundenen Dokumente gezählt. Wenn es größer als Null war, wurde die Zutat als verfügbar für die Zuweisung des Schlüssels "einzeln" angesehen. Ich denke hier können Sie die gesamte Vorlage ohne mich wiederherstellen, die wir bei der Ausgabe erhalten haben:

 { "from": {{ from }}, "size": {{ size }}, "query": { "bool": { "must": [ {{#ingredientTags}} {{#tagList}} {"bool": { "should": [ {"term": {"level_{{ levelId }}": {{ tagId }} }} ] }} {{^isLast}},{{/isLast}} {{/tagList}} {{/ingredientTags}} ], "filter": [ {"script":{ "script": " double nest=0,rest=0; for(ingredient in params._source. ingredients){ if([{{#tags}}{{tagId}}{{^isLast}},{{/isLast}}{{/tags}}].contains(ingredient.level_{{tags.0.levelId}})){ nest+= ingredient.percent; }else{ if (ingredient.percent>rest){ rest= ingredient.percent } } } return(nest>=rest); " }} {{#levelsList}}, {{#levels}} {"script": { "script": " int total=0; for(ingredient in params._source.ingredients){ if ([0,{{tag}}].contains(ingredient.level_{{id}})) total+=1; } return (total==params._source.ingredients.length); " }} {{^isLast}},{{/isLast}} {{/levels}} {{/levelsList}} ] } }, "aggs" : { "tags" :{ "terms" :{ "field" : "ingredients.level_{{ level }}", "order" : {"_term" : "asc"}, "exclude" : [ {{#exclude}}{{ id }},{{/exclude}} 0] }, "aggs": { "reverse_nested": {} } } }, "sort": [ {"_score": {"order": "desc"}} ] } 

Natürlich speichern wir einen Teil dieses Haufens von Vorlagen und Abfragen zwischen (z. B. die Seite aller verfügbaren Gruppen mit der Anzahl der verfügbaren Rezepte), wodurch wir auf der Hauptseite ein wenig Leistung erzielen. Diese Entscheidung ermöglichte es, die Hauptdaten in 50 ms zu erfassen.

Projektergebnisse

Wir haben eine Suche in der Datenbank von mindestens 50.000 Dokumenten auf Elasticsearch durchgeführt, mit der Sie nach Inhaltsstoffen in Produkten suchen und eine Beschreibung des Produkts anhand der darin enthaltenen Inhaltsstoffe erhalten können. Bald wird diese Datenbank um das Sechsfache wachsen (die Daten werden vorbereitet), daher sind wir mit unseren Ergebnissen und Elasticsearch als Suchwerkzeug sehr zufrieden.

In Bezug auf die Leistung haben wir die Anforderungen des Projekts erfüllt und freuen uns, dass die durchschnittliche Antwortzeit auf eine Anfrage 250-300 ms beträgt.

Drei Monate nach Beginn der Arbeit mit Elasticsearch wirkt dies nicht mehr so ​​verwirrend und ungewöhnlich. Die Vorteile des Templating liegen auf der Hand: Wenn wir feststellen, dass die Anforderung erneut zu groß wird, übertragen wir einfach die zusätzliche Logik auf die Vorlage und senden die ursprüngliche Anforderung fast unverändert erneut an den Server.

"Alles Gute und danke für den Fisch!" (c)

PS Im letzten Moment mussten wir auch nach russischen Zeichen im Namen sortieren. Und dann stellte sich heraus, dass Elasticsearch das russische Alphabet nicht angemessen wahrnimmt. Die bedingte Wurst „Ultra Mega Pork 9000 Kalorien“ verwandelte sich innerhalb der Sortierung einfach in „9000“ und stand am Ende der Liste. Wie sich herausstellte, lässt sich dieses Problem leicht lösen, indem russische Zeichen in die Unicode-Notation der Form u042B konvertiert werden.

Source: https://habr.com/ru/post/de413075/


All Articles