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

Feb 23, 2018 12:08 · 1736 words · 9 minute read tutorial elasticsearch

В предыдущей статье мы разобрали основы Elasticsearch: индексы, маппинги и анализаторы. В этой же я хочу углубиться в тему построения запросов. У Elasticsearch свой собственный DSL, который может показаться непривычным, но по своей гибкости не уступает всем знакомому SQL. Набор данных, над которым я буду приводить примеры, можно взять здесь:

Набор данных

POST /twitter/tweet/_bulk
  {"index": {"_id": 1}}
  {"subject": "Первая запись в твиттер", "geotag": "Краснодар", "hashtags": ["твит", "новости", "Краснодар"], "published": "2014-09-12T20:44:42+00:00"}
  {"index": {"_id": 2}}
  {"subject": "Первая запись в Геленджике", "geotag": "Геленджик", "hashtags": ["твит", "море", "Геленджик"], "published": "2014-09-13T20:44:42+00:00"}
  {"index": {"_id": 3}}
  {"subject": "Солнечный Краснодар", "geotag": "Краснодар", "hashtags": ["Краснодар"], "published": "2014-09-11T20:44:42+00:00"}
  {"index": {"_id": 4}}
  {"subject": "Солнечный Геленджик", "geotag": "Геленджик", "hashtags": ["Геленджик"], "published": "2014-09-11T10:44:42+00:00"}

PUT /twitter/retweet/1?pretty
{"source": "https://twitter.com/3564123", "subject": "Солнечный Геленджик", "geotag": "Геленджик", "hashtags": ["Геленджик"], "published": "2014-09-11T10:44:42+00:00"}
Настоятельно рекомендую для выполнения запросов установить kibana. В ней есть отличный инструмент Dev Tools, который хранит историю запросов и форматирует вывод.

Поисковые запросы к Elasticsearch могут быть представлены в 2х видах:

  • упрощённый ‘lite’ - GET /twitter/_search?q=hashtags:sea
  • полный с телом запроса - GET /twitter/_search {"query": {<куча условий>}}

Строка поиска

Рассмотрим первый вариант. На самом деле это наследие от поискового движка Lucene, на котором построен Elasticsearch. Сразу обращаю внимание, что он поддерживает только unicode encoded строку, например такую: GET /twitter/_search?q=\u0437\u0430\u043F\u0438\u0441\u044C

Ответ

{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 0.6650044,
    "hits": [
      {
        "_index": "twitter",
        "_type": "tweet",
        "_id": "1",
        "_score": 0.6650044,
        "_source": {
          "subject": "Первая запись в твиттер",
          "geotag": "Краснодар",
          "hashtags": [
            "твит",
            "новости",
            "Краснодар"
          ],
          "published": "2014-09-12T20:44:42+00:00"
        }
      },
      {
        "_index": "twitter",
        "_type": "tweet",
        "_id": "2",
        "_score": 0.6333549,
        "_source": {
          "subject": "Первая запись в Геленджике",
          "geotag": "Геленджик",
          "hashtags": [
            "твит",
            "море",
            "Геленджик"
          ],
          "published": "2014-09-13T20:44:42+00:00"
        }
      }
    ]
  }
}
Как можно догадаться, я здесь искал слово “запись”. Приводить примеры с кодированными строками не вполне удобно, так что создадим отдельный индекс со значениями, которые не требуют кодирования:

Заполнение данными

POST /games/strategy/_bulk
  {"index": {"_id": 1}}
  {"name": "Civilization V", "release": "2010-09-21T00:00:00+00:00"}
  {"index": {"_id": 2}}
  {"name": "XCOM 2", "release": "2016-02-05T00:00:00+00:00"}
  {"index": {"_id": 3}}
  {"name": "Civilization VI", "release": "2016-10-21T00:00:00+00:00"}

Попробуем поиск по названию: GET /games/_search?q=name:Civilization

Ответ

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 0.25811607,
    "hits": [
      {
        "_index": "games",
        "_type": "strategy",
        "_id": "1",
        "_score": 0.25811607,
        "_source": {
          "name": "Civilization V",
          "release": "2010-09-21T00:00:00+00:00"
        }
      },
      {
        "_index": "games",
        "_type": "strategy",
        "_id": "3",
        "_score": 0.25811607,
        "_source": {
          "name": "Civilization VI",
          "release": "2016-10-21T00:00:00+00:00"
        }
      }
    ]
  }
}
Так выглядит поиск строки в определённом поле. Хоть это и lite версия запроса, тем не менее здесь можно использовать некоторые специальные символы, например:

  • + для объединения условий по AND
  • - для исключения
  • "" для точного совпадения
  • ? или * для нечёткого поиска
  • ~ для поиска близких по расстоянию Дамеру-Левенштайна (например, name:XROM~1 всё равно найдёт “XCOM”)

Например, GET /games/_search?q=+name:(Civilization XCOM) выберет все записи, у которых в поле name есть слова “Civilization” или “XCOM”. Для исключения слишком древних игр добавим фильтр по дате: GET /games/_search?q=+name:(Civilization XCOM) -release:<2016-01-01.

