Blog

12 grudnia 2016 Bartłomiej Gruszka

Serwis autocomplete z użyciem Elasticsearch krok po kroku – część 3/3

Serwis autocomplete z użyciem Elasticsearch krok po kroku – część 3/3

Jest to trzeci, ostatni post z serii postów dotyczących tworzenia systemu podpowiadania wpisywanych fraz z użyciem Elasticsearch. W pierwszej części omówione zostały założenia projektu oraz przedstawiona została koncepcja Elasticsearch. Drugi z postów zawierał pierwsze próby utworzenia takiego systemu, eksperymentując rodzajami zapytań w kolejnych iteracjach. W tym poście przedstawione zostaną dodatkowe funkcjonalności wbudowane w Elasticsearch, które są nieodzownym elementem systemów podpowiadania fraz. Jak poprzednio, artykuł podzielony został na iteracje, które krok po kroku przybliżają nas do celu.

Autocomplete iteracja 6: Korekcja literówek

Jednym z istotnych wymagań systemu podpowiadania fraz jest możliwość zwracania prawidłowych wyników dla zniekształconych fraz. Bardzo często użytkownicy wpisują frazy nie znając dokładnej pisowni lub po prostu popełniając literówki. Elasticsearch dysponuje wbudowanym mechanizmem fuzziness, który oparty jest o odległości Levenshteina. Jest to algorytm, który określa podobieństwo dwóch ciągów od siebie. Każda elementarna różnica między ciągami, taka jak wstawienie, usunięcie lub zmiana znaku na innych jest traktowana w tej metrycja jako jednostkowa. Identyczne ciągi mają odległość 0. Na podstawie tego algorytmu Elasticsearch potrafi szukać nawet przy pomocy zniekształconych zapytań. W praktyce podczas zapytania określić trzeba tylko maksymalną odległość wyniku od zapytania. Przykładowe zapytanie wygląda jak niżej:

curl -XPOST '127.0.0.1:9200/airports_5/airport/_search -d '
{
    "query": {
        "match": {
                "cityName": {
                "query": "krakuw",
                "analyzer": "standard",
                "fuzziness": 1
            }
        }
    }
}'

Jako że dystans według miary odległości Levenshteina wynosi 1 i mieści się w zadanym progu, zwrócony zostanie wynik dla lotniska w Krakowie, tak jakby wpisana została prawidłowa fraza. Dodatkowym przydanym parametrem jest prefix_length, który określa ile pierwszych znaków traktowane będzie jako stałe, i nie podlegać będzie modyfikacji przy wyszukiwaniu. Warto poeksperymentować z tymi parametrami szukając najlepszej konfiguracji dla danego przypadku. Warto zwrócić uwagę iż zbyt optymistyczne ustawienie obu parametrów może doprowadzić do sytuacji że zwracane wyniki będą na tyle źle dopasowane że cały mechanizm straci wartość biznesową zwracając bezsensowne dane.

Warto zapamiętać:

  • parametr fuzziness określa próg w mierze odległości Levenshteina
  • parametr prefix_length określa ile pierwszych znaków nie będzie polegać zmianie

Autocomplete iteracja 7: Ignorowanie znaków diakrytycznych

Kolejnym wymaganiem biznesowym jest ignorowanie w zapytaniach znaków diakrytycznych. Bardzo wiele osób pisząc w Internecie nie używa znaków charakterystycznych dla natywnego języka, jak również wiele osób korzystających z naszej aplikacji będąc za granicą nie ma ich w domyślnej mapie klawiatury. Chcemy więc by zarówno wyszukanie Krakow zwróciło prawidłowe wyniki, identyczne jak Kraków. Oczywiście do tego typu operacji wykorzystać można poznany przed momentem fuzziness, lecz można to zrobić lepiej, z wykorzystaniem na etapie indeksacji specjalnie do tego przeznaczonego filtra asciifolding. Utwórzmy indeks airports_6 z mapowaniem:

curl -XPOST '127.0.0.1:9200/airports_6/' -d '
{
    "settings": {
        "analysis": {
            "analyzer": {
                "asciifolding_analyzer": {
                    "type": "custom",
                    "tokenizer": "standard",
                    "filter": [
                        "lowercase",
                        "asciifolding"
                    ]
                }
            }
        }
    },
    "mappings": {
        "airport": {
            "properties": {
                "cityName": {
                    "type": "string",
                    "analyzer": "asciifolding_analyzer"
                }
            }
        }
    }
}'

