Организация поиска по сайту: основы
Feb 6, 2018 20:08 · 2866 words · 14 minute read
Отсутствие опыта работы с Elasticsearch всегда считал своим слабым местом. Эту NoSQL БД используют для хранения логов, анализа информации и, самое главное, поиска. Собственно, она и представляет собой поисковый движок на базе Apache Lucene по json документам. Ну, плюс, конечно же, шардирование и репликация из коробки, настройки которых я пока касаться не буду.
Видео доклада
На базе этой статьи был подготовлен доклад на Krasnodar Backend: Meetup #2. Возможно, кому-то такая подача материала ближе, так что добавлю видео и слайды.
Знакомство со стеком
Цель данной статьи - объяснить базовые вещи, с которыми придётся столкнутся при реализации поиска. Как не странно, информации по основам ES в интернете не так много (а на русском и того меньше), так что мне кажется, что этот текст будет полезен. Конечно же, есть официальная документация, но её чтение может затянуться надолго. Да и примеры там приводятся на каких-то абстрактных данных, которые непонятно откуда взялись. Обычно Elasticsearch используется в так называемом ELK стеке:
- Elasticsearch - собственно, сам сервис по работе с данными; из интерфейсов имеет только REST API;
- Logstash - сервис по модификации данных перед их попаданием в ES;
- Kibana - Web-UI для работы с ES, умеет выполнять запросы и строить графики.
Штаб-квартира Elasticsearch располагается в Амстердаме, так что некоторые вещи могут показаться весьма странными. Например, куда делись ветки 3.x и 4.x - не знают даже сами разработчики ¯\(ツ)/¯.
Установка
Итак, есть чистая ubuntu, на которой надо развернуть систему для индексации информации на сайте. Я решил поставить версию 5.6 как наиболее стабильную в плане фич и документации. Вообще, с версиями беда:
- 2.4 уж слишком древняя, последний релиз в июле 2017
- 5.0 - 5.5 аналогично 2.4
- 5.6 - по документации совместима с 2.x веткой, но развивается довольно активно
- 6.x - новое поколение, активно развивается, но сильно не совместимо с предыдущими версиями
Так что ставим версию 5.6 любым удобным способом. При запуске Elasticsearch по умолчанию отъедает 2Gb RAM, но можно этот объём уменьшить до минимальных 512 Mb в файле /etc/elasticsearch/jvm.options, указав параметры -Xms512m
и -Xmx512m
. Кому же необходимо запускать docker-контейнер с этой штукой, для ограничения потребляемой памяти JVM есть переменная окружения ES_JAVA_OPTS="-Xms512m -Xmx512m"
. Для проверки состояния отправляем GET-запрос на нужный :: GET http://localhost:9200/
. В ответ должен прилететь json со всякой служебной информацией: именем кластера и всяким версиями.
Работать с Elasticsearch напрямую по HTTP не совсем удобно, так что советую поставить kibana той же версии. Это инструмент больше для аналитики, построения графиков и диаграмм, но в нём есть удобная панель разработчика ‘Dev Tools’.
Синтаксис запросов
Как я уже говорил, ES имеет лишь своеобразный REST API для работы со всем: создание индекса или анализатора, поиска, аггрегации, получения справочной информации… В дальнейшем вместо подробного GET http://localhost:9200/_cluster/health
я буду использовать сокращённую версию GET /_cluster/health
, опуская имя сервера и порт. Также у запроса есть опциональные флаги, например, pretty
, который форматирует вывод в человекочитаемый вид, или explain
, необходимый для анализа плана выполнения. Каждый ответ сопровождается ещё некоторой служебной информацией: сколько времени заняло вполнение запроса, количество шардов, количество найденных объектов и т.п. вполть до с какого шарда документ был взят. Кстати, по умолчанию в ответе не более 10 записей, чтобы вывести больше надо либо явно в параметрах запроса указать значения size
и from
, либо открывать скроллируемый поиск - аналог серверного курсора для реляционных БД.
Индексы, маппинги
В качестве примера я буду работать с базой данных твитов. Реализуем там поиск по содержимому, тегам, автодополнение… Но обо всём по-порядку. Все документы хранятся в индексе, который создаётся командой: PUT /twitter/
. Теперь туда можно писать наши объекты, а ES сам постарается вывести схему данных, которая здесь называется маппингом. Конечно, этот способ его задания далёк от идеала, к тому же маппинг - нечто большее, чем набор полей с указанием их типов, но так будет проще разобраться в материале.
POST /twitter/tweet/1?pretty
{
"subject": "Первая запись в твиттер",
"geotag": "Краснодар",
"hashtags": ["твит", "новости", "Краснодар"],
"published": "2014-09-12T20:44:42+00:00"
}
Ответ
{
"_index": "twitter",
"_type": "tweet",
"_id": "1",
"_version": 1,
"result": "created",
"_shards": {
"total": 2,
"successful": 1,
"failed": 0
},
"created": true
}
_id
задан в адресной строке, но если его нет, он создастся автоматически. Кстати, т.к. Elasticsearch распределённая система, то необходимо как-то разрешать конфликты, здесь это делается с помощью поля _version
.
Создадим ещё несколько подобных записей
POST /twitter/tweet/2?pretty
{"subject": "Первая запись в Геленджике", "geotag": "Геленджик", "hashtags": ["твит", "море", "Геленджик"], "published": "2014-09-13T20:44:42+00:00"}
POST /twitter/tweet/3?pretty
{"subject": "Солнечный Краснодар", "geotag": "Краснодар", "hashtags": ["Краснодар"], "published": "2014-09-11T20:44:42+00:00"}
POST /twitter/tweet/4?pretty
{"subject": "Солнечный Геленджик", "geotag": "Геленджик", "hashtags": ["Геленджик"], "published": "2014-09-11T10:44:42+00:00"}
Посмотрим какой маппинг вывел Elasticsearch GET /twitter/_mapping/
:
{
"twitter": {
"mappings": {
"tweet": {
"properties": {
"geotag": {
"type": "text",
"fields": {
"keyword": {"type": "keyword", "ignore_above": 256}
}
},
"hashtags": {
"type": "text",
"fields": {
"keyword": {"type": "keyword", "ignore_above": 256}
}
},
"published": {
"type": "date"
},
"subject": {
"type": "text",
"fields": {
"keyword": {"type": "keyword", "ignore_above": 256}
}
}
}
}
}
}
}
Расшифровывается вывод так: для индекса “twitter” задан маппинг из документа “tweet”, в котором 3 текстовых поля и одно для даты-времени - что и ожидалось. ES не делает различие между одной строкой и массивом, так что теги всё равно обычные строки, а всё потому, что каждый элемент такого массива всё равно обрабатывается отдельно. Попробуем добавить ещё один тип документа в тот же самый индекс:
PUT /twitter/retweet/1?pretty
{"source": "https://twitter.com/3564123", "subject": "Солнечный Геленджик", "geotag": "Геленджик", "hashtags": ["Геленджик"], "published": "2014-09-11T10:44:42+00:00"}
Ответ
{
"_index": "twitter",
"_type": "retweet",
"_id": "1",
"_version": 1,
"result": "created",
"_shards": {
"total": 2,
"successful": 1,
"failed": 0
},
"created": true
}
Вернёмся к маппингам, а точне к части описания одного поля:
"subject": {
"type": "text",
"fields": {
"keyword": {"type": "keyword", "ignore_above": 256}
}
}
В данном случае поле subject
рассматривается как обычное текстовое с анализатором по умолчанию, так и как целое ключевое слово (тег). Анализаторы будут разобраны чуть ниже, а здесь хочу обратить внимание, что одно и то же поле может быть одновременно проанализировано по-разному: как строка целиком, как отдельные токены, как набор буквосочетаний для автодополнения… И в запросах вы можете их использовать одновременно. Маппинг для таких полей выглядит как-то так:
"subject": {
"type": "text",
"fields": {
"raw": {
"type": "keyword"
}
"autocomplete": {
"type": "text",
"analyzer": "autocomplete",
"search_analyzer": "standard"
},
},
"analyzer": "english"
}
subject
- обычный текст с английской морфологиейsubject.raw
- текст целиком без какого-либо анализаsubject.autocomplete
- текст поляsubject
, пропущенный, через анализаторautocomplete
Попробуем изменить анализатор у уже существующего поля:
PUT twitter/_mapping/tweet
{
"properties": {
"subject": {
"type": "text",
"analyzer": "russian",
"search_analyzer": "russian"
}
}
}
Ответ
{
"error": {
"root_cause": [
{
"type": "illegal_argument_exception",
"reason": "Mapper for [subject] conflicts with existing mapping in other types:\n[mapper [subject] has different [analyzer], mapper [subject] is used by multiple types. Set update_all_types to true to update [search_analyzer] across all types., mapper [subject] is used by multiple types. Set update_all_types to true to update [search_quote_analyzer] across all types.]"
}
],
"type": "illegal_argument_exception",
"reason": "Mapper for [subject] conflicts with existing mapping in other types:\n[mapper [subject] has different [analyzer], mapper [subject] is used by multiple types. Set update_all_types to true to update [search_analyzer] across all types., mapper [subject] is used by multiple types. Set update_all_types to true to update [search_quote_analyzer] across all types.]"
},
"status": 400
}
PUT twitter/_mapping/tweet
{
"properties": {
"subject": {
"type": "text",
"fields": {
"ru": {
"type": "text",
"analyzer": "russian",
"search_analyzer": "russian"
}
}
}
}
}
Ответ
{
"acknowledged": true
}
subject
выглядит как набор полей, по-разному проанализированных (а значит подходящих под разные запросы):
"subject": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
},
"ru": {
"type": "text",
"analyzer": "russian"
}
}
}
Однако, добавление таким образом поля не приведёт к его индексации - для этого необходимо пересохранить каждый документ либо вызвать POST twitter/_update_by_query
:
Ответ
{
"took": 69,
"timed_out": false,
"total": 6,
"updated": 6,
"deleted": 0,
"batches": 1,
"version_conflicts": 0,
"noops": 0,
"retries": {
"bulk": 0,
"search": 0
},
"throttled_millis": 0,
"requests_per_second": -1,
"throttled_until_millis": 0,
"failures": []
}
Запросы
Мы разобрались с тем, как Elasticsearch обрабатывает объекты, этого уже достаточно, чтобы попрактиковаться в написании запросов к нему. Для начала убедимся, что данные всё ещё есть в базе: GET /twitter/tweet/1/
. Должен вернуться документ, который мы записали на предыдущем шаге; так осуществляется доступ по id. А сейчас попробуем найти твиты, которые были сделаны после 1 сентября 2010:
GET /twitter/_search
{
"query": {
"range": {
"published": {
"gte": "2010-09-01"
}
}
}
}
Ответ
{
"took": 0,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 5,
"max_score": 1,
"hits": [
{
"_index": "twitter",
"_type": "tweet",
"_id": "2",
"_score": 1,
"_source": {
"subject": "Первая запись в Геленджике",
"geotag": "Геленджик",
"hashtags": [
"твит",
"море",
"Геленджик"
],
"published": "2014-09-13T20:44:42+00:00"
}
},
{
"_index": "twitter",
"_type": "tweet",
"_id": "4",
"_score": 1,
"_source": {
"subject": "Солнечный Геленджик",
"geotag": "Геленджик",
"hashtags": [
"Геленджик"
],
"published": "2014-09-11T10:44:42+00:00"
}
},
{
"_index": "twitter",
"_type": "tweet",
"_id": "1",
"_score": 1,
"_source": {
"subject": "Первая запись в твиттер",
"geotag": "Краснодар",
"hashtags": [
"твит",
"новости",
"Краснодар"
],
"published": "2014-09-12T20:44:42+00:00"
}
},
{
"_index": "twitter",
"_type": "retweet",
"_id": "1",
"_score": 1,
"_source": {
"source": "https://twitter.com/3564123",
"subject": "Солнечный Геленджик",
"geotag": "Геленджик",
"hashtags": [
"Геленджик"
],
"published": "2014-09-11T10:44:42+00:00"
}
},
{
"_index": "twitter",
"_type": "tweet",
"_id": "3",
"_score": 1,
"_source": {
"subject": "Солнечный Краснодар",
"geotag": "Краснодар",
"hashtags": [
"Краснодар"
],
"published": "2014-09-11T20:44:42+00:00"
}
}
]
}
}
GET /twitter/_search?pretty
{
"query": {"match": {
"subject": "Солнечный"
}}
}
Ответ
{
"took": 6,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 3,
"max_score": 0.7373906,
"hits": [
{
"_index": "twitter",
"_type": "tweet",
"_id": "4",
"_score": 0.7373906,
"_source": {
"subject": "Солнечный Геленджик",
"geotag": "Геленджик",
"hashtags": [
"Геленджик"
],
"published": "2014-09-11T10:44:42+00:00"
}
},
{
"_index": "twitter",
"_type": "retweet",
"_id": "1",
"_score": 0.7373906,
"_source": {
"source": "https://twitter.com/3564123",
"subject": "Солнечный Геленджик",
"geotag": "Геленджик",
"hashtags": [
"Геленджик"
],
"published": "2014-09-11T10:44:42+00:00"
}
},
{
"_index": "twitter",
"_type": "tweet",
"_id": "3",
"_score": 0.25811607,
"_source": {
"subject": "Солнечный Краснодар",
"geotag": "Краснодар",
"hashtags": [
"Краснодар"
],
"published": "2014-09-11T20:44:42+00:00"
}
}
]
}
}
GET /twitter/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"subject": "Солнечный"
}
}
],
"filter": [
{
"match": {
"hashtags": "Геленджик"
}
}
]
}
}
}
Ответ
{
"took": 0,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 2,
"max_score": 0.7373906,
"hits": [
{
"_index": "twitter",
"_type": "tweet",
"_id": "4",
"_score": 0.7373906,
"_source": {
"subject": "Солнечный Геленджик",
"geotag": "Геленджик",
"hashtags": [
"Геленджик"
],
"published": "2014-09-11T10:44:42+00:00"
}
},
{
"_index": "twitter",
"_type": "retweet",
"_id": "1",
"_score": 0.7373906,
"_source": {
"source": "https://twitter.com/3564123",
"subject": "Солнечный Геленджик",
"geotag": "Геленджик",
"hashtags": [
"Геленджик"
],
"published": "2014-09-11T10:44:42+00:00"
}
}
]
}
}
must
, строить отрицание must_not
или фильтрацию filter
(про тип should
лучше обратиться к документации). must
отличается от filter
тем, что запросы в нём участвуют в вычислении релевантности. Elasticsearch славится своим нечётким поиском, попробуем поискать какой-нибудь похожий текст:
GET /twitter/_search?pretty
{
"query": {"match": {
"subject": "Солнечная"
}}
}
Ответ
{
"took": 0,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 0,
"max_score": null,
"hits": []
}
}
GET /twitter/_search?pretty
{
"query": {"match": {
"subject.ru": "Солнечная"
}}
}
Ответ
{
"took": 0,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 2,
"max_score": 0.68640786,
"hits": [
{
"_index": "twitter",
"_type": "tweet",
"_id": "4",
"_score": 0.68640786,
"_source": {
"subject": "Солнечный Геленджик",
"geotag": "Геленджик",
"hashtags": [
"Геленджик"
],
"published": "2014-09-11T10:44:42+00:00"
}
},
{
"_index": "twitter",
"_type": "tweet",
"_id": "3",
"_score": 0.25811607,
"_source": {
"subject": "Солнечный Краснодар",
"geotag": "Краснодар",
"hashtags": [
"Краснодар"
],
"published": "2014-09-11T20:44:42+00:00"
}
}
]
}
}
GET /twitter/_search
{
"query": {"match": {
"subject.ru": "солнце"
}}
}
Овтет
Анализаторы, фильтры и токенайзеры
Для отладки анализатора (а вы можете добавлять и собственные) ES предоставляет ендпоинт _analyze
, с помощью которого можно оценить какая информация попадёт в итоге в индекс:
GET /twitter/_analyze
{
"analyzer": "russian",
"text": "Солнечный Геленджик"
}
Ответ
{
"tokens": [
{
"token": "солнечн",
"start_offset": 0,
"end_offset": 9,
"type": "<ALPHANUM>",
"position": 0
},
{
"token": "геленджик",
"start_offset": 10,
"end_offset": 19,
"type": "<ALPHANUM>",
"position": 1
}
]
}
standard
- просто разбивает текст на слова и приводит к нижнему региструsimple
- разбивает на слова, приводит к нижнему регистру и выбрасывает все неалфавитные вхождения (цифры)keyword
- ничего не делает с текстом, а считает это одним словом (подходит для тегов, чтобы не искать внутри них)stop
- убирает токены, которые совпадают со стоп-словамиenglish
,russian
,italian
- множество лексических анализаторов
Анализатор - собирательное понятие для преобразования строки в набор токенов, в него входит:
char_filter
- обрабатывает анализируемую строку целиком. Например, в стандартной поставке есть скриптhtml_strip
, который удаляет HTML теги, однако вы можете написать и свойtokenizer
- разбивает строку на отдельные токены (может быть только один)filter
- обрабатывает каждый токен в отдельности (приводит к нижнему регистру, удаляет стоп-слова и пр.), в том числе может и добавлять синонимы
Для примера напишем анализатор для автокомплита. Основная идея заключается в том, чтобы разбить все слова на N-граммы и искать вхождение текста из поля ввода в этот массив. Но для начала определимся с токенайзером - как мы будем выделять токены из текста. Думаю, подойдёт standard
, который оставит только слова (не путать с анализатором standard
, который включает в себя также и приведение к нижнему регистру!), которые уже мы и будем резать на N-граммы (перед этим надо закрыть индекс POST /twitter/_close
, а потом открыть POST /twitter/_open
):
PUT twitter/_settings
{
"analysis": {
"analyzer": {
"autocomplete": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "autocomplete_filter"]
}
},
"filter": {
"autocomplete_filter": {
"type": "edge_ngram",
"min_gram": 2,
"max_gram": 12
}
}
}
}
Хм, Elasticsearch даже сказал {"acknowledged": true}
- это 200 OK
на его диалекте. Проверим работу анализатора на каком-нибудь примере:
POST /twitter/_analyze
{
"analyzer": "autocomplete",
"text": "В Краснодаре солнечно"
}
Ответ
{
"tokens": [
{
"token": "кр",
"start_offset": 2,
"end_offset": 12,
"type": "<ALPHANUM>",
"position": 1
},
{
"token": "кра",
"start_offset": 2,
"end_offset": 12,
"type": "<ALPHANUM>",
"position": 1
},
...
{
"token": "солнечно",
"start_offset": 13,
"end_offset": 21,
"type": "<ALPHANUM>",
"position": 2
}
PUT twitter/_mapping/tweet
{
"properties": {
"subject": {
"type": "text",
"fields": {
"autocomplete": {
"type": "text",
"analyzer": "autocomplete",
"search_analyzer": "standard"
}
}
}
}}
POST twitter/_update_by_query
GET /twitter/_search
{
"query": {"match": {
"subject.autocomplete": "Солн"
}}
}
Ответ
{
"took": 0,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 2,
"max_score": 1.065146,
"hits": [
{
"_index": "twitter",
"_type": "tweet",
"_id": "4",
"_score": 1.065146,
"_source": {
"subject": "Солнечный Геленджик",
"geotag": "Геленджик",
"hashtags": [
"Геленджик"
],
"published": "2014-09-11T10:44:42+00:00"
}
},
{
"_index": "twitter",
"_type": "tweet",
"_id": "3",
"_score": 0.4382968,
"_source": {
"subject": "Солнечный Краснодар",
"geotag": "Краснодар",
"hashtags": [
"Краснодар"
],
"published": "2014-09-11T20:44:42+00:00"
}
}
]
}
}
Солнdfs
разбивается на N-граммы [‘со’, ‘сол’, ‘солн’, ‘солнd’, ‘солнdf’, ‘солнdfs’], которые пересекаются с теми, что хранятся в индексе. Выход - рассматривать объект поиска как одно слово. Это регулируется настройкой search_analyzer
у поля:
"autocomplete": {
"type": "text",
"analyzer": "autocomplete",
"search_analyzer": "standard"
}
После обновления маппинга поля автокомплит ведёт себя как полагается!
Заключение
Надеюсь, что теперь у вас появилось понимание как работать с Elasticsearch, что такое мапперы и анализаторы, а также как строить простейшие запросы. Я постарался описать минимально необходимый набор знаний, чтобы вы смогли самостоятельно организовать поиск по своему контенту. На этом пока всё, однако, в Elasticsearch ещё очень много других интересных вещей, которые выходят за рамки данной статьи. Вот лишь небольшой список тем:
- подсветка найденных выражений
- спец-символы для уточнения запроса (”” для точного поиска, - для исключения слова и т.п.)
- работа с геообъектами - поиск в окрестностях точки
- сама настройка нод и шардирование индекса по ним
- написание собственных скриптов на языке Painless
- замечательный эндпоинт для мониторинга
GET /_cat
- интеграция с logstash для обработки логов
- аггрегация
- флаги запросов
_source
,size
,sort
и т.п. - работа с алиасами, или переиндексация без даунтайма (
_reindex
,_aliases
) - просмотр плана выполнения запроса
_explain
- вычисление релевантности