Напомню, что запросы должны быть закодированы, так что последний превратится в мало читаемый GET /games/_search?q=+name%3A%28Civilization%20XCOM%29%20+date%3A%3C2016-01-01. Пожалуй, это достаточная причина, чтобы познакомиться ближе с полной версией.

Полный формат запросов

Все поисковые запросы начинаются одинаково:

GET /twitter/_search
{
  "query": {
    <тип>: {
      <параметр1>: <значение1>,
      ...
      <параметрN>: <значениеN>,
    }
  }
}

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

match и multi_match

Эта пара типов, пожалуй, самая частоиспользуемая для поиска текста. В самом простом случае при поиске слова в определённом поле запрос будет следующим:

GET /twitter/_search
{
  "query": {
    "match": {
      "hashtags": "море"
    }
  }
}

Ответ

{
  "took": 0,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 1,
    "max_score": 0.49191087,
    "hits": [
      {
        "_index": "twitter",
        "_type": "tweet",
        "_id": "2",
        "_score": 0.49191087,
        "_source": {
          "subject": "Первая запись в Геленджике",
          "geotag": "Геленджик",
          "hashtags": [
            "твит",
            "море",
            "Геленджик"
          ],
          "published": "2014-09-13T20:44:42+00:00"
        }
      }
    ]
  }
}
Или, в случае поиска по нескольким полям, таким:

GET /twitter/_search
{
  "query": {
    "multi_match" : {
        "query": "Геленджик",
        "fields": ["hashtags^3", "subject"],
        "type": "best_fields"
    }
  }
}

Обратите внимание на ^3 в запросе в перечислении полей - так указываются веса (boost) для ранжирования результатов поиска (поле _score в ответе). К этому мы ещё вернёмся в следующей статье, когда будем разбирать такие понятия как dis_max и tie_breaker.

По умолчанию multi_match использует тип best_fields - строка запроса будет искаться в каждом поле отдельно, и результат _score будет максимумом по всем полям. Если же поиск идёт по одному и тому же полю, но с использованием разных анализаторов, то возможно имеет смысл применить тип most_fields - в таком случае _score будет средним арифметическим между полями. То есть запрос

GET /twitter/_search
{
  "query": {
    "multi_match" : {
        "query": "Геленджик",
        "fields": ["subject", "subject.ru", "subject.autocomplete"],
        "type": "most_fields"
    }
  }
}

будет выполняться как

GET /twitter/_search
{
  "query": {
    "bool": {
      "should": [
        {"match": {"subject": "Геленджик"}},
        {"match": {"subject.ru": "Геленджик"}},
        {"match": {"subject.autocomplete": "Геленджик"}}
      ]
    }
  }
}

И, наконец, поиск каждого слова в запросе по каждому полю - cross_fields. Причём, для включения в результат достаточно, чтобы каждая часть запроса была найдена хотя бы в одном поле.

GET /twitter/_search
{
  "query": {
    "multi_match" : {
        "query": "море в Геленджике",
        "fields": ["subject", "hashtags"],
        "type": "cross_fields"
    }
  }
}

Ответ

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 1.7118499,
    "hits": [
      {
        "_index": "twitter",
        "_type": "tweet",
        "_id": "2",
        "_score": 1.7118499,
        "_source": {
          "subject": "Первая запись в Геленджике",
          "geotag": "Геленджик",
          "hashtags": [
            "твит",
            "море",
            "Геленджик"
          ],
          "published": "2014-09-13T20:44:42+00:00"
        }
      },
      {
        "_index": "twitter",
        "_type": "tweet",
        "_id": "1",
        "_score": 0.6099695,
        "_source": {
          "subject": "Первая запись в твиттер",
          "geotag": "Краснодар",
          "hashtags": [
            "твит",
            "новости",
            "Краснодар"
          ],
          "published": "2014-09-12T20:44:42+00:00"
        }
      }
    ]
  }
}
_score в этом случае является суммой всех _score в отдельности. То есть для tweet с id 2 это будет ~1.7117:

  • ~0.6099 для вхождения “в” в “subject”
  • ~0.4919 для вхождения “море” в “hashtags”
  • ~0.6099 для вхождения “Геленджике” в “subject”

Подводя итоги:

  • best_fields - учитывается самый лучший результат по каждому полю
  • most_fields - _score является средним арифметическим по _score каждого поля
  • cross_fields - _score вычисляется как сумма _score по каждому полю для каждого слова в запросе

term и match_phrase

Наряду с match есть ещё условие term, которое предотвращает какой-либо анализ содержимого запроса. Это полезно, когда ищется точное соответствие. В случае поиска по hashtags это никак не повлияет на результат. Сходным образом работает и match_phrase, который ищет последовательность слов, входящих в точно таком же порядке. Ниже пример для строки строки “запись в”, а вот поиск “в запись” уже ни к чему не приведёт, т.к. слова поменяны местами.

GET /twitter/_search
{
  "query": {
    "match_phrase": {
      "subject": "запись в"
    }
  }
}

