Организация поиска по сайту

Feb 6, 2018 20:08 · 2081 words · 10 minute read tutorial elasticsearch

Отсутствие опыта работы с Elasticsearch всегда считал своим слабым местом. Эту NoSQl БД используют для хранения логов, анализа информации и, самое главное, поиска. Собственно, она и представляет собой поисковый движок на базе Apache Lucene по json документам. Ну, плюс, конечно же, шардирование и репликация из коробки, настройки которых я пока касаться не буду.

Знакомство со стеком

Цель данной статьи - объяснить базовые вещи, с которыми придётся столкнутся при реализации поиска. Как не странно, информации по основам 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
}

Главное, что 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"}

Объект успешно добавлен и проиндексирован, то есть в индекс можно добавлять различные доументы, которые мы хотим найти с помощью одного запроса в ES.

Вернёмся к маппингам, а точне к части описания одного поля:

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

И получаем ошибку 400: illegal_argument_exception. Это из-за того, что изменить параметры уже существующего поля невозможно, зато можно создать новое:

PUT twitter/_mapping/tweet
{
  "properties": {
    "subject": {
      "type": "text",
      "fields": {
        "ru": {
          "type": "text",
          "analyzer": "russian",
          "search_analyzer": "russian"
        }
      }
    }
  }
}

Теперь в маппинг subject выглядит как набор полей, по-разному проанализированных (а значит подходящих под разные запросы):

"subject": {
  "type": "text",
  "fields": {
    "keyword": {
      "type": "keyword",
      "ignore_above": 256
    },
    "ru": {
      "type": "text",
      "analyzer": "russian"
    }
  }
}

Однако, добавление таким образом поля не приведёт к его индексации - для этого необходимо пересохранить каждый документ либо вызвать POST twitter/_update_by_query.

Запросы

Мы разобрались с тем, как Elasticsearch обрабатывает объекты, этого уже достаточно, чтобы попрактиковаться в написании запросов к нему. Для начала убедимся, что данные всё ещё есть в базе: GET /twitter/tweet/1/. Должен вернуться документ, который мы записали на предыдущем шаге; так осуществляется доступ по id. А сейчас попробуем найти твиты, которые были сделаны после 1 сентября 2010:

GET /twitter/_search
{
  "query": {
    "range": {
      "published": {
        "gte": "2010-09-01"
      }
    }
  }
}

Попробуем поискать объекты, в которых есть слово “Солнечный”:

GET /twitter/_search?pretty
{
  "query": {"match": {
    "subject": "Солнечный"
  }}
}

Т.к. мы искали в индексе, то тут как объекты типа “tweet”, так и “retweet”. Всего ж нашлось 3 объекта, один из которых относится к Краснодару - попробуем исключить его:

GET /twitter/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "subject": "Солнечный"
          }
        }
      ],
      "filter": [
        {
          "match": {
            "hashtags": "Геленджик"
          }
        }
      ]
    }
  }
}

Здесь добавлен узел с типом bool, который может объединять несколько запросов must, строить отрицание must_not или фильтрацию filter (про тип should лучше обратиться к документации). must отличается от filter тем, что запросы в нём участвуют в вычислении релевантности. Elasticsearch славится своим нечётким поиском, попробуем поискать какой-нибудь похожий текст:

GET /twitter/_search?pretty
{
  "query": {"match": {
    "subject": "Солнечная"
  }}
}

Не нашлось ни одного документа?! Всё дело в том по какому представлению поля выполняется поиск. Если вспомнить маппинг для поля subject, то у него есть ещё дополнительные поля: “raw” и “ru”.

GET /twitter/_search?pretty
{
  "query": {"match": {
    "subject.ru": "Солнечная"
  }}
}

Готово - нашлась даже запись про Краснодар! Отлично, попробуем поискать просто “солнце”:

GET /twitter/_search
{
  "query": {"match": {
    "subject.ru": "солнце"
  }}
}

На этот раз результат пустой, то есть анализатор “russian” настолько сильное различие в морфологии уже не понимает. Поиск по словам “Краснодару” или “Первый” также будет успешным, но как понять почему не нашлось наше “солнце”? Обратимся к анализаторам!

Анализаторы, фильтры и токенайзеры

Для отладки анализатора (а вы можете добавлять и собственные) 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":     "В Краснодаре солнечно"
}

В ответ прилетит список токенов: [‘кр’, ‘кра’, ‘крас’, ‘красн’, ‘красно’…]. Работает ли это? Подключим ещё одно под-поле в subject и проверим:

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": "Солн"
  }}
}

И видим свои записи про солнечные города! Однако, по слову ‘Солнdfs’ они также будут находиться - это уже непорядок. Всё из-за того, что при поиске используется тот же анализатор: Солн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
  • вычисление релевантности