Marcin Lewandowski
Marcin Lewandowski
Programista PHP ( Symfony ), blogger, trener oraz miłośnik kawy. Na co dzień pracuję z Symfony, RabbitMQ, ElasticSearch, Node.js, Redis, Docker, MySQL.

Relacje w ElasticSearch ( parent – child )

Relacje w ElasticSearch ( parent – child )

W relacyjnych bazach danych tworzenie relacji jest naturalnym sposobem odwzorowywania rzeczywistości. Jednak w przypadku, gdy mamy odczynienia z wyszukiwarką opartą o nierelacyjną bazę danych sprawy nieco się komplikują. Choć ElasticSeach dostarcza mechanizm parent / child, który można potraktować jako odpowiednik relacji w tradycyjnej bazie danych.

Drzewo genealogiczne

Najprostszym przykładem, który każdy z nas może sobie łatwo wyobrazić i stworzyć samemu będzie drzewo genealogiczne rodziny.

Mamy tutaj Piotra, który jest rodzicem oraz trójkę jego dzieci: Marka, Franka i Anię. W przypadku relacyjnej bazy danych odwzorowanie takiej struktury było by banalnie proste. Jest to relacja jeden do wielu, więc nasze dzieci miały by pole przechowujące identyfikator rodzica. W ten sposób możliwe jest stworzenie struktury o dowolnym poziomie zagłębienia. Ale jak sprawa się ma w przypadku ElasticSearch ??

Otóż nikt nam nie zabroni stworzenia pola np. parent, które będzie przechowywało identyfikator rodzica. Jak ma to miejsce w relacyjnej bazie danych. Problemem jest to, że nie mamy operatora JOIN, który pozwala na różne sposoby łączyć ze sobą rekordy w bazie danych, a w wyszukiwarce dokumenty.

Z pomocą przychodzi mechanizm parent / child, który jest odpowiednikiem relacji i pozwala nam zdefiniować zależności pomiędzy dokumentami. Niestety to rozwiązanie ma sporo ograniczeń. Jednym z nich jest brak możliwości zdefiniowania relacji wiele do wielu. Drugim, jest konieczność utrzymywania dokumentów w ramach tego samego routingu. Co oznacza, że dokumenty muszą być przechowywane w tym samym indeksie oraz shardzie. Ograniczenia o których wspomniałem, wynikają z chęci zachowania ekstremalnie wysokiej wydajności wyszukiwarki. Więc nie myślcie, o ElasticSearch jak o relacyjnej bazie danych. Mechanizm, któremu się przyjrzymy

Projekt indeksu

Zacznijmy od stworzenia bardzo prostego indeksu.

PUT family_tree
{
    "mappings": {
        "properties": {
            "name": {
                "type": "text"
            }, 
            "gender": {
                "type": "text"
            },
            "relation_type": {
                "type": "join",
                "eager_global_ordinals": true,
                "relations": {
                    "parent": "child"
                }
            }
        }
    }
}

Indeks zawiera pole name w którym będziemy przechowywali imię i nazwisko. W polu gender znajdzie się informacja na temat płci.

Ostatnie pole, jest typu join. Pole tego typu pozwala określić sposób relacji pomiędzy dokumentami. Definicję relacji podajemy w kluczu relations. Pod kluczem umieszczamy nazwę oznaczająca rodzica, u nas tą nazwą będzie parent. Wartość ukrywająca się pod kluczem parent oznacza dziecko, u nas jest to child.

Struktura pola typu join przekłada się na konieczność dodawania do dokumentów będących rodzicami poniższego zapisu.

"relation_type":{
  "name":"parent"
}

Zaś w przypadku, gdy dodajemy dokumenty będące dziećmi to struktura wygląda następująco.

"relation_type":{
  "name":"child",
  "parent": 999
}

Oczywiście zamiast 999 podajemy prawdziwy identyfikator rodzica.

Zobaczmy teraz jak to będzie wyglądało na prawdziwych danych.

Dodajemy dane do indeksu

Dodawanie zaczniemy od rodzica. I jak pamiętamy z drzewa rodziny. Rodzicem jest Piotr.