Ответ

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 1.219939,
    "hits": [
      {
        "_index": "twitter",
        "_type": "tweet",
        "_id": "2",
        "_score": 1.219939,
        "_source": {
          "subject": "Первая запись в Геленджике",
          "geotag": "Геленджик",
          "hashtags": [
            "твит",
            "море",
            "Геленджик"
          ],
          "published": "2014-09-13T20:44:42+00:00"
        }
      },
      {
        "_index": "twitter",
        "_type": "tweet",
        "_id": "1",
        "_score": 1.219939,
        "_source": {
          "subject": "Первая запись в твиттер",
          "geotag": "Краснодар",
          "hashtags": [
            "твит",
            "новости",
            "Краснодар"
          ],
          "published": "2014-09-12T20:44:42+00:00"
        }
      }
    ]
  }
}

simple_query_string и query_string

GET /twitter/_search
{
  "query": {
    "simple_query_string" : {
        "query": "+(твит | Геленджик) -море",
        "fields": ["hashtags^3", "subject"],
        "default_operator": "and"
    }
  }
}

Ответ

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 3,
    "max_score": 4.3515453,
    "hits": [
      {
        "_index": "twitter",
        "_type": "retweet",
        "_id": "1",
        "_score": 4.3515453,
        "_source": {
          "source": "https://twitter.com/3564123",
          "subject": "Солнечный Геленджик",
          "geotag": "Геленджик",
          "hashtags": [
            "Геленджик"
          ],
          "published": "2014-09-11T10:44:42+00:00"
        }
      },
      {
        "_index": "twitter",
        "_type": "tweet",
        "_id": "1",
        "_score": 2.4757328,
        "_source": {
          "subject": "Первая запись в твиттер",
          "geotag": "Краснодар",
          "hashtags": [
            "твит",
            "новости",
            "Краснодар"
          ],
          "published": "2014-09-12T20:44:42+00:00"
        }
      },
      {
        "_index": "twitter",
        "_type": "tweet",
        "_id": "4",
        "_score": 2.4250035,
        "_source": {
          "subject": "Солнечный Геленджик",
          "geotag": "Геленджик",
          "hashtags": [
            "Геленджик"
          ],
          "published": "2014-09-11T10:44:42+00:00"
        }
      }
    ]
  }
}```
Более интересным выглядит simple_query_string и query_string - они схожи по синтаксису с lite версией запроса, но имеют большее число настроек. Нашлись все записи, у которых в полях “hashtags” или “subject” есть упоминание слов “твит” или “Геленджик”, но не должно быть упоминания слова “море”.

ids, range, terms, exists, etc…

Наряду с рассмотренным выше term есть ещё некоторое количество запросов, которые не анализируют (морфологически) искомое выражение. Все они описаны в документации, а здесь рассмотрены лишь самые распространённые. Например, поиск по id документов:

GET /twitter/tweet/_search
{
  "query": {
    "ids" : {
      "values" : ["1", "4"]
    }
  }
}

Покажет соответственно всего 2 объекта. Поиск по дате:

GET /twitter/tweet/_search
{
  "query": {
    "range": {
      "published": {
        "gte": "13.09.2014",
        "lt": "now-1d/d",
        "format": "dd.MM.yyyy"
      }
    }
  }
}

выберет все твиты, которые были опубликованы с 13 сентября 2014 и по вчерашний день. Параметер “format” не обязателен, по умолчанию ISO 8601.

bool

Куда ж без объединения условий? Для этого существует специальный запрос, в котором собраны все булевые операции:

GET /twitter/_search
{
  "query": {
    "bool": {
      "must": {
        "match": {"subject": "запись"}
      },
      "filter": {
        "match": {"geotag": "Краснодар"}
      },
      "must_not": {
        "range": {"published": {"lte": "2010-01-01"}}
      },
      "should": [
        {"term": {"hashtags": "море"}},
        {"term": {"hashtags": "твит"}}
      ],
      "minimum_should_match" : 1
    }
  }
}

Ответ

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 1,
    "max_score": 1.1018803,
    "hits": [
      {
        "_index": "twitter",
        "_type": "tweet",
        "_id": "1",
        "_score": 1.1018803,
        "_source": {
          "subject": "Первая запись в твиттер",
          "geotag": "Краснодар",
          "hashtags": [
            "твит",
            "новости",
            "Краснодар"
          ],
          "published": "2014-09-12T20:44:42+00:00"
        }
      }
    ]
  }
}
Эти гении сумели в одном запросе объединить всё:

  • must - объединение по AND - должно выполняться во всех найденных объектах (и будет участвовать в подсчёте _score)
  • filter - аналогично must, но не будет участвовать в подсчёте _score
  • must_not - отрицание
  • should - объеддинение по OR, причём с помощью параметра minimum_should_match можно указать какое минимальное количество условий должно выполняться. Если в запросе выше поставить 2, то результатов уже не будет.

Заключение

Данная статья не претендует на полноту, за этим лучше обратиться к документации. В частности, не описаны запросы по геопозиции, со вложенными условиями, параметризованные, а также написанные на встроенном языке painless. Они настолько специфичны, что могут никогда вам и не понадобиться, так что я решил опустить их перечисление