Основи Elasticsearch

Elasticsearch — пошуковий движок з json rest api, що використовує Lucene і написаний на Java. Опис всіх переваг цього движка доступно на офіційному сайті. Далі по тексту будемо називати Elasticsearch як ES.
Подібні механізми використовуються при складному пошуку по базі документів. Наприклад, пошук з урахуванням морфології мови або пошук по geo координатах.
У цій статті я розповім про основи ES на прикладі індексації постів блогу. Покажу як фільтрувати, сортувати та шукати документи.
Щоб не залежати від операційної системи, всі запити до ES я буду робити з допомогою CURL. Також є плагін для google chrome під назвою sense.
За текстом розставлені посилання на документи та інші джерела. Наприкінці розміщені посилання для швидкого доступу до документації. Визначення незнайомих термінів можна прочитати в глоссарии.

Установка 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 генерується автоматично при індексації документа:
# Отримаємо 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 я покажу пізніше.

Фільтрація по даті
Використовуємо запит range в контексті 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, що означає повну відповідність фільтру.

Аналізатори
Аналізатори потрібні, щоб перетворити вихідний текст в набір токенів.
Аналізатори складаються з одного Tokenizer і кількох необов'язкових TokenFilters. Tokenizer може передувати кільком CharFilters. Tokenizer розбивають вихідну рядок на токени, наприклад, по прогалин і символів пунктуації. TokenFilter може змінювати токени, видаляти або додавати нові, наприклад, залишати тільки основу слова, прибирати прийменники, додавати синоніми. CharFilter — змінює вихідну рядок, наприклад, вирізає html теги.
В ES є кілька стандартних аналізаторів. Наприклад, аналізатор russian.
Скористаємося 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 використовує аналізатор russian:
{
"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, отримані токени перейдуть в нижній регістр, віддаляться незначущі слова і від решти токенів залишиться основа слова.

Створення індексу
Вище ми описали dafault аналізатор. Він буде застосовуватися до всіх строкових полів. Наш пост містить масив тегів, відповідно, теги теж будуть оброблені аналізатором. Оскільки ми шукаємо пости по точному відповідності тегу, то необхідно відключити аналіз поля 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
Якщо цікаві подібні статті-уроки, є ідеї нових статей або є пропозиції про співпрацю, то буду радий повідомлення в лічку або на пошту m.kuzmin+habr@darkleaf.ru.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.