PUT /family_tree/_doc/1?routing=1
{
  "name": "Piotr",
  "gender": "Male",
  "relation_type":{
      "name":"parent"
  }
}

Imię i płeć tutaj są najmniej interesujące. Bardziej interesuje nas pole relation_type w którym podajemy, że mamy do czynienia z rodzicem.

Czas na nasze kochane dzieci ;)

PUT /family_tree/_doc/2?routing=1
{
  "name": "Marek",
  "gender": "Male",
  "relation_type":{
      "name": "child",
      "parent": 1
  }
}

Widzimy, że pole relation_type zawiera komplet informacji. Pierwsza, czyli informacja, że mamy do czynienia z dzieckiem child znajduje się pod kluczem name. I druga informacja o identyfikatorze rodzica, którą znajdziemy pod kluczem parent. W przypadku pozostałych dzieci wyglądało będzie to identycznie.

Franek

PUT /family_tree/_doc/3?routing=1
{
  "name": "Franek",
  "gender": "Male",
  "relation_type":{
      "name": "child",
      "parent": 1
  }
}

Ania

PUT /family_tree/_doc/4?routing=1
{
  "name": "Ania",
  "gender": "Female",
  "relation_type":{
      "name": "child",
      "parent": 1
  }
}

Mając dane, możemy zacząć przeszukiwać nasz skromny zbiór danych.

Wyszukujemy

Dotychczas poznane metody przeszukiwania działają na takich dokumentach tak samo jak na dokumentach bez zdefiniowanych relacji. Jednak to co wyróżnia dokumenty posiadające informacje o relacjach jest możliwość użycia dwóch dodatkowych metod wyszukiwania.

Aby wyszukiwanie miało jakiś większy sens dodamy sobie do indeksu dodatkowe dwa dokumenty. Dokumenty te odzwierciedlają bardzo prostą relację.

Rodzic Ula

PUT /family_tree/_doc/5?routing=1
{
  "name": "Ula",
  "gender": "Female",
  "relation_type":{
      "name":"parent"
  }
}

Dziecko Kasia

PUT /family_tree/_doc/6?routing=1
{
  "name": "Kasia",
  "gender": "Female",
  "relation_type":{
      "name":"child",
      "parent": 5
  }
}

Te dodatkowe dokumenty pozwolą, choć w minimalnym stopniu urozmaicić wyszukiwanie.

has_child

Pierwszym rodzajem wyszukiwania jest wyszukanie rodziców, których dzieci spełniły określone wymagania.

Znajdź rodziców, którzy mają dziewczynki

Najprostsze zapytanie, będzie wyglądało następująco.

POST /family_tree/_search
{
  "query": {
    "has_child": {
      "type": "child",
      "query": {
        "match": {
          "gender": "female"
        }
      }
    }
  }
}

Zapytanie, jest bardzo proste. Mamy standardową strukturę query, gdzie zamiast np. wyszukiwania match mamy wyszukiwanie typu has_child.

POST /family_tree/_search
{
  "query": {
    "has_child": {
       
    }
  }
}

Teraz rozbudowujemy nasze zapytanie o pole type w którym podajemy nazwę dziecka jakie ma spełnić warunek. My mamy tylko jeden rodzaj dziecka więc podajemy child.

POST /family_tree/_search
{
  "query": {
    "has_child": {
      "type": "child"
    }
  }
}

Ostatni element to dodanie parametru query, i jest to dokładnie to samo query, które już znamy. Mamy do dyspozycji wszystkie możliwe metody przeszukiwania. My wykorzystamy najprostsze wyszukiwanie match, co w rezultacie da nam początkowe zapytanie.

POST /family_tree/_search
{
  "query": {
    "has_child": {
      "type": "child",
      "query": {
        "match": {
          "gender": "female"
        }
      }
    }
  }
}

W wyniku działania zapytania otrzymamy dwa wyniki.

I jest takiego wyniku bym oczekiwali. Zobaczmy co się stanie, gdy wyszukamy tylko chłopców.

