Поиск по большим документам в ElasticSearch
Продолжаем цикл статей о том, как мы постигали ES в процессе создания Ambar. Первая статья цикла была о Хайлайтинге больших текстовых полей в ElasticSearch.
В этой статье мы расскажем о том как заставить ES работать быстро с документами более 100 Мб. Поиск в таких документах при подходе «в лоб» занимает десятки секунд. У нас получилось уменьшить это время до 6 мс.
Заинтересовавшихся просим под кат.
Проблема поиска по большим документам
Как известно, всё действо поиска в ES строится вокруг поля _source
— исходного документа, пришедшего в ES и затем проиндексированного Lucene.
Вспомним пример документа, который мы храним в ES:
{
sha256: "1a4ad2c5469090928a318a4d9e4f3b21cf1451c7fdc602480e48678282ced02c",
meta: [
{
id: "21264f64460498d2d3a7ab4e1d8550e4b58c0469744005cd226d431d7a5828d0",
short_name: "quarter.pdf",
full_name: "//winserver/store/reports/quarter.pdf",
source_id: "crReports",
extension: ".pdf",
created_datetime: "2017-01-14 14:49:36.788",
updated_datetime: "2017-01-14 14:49:37.140",
extra: [],
indexed_datetime: "2017-01-16 18:32:03.712"
}
],
content: {
size: 112387192,
indexed_datetime: "2017-01-16 18:32:33.321",
author: "John Smith",
processed_datetime: "2017-01-16 18:32:33.321",
length: "",
language: "",
state: "processed",
title: "Quarter Report (Q4Y2016)",
type: "application/pdf",
text: ".... очень много текста здесь ...."
}
}
_source
для Lucene это атомарная единица, которая по умолчанию содержит в себе все поля документа. Индекс в Lucene представляет собой последовательность токенов из всех полей всех документов.
Итак, индекс содержит N
документов. Документ содержит около двух десятков полей, при этом все поля довольно короткие, в основном типов keyword
и date
, за исключением длинного текстового поля content.text
.
Теперь попытаемся в первом приближении понять, что будет происходить когда вы попытаетесь выполнить поиск по какому-либо из полей в приведенных выше документах. Например, мы хотим найти документы с датой создания больше 14 января 2017 года. Для этого выполним следующий запрос:
curl -X POST -H "Content-Type: application/json" -d '{ range: { 'meta.created_datetime': { gt: '2017-01-14 00:00:00.000' } } }' "http://ambar:9200/ambar_file_data/_search"
Результат этого запроса вы увидите очень нескоро, по нескольким причинам:
Во-первых, в поиске будут участвовать все поля всех документов, хотя казалось бы зачем они нам нужны если мы делаем фильтрацию только по дате создания. Это происходит, т.к. атомарная единица для Lucene это _source
, а индекс по умолчанию состоит из последовательности слов из всех полей документов.
Во-вторых, ES в процессе формирования результатов поиска выгрузит в память из индекса все документы целиком с огромным и не нужным нам content.text
.
В-третьих, ES собрав эти огромные документы в памяти будет пытаться отослать их нам единым ответом.
Ок, третью причину легко решить включив source filtering
в запрос. Как быть с остальными?
Ускоряем поиск
Очевидно, что поиск, выгрузка в память и сериализация результатов с участием большего поля content.text
— это плохая идея. Чтобы избежать этого необходимо заставить Lucene раздельно хранить и обрабатывать большие поля отдельно от остальных полей документов. Опишем необходимые для этого шаги.
Во-первых, в маппинге для большого поля следует указать параметр store: true
. Так вы скажете Lucene что хранить это поле необходимо отдельно от _source
, т.е. от остального документа. При этом важно понимать, что на уровне логики, из _source
данное поле не исключится! Просто Lucene при обращении к документу будет собирать его в два приёма: берём _source
и добавляем к нему хранимое поле content.text
.
Во-вторых, надо указать Lucene что «тяжелое» поле больше нет необходимости включать в _source
. Таким образом при поиске мы больше не будем выгружать большие 100 Мб документы в память. Для этого в маппинг надо добавить следующие строчки:
_source: {
excludes: [
"content.text"
]
}
Итак, что получаем в итоге: при добавлении документа в индекс, _source
индексируется без «тяжелого» поля content.text
. Оно индексируется отдельно. В поиске по любому «лёгкому» полю, content.text
никакого участия не принимает, соответственно Lucene при этом запросе работает с обрезанными документами, размером не 100Мб, а пару сотен байт и поиск происходит очень быстро. Поиск по «тяжелому» полю возможен и эффективен, теперь он производится по массиву полей одного типа. Поиск одновременно по «тяжёлому» и «лёгкому» полям одного документа также возможен и эффективен. Он делается в три этапа:
- лёгкий поиск по обрезанным документам (
_source
) - поиск в массиве «тяжелых полей» (
content.text
) - быстрый merge результатов без возвращения всего поля
content.text
Для оценки скорости работы будем искать фразу «Иванов Иван» в поле content.text
с фильтрацией по полю content.size
в индексе из документов размером более 100 Мб. Пример запроса приведен ниже:
curl -X POST -H "Content-Type: application/json" -d '{
"from": 0,
"size": 10,
"query": {
"bool": {
"must": [
{ "range": { "content.size": { "gte": 100000000 } } },
{ "match_phrase": { "content.text": "иванов иван"} }
]
}
}
}' "http://ambar:9200/ambar_file_data/_search"
Наш тестовый индекс содержит около 3.5 млн документов. Все это работает на одной машине небольшой мощности (16Гб RAM, обычное хранилище на RAID 10 из SATA дисков). Результаты следующие:
- Базовый маппинг «в лоб» — 6.8 секунд
- Наш вариант — 6 мс
Итого, выигрыш в производительности примерно в 1 100 раз. Согласитесь, ради такого результат стоило потратить несколько вечеров на исследование работы Lucene и ElasticSearch, и еще несколько дней на написание этой статьи. Но есть у нашего подхода и один подводный камень.
Побочные эффекты
В случае, если вы храните какое-либо поле отдельно и исключаете его из _source
вас ждёт один довольно неприятный подводный камень о котором совершенно нет информации в открытом доступе или в мануалах ES.
Проблема следующая: вы не можете частично обновить поле документа из _source
с помощью update scipt
не потеряв отдельно хранимое поле! Если вы, к примеру, скриптом добавите в массив meta
новый объект, то ES будет вынужден переиндексировать весь документ (что естественно), однако при этом отдельно хранимое поле content.text
будет потеряно. На выходе вы получите обновлённый документ, но в stored_fields
у него не будет ничего, кроме _source
. Таким образом если вам необходимо обновлять какое-то из полей _source
— вам придётся вместе с ним переписывать и хранимое поле.
Итог
Для нас это второе использование ES в крупном проекте, и снова мы смогли решить все наши задачи сохранив скорость и эффективность поиска. ES действительно очень хорош, нужно лишь быть терпеливым и уметь его правильно настроить.