Асновы Elasticsearch

Elasticsearch – пошукавы рухавічок з json rest api, які выкарыстоўвае Lucene і напісаны на Java. Апісанне ўсіх пераваг гэтага рухавічка даступна на афіцыйным сайце. Далей па тэксце будзем зваць Elasticsearch як ES.

Падобныя рухавічкі выкарыстоўваюцца пры складаным пошуку па базе дакументаў. Напрыклад, пошук з улікам марфалогіі мовы ці пошук па geo каардынатах.

У гэтым артыкуле я раскажу пра асновы ES на прыкладзе індэксацыі пастоў блога. Пакажу як фільтраваць, сартаваць і шукаць дакументы.

Каб не залежаць ад аперацыйнай сістэмы, усе запыты да ES буду рабіць з дапамогай CURL. Таксама ёсць убудова для google chrome пад назвай сэнс.

Па тэксце расстаўлены спасылкі на дакументацыю і іншыя крыніцы. У канцы размешчаны спасылкі для хуткага доступу да дакументацыі. Азначэнні незнаёмых тэрмінаў можна прачытаць у гласарыі.

Устаноўка ES

Для гэтага нам спачатку запатрабуецца Java. Распрацоўнікі рэкамендуюць ўсталяваць версіі Java навей, чым Java 8 update 20 ці Java 7 update 55.

Дыстрыбутыў ES даступны на сайце распрацоўніка. Пасля распакавання архіва трэба запусціць bin/elasticsearch. Таксама даступныя пакеты для apt і yum. ёсць афіцыйны image для docker. Падрабязней аб усталёўцы.

Пасля ўстаноўкі і запуску праверым працаздольнасць:

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

curl -X GET $ES_URL

Нам прыйдзе прыблізна такі адказ:

{
  "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"
}

Індэксацыя

Дадамо пост у ES:

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

curl -XPUT "$ES_URL/blog/post/1?pretty" -d'
{
  "title": "Веселые котята",
  "content": "<p>Смешная история про котят<p>",
  "tags": [
    "котята",
    "смешная история"
  ],
  "published_at": "2014-09-12T20:44:42+00:00"
}'

адказ сервера:

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

ES аўтаматычна стварыў індэкс blog і тып post. Можна правесці ўмоўную аналогію: індэкс - гэта база дадзеных, а тып - табліца ў гэтай БД. Кожны тып мае сваю схему. адлюстраванне, таксама як і рэляцыйная табліца. Mapping генеруецца аўтаматычна пры індэксацыі дакумента:

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

У адказе сервера я дадаў у каментарах значэння палёў праіндэксаванага дакумента:

{
  "blog" : {
    "mappings" : {
      "post" : {
        "properties" : {
          /* "content": "<p>Смешная история про котят<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": ["котята", "смешная история"] */
          "tags" : {
            "type" : "string"
          },
          /*  "title": "Веселые котята" */
          "title" : {
            "type" : "string"
          }
        }
      }
    }
  }
}

Варта адзначыць, што ES не робіць адрозненняў паміж адзінкавым значэннем і масівам значэнняў. Напрыклад, поле title утрымоўвае проста загаловак, а поле tags - масіў радкоў, хоць яны прадстаўлены ў mapping аднолькава.
Пазней мы пагаворым аб мапінгу больш падобна.

запыты

Выманне дакумента па яго 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" : "Веселые котята",
    "content" : "<p>Смешная история про котят<p>",
    "tags" : [ "котята", "смешная история" ],
    "published_at" : "2014-09-12T20:44:42+00:00"
  }
}

У адказе з'явіліся новыя ключы: _version и _source. Наогул, усе ключы, якія пачынаюцца з _ ставяцца да службовых.

ключ _version паказвае версію дакумента. Ён патрэбен для працы механізму аптымістычных блакіровак. Напрыклад, мы жадаем змяніць дакумент, які мае версію 1. Мы адпраўляем зменены дакумент і паказваем, што гэта праўка дакумента з версіяй 1. Калі хтосьці іншы таксама рэдагаваў дакумент з версіяй 1 і адправіў змены раней за нас, то ES не прыме нашы змены, т.я. ён захоўвае дакумент з версіяй 2.

ключ _source змяшчае той дакумент, які мы індэксавалі. ES не выкарыстоўвае гэтае значэнне для пошукавых аперацый, т.я. для пошуку выкарыстоўваюцца азначнікі. Для эканоміі месца ES захоўвае сціслы зыходны дакумент. Калі нам патрэбен толькі id, а не ўвесь зыходны дакумент, то можна адключыць захоўванне зыходніка.

