Строим продвинутый поиск с ElasticSearch

Всем привет! Меня зовут Евгений Радионов, я бэкенд-разработчик, последние два года пишу на языке Go, до этого работал с Ruby. За это время столкнулся со множеством интересных и сложных задач, в одной из которых и познакомился с ElasticSearch. В этой статье мы разберем, как настроить продвинутый полнотекстовый поиск с использованием ElasticSearch и — в качестве бонуса — интегрировать его в приложение на Go.

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

Сначала хочу ознакомить вас со структурой статьи. Она разделена на три раздела, первый из которых расскажет о базовых принципах работы полнотекстового поиска, его возникновении и покажет, как можно построить неплохой полнотекстовый поиск на базе PostgreSQL.

Второй, основной и самый большой раздел посвящен ElasticSearch, принципам его работы, разным подходам к описанию настроек индекса и вариантам написания запросов.

Третий, бонусный раздел расскажет о том, как можно интегрировать ElasticSearch в приложение на Go так, чтобы это было удобно поддерживать и расширять.

Почему ElasticSearch

На старте одного из проектов в компании, с которой я сотрудничаю, возник вопрос о реализации полнотекстового поиска с фильтрацией и поиском объектов по их геопозиции в дальнейшем. Один вариант для реализации такой задачи — это PostgreSQL, с его возможностями полнотекстового поиска и фильтрации, а поддержку работы с пространственными данными можно обеспечить расширением PostGIS.

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

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

Немножко поисковой истории

Перед тем как перейти непосредственно к полнотекстовому поиску и ElasticSearch, давайте ненадолго вернемся в прошлое и посмотрим, с чего все начиналось.

Самый простой способ что-нибудь найти — это перебрать все доступные записи и сравнить значения интересующих нас полей с поисковым запросом в надежде увидеть полное совпадения поля и запроса. Например, если необходимо найти всех клиентов, имя которых John, а фамилия Smith, то на языке SQL это может выглядеть так:

SELECT * FROM customers
WHERE first_name = 'John' AND last_name = 'Smith'

Однако мы не всегда точно знаем, как зовут человека или как правильно пишется его имя/фамилия. В таком случае применим поиск с использованием символа подстановки (wildcard search). Так, например, чтобы найти все книги про Гарри Поттера, выполним такой запрос:

SELECT * FROM books
WHERE name LIKE 'Harry Potter%'

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

Если продолжать рассматривать варианты в PostgreSQL, то на помощь может прийти поиск с использованием триграмм (trigram).

Поиск с использованием триграмм

Триграмма — это частный случай n-граммы (n-gram), где n = 3, а, в свою очередь, n-грамма — это непрерывная последовательность из n-элементов из заданного образца текста или речи. Изменяя значение n, получаем юниграммы (unigram, n = 1), биграммы (bigram, n = 2), триграммы (trigram, n = 3) и так далее.

В биологии и химии существует похожее понятие — k-мер (k-mer), однако вместо числовых приставок, взятых из английского языка, там используются приставки из греческого. Так получаются знакомые некоторым названия: мономер (monomer, k = 1), димер (dimer, k = 2), и знакомый миллионам полимер (от греч. πολύ «много» + μέρος «часть»), состоящий из множества частей.

Чтобы было проще разобраться с n-граммами, рассмотрим небольшой пример разбиения фразы the quick red fox jumps over the lazy brown dog на триграммы.

Разбить предложение (строку) на триграммы можно на уровне слов (word-level) или на уровне символов (character-level). В таблице ниже — результат таких операций, где «_» означает пробел.

Word-levelCharacter-level («_» is space)
the quick red
quick red fox
red fox jumps
fox jumps over
jumps over the
over the lazy
the lazy brown
lazy brown dog
the
he_
e_q
_qu
qui
uic
ick
ck_
k_r
_re
red

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

Чтобы сделать это в PostgreSQL, нужно подключить расширение pg_trgm, создать таблицу и добавить индекс с триграммами:

CREATE EXTENSION IF NOT EXISTS "pg_trgm";
CREATE TABLE test_trgm (t text);
CREATE INDEX trgm_idx ON test_trgm USING GIST (t gist_trgm_ops);

Тогда для поиска используем следующий запрос:

SELECT t, similarity(t, 'word') AS sml
  FROM test_trgm
  WHERE t % 'word'
  ORDER BY sml DESC, t;

В результате получим значения текстового столбца t, уровень схожести (от 0 до 1) текста из этого столбца и слова word, при этом будут возвращены только те записи, схожесть которых выше порогового значения схожести t % ’word’ (устанавливается в настройках расширения pg_trgm.similarity_threshold).

Полнотекстовый поиск в PostgreSQL

Чтобы воспользоваться всеми возможностями PostgreSQL по полнотекстовому поиску, нужно применить такие типы, как tsvector и tsquery, которые конвертируют хранимые и входящие данные в формат, наиболее подходящий для полнотекстового поиска.

SELECT to_tsvector('The quick brown fox jumped over the lazy dog.');

                     to_tsvector
-------------------------------------------------------
 'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2

Из примера выше мы видим, что в представлении tsvector наша входная строка немного преобразилась. Первое, что бросается в глаза, — это измененный порядок слов и наличие порядкового номера напротив каждого из них. Если рассмотреть подробнее результаты преобразования, можно заметить, что слова The и over куда-то потерялись, а jumped и lazy заменены на jump и lazi соответственно.