Nawet bez ładowania do niego danych spróbujmy przeprowadzić analizę tego, jak analizowane będą dodawane do niego dane

curl -XGET '127.0.0.1:9200/airports_6/_analyze?analyzer=asciifolding_analyzer&text=Kraków'

Odpowiedź:

{
    "tokens": [
        {
            "token": "krakow",
            "start_offset": 0,
            "end_offset": 6,
            "type": "",
            "position": 0
        }
    ]
}

Jak widać z tekstu Kraków zostanie po przepuszczeniu przez filtry lowercase i asciifolding utworzony token krakow – zignorowane zostaną znaki diakrytyczne. Działanie filtra asciifolding polega na zmapowaniu znaków z poza ASCII do ASCII.

Należy pamiętać o zastosowaniu filtra asciifolding również w momencie wyszukiwania, tak by wygenerowane tokeny były spójne. W przeciwnym przypadku dopasowanie nie nastąpi, i wyniki nie zostaną zwrócone.

Przykład błędnego wyszukiwania:

curl -XPOST '127.0.0.1:9200/airports_6/airport/_search -d '
{
    "query": {
        "match": {
            "cityName": {
                "query": "kraków",
                "analyzer": "standard"
            }
        }
    }
}'

Standardowy analizer wygeneruje token kraków, gdy w indeksie istnieje krakow, wygenerowany przy indeksacji z użyciem asciifolding_analyzer.

Warto zapamiętać:

  • filtr asciifolding konwertuje znaki do ASCII – podstawowego alfabetu łacińskiego
  • dane podczas wyszukiwania i indeksacji powinny być analizowane w spójny sposób

Autocomplete iteracja 8: Podświetlanie wpisanych fraz

Kolejnym z wymagań postawionych przez biznes jest podświetlanie wpisanej frazy. Przez podświetlanie rozumiemy wyróżnienie jej we wpisanym słowie przez otoczenie tagiem HTML. Wydawać się może że taka funkcjonalność jest typowo frontendową, i powinna być zrealizowana w innej warstwie aplikacji, na przykład z użyciem wyrażeń regularnych. Jest to jednak wysoce problematyczne gdy pod uwagę weźmiemy ignorowanie znaków diakrytycznych czy korekcję literówek. Elasticsearch posiada wbudowany mechanizm podświetlania wpisanych fraz i zastosowanie go jest najlepszym sposobem by sprostać postawionym nam wymaganiom.

Załóżmy że mamy indeks airports_7 z mapowaniem:

curl -XPOST '127.0.0.1:9200/airports_7/' -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"
                }
            }
        }
    }
}'

Na potrzeby tego przykładu dodajmy dane dla trzech lotnisk

curl -XPOST '127.0.0.1:9200/airports_7/airport?pretty' -d '
{
    "code": "WMI",
    "name": "Modlin",
    "cityName": "Warszawa",
    "countryName": "Polska",
    "fullName": "Warszawa Modlin"
}'

curl -XPOST '127.0.0.1:9200/airports_7/airport?pretty' -d '
{
    "code": "WAW",
    "name": "Okęcie",
    "cityName": "Warszawa",
    "countryName": "Polska",
    "fullName": "Warszawa Okęcie"
}'

curl -XPOST '127.0.0.1:9200/airports_7/airport?pretty' -d '
{
    "code": "VAR",
    "name": "Warna Airport",
    "cityName": "Warna",
    "countryName": "Bułgaria",
    "fullName": "Warna Airport Warna"
}'

Standardowe zapytanie dla frazy war wygląda jak niżej:

curl -XPOST '127.0.0.1:9200/airports_7/airport/_search -d '
{
    "query": {
        "match": {
            "fullName": {
                "query": "war",
                "analyzer": "standard"
            }
        }
    }
}'

Zwrócone zostają wszystkie trzy wyniki, ponieważ pełna nazwa każdego z nich zawiera ciąg war, teraz chcemy go wyróżnić. Z pomocą przychodzi funkcjonalność highlighting z Elasticsearch, konfiguracja polega na wskazaniu które pole ma być przetwarzane w poszukiwaniu i podświetlaniu wpisanego ciągu.

