Привет! Меня зовут Глеб, я разработчик команды продукта «Сервис персонализации» в SM Lab. В цикле из трех постов я расскажу про основы полнотекстового поиска в Elasticsearch.
Данный цикл статей предназначен для всех, но будет особенно актуальным для тех читателей, кто только начинает свое знакомство с Elasticsearch. Я надеюсь, каждый из вас найдет что-то полезное для себя.
В первой части обсудим самые базовые понятия Elasticsearch. Во второй части разберем механизмы анализа текста и полнотекстового поиска. В заключительной части взглянем на стандартную модель ранжирования документов в Elasticsearch.
Итак, начнём с самых базовых понятий.
В двух словах про Elasticsearch
Сперва разберемся — что такое Elasticsearch. Одно из определений, которое можно дать — это распределенная документно-ориентированная БД с мощными возможностями поиска. Для быстрого поиска Elasticsearch использует под капотом специальные структуры данных. Вы наверняка слышали такое понятие как обратный индекс (inverted index). Ниже приведен простой пример, который освежит в памяти суть этого понятия:
У нас есть два документа. Их содержимое анализируется и строится обратный индекс. Например, документ с id = 1 разбивается на токены (термы) — pear и tart. Полученным токенам ставится в соответствии id-шник документа. Вы, скорее всего, заметили, что содержимое документа разбилось на самые обычные слова. Достаточно часто обратный индекс строится именно таким образом. Благодаря такому виду Elasticsearch сможет быстро найти документы со словами pear или tart.
Помимо обратного индекса Elasticsearch может использовать другие структуры данных. Например, BKD (Block K-Dimensional)-деревья. Такая экзотическая структура данных применяется для хранения числовых и геополей.
Индекс
В Elasticsearch индекс (index) — это логическое хранилище документов, которые объединены одним смыслом. Такое хранилище является по сути коллекцией, которая оптимизирована под поисковые запросы. Содержимое полей документов сканируется и сохраняется в соответствующие структуры данных.
У индекса есть и физическое представление, заключающееся в том, что Elasticsearch имеет распределенные свойства и способен к горизонтальному масштабированию. Индекс может биться по шардам, а у каждого шарда могут быть реплики и т.д.
Но в контексте данного поста физическое представление не так важно, поэтому просто представляем, что индекс — это коллекция документов.
Маппинг
Маппинг (mapping) — это процесс, который определяет, как именно будут храниться и проиндексированы документ и его поля.
JSON-документ можно рассматривать как коллекцию пар «поле-значение». Каждое поле имеет какой-то тип данных. В зависимости от этого Elasticsearch будет использовать различные структуры данных для индексации содержимого документа.
Рассмотрим пример:
В столбце Type приведены типы данных, которые Elasticsearch по умолчанию проставит для каждого поля документа. Например, поле name имеет тип Text. Elasticsearch для такого типа поля будет производить анализ текста при поиске и индексации.
Поле age имеет тип Long, и Elasticsearch не будет применять анализ текста при индексации и поиске.
В Elasticsearch существует два вида маппинга:
dynamic — динамический маппинг;
explicit — явный маппинг.
Рассмотрим каждый из них поподробнее.
Динамический маппинг
В процессе сохранения документов в индекс Elasticsearch сканирует содержимое документа и каждому полю ставит в соответствии свой тип данных. Стандартные правила определения типов данных приведены на следующей картинке:
Правила тривиальные, но стоит обратить внимание на последний три JSON-типа.
Когда Elasticsearch парсит поле и определяет, что это дата, то ставит тип date. Если это число, то ставит либо float, либо long.
Самое интересное происходит, когда значение является самой тривиальной строкой, т.е. не является ни датой, ни числом. В этом случае Elasticsearch создает два поля — с типами text и keyword.
Поле text предназначено для полнотекстового поиска, а поле c типом keyword подходит для точного поиска. Значения с типом keyword сохраняются преимущественно без каких-либо изменений. Разберем на примере:
{
"name": "Ivan",
"age": 28,
"joiningDate": "2023/01/01",
"active": true
}
Cохраняем данный документ в индекс под названием custom_index . Описание маппинга на языке Elasticsearch:
{
"custom_index": {
"mappings": {
"properties": {
"active": {
"type": "boolean"
},
"age": {
"type": "long"
},
"joiningDate": {
"type": "date",
"format": "yyyy/MM/dd HH:mm:ss||yyyy/MM/dd||epoch_millis"
},
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
}
}
}
custom_index — название индекса в Elasticsearch;
mappings — описание маппинга;
properties — описание полей и их свойств;
type — тип поля в Elasticsearch.
Практически все интуитивно понятно, но стоит обратить внимание на поле name. Как уже говорилось, для полей со строковым значением, которое не является ни датой, ни числом, Elasticsearch создает два поля для хранения — с типом text и keyword. Другими словами, Elasticsearch под капотом создал два поля для оригинального поля name:
name с типом text;
name.keyword с типом keyword.
Реляционный вид полученного маппинга:
Elasticsearch проанализировал содержимое поля name и привел его к нижнему регистру — ivan. Данное значение будет сохранено в одноименном поле маппинга.
Также стоит обратить внимание на поле маппинга с названием name.keyword. Здесь Elasticsearch сохранил значение без каких-либо изменений — Ivan.
Стандартные правила маппинга можно переопределять с помощью динамических темплейтов (dynamic template). Благодаря этому возможно создавать свои хитрые правила маппинга.
Заканчивая раздел про динамический маппинг, обозначим плюсы и минусы.
Плюсы:
Динамическое определение типа данных.
Минусы:
Потребление дополнительной памяти для индексации text-полей.
Перечень типов данных ограничен.
Стандартные правила определения типов могут привести к неожиданным результатам поиска.
Явный маппинг
Такой вид маппинга дает возможность точной настройки индекса, что позволяет использовать особые типы полей и запросов. Пример особых полей:
Геополя (geo_points);
Поля диапазоны (date_range);
Поля автокомплита (search_as_you_type).
В минусах динамического маппинга был отмечен пункт про неожиданные результаты. Так давайте разберем это на примере. У нас есть тривиальный JSON-документ:
{
"id": 1,
"values": [
{
"id": 1,
"value": 10
},
{
"id": 2,
"value": 20
}
]
}
У этого документа есть поле values, которое представляет собой массив вложенных объектов. Elasticsearch проиндексирует этот документ особым образом:
{
"id": 1,
"values.id": [1, 2],
"values.value": [10, 20]
}
Представлен упрощенный вид документа при индексации. Стоит обратить внимание на поля values.id и values.value. Это были поля вложенных документов в массиве. Связь значений к конкретному объекту теряется и внутреннее «размазываются» по массивам values.id и values.value. Если искать документы в индексе, которые содержат вложенный объект с полями values.id = 1 и values.value = 20, то Elasticsearch выдаст рассматриваемый документ, что неправильно.
Такое поведение объясняется стандартным типом поля values — object. Иерархия вложенных объектов «расплющилась» до простого списка.
Чтобы исправить ситуацию, необходимо явно задать особый тип nested для этого поля. Тут нам помощь и приходит явный маппинг.
Обозначим плюсы и минусы.
Плюсы:
Поиск без сюрпризов.
Комбинированное использование с динамическим маппингом. Поля, которые не были явно заданы при создании индекса, могут быть без особых проблем проиндексированы.
Минусы:
Необходимость первоначальной настройки.
На этом первая часть закончена. В следующей части будут рассмотрены механизмы анализа текста и полнотекстового поиска.