В первом случае мы отбросили ненужные (незначимые) для поиска слова, а во втором — преобразовали их в формы, более подходящие для полнотекстового поиска (убрали шум). Запомните это поведение формата tsvector, оно понадобится, когда будем рассматривать составляющие анализатора (analyzer) в ElasticSearch.

Для того чтобы выполнить запрос на полнотекстовый поиск в PostgreSQL, нужно воспользоваться одним из операторов, например @@, который возвращает true, если tsvector (документ) совпадает с tsquery (запросом). Следующие запросы вернут true:

SELECT to_tsvector('The quick brown fox jumped over the lazy dog') @@ to_tsquery('foxes');

SELECT to_tsvector('The quick brown fox jumped over the lazy dog') @@ to_tsquery('jumping');

SELECT to_tsvector('fat cats ate fat rats') @@ to_tsquery(fat & rat);

Так как полнотекстовый поиск в PostgreSQL — это тема для отдельной статьи, подробнее прочитать про него можно в документации, а мы будем переходить непосредственно к ElasticSearch.

Введение в ElasticSearch

ElasticSearch — это распределенный поисковый и аналитический движок с открытым исходным кодом, написанный на Java, который поддерживает большое количество типов данных, включая текстовые, числовые, геопространственные, структурированные и неструктурированные.

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

За годы успешного существования на рынке (первая версия вышла в 2010 году) ElasticSearch стал центральным элементом экосистемы Elastic: ELK Stack. ELK — это акроним трех продуктов компании Elastic: ElasticSearch (поисковый и аналитический движок), Logstash (конвейер обработки данных) и Kibana (интерфейс для визуализации данных). Сегодня можно выделить два самых популярных сценария использования ElasticSearch:

  • движок для полнотекстового поиска;
  • хранилище логов и метрик в ELK Stack.

В статье мы настроим полнотекстовый поиск в ElasticSearch и немного затронем Kibana для повышения удобства разработки, просмотра и отладки поисковых запросов и их результатов.

Начало работы с ElasticSearch

Перед началом работы еще немного теории. Идея создания ElasticSearch состоит в том, чтобы предоставить возможности библиотеки полнотекстового поиска Apache Lucene для Java пользователям других языков через простой и понятный всем интерфейс: JSON поверх HTTP. Так что все запросы представляют собой JSON, а передаются через HTTP и сегодня. Для исполнения запросов из примеров можно взять любой HTTP-клиент, будь то Postman или cURL. Но я рекомендую воспользоваться Dev Tools в Kibana, хотя бы потому, что там есть автокомплит запросов и подсветка синтаксиса.

Итак, чтобы начать использовать ElasticSearch, вам нужно развернуть его кластер. Проще всего это сделать с помощью docker-compose, заодно запустив Kibana:

version: "2"
services:
  elasticsearch:
    image: 'docker.elastic.co/elasticsearch/elasticsearch:7.4.0'
    container_name: 'elasticsearch'
    ports:
      - 9200:9200
    environment:
      discovery.type: single-node
  kibana:
    image: 'docker.elastic.co/kibana/kibana:7.4.0'
    container_name: 'kibana'
    ports:
      - 5601:5601
    environment:
      SERVER_NAME: kibana.my-organization.com
      ELASTICSEARCH_URL: http://elasticsearch:9200

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

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

Хоть мы уже можем добавлять данные в ElasticSearch:

PUT /books_index/_doc/1
{
  "name": "Harry Potter and the Philosopher's Stone",
  "publishing_year": 1997,
  "author": {
    "name": "J. K. Rowling"
  }
}

Я рекомендую начать с определения формата хранимых данных (mapping) в индексе, если это возможно.

Давайте рассмотрим небольшой пример простого поиска на базе индекса для книг:

PUT /books_index // Create index first

PUT /books_index/_mappings
{
  "properties": {
    "name": {
      "type": "text"
    },
    "publishing_year": {
      "type": "integer",
      "fields": {
        "keyword": {
          "type": "keyword"
        }
      }
    },
    "author": {
      "properties": {
        "name": {
          "type": "text"
        }
      }
    }
  }
}

Объявляя mapping, мы описываем не только структуру хранимых данных в нашем индексе, но и правила поиска для конкретных полей, которые рассмотрим немного позже. А пока добавим еще один документ в индекс (не забудьте добавить первый документ):

PUT /books_index/_doc/2
{
  "name": "The Great Gatsby",
  "publishing_year": 1925,
  "author": {
    "name": "F. Scott Fitzgerald"
  }
}

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

Обратите внимание, что поля документа, которые будут сохранены, имеют собственный тип (text, integer), могут быть другими вложенными объектами со своими полями (author), а также иметь подполя (fields). Если со вложенными объектами все понятно, то вот с подполями, или, как они называются в документации, multi-fields, возникают вопросы.

Подполя нужны для того, чтобы проиндексировать одно и то же значение в разных форматах данных. Например, если хотим добавить возможность полнотекстового поиска для поля типа boolean (этот случай мы рассмотрим позже) или типа integer (хотя в данном случае это не обязательно), то можно объявить подполя типа text, и в момент вставки значение будет приведено к этому типу и сохранено в подполе.