curl -XPOST '127.0.0.1:9200/airports_7/airport/_search -d '
{
    "query": {
        "match": {
            "fullName": {
                "query": "war",
                "analyzer": "standard"
            }
        }
    },
    "highlight": {
        "fields": {
            "fullName": {}
        }
    }
}'

W odpowiedzi dostaniemy te same wyniki, lecz z dodatkowym polem w sekcji highlight, zawierającym wartość pola fullName z wpisaną frazą otoczoną znacznikiem em (tag HTML stosowany do wyróżnienia frazy można zmienić).

{
    "took": 35,
    "timed_out": false,
    "_shards": {
        "total": 5,
        "successful": 5,
        "failed": 0
    },
    "hits": {
        "total": 3,
        "max_score": 0.2169777,
        "hits": [
            {
                "_index": "airports_7",
                "_type": "airport",
                "_id": "AVgVz1kLG-Qn63NGOTIk",
                "_score": 0.2169777,
                "_source": {
                    "code": "VAR",
                    "name": "Warna Airport",
                    "cityName": "Warna",
                    "countryName": "Bułgaria",
                    "fullName": "Warna Airport Warna"
                },
                "highlight": {
                    "fullName": [
                        "Warna Airport Warna"
                    ]
                }
            },
            {
                "_index": "airports_7",
                "_type": "airport",
                "_id": "AVgVz1djG-Qn63NGOTIj",
                "_score": 0.19178301,
                "_source": {
                    "code": "WAW",
                    "name": "Okęcie",
                    "cityName": "Warszawa",
                    "countryName": "Polska",
                    "fullName": "Warszawa Okęcie"
                },
                "highlight": {
                    "fullName": [
                        "Warszawa Okęcie"
                    ]
                }
            },
            {
                "_index": "airports_7",
                "_type": "airport",
                "_id": "AVgVz1dCG-Qn63NGOTIi",
                "_score": 0.19178301,
                "_source": {
                    "code": "WMI",
                    "name": "Modlin",
                    "cityName": "Warszawa",
                    "countryName": "Polska",
                    "fullName": "Warszawa Modlin"
                },
                "highlight": {
                    "fullName": [
                        "Warszawa Modlin"
                    ]
                }
            }
        ]
    }
}

Jednak wynik nie jest do końca satysfakcjonujący, wymagane było podświetlanie jedynie wpisanej frazy, a w rezultacie dostajemy podświetlone całe słowo zawierający szukaną frazę. By zrozumieć dlaczego tak się dzieje należy zagłębić się w proces powstawania tokenów. Składa się on z dwóch części, tokenizacji i aplikacji filtrów. Należy wiedzieć że dane najpierw są tokenizowane przy użyciu wybranego tokenizera, a następnie powstałe w tym procesie tokeny są poddawane filtracji. W naszym przypadku standardowy tokenizer dzieli ciąg na tokeny po znakach takich jak spacja. Następnie stosujemy na nich filtr typu nGram. Sprawdźmy jak w szczegółach wygląda
ten proces dla frazy Warszawa Okęcie dodając do metody _analyze parametr explain.

curl -XGET '127.0.0.1:9200/airports_7/_analyze?analyzer=autocomplete_analyzer&text=Warszawa+Okęcie&explain'

Odpowiedź:

{
    "detail": {
        "custom_analyzer": true,
        "charfilters": [],
        "tokenizer": {
            "name": "standard",
            "tokens": [
                {
                    "token": "Warszawa",
                    "start_offset": 0,
                    "end_offset": 8,
                    "type": "",
                    "position": 0,
                    "bytes": "[57 61 72 73 7a 61 77 61]",
                    "positionLength": 1
                },
                {
                    "token": "Okęcie",
                    "start_offset": 9,
                    "end_offset": 15,
                    "type": "",
                    "position": 1,
                    "bytes": "[4f 6b c4 99 63 69 65]",
                    "positionLength": 1
                }
            ]
        },
        "tokenfilters": [
            {
                "name": "autocomplete_filter",
                "tokens": [
                    {
                        "token": "W",
                        "start_offset": 0,
                        "end_offset": 8,
                        "type": "word",
                        "position": 0,
                        "bytes": "[57]",
                        "positionLength": 1
                    },
                    [...]
                    {
                        "token": "e",
                        "start_offset": 9,
                        "end_offset": 15,
                        "type": "word",
                        "position": 1,
                        "bytes": "[65]",
                        "positionLength": 1
                    }
                ]
            },
            {
                "name": "lowercase",
                "tokens": [
                    {
                        "token": "w",
                        "start_offset": 0,
                        "end_offset": 8,
                        "type": "word",
                        "position": 0,
                        "bytes": "[77]",
                        "positionLength": 1
                    }
                    [...]
                    {
                        "token": "e",
                        "start_offset": 9,
                        "end_offset": 15,
                        "type": "word",
                        "position": 1,
                        "bytes": "[65]",
                        "positionLength": 1
                    }
                ]
            }
        ]
    }
}

