Поиск по большим документам в ElasticSearch

055df7ed0cc8404f8ab20eb3c1764caf.png

Продолжаем цикл статей о том, как мы постигали 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 действительно очень хорош, нужно лишь быть терпеливым и уметь его правильно настроить.

Комментарии (0)

© Habrahabr.ru