Сразу отмечу, что поля типа keyword и text с виду похожи, но используются для разных целей. И важно понимать разницу: keyword — для поиска по полному совпадению, в то время как text — для полнотекстового поиска, то есть по частичному совпадению. Более подробно мы рассмотрим это позже, когда будем разбираться с анализаторами.

Теперь, когда у нас есть несколько документов в индексе, попробуем по ним поискать. Один из самых мощных запросов для полнотекстового поиска — это запрос query_string:

GET /books_index/_search
{
  "query": {
    "query_string": {
      "query": "harry"
    }
  }
}

GET /books_index/_search
{
  "query": {
    "query_string": {
      "query": "1925"
    }
  }
}

В первом случае будет найдена книга Harry Potter and the Philosopher’s Stone, а во втором — The Great Gatsby. Давайте более подробно разберем ответ, который получили от ElasticSearch:

"hits" : {
  "total" : {
    "value" : 1,
    "relation" : "eq"
  },
  "max_score" : 0.105360515,
  "hits" : [
    {
      "_index" : "books_index",
      "_type" : "_doc",
      "_id" : "1",
      "_score" : 0.105360515,
      "_source" : {
        "name" : "Harry Potter and the Philosopher's Stone",
        "publishing_year" : 1997,
        "author" : {
          "name" : "J. K. Rowling"
        }
      }
    }
  ]
}

Первым нас встречает total, он показывает, сколько документов, соответствующих нашему запросу, было найдено (value). Но есть нюанс: по умолчанию это число считается приблизительно. Чтобы получить точный результат, нужно в запросе указать «track_total_hits»: true или «track_total_hits»: 100, где 100 — количество записей, которые вы хотите точно подсчитать.

Но будьте осторожны: включение этой опции приведет к тому, что ElasticSearch будет «пробегаться» по всем документам в индексе, соответствующим запросу, что непременно скажется на скорости его выполнения. Второй параметр в total — relation: он может принимать либо значение eq (точное количество записей), либо gte (записей больше, чем написано в value).

Далее находится max_score — это максимальное значение _score среди найденных документов. Само же _score показывает, насколько хорошо документ подходит под критерии поиска (больше — лучше). По умолчанию оно колеблется между 0 и 1 для каждого документа, однако есть механизмы, которые позволяют это изменить (boost, tie_breaker).

Я бы советовал не привязываться к каким-то конкретным значениям показателя, так как он носит относительный характер и позволяет понять, насколько хорошо тот или иной документ из результатов выдачи соотносится с конкретным запросом. Еще один важный момент — без указания параметров сортировки поисковая выдача будет отсортирована по показателю _score в порядке убывания, что автоматически поднимет лучшие записи наверх.

Двигаемся дальше. Массив hits, который hits.hits, — это массив документов из выдачи. Наиболее важные параметры в нем — _score, _id и _source. Про _score мы уже говорили, _id — это уникальный идентификатор записи в индексе (указывается при запросе на вставку PUT/books_index/_doc/1, _id = 1). Он хранится как значение строчного типа, так что можно использовать не только числа, но и другие уникальные идентификаторы, например UUID. Поле _source — это те данные, которые были переданы ElasticSearch для вставки, то есть исходный документ.

Анализ и поиск в ElasticSearch

Любой поисковый запрос в ElasticSearch перед непосредственным исполнением попадает в анализатор (analyzer). Analyzer — это конвейер (pipeline), который состоит из нескольких частей: character filter, tokenizer и token filter.

ElasticSearch предоставляет набор встроенных анализаторов для базовых потребностей, однако, скорее всего, вам придется написать собственный.

Разберем принципы работы анализатора на небольшом примере. Для этого нужно добавить в mapping собственный analyzer и назначить его для какого-нибудь поля:

PUT /books_index
{
  "settings": {
    "analysis": {
      "analyzer": {
        "customHTMLAnalyzer": {
          "type": "custom",
          "char_filter": [
            "html_strip"
          ],
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "stop"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "customHTMLAnalyzer"
      },
      "publishing_year": {
        "type": "integer",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      },
      "author": {
        "properties": {
          "name": {
            "type": "text"
          }
        }
      }
    }
  }
}

Объявляем analyzer с именем customHTMLAnalyzer, определяем для него параметры character filters, tokenizer, filters (token filters) и указываем, что для поля имени книги будем использовать его.

Тогда и при вставке, и при поиске для поля документа и поискового запроса будет применено следующие:

  1. Убрать все HTML-теги из входящей строки (html_strip char filter).
  2. Разбить входную строку на токены, в данном случае на слова, при этом удаляя знаки пунктуации (standard tokenizer).
  3. Изменить регистр каждого токена на нижний (lowercase filter).
  4. Убрать токены, которые являются стоп-словами (spot filter) и не имеют важного значения для поиска. Например, для английского языка это a, an, and, for, if, in, the и так далее.

Tokenizer в ElasticSearch

Начнем подробный разбор анализатора с такой его составляющей, как tokenizer (токенайзер). Он принимает на вход массив символов (обычно текст или поисковую строку), разбивает их на отдельные токены (обычно слова) и передает их дальше в фильтры. Рассмотрим таблицу с наиболее популярными токенайзерами в ElasticSearch, полный список которых можно найти в документации.

Наиболее популярные токенайзеры
standardразбивает текст на слова, удаляет знаки пунктуации
The 2 QUICK Brown-Foxes jumped over the lazy dog’s bone. →[The, 2, QUICK, Brown, Foxes, jumped, over, the, lazy, dog’s, bone]
classicтокенайзер, основанный на правилах грамматики английского языка
ngramте самые n-граммы из части про PostgreSQL, разбивает текст на слова, затем каждое слово разбивает на n-граммы
quick → [qu, ui, ic, ck]
edge_ngramпохож на ngram, тоже разбивает текст на слова, затем каждое слово разбивает на n-граммы, но делает это, сохраняя привязку к началу слова
quick → [q, qu, qui, quic, quick]
keywordэто токенайзер, который ничего не делает, идеально подходит, когда нужно найти что-то по полному совпадению
quick → quick

Выбирать токенайзер нужно исходя из ваших входных данных и целей, для которых будет использоваться конкретное поле. Например, для полнотекстового поиска хорошо подходят standard (для любого языка) и classic (если планируется поиск только по английскому языку), для поиска по полному совпадению — keyword, для нечеткого (fuzzy) поиска — ngram и edge_ngram, последний — удачное решение для автодополнения (autocomplete или completion suggester, как это называется в ElasticSearch). Кроме этих токенайзеров, есть более специфические: UAX URL Email Tokenizer (uax_url_email) — такой же, как standard, только распознает email и URL-адреса как один токен, или же Path Tokenizer (path_hierarchy), который может построить иерархию пути (например, к файлу): /foo/bar/baz → [/foo, /foo/bar, /foo/bar/baz].

Filters в ElasticSearch

Переходим к фильтрам. Фильтр принимает на вход массив токенов из токенайзера и может их изменять (например, привести к нижнему регистру), удалять (стоп-слова) или добавлять новые (синонимы). Фильтры разделены на две группы: те, которые зависят от языка (отмечены *), и те, которые не зависят. Ключевая разница в том, что для языковых фильтров в их настройках нужно явно указывать язык или по умолчанию будет выбран английский (это поведение вряд ли изменится с приходом новых версий ElasticSearch, но лучше перестраховаться и всегда указывать явно). Соответственно, языковые фильтры для разных языков будут работать немного по-разному и учитывать особенности того или иного языка, чтобы предоставить наиболее релевантное поведение и, как следствие, наиболее релевантную поисковую выдачу.

Важно отметить, что фильтры будут применены в том порядке, в котором указаны в mapping. Поэтому важно следить как за порядком объявления фильтров, так и за их внутренним устройством.

Наиболее популярные фильтры
lowercaseменяет регистр токена на нижний

«QuIck» → «quick»

trimудаляет пробельные символы в начале и в конце токена

" quick " → «quick»

stop*удаляет стоп-слова, для английского языка это a, an, and, for, if, in, the и так далее
stemmer*stemming — это процесс сокращения слова до его корневой формы. Хоть это и языковой фильтр, но чаще всего включает в себя удаление суффиксов и префиксов из слова.

«the foxes jumping quickly» → [ the, fox, jump, quickli ]

conditionalпозволяет применять фильтры в зависимости от условия

Character filters в ElasticSearch

Character filters используются для предварительной обработки текста до того, как он попадет в tokenizer. Встроенных фильтров для этой группы на удивление немного, но они универсальны.

Character filters
html_stripудаляет теги HTML, а также декодирует экранированные символы, например
<p>I'm so <b>happy</b>!</p>” → “\nI'm so happy!\n
mappingпозволяет определить соответствие"ключ-значение" для последующей замены каждого найденного ключа на соответствующее ему значение
pattern_replaceпозволяет определить регулярное выражение (на диалекте языка Java) для нахождения символов (pattern), которые должны быть заменены согласно правилу в строке replacement

Analyze API в ElasticSearch

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

ЗапросОтвет
GET /_analyze
{
  "tokenizer" : "whitespace",
  "filter" : [
    "lowercase",
    {
      "type": "stop",
      "stopwords": [
        "a",
        "is",
        "this"
      ]
    }
  ],
  "text" : "this is a test"
}
{
  "tokens" : [
    {
      "token" : "test",
      "start_offset" : 10,
      "end_offset" : 14,
      "type" : "word",
      "position" : 3
    }
  ]
}

Типы запросов в ElasticSearch

После того как мы разобрались с тем, как документы будут сохраняться и анализироваться в индексе, можно начинать выполнять поисковые запросы. Запросы — это то, что каждому нужно осваивать индивидуально. Описать универсальный подход к созданию запросов, чтобы они работали в большинстве случаев, невозможно. Я настоятельно рекомендую самостоятельно внимательно и детально изучить механизмы запросов в ElasticSearch, а также изучить то, как и из чего формируется _score документа в процессе запроса. Так что добро пожаловать на страницы документации, а мы проведем небольшой обзор наиболее распространенных типов запросов.

Начнем с понятий контекста запроса и фильтров (да-да, и тут фильтры). По умолчанию ElasticSearch сортирует результаты поиска по оценке релевантности (relevance score) — это то самое поле _score. Однако бывают ситуации, когда нужно произвести поиск внутри группы документов, которые соответствуют общим критериям, например, созданы не раньше определенной даты или которые имеют определенный статус. Здесь на помощь приходят фильтры запросов.

И поисковый запрос (query), и фильтр (filter) принимают на вход запросы в одном формате, но с той лишь разницей, что запросы, написанные внутри filter, не влияют на итоговое значение _score.

Рассмотрим небольшой пример:

GET /books_index/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "name": "The Great Gatsby"
          }
        }
      ],
      "filter": [
        {
          "range": {
            "publishing_year": {
              "gte": "1900"
            }
          }
        }
      ]
    }
  }
}

