Начало работы с Elasticsearch (с примерами на Elastica, PHP)

Статья ориентирована на PHP программистов, которые только начинают разбираться с Elasticsearch. Не смотря на то что документация к Elasticsearch достаточно подробная, ее как и любую документацию, сложно читать неподготовленному пользователю — не совсем понятно с чего начинать, и на что обратить внимание в первую очередь. Чтобы побыстрее разобраться в основных понятиях и увидеть общую картину я попытался систематизировать базовую информацию и сложить из нее такое «вводное» руководство. В первую очередь для себя, чтобы иметь возможность периодически возвращаться и освежать в памяти некоторые моменты, но будет здорово если мой труд окажется полезным кому-то еще. Дополнения и исправления приветствуются — пишите мне в LinkedIn. Для работы с Elasticsearch мы будем использовать библиотеку Elastica.

Elasticsearch заполучил широкую популярность благодаря удачному сочетанию ряда возможностей:

  • Поиск с оценкой релевантности
  • Полнотекстовый поиск
  • Подсчет статистики (агрегационные функции)
  • Нет ограничений на структуру сохраняемых документов (NoSQL), документо-ориентированный
  • Богатый выбор типов данных
  • Горизонтальная расширяемость
  • Отказоустойчивость

Контекст запроса и контекст фильтра

Историческая справка

Пользователи Elasticsearch, которым довелось поработать с 1-й версией, знакомы с концепцией фильтров и запросов. Тело запроса могло включать в себя как фильтры (Filters) так и запросы (Queries). Различие между ними заключалось в том, что фильтры в целом быстрее, так как они проверяют, соответствует ли рассматриваемый документ поставленным условиям полностью или нет. Фильтры не проверяют «качество соответствия». Другими словами, фильтры дают логический ответ (да/нет), тогда как запросы возвращают подсчитанный рейтинг (количество очков), показывающий насколько хорошо документ соответствует поставленным условиям. Благодаря тому что природа фильтров более проста, производительность их выше. Если проводить аналогию с SQL, то фильтры работают как WHERE, возвращая строки, строго соответствующие условию. SQL запрос никогда не вернет неоднозначный результат.

Самый главный вопрос, который поможет определиться между Filters и Queries это:
Действительно ли нам нужно ранжировать документы в выдаче по качеству соответствия? Если сортировка по релевантности и полнотекстовый поиск не нужен, всегда используйте фильтры. Фильтры «дешевле».

Начиная с версии 2, фильтры и запросы обьединены и любой запрос может работать либо как фильтр, либо как запрос (в зависимости от контекста). Так же как в первой версии, результат работы фильтров кешируется и их рекомендуется использовать если «качество соответствия» не имеет значения (а интересует строгое соответствие на да/нет).

Оценка релевантности

По умолчанию Elasticsearch сортирует результаты поиска по релевантности, оценивая _насколько хорошо_ каждый найденный документ соответствует запросу. Оценка релевантности это положительное float-число, которое возвращается в поле _score в ответе поискового API. Чем выше это число, тем более релевантен документ. Подсчет оценки может варьироваться в зависимости от типа запроса, а также от того в каком контексте выполняется запрос.

Контекст запроса (Query context)

В контексте запроса поисковый запрос отвечает на вопрос «Насколько хорошо данный документ соответствует условиям выборки?» То есть помимо определения того соответствует документ условиям поиска или нет, также будет вычислена и помещена в поле _score оценка релевантности.
Когда оператор(ы) запроса передае(ю)тся параметру query поискового API, поиск будет выполняться в контексте запроса.

Контекст фильтра (Filter context)

В контексте фильтра поисковый запрос отвечает на вопрос «Соответствует ли данный документ условиям выборки?» Ответ должен быть прост — либо Да, либо Нет. Подсчет оценки релевантности не производится. Контекст фильтра в основном используется для отбора структурированных данных (как WHERE в SQL), например:

  • Попадает ли данная отметка времени timestamp в диапазон от 2015 до 2016?
  • Установлено ли поле status в значение «published»?

Часто используемые фильтры будут автоматически закешированы в Elasticsearch для улучшения производительности.