POST /family_tree/_search
{
  "query": {
    "has_child": {
      "type": "child",
      "query": {
        "match": {
          "gender": "male"
        }
      }
    }
  }
}

W wyniku działania otrzymamy tylko jednego rodzica Piotra, gdyż Ula ma tylko córkę Kasię.

Jeśli chcemy zobaczyć jakie pozycje spowodowały pojawienie się danego rodzica na liście, to mamy taką możliwość. Do naszego zapytania dodajemy inner_hits. W takim przypadku nasze zapytanie będzie wyglądało następująco.

POST /family_tree/_search
{
  "query": {
    "has_child": {
      "type": "child",
      "query": {
        "match": {
          "gender": "male"
        }
      },
      "inner_hits": {}
    }
  }
}

Wynik takiego zapytania będzie wyglądał następująco.

Należy tylko zwrócić uwagę, że inner_hits zwraca domyślnie 3 dokumenty. A jeśli będziemy chcieli zmienić tę wartość to należy dodać parametr size do inner_hits.

POST /family_tree/_search
{
  "query": {
    "has_child": {
      "type": "child",
      "query": {
        "match": {
          "gender": "male"
        }
      },
      "inner_hits": {
        "size": 10
      }
    }
  }
}

has_parent

Drugim rodzajem wyszukiwania jest wyszukiwanie dzieci, które posiadają rodziców spełniających określone warunki.

Znając konstrukcję zapytania has_child praktycznie znamy konstrukcję zapytania has_parent

POST /family_tree/_search
{
  "query": {
    "has_parent": {
      "parent_type": "parent",
      "query": {
        "match": {
          "gender": "male"
        }
      }
    }
  }
}

W wyniku powinniśmy otrzymać dokumenty będące dziećmi dokumentów spełniających zdefiniowany warunek. W tym przypadku warunkiem do spełnienia jest płeć rodzica, który musi być mężczyzną.

Sklep internetowy

Mam nadzieję, że przykład z drzewem genealogicznym jest jasny i nie będzie zaskoczeń przy realizacji czegoś bardziej przydatnego.

Otóż przyjrzymy się jak możemy pracować z danymi typowymi dla sklepów internetowych. Otóż w takich sklepach zwłaszcza tych połączonych z systemami magazynowymi, często mamy bardzo płaską strukturę. Taka struktura wygląda w ten sposób, że mamy kartę katalogową, a później długą listę produktów z różnymi parametrami.

Typowy przykład. Mamy podkoszulek, który występuje w różnych rozmiarach i kolorach. Więc struktura w bazie wygląda w ten sposób, że jest produkt Podkoszulek i ma on mnóstwo dzieci.

Taka struktura jest bardzo wygodna w kontekście czy to magazynu, czy też sprzedaży w sklepie internetowym. A to dlatego, że mając w zamówieniu identyfikator produktu od razu wiemy jaki jest to rozmiar i kolor. Niestety w przypadku prezentowania wyników wyszukiwania na stronie jest zdecydowanie gorzej.

Projekt indeksu

Na podstawie tak przedstawionych danych projektujemy prosty indeks.

PUT sklep
{
    "mappings": {
        "properties": {
            "name": {
                "type": "text"
            },
            "color_name": {
                "type": "text"
            },
            "color_id": {
                "type": "integer"
            },
            "size_name": {
                "type": "text"
            },
            "size_id": {
                "type": "integer"
            },
            "relation_type": {
                "type": "join",
                "eager_global_ordinals": true,
                "relations": {
                    "parent": "child"
                }
            }
        }
    }
}

Dodajemy produkty

Na razie będziemy operować tylko na jednym produkcie. W późniejszej fazie pokusimy się o dodanie kolejnego produktu, aby zobaczyć jak będą wyglądały wyniki wyszukiwania.

Zaczynamy od dodania rodzica, czyli naszej karty produktu.

PUT /sklep/_doc/70077?routing=1
{
  "name": "Podkoszulek",
  "relation_type":{
      "name":"parent"
  }
}

Mając rodzica, możemy dodać dzieci, czyli produkty z przypisanymi parametrami.

