Ein Elasticsearch-Tutorial für .NET-Entwickler
Veröffentlicht: 2022-03-11Sollte ein .NET-Entwickler Elasticsearch in seinen Projekten verwenden? Obwohl Elasticsearch auf Java basiert, bietet es meines Erachtens viele Gründe, warum Elasticsearch für die Volltextsuche für jedes Projekt einen Versuch wert ist.
Elasticsearch hat als Technologie in den letzten Jahren einen langen Weg zurückgelegt. Dadurch fühlt sich die Volltextsuche nicht nur magisch an, sondern bietet auch andere ausgefeilte Funktionen wie automatische Textvervollständigung, Aggregationspipelines und mehr.
Wenn Ihnen der Gedanke, einen Java-basierten Dienst in Ihr übersichtliches .NET-Ökosystem einzuführen, unangenehm ist, machen Sie sich keine Sorgen, denn sobald Sie Elasticsearch installiert und konfiguriert haben, werden Sie die meiste Zeit mit einem der coolsten .NET-Pakete verbringen dort: NEST.
In diesem Artikel erfahren Sie, wie Sie die erstaunliche Suchmaschinenlösung Elasticsearch in Ihren .NET-Projekten verwenden können.
Installieren und Konfigurieren
Die Installation von Elasticsearch selbst in Ihrer Entwicklungsumgebung läuft darauf hinaus, Elasticsearch und optional Kibana herunterzuladen.
Nach dem Entpacken ist eine bat-Datei wie diese praktisch:
cd "D:\elastic\elasticsearch-5.2.2\bin" start elasticsearch.bat cd "D:\elastic\kibana-5.0.0-windows-x86\bin" start kibana.bat exit
Nachdem Sie beide Dienste gestartet haben, können Sie jederzeit den lokalen Kibana-Server (normalerweise verfügbar unter http://localhost:5601) überprüfen, mit Indizes und Typen herumspielen und mit reinem JSON suchen, wie hier ausführlich beschrieben.
Der erste Schritt
Als gründlicher und guter Entwickler mit vollständiger Unterstützung und Verständnis des Managements beginnen Sie damit, ein Komponententestprojekt hinzuzufügen und einen Suchdienst mit mindestens 90 % Codeabdeckung zu schreiben.
Der erste Schritt besteht eindeutig darin, die Datei app.config
zu konfigurieren, um eine Art Verbindungszeichenfolge für den Elasticsearch-Server bereitzustellen.
Das Coole an Elasticsearch ist, dass es völlig kostenlos ist. Aber ich würde trotzdem raten, den von Elastic.co bereitgestellten Elastic Cloud-Service zu verwenden. Der gehostete Service macht die gesamte Wartung und Konfiguration ziemlich einfach. Darüber hinaus haben Sie zwei Wochen kostenlose Testversion, was mehr als genug sein sollte, um alle Beispiele hier auszuprobieren!
Da wir hier lokal laufen, sollte ein Konfigurationsschlüssel wie dieser ausreichen:
<add key="Search-Uri" value="http://localhost:9200" />
Die Elasticsearch-Installation wird standardmäßig auf Port 9200 ausgeführt, aber Sie können dies ändern, wenn Sie möchten.
ElasticClient und das NEST-Paket
ElasticClient ist ein netter kleiner Kerl, der die meiste Arbeit für uns erledigt, und er wird mit dem NEST-Paket geliefert.
Lassen Sie uns zuerst das Paket installieren.
Um den Client zu konfigurieren, kann so etwas verwendet werden:
var node = new Uri(ConfigurationManager.AppSettings["Search-Uri"]); var settings = new ConnectionSettings(node); settings.ThrowExceptions(alwaysThrow: true); // I like exceptions settings.PrettyJson(); // Good for DEBUG var client = new ElasticClient(settings);
Indizierung und Zuordnung
Um etwas suchen zu können, müssen wir einige Daten in ES speichern. Der verwendete Begriff ist „Indizierung“.
Der Begriff „Mapping“ wird verwendet, um unsere Daten in der Datenbank Objekten zuzuordnen, die in Elasticsearch serialisiert und gespeichert werden. In diesem Tutorial verwenden wir Entity Framework (EF).
Im Allgemeinen suchen Sie bei der Verwendung von Elasticsearch wahrscheinlich nach einer seitenweiten Suchmaschinenlösung. Sie verwenden entweder eine Art Feed oder Digest oder eine Google-ähnliche Suche, die alle Ergebnisse von verschiedenen Entitäten wie Benutzern, Blogeinträgen, Produkten, Kategorien, Ereignissen usw. zurückgibt.
Dies wird wahrscheinlich nicht nur eine Tabelle oder Entität in Ihrer Datenbank sein, sondern Sie werden verschiedene Daten aggregieren und vielleicht einige gemeinsame Eigenschaften wie Titel, Beschreibung, Datum, Autor/Eigentümer, Foto usw. extrahieren oder ableiten wollen. Eine andere Sache ist, dass Sie es wahrscheinlich nicht in einer Abfrage tun werden, aber wenn Sie ein ORM verwenden, müssen Sie eine separate Abfrage für jeden dieser Blogeinträge, Benutzer, Produkte, Kategorien, Ereignisse oder etwas anderes schreiben.
Ich habe meine Projekte strukturiert, indem ich für jeden „großen“ Typ, zB Blogpost oder Produkt, einen Index erstellt habe. Einige Elasticsearch-Typen können dann für spezifischere Typen hinzugefügt werden, die unter denselben Index fallen würden. Wenn ein Artikel beispielsweise eine Geschichte, ein Videoartikel oder ein Podcast sein kann, wäre er immer noch im „Artikel“-Index, aber wir hätten diese vier Typen in diesem Index. Es ist jedoch wahrscheinlich immer noch dieselbe Abfrage in der Datenbank.
Denken Sie daran, dass Sie für jeden Index mindestens einen Typ benötigen – wahrscheinlich einen Typ, der den gleichen Namen wie der Index hat.
Um Ihre Entitäten abzubilden, sollten Sie einige zusätzliche Klassen erstellen. Normalerweise verwende ich die Klasse DocumentSearchItemBase
, von der jede der spezialisierten Klassen BlogPostSearchItem
, ProductSearchItem
usw. erbt.
Ich mag Mapper-Ausdrücke in diesen Klassen. Ich kann die Ausdrücke bei Bedarf jederzeit ändern.
In einem meiner frühesten Projekte mit Elasticsearch habe ich eine ziemlich große SearchService-Klasse mit Mappings und Indexierung geschrieben, die mit netten und langen switch-case-Anweisungen durchgeführt wurden: Für jeden Entitätstyp, den ich in Elasticsearch werfen möchte, gab es einen Schalter und eine Abfrage mit Mapping which Tat dies.
Während des gesamten Prozesses habe ich jedoch gelernt, dass dies nicht der beste Weg ist, zumindest nicht für mich.
Eine elegantere Lösung besteht darin, eine Art intelligente IndexDefinition
-Klasse und eine spezifische Indexdefinitionsklasse für jeden Index zu haben. Auf diese Weise kann meine IndexDefinition
-Basisklasse eine Liste aller verfügbaren Indizes und einige Hilfsmethoden wie erforderliche Analysatoren und Statusberichte speichern, während abgeleitete indexspezifische Klassen die Abfrage der Datenbank und die spezifische Zuordnung der Daten für jeden Index handhaben. Dies ist besonders nützlich, wenn Sie später eine zusätzliche Entität zu ES hinzufügen müssen. Es kommt darauf an, eine weitere SomeIndexDefinition
-Klasse hinzuzufügen, die von IndexDefinition
erbt und erfordert, dass Sie nur ein paar Methoden implementieren, die die Daten abfragen, die Sie in Ihrem Index haben möchten.
Das Elasticsearch-Gespräch
Der Kern von allem, was Sie mit Elasticsearch tun können, ist seine Abfragesprache. Um mit Elasticsearch kommunizieren zu können, müssen Sie im Idealfall nur wissen, wie ein Abfrageobjekt erstellt wird.
Hinter den Kulissen stellt Elasticsearch seine Funktionalitäten als JSON-basierte API über HTTP bereit.
Obwohl die API selbst und die Struktur des Abfrageobjekts ziemlich intuitiv sind, kann der Umgang mit vielen realen Szenarien immer noch mühsam sein.
Im Allgemeinen erfordert eine Suchanfrage an Elasticsearch die folgenden Informationen:
Welcher Index und welche Typen werden durchsucht
Paginierungsinformationen (wie viele Elemente zu überspringen und wie viele Elemente zurückzugeben)
Eine konkrete Typauswahl (bei einer Aggregation, wie wir es hier tun werden)
Die Abfrage selbst
Hervorhebungsdefinition (Elasticsearch kann Treffer automatisch hervorheben, wenn wir dies wünschen)
Beispielsweise möchten Sie möglicherweise eine Suchfunktion implementieren, bei der nur einige der Benutzer die Premium-Inhalte auf Ihrer Website sehen können, oder Sie möchten, dass einige Inhalte nur für die „Freunde“ ihrer Autoren sichtbar sind, und so weiter.
Das Erstellen des Abfrageobjekts ist der Kern der Lösungen für diese Probleme, und es kann wirklich ein Problem sein, wenn versucht wird, viele Szenarien abzudecken.
Das wichtigste und am schwierigsten einzurichtende Segment ist natürlich das Abfragesegment – und hier werden wir uns hauptsächlich darauf konzentrieren.
Abfragen sind rekursive Konstrukte, die aus BoolQuery
und anderen Abfragen wie MatchPhraseQuery
, TermsQuery
, DateRangeQuery
und ExistsQuery
werden. Diese reichten aus, um alle Grundanforderungen zu erfüllen, und sollten für den Anfang gut sein.
Eine MultiMatch
-Abfrage ist ziemlich wichtig, da sie es uns ermöglicht, Felder anzugeben, für die wir die Suche durchführen möchten, und die Ergebnisse etwas weiter zu optimieren – worauf wir später zurückkommen werden.
Eine MatchPhraseQuery
kann Ergebnisse nach Fremdschlüsseln in herkömmlichen SQL-Datenbanken oder nach statischen Werten wie Aufzählungen filtern – beispielsweise beim Abgleich von Ergebnissen nach einem bestimmten Autor ( AuthorId
) oder beim Abgleich aller öffentlichen Artikel ( ContentPrivacy=Public
).
TermsQuery
würde als „in“ in die herkömmliche SQL-Sprache übersetzt werden. Beispielsweise kann es alle Artikel zurückgeben, die von einem der Freunde des Benutzers geschrieben wurden, oder Produkte ausschließlich von einer festen Gruppe von Händlern beziehen. Wie bei SQL sollte man dies nicht überbeanspruchen und 10.000 Mitglieder in dieses Array einfügen, da dies Auswirkungen auf die Leistung hat, aber im Allgemeinen angemessene Mengen recht gut handhabt.
DateRangeQuery
ist selbstdokumentierend.
ExistsQuery
ist interessant: Es ermöglicht Ihnen, Dokumente zu ignorieren oder zurückzugeben, die kein bestimmtes Feld haben.
In Kombination mit BoolQuery
können Sie damit eine komplexe Filterlogik definieren.
Denken Sie zum Beispiel an eine Blog-Site, wo Blog-Posts ein AvailableFrom
-Feld haben können, das angibt, wann sie sichtbar werden sollen.
Wenn wir einen Filter wie AvailableFrom <= Now
anwenden, erhalten wir keine Dokumente, die dieses bestimmte Feld überhaupt nicht haben (wir aggregieren Daten, und für einige Dokumente ist dieses Feld möglicherweise nicht definiert). Um das Problem zu lösen, würden Sie ExistsQuery
mit DateRangeQuery
kombinieren und es in BoolQuery
mit der Bedingung einschließen, dass mindestens ein Element in BoolQuery
erfüllt ist. Etwas wie das:

BoolQuery Should (at least one of the following conditions should be fulfilled) DateRangeQuery with AvailableFrom condition Negated ExistsQuery for field AvailableFrom
Das Negieren von Abfragen ist keine so einfache, sofort einsatzbereite Aufgabe. Aber mit Hilfe von BoolQuery
ist es trotzdem möglich:
BoolQuery MustNot ExistsQuery
Automatisierung und Test
Um die Dinge einfacher zu machen, ist die empfohlene Methode definitiv das Schreiben von Tests, während Sie gehen.
Auf diese Weise können Sie effizienter experimentieren und – was noch wichtiger ist – Sie stellen sicher, dass alle neuen Änderungen, die Sie vornehmen (z. B. komplexere Filter), die vorhandene Funktionalität nicht beeinträchtigen. Ich wollte ausdrücklich nicht „Unit-Tests“ sagen, da ich kein Fan davon bin, etwas wie die Engine von Elasticsearch zu verspotten – das Verspotten wird fast nie eine realistische Annäherung an das tatsächliche Verhalten von ES sein – daher könnten dies Integrationstests sein, falls Sie sind ein Terminologie-Fan.
Beispiele aus der Praxis
Nachdem die Grundarbeit mit Indizierung, Zuordnung und Filterung erledigt ist, sind wir nun bereit für den interessantesten Teil: die Optimierung der Suchparameter, um bessere Ergebnisse zu erzielen.
In meinem letzten Projekt habe ich Elasticsearch verwendet, um einen Benutzer-Feed bereitzustellen: alle Inhalte an einem Ort aggregiert, geordnet nach Erstellungsdatum und Volltextsuche mit einigen Optionen. Der Feed selbst ist recht unkompliziert; Stellen Sie einfach sicher, dass sich irgendwo in Ihren Daten ein Datumsfeld befindet, und sortieren Sie nach diesem Feld.
Auf der anderen Seite wird die Suche nicht sonderlich gut funktionieren. Das liegt daran, dass Elasticsearch natürlich nicht wissen kann, was die wichtigen Dinge in Ihren Daten sind. Nehmen wir an, wir haben einige Daten, die (neben anderen Feldern) die Felder Title
, Tags
(Array) und Body
enthalten. Das Body-Feld kann HTML-Inhalt sein (um die Dinge etwas realistischer zu machen).
Rechtschreibfehler
Die Anforderung: Unsere Suche soll auch bei Rechtschreibfehlern oder abweichenden Wortendungen Ergebnisse liefern. Wenn es zum Beispiel einen Artikel mit dem Titel „Großartige Dinge, die man mit einem Holzlöffel machen kann“ gibt, wenn ich nach „Ding“ oder „Holz“ suche, möchte ich immer noch eine Übereinstimmung finden.
Dazu müssen wir uns mit Analysatoren, Tokenizern, Char-Filtern und Token-Filtern vertraut machen. Das sind die Transformationen, die zum Zeitpunkt der Indizierung angewendet werden.
Analysatoren müssen definiert werden. Dies kann pro Index definiert werden.
Analysatoren können auf einige Felder in unseren Dokumenten angewendet werden. Dies kann mithilfe von Attributen oder einer fließenden API erfolgen. In unserem Beispiel verwenden wir Attribute.
Analysatoren sind eine Kombination aus Filtern, Char-Filtern und Tokenizern.
Um die Anforderung (Teilwortübereinstimmung) zu erfüllen, erstellen wir den „Autocomplete“-Analyzer, der aus Folgendem besteht:
Ein englischer Stoppwortfilter: Der Filter, der alle gebräuchlichen englischen Wörter wie „and“ oder „the“ entfernt.
Trim-Filter: Entfernt Leerzeichen um jedes Token
Kleinbuchstabenfilter: wandelt alle Zeichen in Kleinbuchstaben um. Das bedeutet nicht, dass unsere Daten beim Abrufen in Kleinbuchstaben umgewandelt werden, sondern ermöglicht stattdessen eine case-invariante Suche.
Edge-n-Gram-Tokenizer: Dieser Tokenizer ermöglicht uns partielle Übereinstimmungen. Wenn wir beispielsweise einen Satz „Meine Oma hat einen Holzstuhl“ haben, wenn wir nach dem Begriff „Holz“ suchen, möchten wir trotzdem einen Treffer für diesen Satz erhalten. Was edge-n-gram tut, ist „woo“, „wood“, „woode“ und „wooden“ zu speichern, sodass jede Teilwortübereinstimmung mit mindestens drei Buchstaben gefunden wird. Die Parameter MinGram und MaxGram definieren die minimal und maximal zu speichernde Zeichenanzahl. In unserem Fall haben wir mindestens drei und maximal 15 Buchstaben.
Im folgenden Abschnitt werden all diese miteinander verbunden:
analysis.Analyzers(a => a .Custom("autocomplete", cc => cc .Filters("eng_stopwords", "trim", "lowercase") .Tokenizer("autocomplete") ) .Tokenizers(tdesc => tdesc .EdgeNGram("autocomplete", e => e .MinGram(3) .MaxGram(15) .TokenChars(TokenChar.Letter, TokenChar.Digit) ) ) .TokenFilters(f => f .Stop("eng_stopwords", lang => lang .StopWords("_english_") ) );
Und wenn wir diesen Analysator verwenden möchten, sollten wir die gewünschten Felder einfach wie folgt kommentieren:
public class SearchItemDocumentBase { ... [Text(Analyzer = "autocomplete", Name = nameof(Title))] public string Title { get; set; } ... }
Werfen wir nun einen Blick auf einige Beispiele, die recht häufige Anforderungen in fast jeder Anwendung mit vielen Inhalten demonstrieren.
HTML bereinigen
Die Anforderung: Einige unserer Felder können HTML-Text enthalten.
Natürlich möchten Sie nicht, dass die Suche nach „Abschnitt“ etwas wie „<Abschnitt>…</Abschnitt>“ oder „Body“ zurückgibt, das das HTML-Element „<Body>“ zurückgibt. Um dies zu vermeiden, entfernen wir während der Indizierung den HTML-Code und lassen nur den Inhalt darin.
Zum Glück sind Sie nicht der Erste mit diesem Problem. Elasticsearch bietet dafür einen nützlichen Char-Filter:
analysis.Analyzers(a => a .Custom("html_stripper", cc => cc .Filters("eng_stopwords", "trim", "lowercase") .CharFilters("html_strip") .Tokenizer("autocomplete") )
Und um es anzuwenden:
[Text(Analyzer = "html_stripper", Name = nameof(HtmlText))] public string HtmlText { get; set; }
Wichtige Felder
Die Anforderung: Übereinstimmungen im Titel sollten wichtiger sein als Übereinstimmungen im Inhalt.
Glücklicherweise bietet Elasticsearch Strategien an, um die Ergebnisse zu verbessern, wenn die Übereinstimmung in dem einen oder anderen Feld auftritt. Dies geschieht innerhalb der Suchabfragekonstruktion mit der boost
-Option:
const int titleBoost = 15; .Query(qx => qx.MultiMatch(m => m .Query(searchRequest.Query.ToLower()) .Fields(ff => ff .Field(f => f.Title, boost: titleBoost) .Field(f => f.Summary) ... ) .Type(TextQueryType.BestFields) ) && filteringQuery)
Wie Sie sehen können, ist die MultiMatch
Abfrage in solchen Situationen sehr nützlich, und solche Situationen sind gar nicht so selten! Oft sind einige Felder wichtiger und andere nicht – dieser Mechanismus ermöglicht es uns, dies zu berücksichtigen.
Es ist nicht immer einfach, Boost-Werte auf Anhieb einzustellen. Sie müssen ein wenig damit spielen, um die gewünschten Ergebnisse zu erzielen.
Artikel priorisieren
Die Anforderung: Manche Artikel sind wichtiger als andere. Entweder ist der Autor wichtiger, oder der Artikel selbst hat mehr Likes/Shares/Upvotes/etc. Wichtigere Artikel sollten höher eingestuft werden.
Mit Elasticsearch können wir unsere Bewertungsfunktion implementieren, und wir vereinfachen sie so, dass wir ein Feld „Wichtigkeit“ definieren, das einen doppelten Wert hat – in unserem Fall größer als 1. Sie können Ihre eigene Wichtigkeitsfunktion/Ihren eigenen Wichtigkeitsfaktor definieren und anwenden ähnlich. Sie können mehrere Boost- und Scoring-Modi definieren – je nachdem, was Ihnen am besten passt. Dieser hat bei uns gut funktioniert:
.Query(q => q .FunctionScore(fsc => fsc .BoostMode(FunctionBoostMode.Multiply) .ScoreMode(FunctionScoreMode.Sum) .Functions(f => f .FieldValueFactor(b => b .Field(nameof(SearchItemDocumentBase.Rating)) .Missing(0.7) .Modifier(FieldValueFactorModifier.None) ) ) .Query(qx => qx.MultiMatch(m => m .Query(searchRequest.Query.ToLower()) .Fields(ff => ff ... ) .Type(TextQueryType.BestFields) ) && filteringQuery) ) )
Jeder Film hat eine Bewertung, und wir haben die Schauspielerbewertung aus dem Durchschnitt der Bewertungen für Filme abgeleitet, in denen sie gecastet wurden (keine sehr wissenschaftliche Methode). Wir haben diese Bewertung auf einen doppelten Wert im Intervall [0,1] skaliert.
Vollwortübereinstimmungen
Die Anforderung: Vollwort-Matches sollten höher ranken.
Inzwischen erhalten wir ziemlich gute Ergebnisse für unsere Suchen, aber Sie werden vielleicht feststellen, dass einige Ergebnisse, die teilweise Übereinstimmungen enthalten, möglicherweise einen höheren Rang haben als exakte Übereinstimmungen. Um dem Rechnung zu tragen, haben wir in unserem Dokument ein zusätzliches Feld mit dem Namen „Keywords“ hinzugefügt, das keinen Autocomplete-Analyzer, sondern einen Keyword-Tokenizer verwendet und einen Boost-Faktor bietet, um die Ergebnisse für exakte Übereinstimmungen zu erhöhen.
Dieses Feld wird nur abgeglichen, wenn das genaue Wort abgeglichen wird. Es wird nicht „Holz“ mit „Holz“ vergleichen, wie es der Autocomplete-Analyzer tut.
Einpacken
Dieser Artikel sollte Ihnen einen Überblick darüber gegeben haben, wie Sie Elasticsearch in Ihrem .NET-Projekt einrichten und mit ein wenig Aufwand eine nette Suchfunktionalität bereitstellen.
Die Lernkurve kann etwas steil sein, aber es lohnt sich, besonders wenn Sie es genau richtig einstellen und anfangen, großartige Suchergebnisse zu erhalten.
Denken Sie immer daran, gründliche Testfälle mit erwarteten Ergebnissen hinzuzufügen, um sicherzustellen, dass Sie die Parameter nicht zu sehr durcheinander bringen, wenn Sie Änderungen vornehmen und herumspielen.
Der vollständige Code für diesen Artikel ist auf GitHub verfügbar und verwendet Daten aus der TMDB-Datenbank, um zu zeigen, wie sich die Suchergebnisse mit jedem Schritt verbessern.