Когда оператор(ы) запроса передае(ю)тся параметру filter или must_not в логическом запросе (Bool Query), параметру filter в запросе с фиксированной оценкой или фильтрующей агрегации, поиск будет выполняться в контексте фильтра.

Пример

Ниже представлен пример запроса, который выполняется в поисковом API частично в конекте фильтра и частично в контексте запроса. Представленный запрос вернет документы, для которых все перечисленные условия выполняются:

  • Поле title содержит слово search.
  • Поле content содержит слово elasticsearch.
  • Поле status равно в точности одному слову — published.
  • Поле publish_date — это дата, от 1 Jan 2015 и выше.
GET /_search
{
  "query": { 
    "bool": { 
      "must": [
        { "match": { "title":   "Search"        }},
        { "match": { "content": "Elasticsearch" }}
      ],
      "filter": [ 
        { "term":  { "status": "published" }},
        { "range": { "publish_date": { "gte": "2015-01-01" }}}
      ]
    }
  }
}

Параметр «query» указывает на контекст запроса. Оператор bool и два оператора match используются в контексте запроса, что означает, что они будут использоваться для подсчета оценки релевантности каждого подходящего документа.

Параметр «filter» указывает на контекст фильтра. Операторы term и range используются в контексте фильтра. Они отфильтруют документы, которые не соответствуют условиям, но никак не повлияют на оценку релевантности подходящих документов.

Основное правило, когда уместны фильтры (Filters):

  • Поиск на да/нет
  • Поиск точных значений (численных, по ключевому слову, или по диапазону (напр. дата))

Основное правило для использования запросов (Queries):

  • Совпадение не обязательно должно быть строгим (выдача будет содержать документы, которые в какой-то мере удовлетворяют условиям поиска, но одни подходят лучше, а другие хуже)
  • Полнотекстовый поиск.

Теперь, когда с разницей между фильтрами и запросами разобрались, добавим немного энтропии, и упомянем, что запросы можно конвертировать в фильры 🙂 См. ниже «Запросы с фиксированной оценкой (Constant score query)».

Запросы для поиска по ключевым словам (Term-level queries) и полнотекстовые запросы (Full text queries)

Запросы полнотекстового поиска позволяют производить поиск в проанализированных текстовых полях, таких как тело письма. Строка для поиска обрабатывается с помощью того же анализатора, который применялся к полю при индексировании. Список запросов для полнотекстового поиска. Здесь мы рассмотрим самый популярный полнотекстовый запрос — Match.

Match — это запрос на нечеткое соответствие. Переданная в запрос для поиска строка анализируется (нормализируется и токенизируется), перед тем как будет выполнен поиск в инвертированном индексе. После сверки с инвертированным индексом возвращаются документы с просчитанным рейтингом релевантности, который тем выше, чем больше совпадений между проанализированной входной строкой и инвертированным индексом.

Например, для Match запроса:

{
  "query": {
    "match": {
      "content": "Киев, Украина"
    }
  }
}

Мы можем получить в ответ что-то вроде:

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 2,
    "max_score" : 0.99999994,
    "hits" : [{
      "_index" : "index",
      "_type" : "doc",
      "_id" : "4",
      "_score" : 0.99999994,
      "_source" : {
        "content" : "Киев - один из крупнейших городов Восточной Европы. Его население составляет около 2.8 миллиона человек. Рекомендуем посетить Киев.",
        "title" : "Про Киев",
        "tags" : ["туризм", "город"]
            }
      }, {
      "_index" : "index",
      "_type" : "doc",
      "_id" : "2",
      "_score" : 0.8838835,
      "_source" : {
        "content" : "Украина – это большая страна в Восточной Европе, известная православными церквями, черноморскими курортами и лесистыми горами.",
        "title" : "Страна Украина",
        "tags" : ["страна", "туризм"]
      }
    }]
  }
}

Как видим, в документе не обязательно должны присутствовать оба ключевых слова из поискового запроса. Но документ про Киев в выдаче выше, потому что слово «Киев» встречается в нем дважды.

Если описать Match запрос в синтаксисе Elastica будет что-то типа:

$elasticaQuery = new Elastica\Query();
$boolQuery = new BoolQuery();
$boolQuery->addMust(new Elastica\Query\Match('content', 'Киев, Украина'));
$elasticaQuery->setQuery($boolQuery);

Обратите внимание, так как нам нужен полнотекстовый поиск (Match), мы выполняем его в контексте запроса (оператор addMust). Можно, конечно, выполнить в контексте фильтра (добавив запрос через addFilter), но так как в этом случае не будет вычисляться оценка релевантности, то сверху в выдаче будут находиться первые попавшиеся документы, а не наиболее полно удовлетворяющие условию поиска. Об этом нужно помнить — полнотекстовый поиск всегда выполняем в контексте запроса.

Запросы для поиска по ключевым словам (или запросы на точное соответствие) позволяют выбирать документы исключительно при точном совпадении в структурированных данных. Примерами структурированных данных могут быть диапазон дат, IP адресов, цен, или ID продуктов (и многие другие).

В отличие от полнотекстовых запросов, запросы на точное соответствие не анализируют входную строку (впрочем, нормализация, читай lowercase, все равно происходит). Вместо этого запросы на точное соответствие отбирают документы в которых искомая строка в точности соответствует значению указанного поля. То есть искомая фраза целиком сразу же ищется в инвертированном индексе.

Например, для Term запроса:

{
  "query":{
    "term":{
      "title": "London is the capital of GB"
    }
  }
}

Результат поиска будет типа такого:

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "failed": 0
  },
  "hits": {
    "total": 1,
    "max_score": 0.92055845,
    "hits": [{
      "_index": "index",
      "_type": "doc",
      "_id": "3",
      "_score": 0.92055845,
      "_source": {
        "content": "London is the capital and largest city of England and the United Kingdom.",
        "title": "London is the capital of GB",
        "tags": ["london, capital"]
      }
    }]
  }
}

То есть в результат поиска попадут только те документы, в которых значение поля title в точности равно «London is the capital of GB». Обратите внимание, что так как поиск выполняется в контексте запроса, оценка релевантности высчитывается, хотя не имеет особого смысла, так как поиск на точное соответствие.

Рассмотрим как составить Term и Terms запросы при использовании Elastica (PHP). С запросом Term мы уже познакомились выше, это по сути сопоставление значения указанного поля с искомым ключевым словом (фразой). Запрос Terms работает еще более гибко. Он сопоставляет значение указанного поля с набором (массивом) ключевых слов. Документ будет добавлен в выдачу если есть хотя бы одно совпадение. Запрос Terms по сути аналогичен запросу SELECT * FROM TABLE_NAME WHERE COLUMN_NAME IS IN …
Представим ситуацию, когда мы хотим запросить документы у которых в поле country значение «Украина», а поле city может принимать одно из значений [Киев, Харьков, Львов]. Тогда наш запрос в синтаксисе Elastica будет иметь вид:

$elasticaQuery = new Elastica\Query();
$boolQuery = new BoolQuery();
$boolQuery->addFilter(new Elastica\Query\Term(['country' => 'Украина']));
$boolQuery->addFilter(new Elastica\Query\Terms('city', ['Киев', 'Харьков', 'Львов']));
$elasticaQuery->setQuery($boolQuery);

В новых версиях ES строковые поля по умолчанию сконфигурированы и как full text field и как keyword field.

Составные запросы (Compound Queries)

Составные запросы могут включать в себя простые запросы и другие составные запросы, позволяя обьединить их результаты и оценки, изменить поведение или переключиться с контекста запроса на контекст фильтра.

Итак, составные запросы позволяют:

  • Обьединить оценки
  • Изменить поведение включаемых в себя запросов
  • Переключить контекст запроса на контекст фильра
  • Любое сочетание из перечисленного выше

К составным запросам относятся:

  • Логические запросы (Boolean query);
  • Повышающие (форсирующие, продвигающие?) запросы (Boosting query);
  • Запросы с фиксированной оценкой (Constant score query);
  • Запрос с ранжированием по дизъюнкции (Disjunction max query)
  • Запросы с функционально-вычисляемой оценкой (Function score query).