PUT /sklep/_doc/42242?routing=1
{
  "name":"Podkoszulek RED 3XL",
  "color_name": "czerwony",
  "color_id": 3768,
  "size_name": "3XL",
  "size_id": 4046,
  "relation_type":{
      "name":"child",
      "parent": 70077
  }
}
 
PUT /sklep/_doc/70078?routing=1
{
  "name":"Podkoszulek GREEN XL",
  "color_name": "zielony",
  "color_id": 3771,
  "size_name": "XL",
  "size_id": 3873,
  "relation_type":{
      "name":"child",
      "parent": 70077
  }
}
 
PUT /sklep/_doc/70079?routing=1
{
  "name":"Podkoszulek GREEN M",
  "color_name": "zielony",
  "color_id": 3771,
  "size_name": "M",
  "size_id": 3871,
  "relation_type":{
      "name":"child",
      "parent": 70077
  }
}
 
PUT /sklep/_doc/70080?routing=1
{
  "name":"Podkoszulek RED L",
  "color_name": "czerwony",
  "color_id": 3768,
  "size_name": "L",
  "size_id": 3872,
  "relation_type":{
      "name":"child",
      "parent": 70077
  }
}
 
PUT /sklep/_doc/70081?routing=1
{
  "name":"Podkoszulek RED XL",
  "color_name": "czerwony",
  "color_id": 3768,
  "size_name": "XL",
  "size_id": 3873,
  "relation_type":{
      "name":"child",
      "parent": 70077
  }
}
 
PUT /sklep/_doc/70082?routing=1
{
  "name":"Podkoszulek RED M",
  "color_name": "czerwony",
  "color_id": 3768,
  "size_name": "M",
  "size_id": 3871,
  "relation_type":{
      "name":"child",
      "parent": 70077
  }
}
 
PUT /sklep/_doc/70084?routing=1
{
  "name":"Podkoszulek BLUE M",
  "color_name": "niebieski",
  "color_id": 3773,
  "size_name": "M",
  "size_id": 3871,
  "relation_type":{
      "name":"child",
      "parent": 70077
  }
}
 
PUT /sklep/_doc/70089?routing=1
{
  "name":"Podkoszulek RED S",
  "color_name": "czerwony",
  "color_id": 3768,
  "size_name": "S",
  "size_id": 3870,
  "relation_type":{
      "name":"child",
      "parent": 70077
  }
}
 
PUT /sklep/_doc/70090?routing=1
{
  "name":"Podkoszulek RED 2XL",
  "color_name": "czerwony",
  "color_id": 3768,
  "size_name": "2XL",
  "size_id": 4429,
  "relation_type":{
      "name":"child",
      "parent": 70077
  }
}

Wyszukujemy

Wyszukiwanie w takiej strukturze jest bardzo przyjemne, gdyż bardzo łatwo możemy wyszukać frazę, którą wpisał klient sklepu np. czerwony podkoszulek XL.

GET sklep/_search
{
  "query": {
    "multi_match": {
      "query": "czerwony podkoszulek XL",
      "operator": "and", 
      "fields": [
        "name",
        "color_name",
        "size_name"
      ],
      "type": "cross_fields"
    }
  }
}

I tu mamy przykład sytuacji idealnej, został znaleziony jeden produkt.

Ale co się stanie, gdy zmienimy nasze zapytanie i wpiszemy w wyszukiwarkę czerwony podkoszulek ??

GET sklep/_search
{
  "_source": ["name", "color_name", "size_name"], 
  "query": {
    "multi_match": {
      "query": "czerwony podkoszulek",
      "operator": "and", 
      "fields": [
        "name",
        "color_name",
        "size_name"
      ],
      "type": "cross_fields"
    }
  }
}

Otrzymamy 6 produktów, które nie koniecznie chcemy, aby zajmowały nam cenną przestrzeń na liście wyników wyszukiwania. Przecież wystarczył by jeden produkt, a klient po wejściu w jego szczegóły mógłby sprawdzić czy jest rozmiar, który go interesuje.

Możemy podejść do tematu na dwa sposoby, pierwszy to agregacja na podstawie pola przechowującego identyfikator rodzica. Drugi, to relacja parent / child, czyli coś czym się zajmujemy i ten przypadek sobie omówimy.