Вначале будут отобраны те книги, которые опубликованы в 1900 году и позднее (filter), а затем по ним будет произведен поисковый запрос (query).

Резюмируя, можно сказать, что в контексте запроса (query) мы отвечаем на вопрос: «Насколько хорошо тот или иной документ соответствует этому запросу?», а в контексте фильтра (filter): «Стоит ли рассматривать этот документ вообще?».

В примере запроса мы использовали три конструкции: bool, must и match. Рассмотрим их по порядку: bool относится к категории составных (compound) запросов. Составные оборачивают другие составные или простые запросы, чтобы объединить их результаты и оценки (_score), изменить их поведение или переключиться с запроса на контекст фильтрации. Bool-запрос сопоставляет документы, соответствующие логическим комбинациям других запросов. Наиболее близкий пример — это операция WHERE в SQL-запросе, куда тоже можно передать набор логических операций (x < 0 AND y > 5 OR z = 0). В ElasticSearch операциям AND и OR из SQL есть свои аналоги:

ElasticSearchSQL
"bool" : {
  "must" : [
    "term" : {"id" : 35},
    "term": {"age": 18}
  ]
}
SELECT * FROM users
WHERE id = 35 AND age = 18
"bool" : {
  "should" : [
    "term" : {"id" : 35},
    "term": {"age": 18}
  ]
}
SELECT * FROM users
WHERE id = 35 OR age = 18

В примере выше мы использовали новый тип запроса term — это аналог = в SQL, то есть наш документ попадет в результаты поиска, если значение из поля полностью совпадает с поисковым значением.

Перед тем как перейти к группе полнотекстовых запросов, давайте рассмотрим запросы на основе точных значений (term-level queries). С их помощью можно быстро найти записи, которые соответствуют точным критериям. Например, значение статуса продукта или его уникальный идентификатор (id), или же диапазон дат и прочее.

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

existsВозвращает документы, содержащие проиндексированное значение для поля — если для поля не выключена индексация (index: false) или оно не null или []
fuzzyВозвращает документы, похожие на поисковый запрос
idsВозвращает документы с соответствующими _id
prefixВозвращает документы, содержащие определенный префикс в указанном поле
rangeВозвращает документы, содержащие значения в указанном диапазоне
regexpВозвращает документы, соответствующие указанному регулярному выражению
termВозвращает документы, значение поля которых полностью совпадает с запросом.Аналог WHERE field = 15
termsВозвращает документы, содержащие одно или несколько точных совпадений в указанном поле.Аналог WHERE field IN (15, 26, 31)
terms_setТо же самое, что и terms, но дает возможность указать минимальное число совпадающих значений
typeВозвращает документы с указанным типом
wildcardВозвращает документы, соответствующие шаблону с символом подстановки (? и *).Аналог WHERE field LIKE ‘harry%’

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

match возвращает документы, соответствующие указанному тексту, числу, дате или логическому (boolean) значению. Этот тип запроса наиболее простой и наиболее базовый для проведения полнотекстового поиска. С его помощью также можно выполнять нечеткий (fuzyy) поиск. match анализирует входящий запрос, и в процессе анализа создается логический запрос из предоставленного текста. Параметр operator может быть установлен в and или or (по умолчанию or) для управления поведением. Это означает, что с оператором or будут найдены документы, в которых совпадает хотя бы один из токенов, при чем документы с большим числом совпадений будут иметь большее значение _score. А при использовании оператора and необходимо совпадение всех токенов из поискового запроса.

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

Он поддерживает все возможности поискового синтаксиса. Например, status:active добавит условие, что поле status должно иметь значение active. Поддерживаются и логические операторы title:(quick OR brown). Также можно использовать символы подстановки qu?ck bro* и регулярных выражений name:/joh?n(ath[oa]n)//. Есть нечеткий поиск quikc~, поиск по диапазону (range), по нескольким полям сразу, группировка и многое другое.

Использование этого типа запроса дает обширные поисковые возможности, однако открывает пользователю доступ ко всем полям в индексе.

К счастью, есть более дружелюбный тип запроса с поддержкой поискового синтаксиса, но и с некоторыми ограничениям — это simple_query_string. Работает он точно так же, как и query_string, однако не возвращает ошибку, если в запросе неправильный синтаксис. Подробнее с отличиями между этими двумя запросами можно ознакомиться в документации.

Синонимы в ElasticSearch

Иногда нужно использовать значение константы или булево значение как значение поля в индексе. Например, мы хотим пометить нашу книгу как книгу лимитированного издания и дать пользователю возможность как-то выделить такие книги при полнотекстовом поиске. Тогда можем задать поле книги «limited_edition»: «limited_edition», но релевантность поисковой выдачи может быть не очень высока, так как при выполнении запроса пользователь получит желаемый результат, только если введет limited_edition. В остальных случаях возможны варианты.

