Podstawy Elasticsearch

Elasticsearch — silnik wyszukiwania z json rest api, wykorzystujący Lucene i napisany w Javie. Opis wszystkich zalet tego silnika dostępny jest na oficjalnej stronie. W dalszej części tekstu będziemy nazywać Elasticsearch jako ES.

Podobne silniki są używane przy zaawansowanym wyszukiwaniu w bazach dokumentów. Na przykład, wyszukiwanie z uwzględnieniem morfologii języka lub wyszukiwanie po koordynatach geograficznych.

W tym artykule opowiem o podstawach ES na przykładzie indeksacji postów bloga. Pokażę, jak filtrować, sortować i wyszukiwać dokumenty.

Aby nie zależeć od systemu operacyjnego, wszystkie zapytania do ES będę robił za pomocą CURL. Istnieje również wtyczka do Google Chrome o nazwie sense.

W tekście umieściłem linki do dokumentacji i innych źródeł. Na końcu znajdują się linki do szybkiego dostępu do dokumentacji. Definicje nieznanych terminów można przeczytać w glosariuszu.

Instalacja ES

Aby to zrobić, najpierw potrzebujemy Javy. Programiści zaleca się powinni zainstalować wersje Javy nowsze niż Java 8 update 20 lub Java 7 update 55.

Dystrybucja ES jest dostępna na stronie twórcy. Po rozpakowaniu archiwum należy uruchomić bin/elasticsearch. Dostępne są również pakiety dla apt i yum. Jest oficjalny obraz dla dockera. Więcej informacji na temat instalacji.

Po zainstalowaniu i uruchomieniu sprawdzimy działanie:

# для удобства запомним адрес в переменную
#export ES_URL=$(docker-machine ip dev):9200
export ES_URL=localhost:9200

curl -X GET $ES_URL

Otrzymamy mniej więcej taką odpowiedź:

{
  "name" : "Heimdall",
  "cluster_name" : "elasticsearch",
  "version" : {
    "number" : "2.2.1",
    "build_hash" : "d045fc29d1932bce18b2e65ab8b297fbf6cd41a1",
    "build_timestamp" : "2016-03-09T09:38:54Z",
    "build_snapshot" : false,
    "lucene_version" : "5.4.1"
  },
  "tagline" : "You Know, for Search"
}

Indeksacja

Dodamy post do ES:

# Добавим документ c id 1 типа post в индекс blog.
# ?pretty указывает, что вывод должен быть человеко-читаемым.

curl -XPUT "$ES_URL/blog/post/1?pretty" -d'
{
  "title": "Веселые котята",
  "content": "<p>Śmieszna historia o kotkach<p>",
  "tags": [
    "kotki",
    "śmieszna historia"
  ],
  "published_at": "2014-09-12T20:44:42+00:00"
}'

odpowiedź serwera:

{
  "_index" : "blog",
  "_type" : "post",
  "_id" : "1",
  "_version" : 1,
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "created" : false
}

ES automatycznie utworzył indeks blog i typ post. Można przeprowadzić uproszczoną analogię: indeks — to baza danych, a typ — tabela w tej bazie. Każdy typ ma swoją schemę — mapping, podobnie jak tabela w relacyjnej bazie. Mapping jest generowany automatycznie podczas indeksacji dokumentu:

# Получим mapping всех типов индекса blog
curl -XGET "$ES_URL/blog/_mapping?pretty"

W odpowiedzi serwera dodałem w komentarzu wartości pól zaindeksowanego dokumentu:

{
  "blog" : {
    "mappings" : {
      "post" : {
        "properties" : {
          /* "content": "<p>Śmieszna historia o kotkach<p>", */
          "content" : {
            "type" : "string"
          },
          /* "published_at": "2014-09-12T20:44:42+00:00" */
          "published_at" : {
            "type" : "date",
            "format" : "strict_date_optional_time||epoch_millis"
          },
          /* "tags": ["kotki", "smieszna historia"] */
          "tags" : {
            "type" : "string"
          },
          /*  "title": "Wesołe kotki" */
          "title" : {
            "type" : "string"
          }
        }
      }
    }
  }
}