W naszym przypadku chcemy znaleźć wszystkie produkty główne, które posiadają dzieci spełniające kryteria wyszukiwania. Do tego celu możemy użyć wyszukiwania has_child.

GET sklep/_search
{
  "query": {
    "has_child": {
      "type": "child",
      "query": {
        "multi_match": {
          "query": "czerwony podkoszulek",
          "operator": "and", 
          "fields": [
            "name",
            "color_name",
            "size_name"
          ],
          "type": "cross_fields"
        }
      }
    }
  }
}

Nasze dotychczasowe zapytanie zostało opakowane konstrukcją has_child co spowodowało zwrócenie jedynie jednego rodzica zamiast 6 produktów będących dziećmi.

Gdybym chcieli to oczywiście, możemy zobaczyć dzieci które spełniły warunki wyszukiwania. W tym celu dopisujemy inner_hits dodając parametr size z wartością np. 10.

GET sklep/_search
{
  "query": {
    "has_child": {
      "type": "child",
      "query": {
        "multi_match": {
          "query": "czerwony podkoszulek",
          "operator": "and", 
          "fields": [
            "name",
            "color_name",
            "size_name"
          ],
          "type": "cross_fields"
        }
      },
      "inner_hits": {
        "size": 10
      }
    }
  }
}

W ten oto prosty sposób mamy wyszukiwanie na podstawie płaskiej struktury z zachowaniem grupowania na poziomie zwracania listy wyników.

Dodatkowe grupowanie produktów po kolorze

