SQL LIKE – Kiedy Twój serwer płonie cz. 2

Cześć, ten post jest kontynuacją krótkiej serii na temat alternatyw dla zasobożernej klauzuli LIKE
w SQL Serwerze. Jeśli nie widziałeś poprzedniej części, zachęcam Cię do zapoznania się z nią. Są tam opisane zagadnienia oraz domena problemu o których będę wspominał w tej części. Część pierwszą możesz znaleźć tutaj. Jeśli rozwiązania istniejące w bazie Microsoftu nie są w jakimś stopniu satysfakcjonujące dla Twojego przypadku, to zapraszam do poniższej lektury. W tym wpisie opiszę kilka opcji wyszukiwania pełnotekstowego, jakie oferuje nam Elasticsearch.
Czym w ogóle jest Elasticsearch ?
Jest to NO-SQL’owa baza danych, która umożliwia bardzo wydajne i elastyczne wyszukiwanie pełnotekstowe. Sam Elastic bazuje na bibliotece Lucene, która korzysta ze struktury zwanej odwróconym indeksem, o której pisałem w poprzednim wpisie. Samą bazę możemy odpytywać poprzez REST API. Elasticsearch jest także bardzo dobrze skalowalną bazą danych, wspierającą klastrowanie oraz partycjonowanie.
Co trzeba wziąć pod uwagę
Użycie oddzielnej bazy danych dla przeszukiwania naszych danych niesie ze sobą sporo pracy, więc najpierw warto się zastanowić, czy gra jest warta świeczki. Jak zwykle to zależy. Co musimy wziąć pod uwagę?
Migracja danych
Jak migrować nawet duże ilości danych opisałem już tutaj. Zapraszam do lektury.
Zadbanie o aktualizacje danych
Kod, który aktualnie w jakiś sposób modyfikuje dane po których będziemy wyszukiwać musimy rozszerzyć tak, aby aktualizacje tych danych także w odbywały się Elasticsearchu. Pomocna tutaj może być biblioteka NEST.
Zadbanie o spójność danych
Tutaj sprawy trochę się komplikują. Nasz aktualny kod modyfikujący dane w SQL Serwerze jest transakcyjny. Kod, który będzie aktualizował nasze dane w Elasticsearchu już taki nie jest. Musimy zadbać o to, by w przypadku awarii serwera dane pozostały spójne. Do ponawiania kodu w przypadku wyjątku/błędu/awarii może pomóc biblioteka Polly. Innym rozwiązaniem może być użycie szyny danych np. RabbitMQ lub Azure Service Bus, jednakże wiąże się to z dodatkową pracą i utrzymywaniem dodatkowego rozwiązania. Być może posiadasz już w swoim projekcie taką infrastrukturę. Warto wtedy jej użyć. Jednak dalej użycie szyny, czy polityki ponawiania requestów nie będzie transakcyjne z modyfikacjami naszej tabeli w SQL Serwerze. Jak zadbać o spójność danych, gdy nasza transakcja zaczyna być rozpraszana pomiędzy usługami pisałem już tutaj.
Po przygotowaniu całej infrastruktury możemy w końcu zacząć przeszukiwać nasze dane. Przed tym warto wspomnieć o jednym z kluczowych narzędzi w Elasticsearchu – analizatorach.
Analyzers
Analizatory są wbudowanym mechanizmem analizy naszego tekstu. Mogą analizować nasz tekst podczas indeksowania, jak i również podczas zadawania zapytań do naszej bazy. Możemy używać wbudowanych analizatorów lub stworzyć własne. Analizatory składają się z 3 mechanizmów:
- Character filters
- Tokenizers
- Token filters
Character filters
Mogą zmieniać/usuwać nasze znaki w tekście, np. usuwać znaczniki html, czy kropki z tekstu, lub zamieniać znaki specjalne na bardziej przyjazne wyszukiwaniu odpowiedniki ę
-> e
. Jeden analizator może posiadać wiele filtrów znaków.
Tokenizers
Dzielą nasz tekst na tokeny. Najprostszym przykładem jest tokenizer dzielący nasz tekst usuwając białe znaki. Czyli tekst: `Kiedy Twój serwer płonie` zostanie podzielony na wyrazy: `Kiedy` Twój
serwer
płonie
. Innym przykładem może być n-gram
tokenizer, o którym wspominałem już w części pierwszej. W Elasticsearchu jest on wbudowanym narzędziem. Niestety, W SQL serwerze musimy pisać taki tokenizer sami. W jednym analizatorze musi istnieć tylko jeden tokenizer.
Token filters
Filtrują podzielone tokeny, np. eliminują z wyszukiwania i indeksowania słowa powszechne (stop/noise words), które mogą zaciemniać wynik przeszukania tj the, is, and. Taki filtr istnieje także w Full Text Search w SQL Serverze. Innym przykładem może być zamienianie tokenów, aby zawierały tylko małe litery. Filtrów tokenów może istnieć wiele w jednym analizatorze.
Przejdźmy teraz do samych opcji przeszukiwania indeksu. Przykłady będą z użyciem biblioteki NEST. Połączenie z Elasticsearchem i wybranym indeksem w każdym przykładzie wyglądać będzie tak:
var node = new Uri("http://localhost:9200"); var settings = new ConnectionSettings(node); var client = new ElasticClient(settings); var response = client.Search<Model>(s => s.Index(indexName)
Match
Przed przeszukaniem Match
używa analizatora na naszym query. Jeśli jawnie nie użyjemy żadnego to zostanie użyty domyślny, a jeśli przynajmniej jeden token zostanie znaleziony w naszym tekście to taki dokument zostanie zwrócony jako pasujący do zapytania. Tokeny nie muszą znajdować się w dokumencie w takiej samej kolejności, w jakiej były w query. Im więcej tokenów będzie zawierał nasz dokument oraz im będą znajdować się one bliżej siebie, tym dokument zostanie lepiej wyceniony.
.Query(q => q.Match(c => c.Field(p => p.Body) .Query(txt)))
Dla przykładu dla frazy You don't need Microservices
Zostanie znaleziony dokument z tekstem You need Microservices
, ale także i dokument zawierający te słowa w innej kolejności np. Microservices ? Who need them ?
. Im bliżej dokumentowi do frazy wyszukiwanej, tym będzie on lepiej wyceniony (będzie wyżej w wyniku wyszukiwania). Liczy się także sama ilość wyrazów w dokumencie. Fraza `You need Microservices! You need Microservices!` zostanie wyceniona prawie najlepiej. Bardzo przydatnym parametrem w tym wyszukiwaniu jest parametr cutoff_frequency
oraz `fuzziness`, jednak o nich trochę później.
Zapytanie | Znalezione tokeny | Scoring | Co nie zostanie znalezione |
---|---|---|---|
You don’t need Microservices | This is CRUD app, you don’t need Microservices. Stay with modular monolith | 1.58 | How to center div vertically and horizontally |
Right now on every conference you can hear: You need Microservices! You need Microservices! This is funny | 1.24 | ||
Your app quite complex. You will need Microservices. Remember about DevOps | 0.86 | ||
Hype development with Microservices ? Who need them ? | 0.40 |
Match Phrase
To przeszukanie, podobnie jak Match
użyje analizatora na naszym query. Jednak w tym przypadku nasze tokeny muszą znajdować się w dokumencie w takiej samej kolejności, w jakiej były w query, aby dany dokument został zwrócony. Możemy konfigurować odległość pomiędzy tokenami w dokumencie poprzez właściwość slop
. Aby to wyszukiwanie zwróciło poszczególny dokument, musi on zawierać całą frazę, którą chcemy wyszukać.
.Query(q => q.MatchPhrase(mp => mp.Field(f => f.Body) .Query(txt))));
Dla przykładu dla frazy You need Microservices
, zostanie znaleziony tylko dokument ze zdaniem `You need Microservices! You need Microservices!`. Fraza Microservices ? Who need them ?
nie zostanie znaleziona, podobnie jak You don't need Microservices
. Jednak jeśli ustawimy właściwość slop
na przynajmniej 1 słowo (domyślnie jest to 0) to fraza You don't need Microservices
zostanie znaleziona. W takiej sytuacji słowem, które to wyszukiwanie pominie będzie słowo don't
.
Zapytanie | Znalezione frazy | Co może także zostać znalezione (slop:1) | Co nie zostanie znalezione |
---|---|---|---|
You need Microservices | Right now on every conference you can hear: You need Microservices! You need Microservices! This is funny | Right now on every conference you can hear: You need Microservices! You need Microservices! This is funny | Hype development with Microservices ? Who need them ? |
This is CRUD app, you don’t need Microservices. Stay with modular monolith | How to center div vertically and horizontally | ||
Your app quite complex. You will need Microservices. Remember about DevOps |
Match Phrase Prefix
Działa podobnie jak Match Phrase
jednak ostatnie słowo w zdaniu będzie traktowane jako niepełne ze znakiem wildcard
. Mechanizm wyszukiwania pełnotesktowego w SQL Serverze także umożliwia podobną funkcjonalność, jednak moim zdaniem działa ona w sposób niezadowalający, o czym pisałem już w poprzedniej części.
.Query(q => q.MatchPhrasePrefix(mp => mp.Field(f => f.Body) .Query(txt))));
Dla frazy You need M
znaleziony zostanie dokument z frazą You need Microservices
, ale i również You need more calories
. Tutaj także możemy używać właściwości slop
.
Zapytanie | Znalezione frazy | Scoring | Co może zostać znalezione (slop:1) | Co nie zostanie znalezione |
---|---|---|---|---|
You need M | Right now on every conference you can hear: You need Microservices! You need Microservices! This is funny | 2.37 | Your app quite complex. You will need Microservices. Remember about DevOps | Hype development with Microservices ? Who need them ? |
Your diet looks bad. You need more calories to gain weight. Maybe add more fats | 2.3 | Right now on every conference you can hear: You need Microservices! You need Microservices! This is funny | How to center div vertically and horizontally | |
Your diet looks bad. You need more calories to gain weight. Maybe add more fats | ||||
his is CRUD app, you don’t need Microservices. Stay with modular monolith |
Multi Match
Domyślnie jest to wyszukiwanie typu Match - Best Fields
, czyli
Match
które będzie użyte na wielu polach. Typ tego wyszukiwania może być konfigurowalny. Możemy np. dla kilku pól ustawić typ wyszukiwania na Match Phrase
(w przykładzie jest to enum `TextQueryType`).
.Query(q => q.MultiMatch(mm => mm.Fields(d => d.Field(f => f.Subject) .Field(f => f.Body) .Field(f => f.Description)) .Type(TextQueryType.BestFields) .Query(txt))));
Multi Match
domyślnie dla dokumentu zawierającego dwa pola Topic oraz Body
, będzie preferował sytuacje, gdy większość słów z frazy znajdzie się w jednym polu. Oczywiście, dokument zawierający te słowa w dwóch polach także zostanie znaleziony, ale zostanie oceniony gorzej. Opis typów, które można użyć w tym wyszukiwaniu możemy znaleźć tutaj. Domyślnie operator logiczny wyszukiwania w wielu polach to typ OR
(dostępne operatory to OR
oraz AND
). Oznacza to, że przynajmniej jedno słowo z zapytania musi znaleźć się w przynajmniej w jednym polu.
Zapytanie | Znalezione frazy | Scoring | |
---|---|---|---|
you need M | topic:Microservices body:Right now on every conference you can hear: You need Microservices! You need Microservices! This is funny |
0.92 | |
topic: CRUD App body: This is CRUD app, you don’t need Microservices. Stay with modular monolith |
0.72 | ||
topic: Microservices body: Just joking you need more McDonalds |
0.52 | ||
Query String Query
Jest to bardzo elastyczne wyszukiwanie, które może być bardzo proste lub bardzo skomplikowane. Udostępnia ono bardzo wiele funkcjonalności samej biblioteki Lucene. Jednak nie polecałbym wystawiać tego query jako wyszukiwarki dla użytkownika końcowego w naszym systemie. Dlaczego ? Oczywiście to zależy od stopnia technicznego zaawansowania naszego użytkownika. Ponieważ, gdy input naszego użytkownika nie mógłby zostać sparsowany Elasticsearch wyrzuci wyjątek. W takim wypadku wiąże się to z nauką naszych użytkowników jak działa wyszukiwarka w naszym systemie. Większość ludzi raczej oczekuje prostego rozwiązania.
Dla przykładu query `title:Microservices+status:active` znajdzie artykuły o mikroserwisach oraz wszystkie, które są aktywne. Aby znaleźć tylko aktywne artykuły w tym temacie należałoby zmienić operator: `default_operator=AND&title:Microservices+status:active`. To wyszukiwanie posiada masę dodatkowych opcji, jednak wystawienie tego użytkownikowi może mocno zredukować user experience naszej wyszukiwarki.
Simple Query String Query
Jest to okrojona wersja poprzedniego wyszukiwania. Dodatkowo, nie rzuca wyjątkami przy błędach składniowych, a po prostu pomija części zapytania, którego nie był w stanie sparsować. Jeśli więc chcemy dać użytkownikowi więcej możliwości, tzn. podejmowania decyzji co i jak ma być wyszukiwane, może to być dobre rozwiązanie. Więcej o tym wyszukiwaniu tutaj.
Common Terms Query
Poprzednie zapytania domyślnie używają analizatora z filtrem tokenów, które pomija w wyszukiwania tzw. stop/common words. W niektórych przypadkach takie słowa mogą mieć jednak znaczenie. Przykładowo, słowo not
jest przez domyślny filtr pomijane, co prowadzi do tego, że przy zapytaniu Acceptable solution
zwrócimy dokumenty, które zawierają frazy `not acceptable solution` i `acceptable solution`. Właśnie w takich przypadkach przychodzi z pomocą Common Terms Query. Możemy dzięki niemu obsłużyć słowa, które występują często w naszym indeksie (tzw. stop/noise words) podczas działania samego zapytania, zamiast bazowania na oddzielnym pliku z tymi słowami, który musiał zostać zdefiniowany wcześniej (tak jak np. ma to miejsce w SQL Server Full Text Search). Dla przykładu, jeśli naszą domeną aplikacji jest fryzjerstwo to słówko hair
może być traktowane jako stopword, ponieważ może występować w każdym dokumencie kilka razy. Dzięki użyciu parametru cutoff_frequency
wynik wyszukiwania będzie wtedy skupiony na słowach, które występują rzadziej, co może poprawić jego dokładność. Zapytanie pod spodem zostanie podzielone na dwie grupy. Będą to słowa low frequency terms
, czyli takie, które mają duży wpływ na dokładność naszego query, ale występują rzadziej. Druga grupa to high frequency terms
, czyli słowa które występują często w danym języku/domenie, ale mają mniejszy wpływ na dokładność. Następnie Elastic wykona zapytanie wyszukując tylko wyrazów z grupy high frequency terms
. Następnie na zwróconych dokumentach wykona drugie zapytanie, wyszukując słów z grupy low frequency words
. Dzięki temu impakt na wydajność będzie dużo mniejszy, ponieważ pierwszym zapytaniem mocno ograniczymy ilość dokumentów do przeszukania słowami, które występują często. Tą funkcjonalność posiada także zapytanie Match
, o którym pisałem wyżej. Dodatkowo Common Terms Query
umożliwia ustawienie operatorów logicznych dla tych dwóch grup. Domyślnie low_freq_operator
i high_freq_operator
są ustawione na operator or
.
.Query(q => q.CommonTerms(c => c.Field(p => p.Body) .HighFrequencyOperator(Operator.And) .LowFrequencyOperator(Operator.Or) .Query(txt)))
O czym warto jeszcze wspomnieć
Opisałem tutaj najważniejsze funkcjonalności wyszukiwań pełnotekstowych w Elasticsearchu, jednak warto pokrótce wspomnieć jeszcze o kilku rzeczach.
Fuziness
Niektóre z wyszukiwań (z opisanych tutaj Match
,Multi Match
,`Query string` oraz Simple query string
) wspierają obsługę literówek. Ta funkcjonalność bazuje na tzw. odległości Levenshteina. Sama odległość mówi o tym, ile operacji na danym wyrazie, jest potrzebnych, aby zamienić jeden wyraz w drugi. Dozwolone operacje to wstawienie, zamiana, usunięcie pojedynczej litery. Na przykład do zamiany słowa krzesło
na słowo krzesła
jest potrzebna jedna operacja zamiany litery (o
na a
).
.Query(q => q.Match(fs => fs.Field(f => f.Subject) .Fuzziness(Fuzziness.Auto) .Operator(Operator.And))));
Powyższy operator AND
jest tutaj ustawiony celowo. Inaczej dokumenty tylko z wyrazami you
oraz need
także zostałyby znalezione. Dla przykładu, dla zapytania z literówką you need M
a`croservices` znalazłoby frazy pomijając literówkę.
Zapytanie typu match z parametrem fuziness i operatorem AND | Znalezione frazy | Scoring | Co nie zostanie znalezione |
---|---|---|---|
you need Macroservices | Your app quite complex. You will need Microservices. Remember about DevOps | 1.31 | Hype development with Microservices ? Who need them ? |
Right now on every conference you can hear: You need Microservices! You need Microservices! This is funny | 1.21 | How to center div vertically and horizontally | |
This is CRUD app, you don’t need Microservices. Stay with modular monolith | 0.84 | ||
Język Polski
Niestety, Elasticsearch nie ma wbudowanego wspierania języka polskiego. Jednakże istnieją pluginy, które możemy doinstalować, aby nasz język zaczął być wspierany. Wszystkie z nich są zewnętrznymi narzędziami, jednak jeden z nich jest oficjalnie polecany przez zespół Elasticsearcha.
https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-stempel.html
https://github.com/allegro/elasticsearch-analysis-morfologik
Highlighting
Elasticsearch pozwala w prosty sposób podświetlić znalezione wyniki. Jest to bardzo przydatne, jeśli chcemy pokazać użytkownikowi, co znalazła nasza wyszukiwarka.
.Query(q => q.Match(x=>x.Field(f=>f.Body) .Query(txt))) .Highlight(h => h.PreTags("<span style=\"background-color: #FFFF00\">") .PostTags("</span>") .Fields(fs => fs.Field(p => p.Body))));
Podsumowanie
Opisałem tutaj podstawowe opcje, jakie oferują wyszukiwania pełnotekstowe w Elasticsearchu. Jak zapewne zauważyłeś, jest to rozwiązanie o wiele bardziej elastyczne i dokładniejsze niż Full Text Search w SQL Serverze. Jednak wymaga więcej pracy przy migracji, wdrożeniu oraz utrzymaniu. Jeśli jednak w Twoim systemie wyszukiwarka staje się wąskim gardłem to warto przyjrzeć się alternatywom dla wyszukiwań bazującym tylko na klauzuli LIKE
.