Если же использовать более привычный вариант с полем типа boolean, то как-то описать значимость такого поля в полнотекстовом поиске не представляется возможным. Мы же хотим сделать так, чтобы при вводе таких слов и словосочетаний, как limited, limited edition, deluxe, deluxe edition, special, exclusive и других, пользователь мог увидеть книги из лимитированного издания и, может быть, приобрести их. Для этого понадобятся синонимы.

Синонимы в ElasticSearch — это не что иное, как синонимы в любом естественном языке — разные слова, которые имеют одинаковый смысл, с той лишь разницей, что в разговорной речи понимание синонимов происходит само собой, а в ElasticSearch их нужно явно определить. Для этого в поисковом движке предусмотрен специальный фильтр synonym token filter. Он поддерживает специальный синтаксис объявления синонимов, а также позволяет загрузить их непосредственно из файла. Для задания синонимов лучше использовать один подход: синонимы в файле или в настройках индекса (mapping).

"filter": {
  "synonym_filter": {
    "type": "synonym",
    "synonyms_path": "analysis/synonym.txt",
    "lenient": true,
    "synonyms": [
      "limited, limited edition, deluxe, deluxe edition => limited_edition",
      "free, cheap"
    ]
  }
}

Рассмотрим этот пример. Мы создаем фильтр, который называется synonym_filter и имеет тип synonym. Исключительно для примера были использованы оба способа объявления синонимов: для файла это поле synonyms_path и относительный путь к нему, для списка синонимов — массив synonyms. Свойство lenient отвечает за игнорирование ошибок при обработке синонимов.

Есть два основных способа отождествить два слова: простое отождествление, как в нашем примере free, cheap, и отождествление с заменой — при выполнении этого фильтра limited, limited edition, deluxe, deluxe edition будут заменены на константу limited_edition. Это два разных подхода, которые только на первый взгляд работают похожим образом. Нужно понимать, что при использовании нотации со стрелкой (=>) мы заменяем слова из левой части на слова из правой, что означает, что только документы, в которых есть слово из правой части, будут хоть как-то отображены в результатах поиска. Нотация с запятой позволяет отождествить слова. Можно представить это так: если слово совпало с одним из списка, то замените его на весь список. Таким образом документы, которые содержат в себе хотя бы одно из слов-синонимов, будут оценены выше, поэтому будут находиться выше в результатах поиска.

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

Чтобы решить эту проблему, можно как-то настроить токенайзер, однако лучше использовать встроенную функцию, предназначенную для таких случаев. Она называется synonym graph token filter.

Синтаксис у synonym graph token filter такой же, как у synonym filter, но работает он немного иначе. Во время работы этого фильтра будет создан graph token stream, то есть он будет обрабатывать не отдельные токены, а их набор. Принципы работы этого фильтра отлично описывает картинка из официальной документации. Если необходимо заменить фразу на ее акроним: domain name system => dns, то выглядит это следующим образом:

Будет найдено словосочетание domain name system и заменено на dns, и последующие фильтры будут работать уже с измененным набором токенов. Возможны варианты, когда будут созданы два подзапроса: для исходного набора токенов и для измененного, например, когда используется match_phrase.

Подробнее узнать про синонимы и граф синонимов можно в документации.

Собираем все вместе

Чтобы получить более наглядную картину того, как все эти настройки уживаются вместе, рассмотрим пример:

{
  "index_patterns": [
    "book*"
  ],
  "settings": {
    "analysis": {
      "analyzer": {
        "books_analyzer": {
          "type": "custom",
          "char_filter": [
            "html_strip"
          ],
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "synonym_filter",
            "synonym_graph_filter",
            "english_possessive_stemmer",
            "english_stop"
          ]
        }
      },
      "filter": {
        "english_stop": {
          "type": "stop",
          "stopwords": "_english_"
        },
        "english_possessive_stemmer": {
          "type": "stemmer",
          "language": "possessive_english"
        },
        "synonym_filter": {
          "type": "synonym",
          "synonyms": [
            "limited, limited edition, deluxe, deluxe edition => limited_edition",
            "free, cheap"
          ]
        },
        "synonym_graph_filter": {
          "type": "synonym_graph",
          "synonyms": [
            "limited edition, deluxe edition => limited_edition"
          ]
        }
      }
    }
  },
  "mappings": {
    "dynamic": false,
    "date_detection": false,
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "books_analyzer"
      },
      "publishing_year": {
        "type": "integer",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      },
      "author": {
        "properties": {
          "name": {
            "type": "text",
            "analyzer": "books_analyzer"
          }
        }
      },
      "limited_edition": {
        "type": "text",
        "analyzer": "books_analyzer",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

Мы объявили шаблон индексов, который после добавления будет автоматически применяться ко всем индексам, имя которых начинается с book. Задали свой анализатор, который уберет все HTML-теги из входного текста, затем разобьет его на токены по пробелам, а также уберет символы пунктуации. После этого поля, для которых указано использование анализатора, будут следовать указанным правилам анализа.

Немного хитростей для удобства

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

Первая из них — это шаблоны индексов (index templates). Они позволяют описать настройки индекса и задать имена или паттерны имен индексов, при создании которых будет применен этот шаблон. Подробнее в документации.

Вторая — псевдоним (alias) индекса. Он работает по принципу указателя в языке программирования: указывает на конкретный индекс и может быть в любой момент изменен, чтобы указывать на другой. Таким образом, работая с индексом посредством псевдонима, можно писать код, не опасаясь, что изменение имени индекса приведет к изменению кода.

Используя два предыдущих подхода, можно реализовать третий: переиндексацию без даунтайма. Переиндексация документов может быть полезна как во время разработки, когда mapping индекса меняется, так и когда вашим приложением уже пользуются, а вы хотите добавить новые поисковые возможности или изменить поведение старых.

Опишем этот процесс:

  1. Обновить шаблон индексов.
  2. Создать новый индекс из шаблона.
  3. Добавить все данные из базы данных в новый индекс, во время этого процесса все изменения над данными должны записываться в два индекса (в alias и в новый индекс).
  4. Переключить alias на новый индекс.
  5. Удалить старый индекс.

И последние две настройки, которые позволят вам выделить другие поля в документе на фоне остальных при полнотекстовом поиске: boost и tie_breaker. Опцию boost (по умолчанию = 1) можно указывать как для полей, так и для запросов/подзапросов. Если она задана, то _score поля или запроса будет умножен на ее значение: _score = original _score * boost.

tie_breaker указывается для запроса, причем не каждый запрос его поддерживает, значение его может быть в пределах от 0.0 до 1.0 и работает он так: если его значение >0.0 (по умолчанию = 0.0), то финальное значение документа считается следующим образом:

  1. Выбрать _score наиболее подходящего поля.
  2. Умножить _score остальных полей, подходящих под критерии поиска на tie_breaker.
  3. Сложить и нормализировать полученные результаты.

С этой опцией можно выделить те документы, которые содержат один и тот же токен в разных полях.

Используя boost и tie_breaker, можно сделать акцент полнотекстового поиска на нужных вам полях, а также определить роль второстепенных полей.

Еще пару слов про ElasticSearch

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

Бонусный раздел: интеграция ElasticSearch в приложение на Go

Для работы с ElasticSearch в Go я использую библиотеку github.com/olivere/elastic/v7. Перед тем как отправить запросы в ElasticSearch, нужно к нему подключиться:

options := []elastic.ClientOptionFunc{
    elastic.SetURL("http://localhost:9200"),
}
cli, _ := elastic.NewClient(options...)

В этом и всех последующих примерах ошибки не обрабатываются для краткости, однако в реальных проектах так делать не стоит.

После этого можно создать шаблон индекса и сам индекс, а также добавить к нему псевдоним:

body, _ := ioutil.ReadFile("path/to/index/template.json")
cli.IndexPutTemplate("books_template").BodyString(string(body)).Do(ctx)
cli.CreateIndex("books_index_1").BodyString("").Do(ctx)
cli.Alias().Add("books_index_1", "books_index").Do(ctx)

Как видите, если используется шаблон индекса, то при создании самого индекса его настройки можно не передавать.

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

exists, err := cli.IndexExists("books_index_1").Do(ctx)

И не создавать его, если в этом нет необходимости.

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

На этом этапе уже можно как добавлять новые документы в индекс, так и искать по ним, однако давайте остановимся и попробуем написать удобный интерфейс для составления сложных запросов. Для этого обратимся к шаблону проектирования Builder.

type QueryBuilder struct {
    q *elastic.BoolQuery
}

func NewQueryBuilder() *QueryBuilder {
    return &QueryBuilder{
        q: elastic.NewBoolQuery(),
    }
}

func (b QueryBuilder) Query() elastic.Query {
    return b.q
}

Корневым запросом у нас будет boolean query, на базе которого можно построить разные вариации как поиска, так и фильтрации. Для начала объявим метод для полнотекстового поиска:

func (b *QueryBuilder) Search(query string) *QueryBuilder {
    b.q = b.q.Should(
        elastic.NewQueryStringQuery(query).Boost(2).DefaultOperator("AND").TieBreaker(0.4),
        elastic.NewQueryStringQuery(query).Boost(1).DefaultOperator("OR").TieBreaker(0.1),
    )

    return b
}

В нем мы используем запрос типа query_string (не забываем про экранирование спецсимволов или альтернативы этому запросу в виде simple_query_string или match), объединим (используя should) документы, которые лучше соответствуют поисковому запросу, с теми, которые соответствуют ему хуже. Для документов, в которых совпало больше токенов (default operator AND), мы умножим их _score на 2 (boost), а также увеличим влияние других полей (tie_breaker) на результат. А для документов, которые совпали не идеально с поисковым запросом (90% случаев), в поле boost явно зададим значение по умолчанию 1 и совсем немного увеличим влияние других полей.

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

Сделаю ремарку, что запись b.q = b.q.Should(...), то есть переприсваивание результатов выполнения, не является обязательной, так как в этой библиотеке для работы с ElasticSearch построение запросов модифицирует внутренний объект и возвращает его же в качестве результата. При написании b.q.Should(...) без переприсвоения мы неявно модифицируем объект q посредством библиотеки. Следовать тому или иному подходу — решать вам, я же предпочитаю явные объявления, поэтому и в дальнейших примерах буду использовать вариант с переопределением.

Чтобы добавить объект в индекс, можно воспользоваться следующей командой:

cli.Index().Index("books_index").Id(1).BodyJson(book).Do(ctx)

Где book — это наш объект, например структура, которая будет сериализована в JSON внутри метода BodyJson и добавлена в индекс, на который указывает псевдоним books_index.

Что, если мы захотим отфильтровать книги по какому-то признаку, например по автору или тому, является ли издание лимитированным.

func (b *QueryBuilder) Author(name string) *QueryBuilder {
    b.q = b.q.Filter(elastic.NewTermQuery("author.name", name))

    return b
}

Можно сделать практически то же самое, но чтобы результаты фильтрации влияли на _score.

func (b *QueryBuilder) TermQuery(termKey, termValue string) *QueryBuilder {
    b.q = b.q.Must(elastic.NewTermQuery(termKey, termValue))

    return b
}

Меняя Filter на Must, можно быстро и просто корректировать поведения поиска. А на базе такого запроса просто построить другой запрос, который позволит отфильтровать по полям, для которых было применено сочетание text + keyword (в нашем случае это limited_edition).

func (b *QueryBuilder) BoolKeywordQuery(termKey, termValue string) *QueryBuilder {
    return b.TermQuery(termKey+".keyword", termValue)
}

Как видите, обращение осуществляется так же, как и к вложенному объекту — через точку.

Запрос типа terms может выглядеть так:

func (b *QueryBuilder) TermsQuery(termKey string, vals []interface{}) *QueryBuilder {
    if len(vals) == 0 {
        return b
    }

    b.q = b.q.Must(elastic.NewTermsQuery(termKey, vals...))

    return b
}

А типа range для числовых значений — следующим образом:

func (b *QueryBuilder) RangeQuery(termKey string, min, max int) *QueryBuilder {
    if min == 0 && max == 0 {
        return b
    }

    var rangeQuery = elastic.NewRangeQuery(termKey)

    if min != 0 {
        rangeQuery = rangeQuery.Gte(min)
    }

    if max != 0 {
        rangeQuery = rangeQuery.Lte(max)
    }

    b.q = b.q.Must(rangeQuery)

    return b
}

Комбинируя разные запросы и добавляя новые слои абстракции над ними, получим удобный инструмент для написания сложных запросов простым способом.

Выполнить любой запрос можно так:

query := NewQueryBuilder().
    Search("harry potter limited edition").
    TermQuery("author.name", "J. K. Rowling").
    RangeQuery("publishing_year", 1990, 2001).
    Query()

cli.Search().
    Index("books_index").
    From(offset).
    Size(limit).
    Query(query).
    Do(ctx)

Здесь limit и offset — это параметры пагинации, так как по умолчанию результаты поискового запроса отсортированы в порядке убывания параметра _score, то явным образом сортировку можно не указывать, однако и это возможно.

В процессе настройки и отладки полезно посмотреть запрос, который выполняется. Для этого добавим еще один метод в строителе запроса:

func (b *QueryBuilder) DebugPrint() {
    fmt.Printf("=================\n=  DEBUG START  =\n=================\n")

    source, _ := b.Query().Source()
    sourceJson, _ := json.Marshal(source)
    fmt.Printf("Query:\n%s\n", string(sourceJson))

    fmt.Printf("=================\n=   DEBUG END   =\n=================\n")
}

Что осталось за кадром

Вот мы и подошли к концу статьи, для кого-то неожиданно быстро, а для кого-то неожиданно медленно. Напоследок хочу описать те важные моменты, что не попали в материал.

Во-первых, это внутреннее устройство принципов работы полнотекстового поиска в ElasticSearch, во-вторых — настройка, мониторинг и масштабирование его кластера.

Касательно самого ElasticSearch и его поисковых возможностей, то тут стоит обратить внимание на:

  • работу с сортировкой: для простых случаев она выполняется легко, но для более сложных — местами, нетривиально;
  • работа с агрегациями: агрегации — мощный инструмент в ElasticSearch, который позволяет не только заменить GROUP BY в SQL, но и превзойти его;
  • работа с вложенными полями: хоть мы и рассмотрели примеры по работе с вложенными объектами, однако не всегда вложенные поля — это объекты. Это могут быть и массивы. И работа с ними может оказаться неочевидной, особенно когда нужно осуществить сложную выборку и сортировку;
  • автодополнение (completion suggester) — тоже отдельная тема для разговора и изучения.

Послесловие

Вот и настало время прощаться. Спасибо вам за ваше время, проведенное за чтением статьи, надеюсь, вы потратили его с пользой. Хотелось бы еще раз сказать, что настройка качественного и быстрого поиска — это длительный процесс, так что запаситесь терпением, ищите, ошибайтесь, учитесь, развивайтесь, адаптируйтесь, эволюционируйте.

Похожие статьи:
На YouTube-каналі DOU вийшов новий випуск Книжкового клубу — шоу для тих, хто ніяк не почне читати. Цього разу обговорюємо книгу «Розверніть...
Понад дев’ять місяців росія атакує ракетами українські міста та села. Останні масовані атаки відзначились особливою жорстокістю...
С 28 декабря 2015 года российский оператор «МегаФон» снизит стоимость входящих и исходящих вызовов на территории Казахстана до 7...
Endeavor, міжнародна організація, що підтримує підприємців та допомагає глобалізуватися стартапам, відкрила офіс в Україні. Про...
Тетяна Сухова — Senior бізнес-аналітикиня в IT-компанії UNITY-BARS. А ще вона спеціалістка з аналізу та розробки програмного...
Яндекс.Метрика