Калі нам не патрэбная дадатковая інфармацыя, можна атрымаць толькі змесціва _source:

curl -XGET "$ES_URL/blog/post/1/_source?pretty"
{
  "title" : "Веселые котята",
  "content" : "<p>Смешная история про котят<p>",
  "tags" : [ "котята", "смешная история" ],
  "published_at" : "2014-09-12T20:44:42+00:00"
}

Таксама можна абраць толькі вызначаныя палі:

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

Давайце праіндэксуем яшчэ некалькі пастоў і выканаем больш складаныя запыты.

curl -XPUT "$ES_URL/blog/post/2" -d'
{
  "title": "Веселые щенки",
  "content": "<p>Смешная история про щенков<p>",
  "tags": [
    "щенки",
    "смешная история"
  ],
  "published_at": "2014-08-12T20:44:42+00:00"
}'
curl -XPUT "$ES_URL/blog/post/3" -d'
{
  "title": "Как у меня появился котенок",
  "content": "<p>Душераздирающая история про бедного котенка с улицы<p>",
  "tags": [
    "котята"
  ],
  "published_at": "2014-07-21T20:44:42+00:00"
}'

Сартаванне

# найдем последний пост по дате публикации и извлечем поля 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" : "Веселые котята",
        "published_at" : "2014-09-12T20:44:42+00:00"
      },
      "sort" : [ 1410554682000 ]
    } ]
  }
}

Мы выбралі апошнюю пасаду. size абмяжоўвае колькасць дакументаў у выдачы. total паказвае агульную колькасць дакументаў, якія падыходзяць пад запыт. sort у выдачы змяшчае масіў цэлых лікаў, па якіх робіцца сартаванне. Г.зн. дата пераўтварылася ў цэлы лік. Больш падрабязна аб сартаванні можна прачытаць у дакументацыі.

Фільтры і запыты

ES з версіі 2 не адрознівае фільты і запыты, замест гэтага уводзіцца паняцце кантэкстаў.
Кантэкст запыту адрозніваецца ад кантэксту фільтра тым, што запыт генеруе _score і не кэшуецца. Што такое _score я пакажу пазней.

Фільтраванне па даце

Выкарыстоўваны запыт дыяпазон у кантэксце filter:

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

Фільтраванне па тэгах

Выкарыстоўваны term query для пошуку id дакументаў, якія змяшчаюць зададзенае слова:

# найдем все документы, в поле 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" : "Веселые котята",
        "tags" : [ "котята", "смешная история" ]
      }
    }, {
      "_index" : "blog",
      "_type" : "post",
      "_id" : "3",
      "_score" : 1.0,
      "_source" : {
        "title" : "Как у меня появился котенок",
        "tags" : [ "котята" ]
      }
    } ]
  }
}

Паўнатэкставы пошук

Тры нашыя дакументы ўтрымліваюць у полі content наступнае:

  • <p>Смешная история про котят<p>
  • <p>Смешная история про щенков<p>
  • <p>Душераздирающая история про бедного котенка с улицы<p>

Выкарыстоўваны match query для пошуку id дакументаў, якія змяшчаюць зададзенае слова:

# 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
    } ]
  }
}

Аднак, калі шукаць "гісторыі" ў полі кантэнт, то мы нічога не знойдзем, т.я. у індэксе змяшчаюцца толькі арыгінальныя словы, а не іх асновы. Для таго каб зрабіць якасны пошук, трэба наладзіць аналізатар.

Поле _score паказвае рэлевантнасць. Калі запыт выпоняется ў filter context, тое значэнне _score заўсёды будзе роўна 1, што азначае поўную адпаведнасць фільтру.

Аналізатары

Аналізатары патрэбныя, каб пераўтварыць зыходны тэкст у набор токенаў.
Аналізатары складаюцца з аднаго Токенизатор і некалькіх неабавязковых TokenFilters. Tokenizer можа папярэднічаць некалькім CharFilters. Tokenizer разбіваюць зыходны радок на токены, напрыклад, па прабелах і сімвалам пунктуацыі. TokenFilter можа змяняць токены, выдаляць ці дадаваць новыя, напрыклад, пакідаць толькі аснову слова, прыбіраць прыназоўнікі, дадаваць сінонімы. CharFilter - змяняе зыходны радок цалкам, напрыклад, выразае html тэгі.

У ES ёсць некалькі стандартных аналізатараў. Напрыклад, аналізатар руская.

Скарыстаемся АПА і паглядзім, як аналізатары standard і russian пераўтвораць радок «Вясёлыя гісторыі пра кацянят»:

# используем анализатор 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" : "веселые",
    "start_offset" : 0,
    "end_offset" : 7,
    "type" : "<ALPHANUM>",
    "position" : 0
  }, {
    "token" : "истории",
    "start_offset" : 8,
    "end_offset" : 15,
    "type" : "<ALPHANUM>",
    "position" : 1
  }, {
    "token" : "про",
    "start_offset" : 16,
    "end_offset" : 19,
    "type" : "<ALPHANUM>",
    "position" : 2
  }, {
    "token" : "котят",
    "start_offset" : 20,
    "end_offset" : 25,
    "type" : "<ALPHANUM>",
    "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" : "весел",
    "start_offset" : 0,
    "end_offset" : 7,
    "type" : "<ALPHANUM>",
    "position" : 0
  }, {
    "token" : "истор",
    "start_offset" : 8,
    "end_offset" : 15,
    "type" : "<ALPHANUM>",
    "position" : 1
  }, {
    "token" : "кот",
    "start_offset" : 20,
    "end_offset" : 25,
    "type" : "<ALPHANUM>",
    "position" : 3
  } ]
}

Стандартны аналізатар разбіў радок па прабелах і перавёў усё ў ніжні рэгістр, аналізатар russian - прыбраў не значныя словы, перавёў у ніжні рэгістр і пакінуў аснову слоў.

Паглядзім, якія Tokenizer, TokenFilters, CharFilters выкарыстоўвае аналізатар:

{
  "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 отсутствуют */
    }
  }
}

Апішам свой аналізатар на аснове russian, які будзе выразаць html тэгі. Назавем яго default, т.я. аналізатар з такім імем будзе выкарыстоўвацца па змаўчанні.

{
  "filter": {
    "ru_stop": {
      "type":       "stop",
      "stopwords":  "_russian_"
    },
    "ru_stemmer": {
      "type":       "stemmer",
      "language":   "russian"
    }
  },
  "analyzer": {
    "default": {
      /* добавляем удаление html тегов */
      "char_filter": ["html_strip"],
      "tokenizer":  "standard",
      "filter": [
        "lowercase",
        "ru_stop",
        "ru_stemmer"
      ]
    }
  }
}

Спачатку з зыходнага радка выдаляюцца ўсе html тэгі, потым яе разаб'е на токены tokenizer standard, атрыманыя токены пяройдуць у ніжні рэгістр, выдаляцца малаважныя словы і ад пакінутых токенаў застанецца аснова слова.

Стварэнне індэкса

Вышэй мы апісалі default аналізатар. Ён будзе прымяняцца да ўсіх радковых палёў. Наш пост змяшчае масіў тэгаў, адпаведна, тэгі таксама будуць апрацаваны аналізатарам. Т.к. мы шукаем пасты па дакладнай адпаведнасці тэгу, то неабходна адключыць аналіз для поля tags.

Створым індэкс blog2 з аналізатарам і мапінгам, у якім адключаны аналіз поля tags:

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"
        }
      }
    }
  }
}'

Дадамо тыя ж 3 пасады ў гэты індэкс (blog2). Я апушчу гэты працэс, т.я. ён аналагічны даданню дакументаў у індэкс blog.

Паўнатэкставы пошук з падтрымкай выразаў

Пазнаёмімся з яшчэ адным тыпам запытаў:

# найдем документы, в которых встречается слово 'истории'
# 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"
      ]
    }
  }
}'

Т.к. мы выкарыстоўваем аналізатар з рускім стэмінгам, то гэты запыт верне ўсе дакументы, хаця ў іх сустракаецца толькі слова 'гісторыя'.

Запыт можа змяшчаць спецыяльныя сімвалы, напрыклад:

""fried eggs" +(eggplant | potato) -frittata"

Сінтаксіс запыту:

+ signifies AND operation
| signifies OR operation
- negates a single token
" wraps a number of tokens to signify a phrase for searching
* at the end of a term signifies a prefix query
( and ) signify precedence
~N after a word signifies edit distance (fuzziness)
~N after a phrase signifies slop amount
# найдем документы без слова 'щенки'
curl -XPOST "$ES_URL/blog2/post/_search?pretty" -d'
{
  "query": {
    "simple_query_string": {
      "query": "-щенки",
      "fields": [
        "title^3",
        "tags^2",
        "content"
      ]
    }
  }
}'

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

Спасылкі

PS

Калі цікавыя падобныя артыкулы-ўрокі, ёсць ідэі новых артыкулаў ці ёсць прапановы аб супрацоўніцтве, то буду рады паведамленню ў твары ці на пошту. [электронная пошта абаронена].

Крыніца: habr.com