Blog
Serwis autocomplete z użyciem Elasticsearch krok po kroku – część 2/3
Wstęp
Pierwsza część niniejszej serii traktowała o podstawach pracy z Elasticsearch, w kontekście tworzenia systemu podpowiadania wpisywanych fraz. Powstało spójne środowisko z użyciem dockera, oraz omówione zostały podstawowe sposoby konstruowania zapytań do Elasticsearch. Bazując na tej wiedzy można zacząć tworzyć bardziej skomplikowane zapytania, które w większym lub mniejszym stopniu przybliżać będą nas do realizacji mechanizmu podpowiadania wpisanych fraz. Proces powstawania tego mechanizmu został podzielony na iteracje, w każdej z nich przedstawiona zostanie część problemu wraz z proponowanym jego rozwiązaniem. Jeżeli nie zaznaczono inaczej to przykłady operują na danych z poprzedniego wpisu.
Autocomplete iteracja 1: Dopasowanie wildcard
Celem powstania aplikacji jest umożliwienie podpowiadania nazw lotnisk na podstawie fragmentu wpisanego przez użytkownika. Na początek chcemy dopasowywać te lotniska, których nazwa zaczyna się wpisaną przez użytkownika frazą. Rozwiązaniem, które samo się nasuwa jest stworzenie zapytania, które w pewien sposób dopasowuje dane z użyciem wildcarda.
curl -XPOST '127.0.0.1:9200/airports_1/airport/_search?pretty' -d ' { "query": { "wildcard": { "name": "*" } } } '
Pośród zaindeksowanych danych znajdują się Strachowice i Stansted, zakładamy że użytkownik wpisał frazę St.
curl -XPOST '127.0.0.1:9200/airports_1/airport/_search?pretty' -d ' { "query": { "wildcard": { "name": "St*" } } } '
Niestety odpowiedź zawiera zero dopasowanych dokumentów.
{ "took": 6, "timed_out": false, "_shards": { "total": 5, "successful": 5, "failed": 0 }, "hits": { "total": 0, "max_score": null, "hits": [] } }
Powtórzmy to samo zapytanie, tym razem pytając o ciąg zawierający tylko małe litery.
curl -XPOST '127.0.0.1:9200/airports_1/airport/_search?pretty' -d ' { "query": { "wildcard": { "name": "st*" } } } '
W tym przypadku odpowiedź zawiera oba lotniska pasujące do wzorca.
{ "took": 11, "timed_out": false, "_shards": { "total": 5, "successful": 5, "failed": 0 }, "hits": { "total": 2, "max_score": 1, "hits": [ { "_index": "airports_1", "_type": "airport", "_id": "AVep9w54W1MCuUnfgFrJ", "_score": 1, "_source": { "code": "WRO", "name": "Strachowice", "cityName": "Wrocław", "countryName": "Polska" } }, { "_index": "airports_1", "_type": "airport", "_id": "AVeo_k9bW1MCuUnfgFrC", "_score": 1, "_source": { "code": "STN", "name": "Stansted", "cityName": "Londyn", "countryName": "Wielka Brytania" } } ] } }
By wyjaśnić dlaczego tak się dzieje sięgnijmy do dokumentacji Elasticsearch. Przeczytać tam można że zapytanie typu wildcard dopasowuje wyniki nie analizując ich. Domyślnie jednak pola tekstowe w Elasticsearch są analizowane przy użyciu standardowego analizera. Standardowy analizer zaś składa się ze standardowego tokenizera oraz standardowych filtrów, w tym filtra lowercase. Wyjaśniając, oznacza to że podczas zapytania wpisana fraza nie jest w żaden sposób analizowana, jednak przy wprowadzaniu (indeksacji) danych, zostały one w standardowy dla Elasticsearch sposób przeanalizowane. Między innymi wykonano na nich operacje sprowadzania do tylko małych liter. Elasticsearch posiada wbudowany mechanizm wizualizacji analizowania danych, endpoint _analyze:
By wykonać analizę tekstu Wrocław Strachowice używając standardowego analizera należy wykonać request
curl -XGET '127.0.0.1:9200/_analyze?text=Wrocław+Strachowice&analyzer=standard&pretty'
Odpowiedź zawiera listę wygenerowanych dla tego tekstu tokenów:
{ "tokens": [ { "token": "wrocław", "start_offset": 0, "end_offset": 7, "type": "", "position": 0 }, { "token": "strachowice", "start_offset": 8, "end_offset": 19, "type": "", "position": 1 } ] }
Podczas indeksacji tekst Wrocław Strachowice został przetworzony w ten sposób, że powstały dwa tokeny wrocław i strachowice. Wyjaśnia to dlaczego nie znaleziono wyników dla zapytania o lotniska zaczynające się od frazy St.
Warto zapamiętać:
- analiza danych w Elasticsearch może następować zarówno podczas indeksacji jak i wyszukiwania
- niektóre zapytania (na przykład wildcard) operują na nie analizowanych danych
- domyślnie podczas indeksacji dane są analizowane przy użyciu standardowego analizera
- poprzez endpoint _analyze można przeprowadzić analizę tekstu z użyciem wybranego analizera
Autocomplete iteracja 2: Brak analizy podczas indeksacji
Domyślnie Elasticsearch analizuje wprowadzane dane w standardowy sposób, w tym przypadku jest to działanie niepożądane. By to zmienić należy wrócić do momentu tworzenia indeksu. Wiadomo że Elasticsearch potrafi sam stworzyć mapowanie pól w typie na podstawie wprowadzanych danych. Wymusić sposób ich mapowania można tworząc model mapowania podczas tworzenia typu, tak jak robi się to w relacyjnych bazach danych. Przykładowy mapping dla typu airport z jednym polem name, które nie będzie analizowane podczas indeksacji wygląda jak poniżej:
curl -XPOST '127.0.0.1:9200/airports_2/' -d ' { "mappings": { "airport": { "properties": { "name": { "type": "string", "index": "not_analyzed" } } } } }'
By upewnić się że operacja przebiegła poprawnie można sprawdzić mapowanie zapytaniem zapytaniu
curl -XGET '127.0.0.1:9200/airports_2/airport/_mapping?pretty'
{ "airports_2": { "mappings": { "airport": { "properties": { "name": { "type": "string", "index": "not_analyzed" } } } } } }
Pole name pozostanie nie analizowane. Reszta pól, tak jak poprzednio zostanie utworzona automatycznie. Po załadowaniu danych jak poprzednio, pamiętając o zmianie docelowego indeksu na airports_2 sprawdźmy czy teraz zapytanie typu wildcard zadziała dla obu przypadków:
curl -XPOST '127.0.0.1:9200/airports_2/airport/_search?pretty' -d ' { "query": { "wildcard": { "name": "St*" } } } '
Odpowiedź wygląda zgodnie z oczekiwaniami:
{ "took": 9, "timed_out": false, "_shards": { "total": 5, "successful": 5, "failed": 0 }, "hits": { "total": 2, "max_score": 1, "hits": [ { "_index": "airports_2", "_type": "airport", "_id": "AVeqIFg6W1MCuUnfgFrT", "_score": 1, "_source": { "code": "STN", "name": "Stansted", "cityName": "Londyn", "countryName": "Wielka Brytania" } }, { "_index": "airports_2", "_type": "airport", "_id": "AVeqIFf7W1MCuUnfgFrR", "_score": 1, "_source": { "code": "WRO", "name": "Strachowice", "cityName": "Wrocław", "countryName": "Polska" } } ] } }
Jednak w aktualnej konfiguracji fraza wpisana małymi literami nie zwróci wyników. Dzieje się tak dlatego że dane w indeksie nie są w żaden sposób analizowane, więc zachowana jest ich oryginalna pisownia.
Warto zapamiętać:
- mapowanie pól można wymusić podczas tworzenia typu w indeksie
- zapytanie typu wildcard nie jest dobrym wyborem, gdy chcemy wyszukiwać ignorując wielkość liter
Autocomplete iteracja 3: Inne sposoby wyszukiwania
Rozwiązanie z użyciem zapytania typu wildcard okazało się być problematycznym, gdyż dane mają różną wielkość liter. Wiadomo że dane przy dodawaniu są analizowane, rozbijane na tokeny i wykonywane na nich są pewne standardowe operacje, jak zmiana na małe litery. Wiadomo że poza wyjątkami jak wildcard Elasticsearch wykonuje operacje wyszukiwania na właśnie tych tokenach. Wiemy jak sprawdzić jakie tokeny zostały wygenerowane dla danego tekstu. Wracając do lotniska we Wrocławiu, z nazwy Strachowice powstaje token strachowice i na nim wykonywane są operacje wyszukiwania. Wyszukiwanie pełnotekstowe, które wykonywane jest w Elasticsearch polega na dokładnym dopasowaniu tokenów. By wynik został dopasowany, muszą istnieć tokeny równe tokenom powstałym z szukanego tekstu. W aktualnej konfiguracji nie jest możliwe dopasowanie po części wpisanego słowa. Nie istnieje token strach dla frazy strachowice. By przeprowadzać takie operacje należy wygenerować tokeny będące podciągami szukanego tekstu. Wracając do dokumentacji, przeczytać w niej możemy że: An analyzer of type standard is built using the Standard Tokenizer with the Standard Token Filter, Lower Case Token Filter, and Stop Token Filter. Wnioskiem z tego jest że proces analizy tekstu w ElasticSearch składa się z etapu tokenizacji i filtrowania.
By sprawdzić zachowanie standardowego tokenizera należy wykonać zapytanie:
curl -XGET '127.0.0.1:9200/_analyze?text=Strachowice&tokenizer=standard&pretty'
Odpowiedź:
{ "tokens": [ { "token": "Strachowice", "start_offset": 0, "end_offset": 11, "type": "", "position": 0 } ] }
Standardowy tokenizer dla frazy Strachowice wygenerował tylko jeden token Strachowice. Token zawiera wielkie litery ponieważ jeszcze nie został na nim wykonany filtr lowercase.
Celem jest wygenerowanie większej ilości tokenów, zawierających podciągi zadanego tekstu. Osiągnąć to można przy pomocy filtra nGram, który generuje n-gramy, czyli podciągi danego ciągu. By sprawdzić zachowanie tokenizera typu nGram należy wykonać:
curl -XGET '127.0.0.1:9200/_analyze?text=Strachowice&tokenizer=ngram&pretty'
Rezultatem jest lista tokenów, powstała poprzez podzielenie zadanego tekstu na podciągi o długości od 1 do 2.
{ "tokens": [ { "token": "S", "start_offset": 0, "end_offset": 1, "type": "word", "position": 0 }, { "token": "St", "start_offset": 0, "end_offset": 2, "type": "word", "position": 1 }, { "token": "t", "start_offset": 1, "end_offset": 2, "type": "word", "position": 2 }, [...] { "token": "e", "start_offset": 10, "end_offset": 11, "type": "word", "position": 20 } ] }
By zmienić sposób tokenizacji podczas indeksowania danych należy stworzyć niestandardowy filtr i zaaplikować go w niestandardowym analizerze. Indeks airports_3 z wymaganą konfiguracją wygląda jak niżej:
curl -XPOST '127.0.0.1:9200/airports_3/' -d ' { "settings": { "analysis": { "analyzer": { "autocomplete_analyzer": { "type": "custom", "tokenizer": "standard", "filter": [ "autocomplete_filter", "lowercase" ] } }, "filter": { "autocomplete_filter": { "type": "nGram", "min_gram": 1, "max_gram": 20 } } } }, "mappings": { "airport": { "properties": { "name": { "type": "string", "analyzer": "autocomplete_analyzer" } } } } }'
Podczas indeksacji utworzone przy użyciu filtra typu nGram zostaną tokeny o długości od 1 do 20 znaków. By upewnić się że wszystko przebiegło w porządku wykonać można:
curl -XGET '127.0.0.1:9200/airports_3/_mapping?pretty'
Oczekiwany wynik:
{ "airports_3": { "mappings": { "airport": { "properties": { "name": { "type": "string", "analyzer": "autocomplete_analyzer" } } } } } }
Po utworzeniu indeksu z nowym analizerem można wykonywać przy jego użyciu analizy:
curl -XGET '127.0.0.1:9200/airports_3/_analyze?text=Strachowice&analyzer=autocomplete_analyzer&pretty'
Rezultatem jest lista tokenów powstałych z działania analizera autocomplete_analyzer, na których wykonano filtr lowercase i autocomplete_filter typu nGram.
{ "tokens": [ { "token": "s", "start_offset": 0, "end_offset": 1, "type": "word", "position": 0 }, { "token": "st", "start_offset": 0, "end_offset": 2, "type": "word", "position": 1 }, [...] { "token": "e", "start_offset": 10, "end_offset": 11, "type": "word", "position": 20 } ] }
Przykładowe zapytanie na nowo powstałym indeksie airports_3 wygląda jak niżej:
curl -XPOST '127.0.0.1:9200/airports_3/airport/_search?pretty' -d ' { "query": { "match": { "name": { "query": "str" } } } }'
W rezultacie zwrócone zostanie o wiele więcej wyników niż wynikałoby z wpisanej frazy str. By przekonać się o wewnętrznym algorytmie wyszukiwania należy do zapytania dodać parametr explain. Do odpowiedzi zostanie dołączony schemat, według którego Elasticsearch dopasował wynik.
curl -XPOST '127.0.0.1:9200/airports_3/airport/_search?pretty' -d ' { "query": { "match": { "name": { "query": "str" } } }, "explain": true }'
Odpowiedź jest długa, sprawdźmy więc fragment wyjaśniający dlaczego w wynikach umieszczone zostały Pyrzowice:
{ "took": 9, "timed_out": false, "_shards": { "total": 5, "successful": 5, "failed": 0 }, "hits": { "total": 5, "max_score": 3.4426723, "hits": [ { "_shard": 2, "_node": "RAVqwfxvRVCdRfUZ9zO7Rg", "_index": "airports_3", "_type": "airport", "_id": "AVgAGPKET4ogVsWK9H90", "_score": 0.2553736, "_source": { "code": "KAT", "name": "Pyrzowice", "cityName": "Katowice", "countryName": "Polska", "fullName": "Katowice Pyrzowice Polska" }, "_explanation": { "value": 0.2553736, "description": "sum of:", "details": [ { "value": 0.2553736, "description": "weight(name:r in 0) [PerFieldSimilarity], result of:", "details": [...] } ] } } ] } }
W szczegółach sekcji _explanation widnieje fragment name:r in 0, oznacza on że w polu name znaleziono token r i na tej podstawie dopasowano wynik naliczając odpowiedni score. Fraza str podczas wyszukiwania została przeanalizowana tym samym analizerem co wprowadzane dane, w rezultacie powstały także tokeny o długości jeden. By osiągnąć zamierzony efekt należy zmienić sposób analizowania danych podczas fazy wyszukiwania, chcemy by cała wpisana fraza traktowana była jako jeden ciąg. Do tego celu wykorzystamy standardowy analizer, zmodyfikowane zapytanie, wciąż z parametrem explain wygląda jak poniżej:
curl -XPOST '127.0.0.1:9200/airports_3/airport/_search?pretty' -d ' { "query": { "match": { "name": { "query": "str", "analyzer": "standard" } } }, "explain": true }'
Odpowiedź jest bardziej szczegółowa i zawiera tylko jeden wynik. Patrząc w analogiczne miejsce w sekcji explanation zauważyć można że tym razem dopasowany został pełen wpisany podczas wyszukiwania ciąg name:str in 1.
Warto zapamiętać:
- proces analizy tekstu w Elasticsearch składa się z procesu tokenizacji i filtrowania (w tej kolejności)
- standardowo podczas wyszukiwania i indeksacji używany jest ten sam analizer
- podczas zapytania można zmienić analizer, którym analizowana będzie szukana fraza
- dodanie do zapytania parametru explain wyświetli w odpowiedzi dokładny algorytm dopasowania wyniku i wyliczania jego wyniku (score)
Autocomplete iteracja 4: Wyszukiwanie po wielu polach
Do tej pory wyszukiwanie przeprowadzane było jedynie po polu name. Łatwo sobie wyobrazić scenariusz, w którym wymagane będzie dopasowanie po wielu polach. W naszym przypadku chcemy dopasowywać zarówno po nazwie lotniska jak i po nazwie miasta. Dla każdego z pól definiuje się osobne mapowanie. Indeks airports_4 z dwoma mapowanymi polami wygląda jak poniżej:
curl -XPOST '127.0.0.1:9200/airports_4/' -d ' { "settings": { "analysis": { "analyzer": { "autocomplete_analyzer": { "type": "custom", "tokenizer": "standard", "filter": [ "autocomplete_filter", "lowercase" ] } }, "filter": { "autocomplete_filter": { "type": "nGram", "min_gram": 1, "max_gram": 20 } } } }, "mappings": { "airport": { "properties": { "name": { "type": "string", "analyzer": "autocomplete_analyzer" }, "cityName": { "type": "string", "analyzer": "autocomplete_analyzer" } } } } }'
Wymagane jest by wyszukiwanie prowadzone było zarówno po polu name jak i po polu cityName. W Elasticsearch możliwe jest to przy pomocy wyszukiwania typu multi_match, którego parametrem jest lista pól, po których ma być wykonane wyszukiwanie. Najprostsza konfiguracja wygląda jak poniżej:
curl -XPOST '127.0.0.1:9200/airports_4/airport/_search?pretty' -d ' { "query": { "multi_match": { "query": "warszawa", "fields": ["name", "cityName"], "analyzer": "standard" } } }'
Dla frazy warszawa zwrócone zostaną dwa wyniki, gdy rozszerzymy frazę o drugi człon będący nazwą lotniska i wyszukamy tym samym sposobem warszawa okęcie nadal zwrócone zostaną oba wyniki. Wynika to z faktu iż domyślnym operatorem zapytania multi_match jest or. Fraza jest wyszukiwana w polu name lub w polu cityName. Dodatkowo każde z pól traktowane jest osobno. By poprawić sytuację należy dodatkowo skonfigurować zapytanie zmieniając jego typ oraz operator łączący jego elementy. Zmodyfikowane zapytanie wygląda następująco.
curl -XPOST '127.0.0.1:9200/airports_4/airport/_search?pretty' -d ' { "query": { "multi_match": { "query": "warszawa okęcie", "fields": ["name", "cityName"], "type": "cross_fields", "operator": "and", "analyzer": "standard" } } }'
Zwrócony zostanie dokładnie jeden wynik. Dzieje się tak ponieważ wyszukanie typu cross_fields traktuje pola wylistowane w sekcji fields jako jedno, aplikując na nich wyszukiwanie. Dodatkowo operator and powoduje że zostaną wyszukane tylko wyniki zawierające oba wpisane tokeny.
Warto zapamiętać:
- każde z pól może zawierać osobne mapowanie
- wyszukiwanie po wielu polach prowadzi się używając wyszukiwania typu multi_match
Autocomplete iteracja 5: Wyszukiwanie po wielu polach inaczej
Zapytanie z poprzedniego przykładu nieco się rozrosło, dodatkowo coraz trudniej zrozumieć mechanizm wyszukiwania. Istnieje jeszcze jeden sposób wyszukiwania po wielu polach, który rozwiązuje ten problem. Polega on na utworzeniu w momencie indeksacji dodatkowego pola, zawierającego dane z obu pól, które służyć będzie do wyszukiwania. Zmieńmy zatem format danych oraz mapowanie pól:
curl -XPOST '127.0.0.1:9200/airports_5/' -d ' { "settings": { "analysis": { "analyzer": { "autocomplete_analyzer": { "type": "custom", "tokenizer": "standard", "filter": [ "autocomplete_filter", "lowercase" ] } }, "filter": { "autocomplete_filter": { "type": "nGram", "min_gram": 1, "max_gram": 20 } } } }, "mappings": { "airport": { "properties": { "fullName": { "type": "string", "analyzer": "autocomplete_analyzer" } } } } }'
Nowo przygotowane dane zawierają dodatkowe pole fullName, zawierające dane z pól, po których prowadzić chcemy wyszukiwanie:
curl -XPOST '127.0.0.1:9200/airports_5/airport?pretty' -d ' { "code": "KRK", "name": "Balice", "cityName": "Kraków", "countryName": "Polska", "fullName": "Kraków Balice" }' curl -XPOST '127.0.0.1:9200/airports_5/airport?pretty' -d ' { "code": "KAT", "name": "Pyrzowice", "cityName": "Katowice", "countryName": "Polska", "fullName": "Katowice Pyrzowice" }' curl -XPOST '127.0.0.1:9200/airports_5/airport?pretty' -d ' { "code": "WMI", "name": "Modlin", "cityName": "Warszawa", "countryName": "Polska", "fullName": "Warszawa Modlin" }' curl -XPOST '127.0.0.1:9200/airports_5/airport?pretty' -d ' { "code": "WAW", "name": "Okęcie", "cityName": "Warszawa", "countryName": "Polska", "fullName": "Warszawa Okęcie" }' curl -XPOST '127.0.0.1:9200/airports_5/airport?pretty' -d ' { "code": "WRO", "name": "Strachowice", "cityName": "Wrocław", "countryName": "Polska", "fullName": "Wrocław Strachowice" }' curl -XPOST '127.0.0.1:9200/airports_5/airport?pretty' -d ' { "code": "LGW", "name": "Gatwick", "cityName": "Londyn", "countryName": "Wielka Brytania", "fullName": "Londyn Gatwick" }' curl -XPOST '127.0.0.1:9200/airports_5/airport?pretty' -d ' { "code": "STN", "name": "Stansted", "cityName": "Londyn", "countryName": "Wielka Brytania", "fullName": "Londyn Stansted" }' curl -XPOST '127.0.0.1:9200/airports_5/airport?pretty' -d ' { "code": "LHR", "name": "Heathrow", "cityName": "Londyn", "countryName": "Wielka Brytania", "fullName": "Londyn Heathrow" }'
Dysponując jednym polem wyszukiwanie wraca do prostej formy, jedyną zmianą jest ustawienie operatora na and, tak by wymagane było znalezienie wszystkich tokenów.
curl -XPOST '127.0.0.1:9200/airports_5/airport/_search?pretty' -d ' { "query": { "match": { "fullName": { "query": "warszawa okęcie", "analyzer": "standard", "operator": "and" } } } }'
Warto zapamiętać:
- format danych ma wpływ na stopień skomplikowania zapytania
Podsumowanie
Dzięki wypróbowaniu w praktyce kilku sposobów wyszukiwania w Elasticsearch dokładniej poznaliśmy wewnętrzne mechanizmy tego silnika. Dysponując wiedzą o sposobach analizy tekstu w momencie indeksacji i wyszukiwania, jesteśmy w stanie lepiej kontrolować proces wyszukiwania. Dzięki zastosowaniu n-gramów podczas indeksacji możemy prowadzić wyszukiwanie po części wpisanej frazy. Z wykonywanych wyszukań zaczyna wyłaniać się zarys systemu podpowiadania wpisanych fraz. Dysponując mechanizmem wyszukiwania wpisanego tekstu, w kolejnej części skupimy się na dodatkowych funkcjonalnościach, które taki system powinien posiadać, takich jak ignorowanie znaków diakrytycznych czy podświetlanie wpisanej frazy. Na koniec zwrócimy uwagę na ciągłość działania systemu, oraz połączymy wszystko w całość.