Samouczek Elasticsearch dla programistów .NET
Opublikowany: 2022-03-11Czy programista .NET powinien używać Elasticsearch w swoich projektach? Chociaż Elasticsearch jest zbudowany na Javie, wierzę, że oferuje wiele powodów, dla których Elasticsearch jest wart wypróbowania do wyszukiwania pełnotekstowego dowolnego projektu.
Elasticsearch jako technologia przeszła długą drogę w ciągu ostatnich kilku lat. Nie tylko sprawia, że wyszukiwanie pełnotekstowe jest magiczne, ale oferuje inne zaawansowane funkcje, takie jak autouzupełnianie tekstu, potoki agregacji i inne.
Jeśli myśl o wprowadzeniu usługi opartej na Javie do Twojego zgrabnego ekosystemu .NET sprawia, że czujesz się niekomfortowo, nie martw się, ponieważ po zainstalowaniu i skonfigurowaniu Elasticsearch będziesz spędzać większość czasu z jednym z najfajniejszych pakietów .NET tam: GNIAZDO.
W tym artykule dowiesz się, jak wykorzystać niesamowite rozwiązanie dla wyszukiwarek Elasticsearch w swoich projektach .NET.
Instalacja i konfiguracja
Zainstalowanie samego Elasticsearch w środowisku programistycznym sprowadza się do pobrania Elasticsearch i opcjonalnie Kibany.
Po rozpakowaniu przydaje się taki plik bat:
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
Po uruchomieniu obu usług zawsze możesz sprawdzić lokalny serwer Kibana (zwykle dostępny pod adresem http://localhost:5601), pobawić się indeksami i typami oraz wyszukiwać za pomocą czystego JSON, jak szczegółowo opisano tutaj.
Pierwszy krok
Będąc dokładnym i dobrym programistą, z pełnym wsparciem i zrozumieniem ze strony kierownictwa, zaczynasz od dodania projektu testów jednostkowych i napisania usługi SearchService z co najmniej 90% pokryciem kodu.
Pierwszym krokiem jest jasne skonfigurowanie pliku app.config
, aby zapewnić rodzaj ciągu połączenia dla serwera Elasticsearch.
Fajną rzeczą w Elasticsearch jest to, że jest całkowicie darmowy. Ale nadal radziłbym korzystać z usługi Elastic Cloud dostarczanej przez Elastic.co. Hostowana usługa sprawia, że cała konserwacja i konfiguracja jest dość łatwa. Co więcej, masz dwa tygodnie bezpłatnego okresu próbnego, co powinno wystarczyć, aby wypróbować wszystkie przykłady tutaj!
Ponieważ tutaj działamy lokalnie, taki klucz konfiguracyjny powinien wystarczyć:
<add key="Search-Uri" value="http://localhost:9200" />
Instalacja Elasticsearch domyślnie działa na porcie 9200, ale możesz to zmienić, jeśli chcesz.
ElasticClient i pakiet NEST
ElasticClient to sympatyczny mały gość, który wykona za nas większość pracy i jest dostarczany z pakietem NEST.
Najpierw zainstalujmy pakiet.
Do konfiguracji klienta można użyć czegoś takiego:
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);
Indeksowanie i mapowanie
Aby móc coś wyszukać, musimy przechowywać pewne dane w ES. Użyty termin to „indeksowanie”.
Termin „mapowanie” służy do mapowania naszych danych w bazie danych na obiekty, które będą serializowane i przechowywane w Elasticsearch. W tym samouczku będziemy używać Entity Framework (EF).
Ogólnie rzecz biorąc, korzystając z Elasticsearch, prawdopodobnie szukasz rozwiązania dla całej witryny. Użyjesz albo jakiegoś kanału lub skrótu, albo wyszukiwarki podobnej do Google, która zwraca wszystkie wyniki z różnych podmiotów, takich jak użytkownicy, wpisy na blogu, produkty, kategorie, wydarzenia itp.
Prawdopodobnie nie będzie to tylko jedna tabela lub jednostka w Twojej bazie danych, ale raczej będziesz chciał agregować różne dane i być może wyodrębnić lub uzyskać niektóre wspólne właściwości, takie jak tytuł, opis, data, autor/właściciel, zdjęcie i tak dalej. Inną rzeczą jest to, że prawdopodobnie nie zrobisz tego w jednym zapytaniu, ale jeśli używasz ORM, będziesz musiał napisać osobne zapytanie dla każdego z tych wpisów na blogu, użytkowników, produktów, kategorii, wydarzeń lub czegoś innego.
Uporządkowałem swoje projekty, tworząc indeks dla każdego „dużego” typu, np. wpis na blogu lub produkt. Niektóre typy Elasticsearch mogą być następnie dodawane dla bardziej specyficznych typów, które mieszczą się w tym samym indeksie. Na przykład, jeśli artykuł może być opowieścią, artykułem wideo lub podcastem, nadal będzie znajdował się w indeksie „artykułów”, ale w tym indeksie mielibyśmy te cztery typy. Jednak nadal prawdopodobnie będzie to to samo zapytanie w bazie danych.
Pamiętaj, że potrzebujesz co najmniej jednego typu dla każdego indeksu — prawdopodobnie typu, który ma taką samą nazwę jak indeks.
Aby zmapować swoje jednostki, będziesz chciał stworzyć dodatkowe klasy. Zwykle używam klasy DocumentSearchItemBase
, z której każda z wyspecjalizowanych klas dziedziczy BlogPostSearchItem
, ProductSearchItem
i tak dalej.
Lubię mieć wyrażenia mapujące w tych klasach. Zawsze mogę zmodyfikować wyrażenia, jeśli zajdzie taka potrzeba.
W jednym z moich najwcześniejszych projektów z Elasticsearch napisałem dość dużą klasę SearchService z mapowaniem i indeksowaniem wykonanym za pomocą ładnych i długich instrukcji switch-case: Dla każdego typu encji, który chcę wrzucić do Elasticsearch, był przełącznik i zapytanie z mapowaniem, które Zrobił to.
Jednak przez cały proces nauczyłem się, że nie jest to najlepsza droga, przynajmniej nie dla mnie.
Bardziej eleganckim rozwiązaniem jest posiadanie jakiejś inteligentnej klasy IndexDefinition
i określonej klasy definicji indeksu dla każdego indeksu. W ten sposób moja klasa bazowa IndexDefinition
może przechowywać listę wszystkich dostępnych indeksów i niektóre metody pomocnicze, takie jak wymagane analizatory i raporty o stanie, podczas gdy klasy pochodne specyficzne dla indeksu obsługują zapytania do bazy danych i mapowanie danych dla każdego indeksu. Jest to przydatne zwłaszcza wtedy, gdy jakiś czas później musisz dodać do ES dodatkową encję. Sprowadza się to do dodania kolejnej klasy SomeIndexDefinition
, która dziedziczy po IndexDefinition
i wymaga tylko zaimplementowania kilku metod, które odpytują dane, które chcesz umieścić w indeksie.
Przemówienie Elasticsearch
Podstawą wszystkiego, co możesz zrobić z Elasticsearch, jest język zapytań. W idealnym przypadku wszystko, czego potrzebujesz, aby móc komunikować się z Elasticsearch, to wiedzieć, jak skonstruować obiekt zapytania.
Za kulisami Elasticsearch ujawnia swoje funkcje jako API oparte na JSON przez HTTP.
Chociaż sam interfejs API i struktura obiektu zapytania są dość intuicyjne, radzenie sobie z wieloma rzeczywistymi scenariuszami nadal może być kłopotliwe.
Ogólnie rzecz biorąc, żądanie wyszukiwania do Elasticsearch wymaga następujących informacji:
Jaki indeks i jakie typy są przeszukiwane
Informacje o paginacji (ile elementów do pominięcia i ile elementów do zwrócenia)
Konkretny wybór typu (podczas wykonywania agregacji, tak jak to zrobimy tutaj)
Samo zapytanie
Definicja podświetlenia (Elasticsearch może automatycznie podświetlać trafienia, jeśli tego chcemy)
Na przykład możesz chcieć zaimplementować funkcję wyszukiwania, w której tylko niektórzy użytkownicy mogą zobaczyć zawartość premium w Twojej witrynie lub możesz chcieć, aby niektóre treści były widoczne tylko dla „przyjaciół” jej autorów i tak dalej.
Umiejętność konstruowania obiektu zapytania jest podstawą rozwiązania tych problemów i może to naprawdę stanowić problem, gdy próbujesz objąć wiele scenariuszy.
Z powyższego najważniejszym i najtrudniejszym do skonfigurowania jest oczywiście segment zapytań – i tutaj skupimy się głównie na tym.
Zapytania to konstrukcje rekurencyjne połączone z BoolQuery
i innymi zapytaniami, takimi jak MatchPhraseQuery
, TermsQuery
, DateRangeQuery
i ExistsQuery
. To wystarczyło do spełnienia podstawowych wymagań i na początek powinno wystarczyć.
Zapytanie MultiMatch
jest dość ważne, ponieważ pozwala nam określić pola, w których chcemy przeprowadzić wyszukiwanie i nieco bardziej dostosować wyniki — do czego wrócimy później.
MatchPhraseQuery
może filtrować wyniki według klucza obcego w konwencjonalnych bazach danych SQL lub wartości statycznych, takich jak wyliczenia — na przykład podczas dopasowywania wyników przez określonego autora ( AuthorId
) lub dopasowywania wszystkich artykułów publicznych ( ContentPrivacy=Public
).
TermsQuery
zostałyby przetłumaczone jako „w” na konwencjonalny język SQL. Na przykład może zwrócić wszystkie artykuły napisane przez jednego ze znajomych użytkownika lub otrzymać produkty wyłącznie od ustalonej grupy sprzedawców. Podobnie jak w przypadku SQL, nie należy nadużywać tego i umieszczać w tej tablicy 10 000 elementów, ponieważ będzie to miało wpływ na wydajność, ale generalnie całkiem dobrze radzi sobie z rozsądnymi ilościami.
DateRangeQuery
dokumentuje się samodzielnie.
ExistsQuery
jest interesujące: pozwala zignorować lub zwrócić dokumenty, które nie mają określonego pola.
W połączeniu z BoolQuery
, pozwalają one zdefiniować złożoną logikę filtrowania.
Pomyśl na przykład o witrynie bloga, w której posty na blogu mogą mieć pole AvailableFrom
, które wskazuje, kiedy powinny stać się widoczne.
Jeśli zastosujemy filtr, taki jak AvailableFrom <= Now
, to nie otrzymamy dokumentów, które w ogóle nie mają tego konkretnego pola (agregujemy dane, a niektóre dokumenty mogą nie mieć zdefiniowanego tego pola). Aby rozwiązać problem, należy połączyć ExistsQuery
z DateRangeQuery
i owinąć je w BoolQuery
z warunkiem, że przynajmniej jeden element w BoolQuery
jest spełniony. Coś takiego:

BoolQuery Should (at least one of the following conditions should be fulfilled) DateRangeQuery with AvailableFrom condition Negated ExistsQuery for field AvailableFrom
Negowanie zapytań nie jest tak prostą, niestandardową pracą. Ale z pomocą BoolQuery
jest to jednak możliwe:
BoolQuery MustNot ExistsQuery
Automatyzacja i Testowanie
Aby było łatwiej, zalecaną metodą jest zdecydowanie pisanie testów na bieżąco.
W ten sposób będziesz mógł efektywniej eksperymentować i – co ważniejsze – upewnisz się, że wszelkie nowe zmiany, które wprowadzisz (np. bardziej złożone filtry) nie zepsują dotychczasowej funkcjonalności. Wyraźnie nie chciałem powiedzieć „testy jednostkowe”, ponieważ nie jestem fanem wyśmiewania czegoś takiego jak silnik Elasticsearch – makieta prawie nigdy nie będzie realistycznym przybliżeniem tego, jak naprawdę zachowuje się ES – stąd mogą to być testy integracyjne, jeśli jesteś fanem terminologii.
Przykłady ze świata rzeczywistego
Po zakończeniu wszystkich prac przygotowawczych związanych z indeksowaniem, mapowaniem i filtrowaniem jesteśmy teraz gotowi na najciekawszą część: dostosowanie parametrów wyszukiwania, aby uzyskać lepsze wyniki.
W moim ostatnim projekcie wykorzystałem Elasticsearch, aby zapewnić kanał użytkownika: całą zawartość zagregowaną w jednym miejscu, uporządkowaną według daty utworzenia oraz wyszukiwanie pełnotekstowe z niektórymi opcjami. Sam kanał jest dość prosty; po prostu upewnij się, że gdzieś w twoich danych znajduje się pole daty i uporządkuj według tego pola.
Z drugiej strony wyszukiwanie nie będzie działać zadziwiająco dobrze po wyjęciu z pudełka. Dzieje się tak dlatego, że Elasticsearch nie może wiedzieć, jakie ważne rzeczy znajdują się w Twoich danych. Załóżmy, że mamy pewne dane, które (między innymi) mają pola Title
, Tags
(tablica) i Body
. Pole ciała może być treścią HTML (aby było trochę bardziej realistyczne).
Błędy ortograficzne
Wymóg: Nasze wyszukiwanie powinno zwrócić wyniki, nawet jeśli wystąpią błędy w pisowni lub jeśli końcówka wyrazu jest inna. Na przykład, jeśli jest artykuł zatytułowany „Wspaniałe rzeczy, które możesz zrobić za pomocą drewnianej łyżki”, gdy szukam „rzecz” lub „drewno”, nadal chciałbym uzyskać dopasowanie.
Aby sobie z tym poradzić, będziemy musieli zapoznać się z analizatorami, tokenizerami, filtrami znaków i filtrami tokenów. Są to przekształcenia, które są stosowane podczas indeksowania.
Analizatory muszą zostać zdefiniowane. Można to zdefiniować według indeksu.
Analizatory można zastosować do niektórych pól w naszych dokumentach. Można to zrobić za pomocą atrybutów lub interfejsu Fluent API. W naszym przykładzie używamy atrybutów.
Analizatory to połączenie filtrów, filtrów znaków i tokenizatorów.
Aby spełnić wymaganie (częściowe dopasowanie słów), stworzymy analizator „autouzupełniania”, który składa się z:
Filtr angielskich odrzucanych słów: filtr, który usuwa wszystkie popularne słowa w języku angielskim, takie jak „i” lub „the”.
Filtr przycinania: usuwa białą przestrzeń wokół każdego tokena
Filtr małych liter: konwertuje wszystkie znaki na małe litery. Nie oznacza to, że gdy pobierzemy nasze dane, zostaną one przekonwertowane na małe litery, ale zamiast tego umożliwia wyszukiwanie bez zmian wielkości liter.
Edge-n-gram tokenizer: ten tokenizer umożliwia nam częściowe dopasowania. Na przykład, jeśli mamy zdanie „Moja babcia ma drewniane krzesło”, szukając terminu „drewno”, nadal chcielibyśmy trafić w to zdanie. Edge-n-gram przechowuje „woo”, „wood”, „woode” i „wooden”, aby znaleźć dowolne dopasowanie częściowe z co najmniej trzema literami. Parametry MinGram i MaxGram określają minimalną i maksymalną liczbę znaków do zapisania. W naszym przypadku będziemy mieli minimum trzy, a maksymalnie 15 liter.
W poniższej sekcji wszystkie te elementy są ze sobą powiązane:
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_") ) );
A kiedy chcemy użyć tego analizatora, powinniśmy po prostu opisać pola, które chcemy w ten sposób:
public class SearchItemDocumentBase { ... [Text(Analyzer = "autocomplete", Name = nameof(Title))] public string Title { get; set; } ... }
Przyjrzyjmy się teraz kilku przykładom, które demonstrują dość typowe wymagania w prawie każdej aplikacji z dużą ilością treści.
Czyszczenie HTML
Warunek: Niektóre z naszych pól mogą zawierać tekst HTML.
Oczywiście nie chciałbyś, aby wyszukiwanie „section” zwróciło coś takiego jak „<section>…</section>” lub „body” zwracając element HTML „<body>”. Aby tego uniknąć, podczas indeksowania usuniemy kod HTML i pozostawimy tylko zawartość w środku.
Na szczęście nie jesteś pierwszą osobą, która ma ten problem. Elasticsearch ma w tym celu przydatny filtr znaków:
analysis.Analyzers(a => a .Custom("html_stripper", cc => cc .Filters("eng_stopwords", "trim", "lowercase") .CharFilters("html_strip") .Tokenizer("autocomplete") )
I żeby to zastosować:
[Text(Analyzer = "html_stripper", Name = nameof(HtmlText))] public string HtmlText { get; set; }
Ważne pola
Wymóg: dopasowania w tytule powinny być ważniejsze niż dopasowania w treści.
Na szczęście Elasticsearch oferuje strategie poprawiające wyniki, jeśli dopasowanie występuje w jednym lub drugim polu. Odbywa się to w ramach konstrukcji zapytania wyszukiwania za pomocą opcji boost
:
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)
Jak widać, zapytanie MultiMatch
jest bardzo przydatne w takich sytuacjach, a takie sytuacje wcale nie są takie rzadkie! Często niektóre pola są ważniejsze, a inne nie – ten mechanizm pozwala nam to uwzględnić.
Nie zawsze łatwo jest ustawić wartości wzmocnienia od razu. Musisz się trochę pobawić, aby uzyskać pożądane rezultaty.
Priorytetyzacja artykułów
Warunek: Niektóre artykuły są ważniejsze niż inne. Albo autor jest ważniejszy, albo sam artykuł ma więcej polubień/udostępnień/upvotes/itd. Ważniejsze artykuły powinny mieć wyższą pozycję.
Elasticsearch pozwala nam zaimplementować naszą funkcję scoringową i upraszczamy ją w taki sposób, że definiujemy pole „Importance”, które jest podwójną wartością – w naszym przypadku większą niż 1. Możesz zdefiniować własną funkcję/współczynnik ważności i zastosować ją podobnie. Możesz zdefiniować wiele trybów doładowania i punktacji — w zależności od tego, który najbardziej Ci odpowiada. Ten działał dla nas ładnie:
.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) ) )
Każdy film ma ocenę, a ocenę aktora obliczyliśmy na podstawie średniej ocen filmów, w których został obsadzony (metoda niezbyt naukowa). Przeskalowaliśmy tę ocenę do podwójnej wartości w przedziale [0,1].
Dopasowania całych słów
Wymóg: Dopasowania całego wyrazu powinny mieć wyższą rangę.
Do tej pory uzyskujemy całkiem dobre wyniki dla naszych wyszukiwań, ale możesz zauważyć, że niektóre wyniki zawierające częściowe dopasowania mogą mieć wyższą pozycję niż dokładne dopasowania. Aby sobie z tym poradzić, dodaliśmy w naszym dokumencie dodatkowe pole o nazwie „Słowa kluczowe”, które nie korzysta z analizatora autouzupełniania, ale zamiast tego używa tokenizatora słów kluczowych i zapewnia współczynnik wzmocnienia, aby podnieść dokładne wyniki dopasowania.
To pole będzie pasować tylko wtedy, gdy pasuje dokładnie słowo. Nie dopasuje słowa „drewno” do „drewnianego”, jak robi to analizator autouzupełniania.
Zakończyć
Ten artykuł powinien dać ci omówienie, jak skonfigurować Elasticsearch w projekcie .NET i przy odrobinie wysiłku zapewnić przyjemną funkcjonalność wyszukiwania wszędzie.
Krzywa uczenia się może być nieco stroma, ale warto, zwłaszcza gdy odpowiednio ją dostosujesz i zaczniesz uzyskiwać świetne wyniki wyszukiwania.
Zawsze pamiętaj o dodawaniu dokładnych przypadków testowych z oczekiwanymi wynikami, aby mieć pewność, że nie pomylisz parametrów podczas wprowadzania zmian i zabawy.
Pełny kod tego artykułu jest dostępny w serwisie GitHub i wykorzystuje dane pobrane z bazy danych TMDB, aby pokazać, jak poprawiają się wyniki wyszukiwania na każdym kroku.