Логические запросы (Boolean Query)

С логическими запросами все понятно — это запросы, включающие в себя условия И-ИЛИ-НЕ в разных комбинациях и с неограниченной степенью вложенности. Это наиболее важный и, очевидно, частоиспользуемый подтип составных запросов. Логический запрос имеет 4 вхождения, которые можно комбинировать:

  • Must (документ должен обязательно удовлетворять поставленному условию)
  • Should (если условие удовлетворяется, дополнительные очки будут добавлены к оценке релевантности)
  • Filter (документ должен обязательно удовлетворять поставленному условию, но оценка релевантности не высчитывается)
  • Must Not (обратное к Must, не влияет на оценку релевантности)

Операторы Must и Should выполняются в контексте зарпоса, а Filter и Must Not в контексте фильтра.

Для тех кто знаком с SQL, будет полезна аналогия — Must это как AND, а Should это как OR.

Подробнее о логических запросах в официальной документации.

Повышающие запросы (Boosting Query)

Повышающие запросы возвращают документы, соответствующие «положительному» запросу, снижая при этом оценку релевантности документов, удовлетворяющих «отрицательному» запросу. Повышающий запрос можно использовать для понижения рейтинга определенных документов, не исключая их полностью из результатов поиска. Подробнее о повышающих запросах в официальной документации.

Запросы с фиксированной оценкой (Constant score query)

Запросы с фиксированной оценкой преобразуют любой запрос в контекст фильтра с оценкой релевантности равной параметру boost (по умолчанию 1). Запрос с фиксированной оценкой присваивает одинаковые оценки всем подходящим документам игнорируя оценочные показатели, такие как TF и IDF. Его можно использовать тогда, когда вам не нужно знать насколько хорошо документ соответствует поисковому запросу, а достаточно знать просто подходит документ или нет, и есть необходимость присвоить такому документу положительный рейтинг (если выполнять запрос в контексте фильтра, то рейтинг будет = 0). Запрос с фиксированной оценкой присваивает параметр boost в качестве оценки для каждого возвращенного документа.

В самом простом случае запрос с фиксированной оценкой по ключевому слову выглядит так:

{
  "query" : {
    "constant_score" : {
      "filter" : {
        "term" : {
          "country" : "Ukraine"
        }
      }
    }
  }
}

Этот же запрос в синтаксисе Elastica:

$elasticaQuery = new Elastica\Query\ConstantScore();
$elasticaQuery->setFilter(new Elastica\Query\Term(['country' => 'Ukraine']));

Если же необходимо сделать фильтрацию сразу по нескольким полям (например, выбрать документы у которых country = «Ukraine», и city IN [«Kiev», «Dnepr», «Lvov»]), придется все фильтры вначале завернуть в BoolQuery, а затем получившийся BoolQuery через setFilter добавить к нашему ConstantScore Query:

$elasticaQuery = new Elastica\Query\ConstantScore();

$boolQuery = new BoolQuery();
$boolQuery->addFilter(new Elastica\Query\Term(['country' => 'Ukraine']));
$boolQuery->addFilter(new Elastica\Query\Terms('city', ['Kiev', 'Dnepr', 'Lvov']));

$elasticaQuery->setFilter($boolQuery);

Если у вас нет реальной необходимости присваивать положительный рейтинг возвращаемым документам, просто используйте контекст фильтра.

Подробнее о запросах с фиксированной оценкой в официальной документации.

Агрегация в Elasticsearch

Агрегаций в Elastic очень много, и вряд-ли вы будете пользоваться всеми существующими. По принципу работы агрегации можно поделить на два типа: обобщающие (Buckets) и метрические (Metrics).
Примеры обобщающих агрегаций: Filter, Range, Missing, Terms, Date Range, Global Aggregation, Histogram, Date Histogram, IPv4 range.
Примеры метрических агрегаций: AVG, Cardinality, Stats, Extended Stats, Percentiles, Percentile Ranks.

Обобщающие работают аналогично GROUP BY, позволяя обьединить документы по какому-то признаку. Хороший и частоиспользуемый пример — агрегация по ключевому слову (Terms Aggregation). В качестве примера рассмотрим группировку документов по городу (поле ‘City’).