Należy zauważyć, że ES nie robi różnicy między pojedynczą wartością a tablicą wartości. Na przykład pole title zawiera po prostu nagłówek, a pole tags — tablicę ciągów, chociaż są one przedstawiane w mappingu w ten sam sposób.
Później porozmawiamy o mapowaniu bardziej szczegółowo.

Zapytania

Pobranie dokumentu po jego id:

# извлечем документ с id 1 типа post из индекса blog
curl -XGET "$ES_URL/blog/post/1?pretty"
{
    "_index": "blog",
    "_type": "post",
    "_id": "1",
    "_version": 1,
    "found": true,
    "_source": {
        "title": "\u0412\u0435\u0441\u0435\u043b\u044b\u0435 \u043a\u043e\u0442\u044f\u0442\u0430",
        "content": "<p>\u015amieszna historia o kotkach<p>",
        "tags": [
            "\u043a\u043e\u0442\u044f\u0442\u0430",
            "\u0441\u043c\u0435\u0448\u043d\u0430\u044f \u0438\u0441\u0442\u043e\u0440\u0438\u044f"
        ],
        "published_at": "2014-09-12T20:44:42+00:00"
    }
}

W odpowiedzi pojawiły się nowe klucze: _version i _source. W ogóle, wszystkie klucze, które zaczynają się od _ są kluczami systemowymi.

Klucz _version pokazuje wersję dokumentu. Jest to potrzebne do działania mechanizmu optymistycznych blokad. Na przykład, chcemy zmienić dokument mający wersję 1. Wysyłamy zmieniony dokument i wskazujemy, że jest to poprawka dokumentu z wersją 1. Jeśli ktoś inny również edytował dokument z wersją 1 i wysłał zmiany przed nami, to ES nie zaakceptuje naszych zmian, ponieważ przechowuje dokument z wersją 2.

Klucz _source zawiera ten dokument, który zindeksowaliśmy. ES nie używa tej wartości do operacji wyszukiwania, ponieważ do wyszukiwania używane są indeksy. Aby zaoszczędzić miejsce, ES przechowuje skompresowany oryginalny dokument. Jeśli potrzebujemy tylko id, a nie całego oryginalnego dokumentu, można wyłączyć przechowywanie źródła.

Jeśli nie potrzebujemy dodatkowych informacji, możemy uzyskać tylko zawartość _source:

curl -XGET "$ES_URL/blog/post/1/_source?pretty"
{
    "title": "\u0412\u0435\u0441\u0435\u043b\u044b\u0435 \u043a\u043e\u0442\u044f\u0442\u0430",
    "content": "<p>\u015amieszna historia o kotkach<p>",
    "tags": [
        "\u043a\u043e\u0442\u044f\u0442\u0430",
        "\u0441\u043c\u0435\u0448\u043d\u0430\u044f \u0438\u0441\u0442\u043e\u0440\u0438\u044f"
    ],
    "published_at": "2014-09-12T20:44:42+00:00"
}

Można również wybrać tylko określone pola:

# извлечем только поле title
curl -XGET "$ES_URL/blog/post/1?_source=title&pretty"
{
  "_index" : "blog",
  "_type" : "post",
  "_id" : "1",
  "_version" : 1,
  "found" : true,
  "_source" : {
    "title" : "Wesołe kotki"
  }
}

Zindeksujmy jeszcze kilka postów i wykonajmy bardziej złożone zapytania.

curl -XPUT "$ES_URL/blog/post/2" -d'
{
  "title": "Wesołe szczenięta",
  "content": "<p>Śmieszna historia o szczeniakach<p>",
  "tags": [
    "szczeniaki",
    "śmieszna historia"
  ],
  "published_at": "2014-08-12T20:44:42+00:00"
}'
curl -XPUT "$ES_URL/blog/post/3" -d'
{
  "title": "Jak zdobyłem kociaka",
  "content": "<p>Wzruszająca historia o biednym kociaku z ulicy<p>",
  "tags": [
    "kociaki"
  ],
  "published_at": "2014-07-21T20:44:42+00:00"
}'