Z podanej frazy powstały dwa tokeny Warszawa i Okęcie i na ich podstawie po przepuszczeniu przez filtry powstały kolejne tokeny. By osiągnąć podświetlanie tylko wybranej frazy należy zmodyfikować ten proces tak, by od razu w fazie tokenizacji powstała większa ilość tokenów. Rozwiązaniem jest utworzenie własnego tokenizera typu nGram, zmodyfikowany indeks airports_8 wygląda jak niżej:

curl -XPOST '127.0.0.1:9200/airports_8/' -d '
{
    "settings": {
        "analysis": {
            "analyzer": {
                "autocomplete_analyzer": {
                    "type": "custom",
                    "tokenizer": "ngram_tokenizer",
                    "filter": [
                        "lowercase"
                    ]
                }
            },
            "tokenizer": {
                "ngram_tokenizer": {
                    "type": "nGram",
                    "min_gram": 1,
                    "max_gram": 20
                }
            }
        }
    },
    "mappings": {
        "airport": {
            "properties": {
                "fullName": {
                    "type": "string",
                    "analyzer": "autocomplete_analyzer"
                }
            }
        }
    }
}'

Po dodaniu danych jak wcześniej i zapytaniu dostaniemy wymagany efekt.

curl -XPOST '127.0.0.1:9200/airports_8/airport/_search -d '
{
    "query": {
        "match": {
            "fullName": {
                "query": "war"
            }
        }
    },
    "highlight": {
        "fields": {
            "fullName": {}
        }
    }
}'

Wynik:

{
    "took": 14,
    "timed_out": false,
    "_shards": {
        "total": 5,
        "successful": 5,
        "failed": 0
    },
    "hits": {
        "total": 3,
        "max_score": 0.15007429,
        "hits": [
            {
                "_index": "airports_8",
                "_type": "airport",
                "_id": "AVgWOz16G-Qn63NGOTJK",
                "_score": 0.15007429,
                "_source": {
                    "code": "VAR",
                    "name": "Warna Airport",
                    "cityName": "Warna",
                    "countryName": "Bułgaria",
                    "fullName": "Warna Airport Warna"
                },
                "highlight": {
                    "fullName": [
                        "Warna Airport Warna"
                    ]
                }
            },
            {
                "_index": "airports_8",
                "_type": "airport",
                "_id": "AVgWOzu-G-Qn63NGOTJI",
                "_score": 0.14336428,
                "_source": {
                    "code": "WMI",
                    "name": "Modlin",
                    "cityName": "Warszawa",
                    "countryName": "Polska",
                    "fullName": "Warszawa Modlin"
                },
                "highlight": {
                    "fullName": [
                        "Warszawa Modlin"
                    ]
                }
            },
            {
                "_index": "airports_8",
                "_type": "airport",
                "_id": "AVgWOzvhG-Qn63NGOTJJ",
                "_score": 0.073993534,
                "_source": {
                    "code": "WAW",
                    "name": "Okęcie",
                    "cityName": "Warszawa",
                    "countryName": "Polska",
                    "fullName": "Warszawa Okęcie"
                },
                "highlight": {
                    "fullName": [
                        "Warszawa Okęcie"
                    ]
                }
            }
        ]
    }
}

Warto zapamiętać:

  • proces powstawania tokenów składa się z dwóch elementów, tokenizacji i filtrowania (w tej kolejności)
  • kolejność operacji podczas analizy danych ma znaczenie
  • można tworzyć własne tokenizery
  • funkcjonalność wyróżniania wpisanej frazy osiąga się poprzez dodanie sekcji highlight do zapytania

Autocomplete iteracja 9: Aliasy

Do tej pory podczas każdej zmiany mapowania tworzyliśmy nowy indeks z suffixem będącym kolejnym numerem. Nie działo się to bez powodu. Poza niewielkimi wyjątkami nie da się zmienić istniejącego mapowania. Wynika to z faktu że dane trzymane w indeksie są ściśle związane z mapowaniem. Operacje wykonywane podczas indeksacji są nieodwracalne, z otrzymanych tokenów nie da się jednoznacznie odtworzyć oryginalnego tekstu.

Co zatem zrobić gdy podczas produkcyjnej pracy systemu zajdzie potrzeba dodania lub zmodyfikowania mapowania typu? Z pomocą przychodzi mechanizm aliasowania indeksów. Idea polega na zewnętrznym odwoływaniu się do aliasu, który wewnętrznie może wskazywać na dowolny indeks. W naszym przypadku utworzymy alias airports_current, który początkowo wskazywać będzie na indeks airports_8. Wyświetlmy najpierw wszystkie istniejące aliasy.

curl -XGET '127.0.0.1:9200/_aliases'

Odpowiedź:

{
    "airports_7": {
        "aliases": {}
    },
    "airports_8": {
        "aliases": {}
    },
    "airports_3": {
        "aliases": {}
    },
    "airports_4": {
        "aliases": {}
    },
    "airports_5": {
        "aliases": {}
    },
    "airports_6": {
        "aliases": {}
    },
    "airports_1": {
        "aliases": {}
    },
    "airports_2": {
        "aliases": {}
    }
}

Widzimy wszystkie zdefiniowane indeksy, jednak żaden z nich nie posiada aliasu. Dodajmy dla indeksu airports_8 alias airports_current:

curl -XPOST '127.0.0.1:9200/_aliases' -d '
{
    "actions": [
        {
            "add": {
                "index": "airports_8",
                "alias": "airports_current"
            }
        }
    ]
}'

Ponownie wyświetlmy listę aliasów

{
    [...]
        "airports_8": {
            "aliases": {
                "airports_current": {}
            }
        },
[...]
}

Powstał wirtualny indeks o nazwie airports_current, do którego odwoływać można się tak, jak do każdego innego indeksu, sprawdźmy że dwa poniższe zapytania zwrócą dokładnie to samo:

curl -XPOST '127.0.0.1:9200/airports_8/airport/_search' -d '
{
    "query": {
        "match_all": {}
    }
}'

vs

curl -XPOST '127.0.0.1:9200/airports_current/airport/_search' -d '
{
    "query": {
        "match_all": {}
    }
}'

Gdy teraz zechcemy dodać indeks airports_9, który będzie miał zmienione mapowanie wystarczy zaindeksować do niego dane oraz przepiąć alias na nową wersję:

curl -XPOST '127.0.0.1:9200/_aliases' -d '
{
    "actions": [
        {
            "add": {
                "index": "airports_9",
                "alias": "airports_current"
            }
        },
        {
            "remove": {
                "index": "airports_8",
                "alias": "airports_current"
            }
        }
    ]
}'

Aliasy nie powodują dodatkowego narzutu czasowego i powinny być używane zawsze gdy to możliwe.

Warto zapamiętać:

  • aliasy powinny być stosowane zawsze, gdy to możliwe
  • może istnieć dowolna ilość aliasów
  • produkcyjne aplikacje powinny zawsze odwoływać się do aliasu, zamiast do konkretnego indeksu

Autocomplete iteracja 10: Połączmy wszystko razem

By stworzyć w pełni funkcjonalny system podpowiadania wpisywanych fraz należy połączyć funkcjonalności z poprzednich kroków w jeden indeks. Utwórzmy indeks airports_10 z odpowiednim mapowaniem

curl -XPOST '127.0.0.1:9200/airports_10/' -d '
{
    "settings": {
        "analysis": {
            "analyzer": {
                "autocomplete_analyzer": {
                    "type": "custom",
                    "tokenizer": "ngram_tokenizer",
                    "filter": [
                        "lowercase",
                        "asciifolding"
                    ]
                }
            },
            "tokenizer": {
                "ngram_tokenizer": {
                    "type": "nGram",
                    "min_gram": 1,
                    "max_gram": 20
                }
            }
        }
    },
    "mappings": {
        "airport": {
            "properties": {
                "fullName": {
                    "type": "string",
                    "analyzer": "autocomplete_analyzer",
                    "search_analyzer": "standard
                }
            }
        }
    }
}'

