Основи 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 є кілька стандартних аналізаторів. Наприклад, аналізатор російський.

Скористаємося API і подивимося, як аналізатори 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