Sortowanie

# найдем последний пост по дате публикации и извлечем поля title и published_at
curl -XGET "$ES_URL/blog/post/_search?pretty" -d'
{
  "size": 1,
  "_source": ["title", "published_at"],
  "sort": [{"published_at": "desc"}]
}'
{
  "took" : 8,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 3,
    "max_score" : null,
    "hits" : [ {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "1",
      "_score" : null,
      "_source" : {
        "title" : "Wesołe kotki",
        "published_at" : "2014-09-12T20:44:42+00:00"
      },
      "sort" : [ 1410554682000 ]
    } ]
  }
}

Wybraliśmy ostatni post. rozmiar ogranicza liczbę dokumentów w wynikach. total pokazuje całkowitą liczbę dokumentów odpowiadających zapytaniu. sort w wynikach zawiera tablicę liczb całkowitych, na podstawie których następuje sortowanie. Tzn. data została skonwertowana na liczbę całkowitą. Więcej o sortowaniu można przeczytać w dokumentacji.

Filtry i zapytania

ES od wersji 2 nie rozróżnia filtrów i zapytań, zamiast tego wprowadza pojęcie kontekstów.
Kontekst zapytania różni się od kontekstu filtra tym, że zapytanie generuje _score i nie jest cachowane. Co to jest _score pokażę później.

Filtrowanie po dacie

Używamy zapytania range w kontekście filtru:

# получим посты, опубликованные 1ого сентября или позже
curl -XGET "$ES_URL/blog/post/_search?pretty" -d'
{
  "filter": {
    "range": {
      "published_at": { "gte": "2014-09-01" }
    }
  }
}'

Filtrowanie po tagach

Używamy zapytanie term do wyszukiwania id dokumentów zawierających dane słowo:

# найдем все документы, в поле tags которых есть элемент 'котята'
curl -XGET "$ES_URL/blog/post/_search?pretty" -d'
{
  "_source": [
    "title",
    "tags"
  ],
  "filter": {
    "term": {
      "tags": "котята"
    }
  }
}'
{
  "took" : 9,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 2,
    "max_score" : 1.0,
    "hits" : [ {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "1",
      "_score" : 1.0,
      "_source" : {
        "title" : "Wesołe kotki",
        "tags" : [ "kotki", "zabawna historia" ]
      }
    }, {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "3",
      "_score" : 1.0,
      "_source" : {
        "title" : "Jak dostałem kociaka",
        "tags" : [ "kotki" ]
      }
    } ]
  }
}

Wyszukiwanie pełnotekstowe

Trzy nasze dokumenty zawierają w polu content następujące:

  • <p>Śmieszna historia o kotkach<p>
  • <p>Śmieszna historia o szczeniakach<p>
  • <p>Wzruszająca historia o biednym kociaku z ulicy<p>

Używamy zapytanie match do wyszukiwania id dokumentów zawierających dane słowo:

# source: false означает, что не нужно извлекать _source найденных документов
curl -XGET "$ES_URL/blog/post/_search?pretty" -d'
{
  "_source": false,
  "query": {
    "match": {
      "content": "история"
    }
  }
}'
{
  "took" : 13,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 3,
    "max_score" : 0.11506981,
    "hits" : [ {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "2",
      "_score" : 0.11506981
    }, {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "1",
      "_score" : 0.11506981
    }, {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "3",
      "_score" : 0.095891505
    } ]
  }
}

Jednakże, jeśli szukać „historii” w polu content, nie znajdziemy nic, ponieważ indeks zawiera tylko oryginalne słowa, a nie ich podstawy. Aby zapewnić wysoką jakość wyszukiwania, należy skonfigurować analizator.

Pole _score pokazuje relewantność. Jeśli zapytanie jest wykonywane w kontekście filtru, wartość _score zawsze będzie wynosiła 1, co oznacza pełne dopasowanie do filtru.

Analizatory

