Wyszukiwanie danych w czasie rzeczywistym z wykorzystaniem Elasticsearch
Czymże byłby Internet bez możliwości wyszukiwania interesujących nas treści? Wyszukiwarki tekstowe w ostatnim czasie przeszły dużą metamorfozę. Obecnie królują pola wyszukiwania, które są w stanie dawać odpowiednie wyniki już podczas wpisywania zapytania. W stworzeniu takiej wyszukiwarki niezbędny może być Elasticsearch.
Tworzymy index
Pierwszym krokiem będzie stworzenie prostego indexu. Jeśli nie wiesz, czym jest index, to warto przeczytać poprzedni wpis, w którym wyjaśniłem podstawowe pojęcia. Nie jest to jeszcze końcowy schemat. Do wszystkiego dojdziemy w kolejnych krokach. Planuję przechowywać dane dotyczące osób zatrudnionych w firmach. Będzie to imię, nazwisko oraz nazwa firmy. Zatem:
PUT /companies { "settings": { "number_of_shards" : 1, "number_of_replicas" : 0 }, "mappings": { "properties": { "first_name": {"type": "text"}, "last_name": {"type":"text"}, "company_name": {"type": "text"} } } }
Wypełniamy danymi
Żeby przeszukiwać dane, powinniśmy je najpierw posiadać. Ja posłużę się biblioteką Faker do wygenerowania zbioru losowych danych.
import json from elasticsearch import Elasticsearch from faker import Faker from progress.bar import Bar fake = Faker() es_client = Elasticsearch([{"host": "localhost", "port": 9200}]) DOCUMENTS_COUNT = 10000 for i in Bar('Filling Elasticsearch index', max=DOCUMENTS_COUNT).iter(range(1,DOCUMENTS_COUNT)): body = json.dumps( {"first_name": fake.first_name(), "last_name": fake.last_name(), "company_name": fake.company()} ) es_client.index(index="companies", doc_type="_doc", body=body)
Pierwsze wyszukiwanie
Najprostszym wyszukiwaniem będzie wykorzystanie metody wildcard:
GET /companies/_search { "query": { "wildcard": { "first_name": { "value": "*ali*" } } } }
W ten sposób wyszukamy dokumenty, które zawierają w polu first_name treść zaczynającą się od liter ali. Metoda ta ma jednak w tym przypadku poważną wadę – możemy wyszukiwać tylko po jednym polu.
Zmiana koncepcji
Potrzebne jest coś sprawniejszego. Mógłbym przeprowadzić Cię przez wszystkie możliwe próby rozwiązania tego problemu (przez które sam musiałem przejść). Część z nich nie rozwiązuje problemu, a inne są zbyt wolne. Dlatego przejdziemy od razu do słusznego rozwiązania. Nie obędzie się jednak bez przebudowy indeksu i poznania kilku nowych mechanizmów Elasticsearch.
Analiza
A dokładniej analysis to proces w Elasticsearch polegający na konwersji określonych pól tekstowych do tokenów, które będą przechowywane w odwróconym indeksie. Będziemy używać takich mechanizmów jak analyzer, search_analyzer oraz tokenizer.
Analyzer
Jest to narzędzie, które przetwarza tekst do odpowiedniej formy. Więcej o analyzerach przeczytasz tutaj. Ja podzielę główny analyzer na dwa mniejsze w formie custom. Jeden z nich będzie wykorzystywany podczas indeksowania, drugi uruchomi się w momencie wyszukiwania dokumentów. Zatem:
"analyzer": { "edge_ngram_analyzer": { "type": "custom", "tokenizer": "edge_ngram_tokenizer", "filter": [ "lowercase", "asciifolding" ] }, "search_analyzer": { "type": "custom", "tokenizer": "standard", "filter": [ "lowercase", "asciifolding" ] } }
Więc co tu się dzieje? Analyzer jest stosunkowo prostym obiektem. Posiada definicję, jakiego tokenizera używać oraz dodatkowe filtry. Zacznijmy od filtrów. Pierwszy z nich – lowercase – dba o to, aby wielkie litery w tekście zostały skonwertowane na małe. Asciifolding natomiast zamienia znaki diakrytyczne oraz inne znaki specjalne na ich „niespecjalne” odpowiedniki (np. ą na a).
Tokenizer
Jest to narzędzie odpowiadające za właściwą zamianę tekstu na tokeny. Token to nic innego jak kawałek tekstu. Tokenizery można podzielić ze względu na to, w jaki sposób fragmentują tekst. W Elasticsearch mamy do wyboru tokenizery:
- dzielące tekst na słowa,
- dzielące tekst na jego części (po kilka liter),
- dzielący tekst strukturyzowany.
W przykładowym kodzie wykorzystane zostały dwa tokenizery. Tokenizer standard dzieli tekst na wyrazy. edge_ngram_tokenizer jest tokenizerem z drugiej grupy i wymaga przekazania dodatkowych parametrów.
"tokenizer": { "edge_ngram_tokenizer": { "type": "edge_ngram", "min_gram": 1, "max_gram": 20, "token_chars": [ "letter", "digit" ] } }
Druga linia to nazwa, jaką nadamy naszemu tokenizorowi. min_gram i max_gram określają minimalną i maksymalną długość jednego tokenu. Zatem słowo Ala zostanie podzielone na słowa A, Al, Ala. Doprecyzowujemy również, jakie znaki mają być brane pod uwagę. W tym przypadku litery oraz cyfry.
Więcej o tokenizerach możesz przeczytać tutaj.
Składamy i szukamy
Nadszedł czas na to, aby zebrać wszystko razem i spróbować wyszukać.
PUT /companies { "settings": { "number_of_shards": 1, "number_of_replicas": 0, "analysis": { "analyzer": { "edge_ngram_analyzer": { "type": "custom", "tokenizer": "edge_ngram_tokenizer", "filter": [ "lowercase", "asciifolding" ] }, "search_analyzer": { "type": "custom", "tokenizer": "standard", "filter": [ "lowercase", "asciifolding" ] } }, "tokenizer": { "edge_ngram_tokenizer": { "type": "edge_ngram", "min_gram": 1, "max_gram": 20, "token_chars": [ "letter", "digit" ] } } } }, "mappings": { "properties": { "first_name": { "type": "text", "analyzer": "edge_ngram_analyzer", "search_analyzer": "search_analyzer" }, "last_name": { "type": "text", "analyzer": "edge_ngram_analyzer", "search_analyzer": "search_analyzer" }, "company_name": { "type": "text", "analyzer": "edge_ngram_analyzer", "search_analyzer": "search_analyzer" } } } }
Zwróć uwagę na to, że zmieniły się również definicję pól. Każde pole, po którym będziemy wyszukiwać (ja planuję po wszystkich), otrzymało moduł analizy. Zmieni się także całe zapytanie służące do wyszukiwania:
GET /companies/_search { "query": { "multi_match": { "query": "ali", "type": "cross_fields", "fields": [ "first_name", "last_name", "company_name" ] } } }
Zapytanie typu multi_match pozwala na wykonanie zapytania działającego na wielu polach dokumentu. W odpowiedzi na zapytanie otrzymamy najbardziej dopasowane wyniki. Pozwolę sobie zaprezentować 5 pierwszych:
- Latoya Perez – Ali Inc,
- Carl Ali – Thomas, Long and Espinoza,
- Dustin Ali – Howard PLC,
- Matthew Ali – Schultz-Lynch,
- Danielle Ali – Chang, Edwards and Conley.
Wydawałoby się, że to już koniec. Tylko co w przypadku gdy chcielibyśmy wyszukiwać „bardziej” po imionach pracowników?
Pozwoli na to wykorzystanie mechanizmu boost:
GET /companies/_search { "query": { "multi_match": { "query": "ali", "type": "cross_fields", "fields": [ "first_name^3", "last_name", "company_name" ] } } }
Jak widzisz, wystarczy użyć po nazwie pola znaku ^ oraz podanie wartości. Wartość ta oznacza, że nasze pole będzie kilka razy ważniejsze od pozostałych (w tym przykładzie 3 razy). Jeżeli potrzebujesz sparametryzować także inne pola, możesz bez obaw zastosować boost do kilku pól w tym samym zapytaniu. A oto wyniki, jakie otrzymamy po uznaniu imienia za ważniejsze:
- Alice Fernandez – Farley-Sanchez,
- Alice Hodges – Holloway, Clark and Dalton,
- Alice Lambert – Kent, Carpenter and Fisher,
- Alison Scott – Howell, Cox and Lowe,
- Alisha Hall – Mahoney Group.
Otrzymaliśmy wyniki całkowicie inne niż w poprzednim przypadku, ale dokładnie o to chodziło. Boost sprawił, że inaczej został obliczony wynik dla poszczególnych dokumentów. Elasticsearch pozwala na wiele opcji manipulacji wynikiem wyszukiwania, ale to tak rozległy temat, że można by napisać kolejny wpis.
Wydajność
Jeśli czytałeś poprzedni wpis, to pewnie pamiętasz, że ElasticSearch chwaliłem za szybkość. Poniżej zamieściłem tabelę, przedstawiającą, jak zmienia się czas wywołania zapytania, dla różnej ilości dokumentów.
Ilość rekordów | Średni czas wywołania [ms] |
10 000 | 10 |
100 000 | 12 |
1 000 000 | 13 |
10 000 000 | 15 |
Szybko, prawda? Czas liczony jest dla całego zapytania GET. Nie brałem też pod uwagę pierwszego otrzymanego wyniku, gdyż zazwyczaj pierwsze zapytanie do Elasticsearch przetwarza się dłużej, a każde kolejne zajmuje coraz mniej czasu.
Reasumując
Elasticsearch pozwala przeszukiwać dokumenty na wiele różnych sposobów. Skupiłem się nad przedstawieniem rozwiązania, które według mnie najlepiej działa dla wyszukiwarek. Możemy zintegrować ją z polem tekstowym i ustawić pobieranie kolejnych wyników w momencie, gdy użytkownik wpisze kolejny znak. Rozwiązanie działa bardzo szybko, a więc użytkownik nie powinien zauważyć opóźnień.
Photo by Greg Raines on Unsplash