Jest jeszcze jeden ciekawy przypadek w sklepach internetowych, a mianowicie, gdy nie chcemy grupować produktów do poziomu produktu głównego. Tylko do jakiejś struktury pośredniej. Taką strukturą może być grupowanie na poziomie koloru. Niestety konieczne jest sztuczne wygenerowanie takich produktów oraz relacji :(

Zaczynamy od zmian w projekcie indeksu.

PUT sklep
{
    "mappings": {
        "properties": {
            "name": {
                "type": "text"
            },
            "color_name": {
                "type": "text"
            },
            "color_id": {
                "type": "integer"
            },
            "size_name": {
                "type": "text"
            },
            "size_id": {
                "type": "integer"
            },
            "relation_type": {
                "type": "join",
                "eager_global_ordinals": true,
                "relations": {
                    "parent": "color",
                    "color": "child"
                }
            }
        }
    }
}

To w praktyce oznacza konieczność zmian w danych.

Dodajemy rodzica w którym nie musimy niczego zmieniać.

PUT /sklep/_doc/70077?routing=1
{
  "name": "Podkoszulek",
  "relation_type":{
      "name":"parent"
  }
}

Następnie dodajemy nasze sztuczne produkty, które będą grupowały produkty po kolorze.

Kolor czerwony

PUT /sklep/_doc/70077_3768?routing=1
{
  "name":"Podkoszulek RED",
  "color_name": "czerwony",
  "color_id": 3768,
  "relation_type":{
      "name":"color",
      "parent": 70077
  }
}

Kolor zielony

PUT /sklep/_doc/70077_3771?routing=1
{
  "name":"Podkoszulek GREEN",
  "color_name": "zielony",
  "color_id": 3771,
  "relation_type":{
      "name":"color",
      "parent": 70077
  }
}

Kolor niebieski

PUT /sklep/_doc/70077_3773?routing=1
{
  "name":"Podkoszulek BLUE",
  "color_name": "niebieski",
  "color_id": 3773,
  "relation_type":{
      "name":"color",
      "parent": 70077
  }
}

Mając produkty pośrednie, możemy dodać teraz rzeczywiste produkty. Zwróćmy uwagę na to jak zostały zdefiniowane relacje w produktach bo jest to kluczowe dla działania mechanizmu.

PUT /sklep/_doc/42242?routing=1
{
  "name":"Podkoszulek RED 3XL",
  "color_name": "czerwony",
  "color_id": 3768,
  "size_name": "3XL",
  "size_id": 4046,
  "relation_type":{
      "name":"child",
      "parent": "70077_3768"
  }
}
 
PUT /sklep/_doc/70078?routing=1
{
  "name":"Podkoszulek GREEN XL",
  "color_name": "zielony",
  "color_id": 3771,
  "size_name": "XL",
  "size_id": 3873,
  "relation_type":{
      "name":"child",
      "parent": "70077_3771"
  }
}
 
PUT /sklep/_doc/70079?routing=1
{
  "name":"Podkoszulek GREEN M",
  "color_name": "zielony",
  "color_id": 3771,
  "size_name": "M",
  "size_id": 3871,
  "relation_type":{
      "name":"child",
      "parent": "70077_3771"
  }
}
 
PUT /sklep/_doc/70080?routing=1
{
  "name":"Podkoszulek RED L",
  "color_name": "czerwony",
  "color_id": 3768,
  "size_name": "L",
  "size_id": 3872,
  "relation_type":{
      "name":"child",
      "parent": "70077_3768"
  }
}
 
PUT /sklep/_doc/70081?routing=1
{
  "name":"Podkoszulek RED XL",
  "color_name": "czerwony",
  "color_id": 3768,
  "size_name": "XL",
  "size_id": 3873,
  "relation_type":{
      "name":"child",
      "parent": "70077_3768"
  }
}
 
PUT /sklep/_doc/70082?routing=1
{
  "name":"Podkoszulek RED M",
  "color_name": "czerwony",
  "color_id": 3768,
  "size_name": "M",
  "size_id": 3871,
  "relation_type":{
      "name":"child",
      "parent": "70077_3768"
  }
}
 
PUT /sklep/_doc/70084?routing=1
{
  "name":"Podkoszulek BLUE M",
  "color_name": "niebieski",
  "color_id": 3773,
  "size_name": "M",
  "size_id": 3871,
  "relation_type":{
      "name":"child",
      "parent": "70077_3773"
  }
}
 
PUT /sklep/_doc/70089?routing=1
{
  "name":"Podkoszulek RED S",
  "color_name": "czerwony",
  "color_id": 3768,
  "size_name": "S",
  "size_id": 3870,
  "relation_type":{
      "name":"child",
      "parent": "70077_3768"
  }
}
 
PUT /sklep/_doc/70090?routing=1
{
  "name":"Podkoszulek RED 2XL",
  "color_name": "czerwony",
  "color_id": 3768,
  "size_name": "2XL",
  "size_id": 4429,
  "relation_type":{
      "name":"child",
      "parent": "70077_3768"
  }
}

Po tych zmianach możemy wyszukać frazę podkoszulek i zobaczyć czy będziemy w stanie pogrupować produkty po kolorze.

GET sklep/_search
{
  "query": {
    "has_child": {
      "type": "child",
      "query": {
        "multi_match": {
          "query": "podkoszulek",
          "operator": "and", 
          "fields": [
            "name",
            "color_name",
            "size_name"
          ],
          "type": "cross_fields"
        }
      }
    }
  }
}

Widzimy, że zostały znalezione wszystkie kolory i jest to prawidłowe dla szukanej frazy. Zobaczmy jak to się zmieni, gdy do frazy dodamy rozmiar XL.

GET sklep/_search
{
  "query": {
    "has_child": {
      "type": "child",
      "query": {
        "multi_match": {
          "query": "podkoszulek xl",
          "operator": "and", 
          "fields": [
            "name",
            "color_name",
            "size_name"
          ],
          "type": "cross_fields"
        }
      }
    }
  }
}

Podsumowanie

W ten oto sposób zakończyliśmy przygodę z odpowiednikiem relacji w ElasticSearch. Jednak chciałbym, abyście zapamiętali, że nie należy traktować tego jak dokładny odpowiednik relacji. To jest struktura do specjalnych zastosowań i bardzo szczególnych przypadków, więc jeśli nie musicie to lepiej przygotować tak dane, aby były jak najbardziej zbliżone do wyników jakie chcecie wyświetlać użytkownikom.

W razie wątpliwości lub niejasności piszcie śmiało w komentarzach :)