Analizatory są potrzebne do przekształcenia oryginalnego tekstu w zestaw tokenów.
Analizatory składają się z jednego tokenizera i kilku opcjonalnych filtrów tokenów. Tokenizer może być poprzedzony przez kilka filtrów znaków. Tokenizer dzieli oryginalny ciąg na tokeny, na przykład po spacji i znakach interpunkcyjnych. TokenFilter może zmieniać tokeny, usuwać lub dodawać nowe, na przykład zostawiać tylko podstawę słowa, usuwać przyimki, dodawać synonimy. CharFilter zmienia oryginalny ciąg w całości, na przykład wycina tagi HTML.

W ES istnieje kilka standardowych analizatorów.Na przykład, analizator russian.

Skorzystamy api i zobaczymy, jak analizatory standard i russian przekształcają ciąg „Wesołe historie o kotkach”:

# используем анализатор standard       
# обязательно нужно перекодировать не ASCII символы
curl -XGET "$ES_URL/_analyze?pretty&analyzer=standard&text=%D0%92%D0%B5%D1%81%D0%B5%D0%BB%D1%8B%D0%B5%20%D0%B8%D1%81%D1%82%D0%BE%D1%80%D0%B8%D0%B8%20%D0%BF%D1%80%D0%BE%20%D0%BA%D0%BE%D1%82%D1%8F%D1%82"
{
  "tokens" : [ {
    "token" : "wesołe",
    "start_offset" : 0,
    "end_offset" : 7,
    "type" : "",
    "position" : 0
  }, {
    "token" : "historie",
    "start_offset" : 8,
    "end_offset" : 15,
    "type" : "",
    "position" : 1
  }, {
    "token" : "o",
    "start_offset" : 16,
    "end_offset" : 19,
    "type" : "",
    "position" : 2
  }, {
    "token" : "kotkach",
    "start_offset" : 20,
    "end_offset" : 25,
    "type" : "",
    "position" : 3
  } ]
}
# используем анализатор russian
curl -XGET "$ES_URL/_analyze?pretty&analyzer=russian&text=%D0%92%D0%B5%D1%81%D0%B5%D0%BB%D1%8B%D0%B5%20%D0%B8%D1%81%D1%82%D0%BE%D1%80%D0%B8%D0%B8%20%D0%BF%D1%80%D0%BE%20%D0%BA%D0%BE%D1%82%D1%8F%D1%82"
{
  "tokens" : [ {
    "token" : "wesoły",
    "start_offset" : 0,
    "end_offset" : 7,
    "type" : "",
    "position" : 0
  }, {
    "token" : "historia",
    "start_offset" : 8,
    "end_offset" : 15,
    "type" : "",
    "position" : 1
  }, {
    "token" : "kot",
    "start_offset" : 20,
    "end_offset" : 25,
    "type" : "",
    "position" : 3
  } ]
}

Standardowy analizator dzieli tekst na słowa i zmienia je na małe litery. Analizator rosyjski usuwa nieważne słowa, zamienia tekst na małe litery, a pozostawia rdzenie słów.

Zobaczmy, jakie Tokenizery, Filtry Tokenów i Filtry Znaków używa analizator rosyjski:

{
  "filter": {
    "russian_stop": {
      "type":       "stop",
      "stopwords":  "_russian_"
    },
    "russian_keywords": {
      "type":       "keyword_marker",
      "keywords":   []
    },
    "russian_stemmer": {
      "type":       "stemmer",
      "language":   "russian"
    }
  },
  "analyzer": {
    "russian": {
      "tokenizer":  "standard",
      /* TokenFilters */
      "filter": [
        "lowercase",
        "russian_stop",
        "russian_keywords",
        "russian_stemmer"
      ]
      /* CharFilters brak */
    }
  }
}

Opiszmy nasz analizator oparty na rosyjskim, który będzie usuwał tagi HTML. Nazwiemy go domyślnym, ponieważ analizator o tej nazwie będzie używany domyślnie.