// создаем запрос
$elasticaQuery = new Elastica\Query();

// добавляем логику запроса, например укажем период дат для выборки

// создаем обобщающую агрегацию, например по полю 'City'
$termsAgg = new Elastica\Aggregation\Terms('AggregationByCity');
$termsAgg->setField('City');

// добавляем агрегацию к запросу
$elasticaQuery->addAggregation($termsAgg);

// выполняем поиск

// запрашиваем результат агрегации
$result->getAggregation('AggregationByCity');

Метрические агрегации высчитывают интересующее численное значение на основе набора документов. К метрическим агрегациям относятся Sum, Avg, Min, Max, и также Stat, которая включает в себя все 4 указанных агрегации. Так что если вам нужно посчитать стандартный набор статистических данных (минимальное, максимальное, среднее, сумму) — создавайте Stat агрегацию.

Для примера Stat агрегации, и чтобы как-то связать с предыдущей агрегацией, выдумаем довольно искусственый пример. Представим, что в каждом городе в конце дня в базу пишется документ, в котором фиксируется кол-во произведенных за этот день бутылок лимонада. Пусть поле, в котором прописывается кол-во произведенных бутылок называется «BottlesProducedPerDay». Теперь к уже существующей агрегации (по факту группировке) по городам, добавим Stat агрегацию:

// создаем запрос
$elasticaQuery = new Elastica\Query();

// добавляем логику запроса, например укажем период дат для выборки

// создаем обобщающую агрегацию, например по полю 'City'
$termsAgg = new Elastica\Aggregation\Terms('AggregationByCity');
$termsAgg->setField('City');

// создаем метрическую агрегацию по полю 'BottlesProducedPerDay'
$statAgg = new Elastica\Aggregation\Stats('AggregationByBottlesProducedPerDay');
$statAgg->setField('BottlesProducedPerDay');

// вкладываем метрическую агрегацию в обобщающую
$termsAgg->addAggregation($statAgg);

// тут желательно проставить кол-во элементов в выдаче = кол-ву городов,
// иначе по умолчанию будет 10. См. также Cardinality Aggregation.
$termsAgg->setSize(100);

// добавляем агрегацию к запросу
$elasticaQuery->addAggregation($termsAgg);

// выполняем поиск

// запрашиваем результат агрегации
$result->getAggregation('AggregationByCity');

В результате выполнения запроса у нас ответ будет содержать массив из городов, внутри каждого из которых будет статистика по количеству произведенных бутылок лимонада (мин, макс, среднее, общее). Вложенная агрегация это очень удобный инструмент.

Агрегации могут быть вложенными, ограничений на уровень вложенности нет, благодаря чему мы можем получать статистические данные в любом «разрезе», что, безусловно, очень удобно.

Еще одним полезным примером метрической агрегации является агрегирование кардинальности (Cardinality Aggregation). Она применяется тогда когда нужно найти примерное количество уникальных значений. Что-то типа COUNT DISTINCT в MySQL. Только подсчитанное значение не будет гарантировано точным. Подробнее можно почитать в официальной документации.

Дабы далеко не ходить за примером, покажем, как применить агрегацию кардинальности для подсчета городов записи по которым есть в нашей воображаемой БД.

// создаем запрос
$elasticaQuery = new Elastica\Query();

// добавляем логику запроса, например укажем период дат для выборки

// создаем агрегацию кардинальности, например по полю 'City'
$cardinalityAgg = new Elastica\Aggregation\Cardinality('CitiesCount');
$cardinalityAgg->setField('City');

// добавляем агрегацию к запросу
$elasticaQuery->addAggregation($cardinalityAgg);

// выполняем поиск

// запрашиваем результат агрегации
$citiesCount = $result->getAggregation('VisitsCount')['value']

Дополнительное чтение

Информации по Elasticsearch много не бывает 🙂 Надеюсь, данная статья оказалось вам полезной, и вы узнали что-то новое. Тема очень обширная, и если захотите копнуть глубже, вот еще несколько ссылок:

Оставьте первый комментарий

Оставить комментарий