Oraz przykładowe zapytanie bez korekcji literówek:

curl -XPOST '127.0.0.1:9200/airports_10/airport/_search?pretty' -d '
{
    "query": {
        "match": {
            "fullName": {
                "query": "kra",
                "operator": "and"
            }
        }
    },
    "highlight": {
        "fields": {
            "fullName": {}
        }
    }
}'

I z korekcją literówek

curl -XPOST '127.0.0.1:9200/airports_10/airport/_search?pretty' -d '
{
    "query": {
        "match": {
            "fullName": {
                "query": "kra",
                "operator": "and",
                "fuzziness": 2,
                "prefix_length": 2
            }
        }
    },
    "highlight": {
        "fields": {
            "fullName": {}
        }
    }
}'

Dodamy alias airports_current dla indeksu airports_10

curl -XPOST '127.0.0.1:9200/_aliases' -d '
{
    "actions": [
        {
            "add": {
                "index": "airports_10",
                "alias": "airports_current"
            }
        }
    ]
}'

Podsumowanie

Teraz, gdy dysponujemy możliwościami wyszukiwania lotnisk na podstawie fragmentu wpisanego przez użytkownika zastanówmy się jak wykorzystać to w aplikacji. Scenariusz zawsze jest podobny i zakłada że użytkownik w polu wyszukiwania zaczyna wpisywać frazę, chcemy uprzedzić go i podpowiedzieć najtrafniejsze wyniki na podstawie wpisywanego przez niego tekstu. Z powodu ilości dopasowań dla krótkich fraz najlepiej zacząć to robić dopiero po wpisaniu określonej długości ciągu. Kolejną dobrą praktyką jest wyświetlanie w pierwszej kolejności dokładnych dopasowań, dopiero gdy żaden wynik nie zostanie zwrócony wyszukamy raz jeszcze uwzględniając literówki.

Zakładamy że istnieje klient HTTP, który pyta endpoint 127.0.0.1:9200/airports_current/airport/_search, oraz że istnieją metody search i searchWithFuzziness, które wykonują odpowiednie (jak wyżej) requesty metodą POST. Pseudokod akcji realizującej logikę biznesową opisaną wyżej może wyglądać z ten sposób.

public function getSuggestionsAction($phrase) {
    if (strlen($phrase) < 3) {
        return [];
    }
    
    $results = $this->client->search($phrase);

    if (empty($results)) {
        $results = $this->client->searchWithFuzziness($phrase);
    }

    return $results;
}

Wracając do listy wymagań postawionych na początku tego posta:

  • system musi działać bez przerw, możliwe jednak musi być modyfikowanie zawartych w nim danych
  • system powinien ignorować wielkość liter w wpisywanych frazach (kraków = Kraków)
  • system powinien ignorować znaki diakrytyczne (krakow = kraków)
  • system powinien potrafić zwracać wyniki dla fraz wpisanych z literówkami (krakuw = kraków)
  • system powinien potrafić wyróżnić wpisaną przez użytkownika frazę w tekście

Wszystkie z nich zostały spełnione, przy tym konfiguracja nie jest skomplikowana. W tej chwili dysponujemy w pełni funkcjonalnym serwisem do podpowiadania fraz wpisywanych przez użytkownika. Analogiczne rozwiązanie można zastosować w dowolnej wyszukiwarce, zmieniając jedynie format i typ danych.

PS. W międzyczasie pisania tego posta wyszła nowa wersja Elasticsearch oznaczona 5.0

Zobacz na blogu

09.09.2022
Marcin Jahn
It’s Not Just HTTP It’s Not Just HTTP

In today’s world of cloud-based solutions, distributed systems, and microservices-based architectures, network communication is a...

23.08.2022
Adam Mrowiec
Konferencja IPC 2022 Berlin Konferencja IPC 2022 Berlin

Pandemia wreszcie się kończy, dlatego w tym roku postanowiliśmy wrócić do naszych wyjazdów na konferencje....