{
  "filter": {
    "ru_stop": {
      "type":       "stop",
      "stopwords":  "_russian_"
    },
    "ru_stemmer": {
      "type":       "stemmer",
      "language":   "russian"
    }
  },
  "analyzer": {
    "default": {
      /* dodajemy usuwanie tagów HTML */
      "char_filter": ["html_strip"],
      "tokenizer":  "standard",
      "filter": [
        "lowercase",
        "ru_stop",
        "ru_stemmer"
      ]
    }
  }
}

Najpierw z oryginalnego tekstu usunięte zostaną wszystkie tagi HTML, następnie tekst podzieli się na tokeny za pomocą tokenizera standard, otrzymane tokeny zostaną przekonwertowane na małe litery, usunięte zostaną nieważne słowa, a z pozostałych tokenów pozostanie rdzeń słowa.

Tworzenie indeksu

Powyżej opisaliśmy analizator domyślny. Będzie on stosowany do wszystkich pól tekstowych. Nasz post zawiera tablicę tagów, w związku z tym tagi również zostaną przetworzone przez analizator. Ponieważ szukamy postów według dokładnego dopasowania tagu, konieczne jest wyłączenie analizy dla pola tagów.

Stworzymy indeks blog2 z analizatorem oraz mapowaniem, w którym wyłączono analizę pola tagów:

curl -XPOST "$ES_URL/blog2" -d'
{
  "settings": {
    "analysis": {
      "filter": {
        "ru_stop": {
          "type": "stop",
          "stopwords": "_russian_"
        },
        "ru_stemmer": {
          "type": "stemmer",
          "language": "russian"
        }
      },
      "analyzer": {
        "default": {
          "char_filter": [
            "html_strip"
          ],
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "ru_stop",
            "ru_stemmer"
          ]
        }
      }
    }
  },
  "mappings": {
    "post": {
      "properties": {
        "content": {
          "type": "string"
        },
        "published_at": {
          "type": "date"
        },
        "tags": {
          "type": "string",
          "index": "not_analyzed"
        },
        "title": {
          "type": "string"
        }
      }
    }
  }
}'

Dodamy te same 3 posty do tego indeksu (blog2). Pomińmy ten proces, ponieważ jest on analogiczny do dodawania dokumentów do indeksu blog.

Wyszukiwanie pełnotekstowe z obsługą wyrażeń

Zapoznajmy się z jeszcze jednym rodzajem zapytań:

# найдем документы, в которых встречается слово 'истории'
# query -> simple_query_string -> query содержит поисковый запрос
# поле title имеет приоритет 3
# поле tags имеет приоритет 2
# поле content имеет приоритет 1
# приоритет используется при ранжировании результатов
curl -XPOST "$ES_URL/blog2/post/_search?pretty" -d'
{
  "query": {
    "simple_query_string": {
      "query": "истории",
      "fields": [
        "title^3",
        "tags^2",
        "content"
      ]
    }
  }
}'

Ponieważ używamy analizatora ze stemmingiem rosyjskim, to to zapytanie zwróci wszystkie dokumenty, choć w nich pojawia się tylko słowo ‘historia’.

Zapytanie może zawierać znaki specjalne, na przykład:

""jajka sadzone" +(bakłażan | ziemniak) -frittata"

Składnia zapytania:

+ oznacza operację AND
| oznacza operację OR
- neguje pojedynczy token
" otacza grupę tokenów, aby oznaczyć frazę do przeszukiwania
* na końcu terminu oznacza zapytanie prefixowe
( i ) wskazują pierwszeństwo
~N po słowie oznacza dystans edycyjny (nietrafność)
~N po frazie oznacza ilość slop
# найдем документы без слова 'щенки'
curl -XPOST "$ES_URL/blog2/post/_search?pretty" -d'
{
  "query": {
    "simple_query_string": {
      "query": "-щенки",
      "fields": [
        "title^3",
        "tags^2",
        "content"
      ]
    }
  }
}'

# получим 2 поста про котиков

Linki

PS

Jeśli interesują Cię podobne artykuły-lekcje, masz pomysły na nowe artykuły lub masz propozycje współpracy, chętnie przyjmę wiadomość na priv lub na maila m.kuzmin+habr@darkleaf.ru.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster