Что происходит, когда вы индексируете документ

Вы отправляете PUT /products/_doc/1 с JSON-объектом, и Elasticsearch отвечает "result": "created". Но что реально произошло внутри? Не «документ сохранился», а что именно? За ответом нужно заглянуть в Apache Lucene — библиотеку, на которой построен весь ES.

Инвертированный индекс: термин → документы

Реляционная БД при поиске перебирает строки таблицы (или читает отдельный индекс по столбцу). Elasticsearch делает принципиально иначе: уже при индексации он строит структуру «термин → список документов, в которых этот термин встречается». Это и есть инвертированный индекс — центральная структура данных в Lucene.

Конкретный пример. Добавим три документа с полем title:

IDtitle
1«быстрый поиск»
2«точный поиск»
3«быстрый движок»

После анализа (приведение к нижнему регистру, разбивка на токены) инвертированный индекс для поля title выглядит так:

ТерминДокументы
быстрый[1, 3]
поиск[1, 2]
точный[2]
движок[3]

Теперь запрос match: { "title": "быстрый поиск" } превращается в поиск токенов быстрый и поиск. ES смотрит в таблицу выше: быстрый даёт {1, 3}, поиск даёт {1, 2}. В режиме OR (по умолчанию) объединяем — документы 1, 2, 3. В режиме AND ("operator": "and") берём пересечение — только документ 1. Никакого перебора всего индекса.

flowchart LR subgraph docs[" Документы "] D1["Doc 1: быстрый поиск"] D2["Doc 2: точный поиск"] D3["Doc 3: быстрый движок"] end subgraph idx[" Инвертированный индекс "] T1["быстрый → 1, 3"] T2["поиск → 1, 2"] T3["точный → 2"] T4["движок → 3"] end D1 --> T1 D1 --> T2 D2 --> T2 D2 --> T3 D3 --> T1 D3 --> T4
flowchart LR
    subgraph docs[" Документы "]
        D1["Doc 1: быстрый поиск"]
        D2["Doc 2: точный поиск"]
        D3["Doc 3: быстрый движок"]
    end
    subgraph idx[" Инвертированный индекс "]
        T1["быстрый → 1, 3"]
        T2["поиск → 1, 2"]
        T3["точный → 2"]
        T4["движок → 3"]
    end
    D1 --> T1
    D1 --> T2
    D2 --> T2
    D2 --> T3
    D3 --> T1
    D3 --> T4
Три документа и их инвертированный индекс: каждый токен указывает на список документов, где он встречается
Check yourself
Предположите: пользователь ищет «быстрый движок». Как ES находит нужные документы — перебирает ли он все три документа по очереди?

От текста к токенам: связь с анализатором

Прежде чем попасть в инвертированный индекс, строка проходит через анализатор (подробно — в статье Анатомия анализатора: char filters, токенизатор, token filters):

1. Char filters — предобработка символов: убрать HTML-теги, нормализовать апострофы.

2. Tokenizer — разбить строку на токены: "Быстрый поиск!"["быстрый", "поиск"].

3. Token filters — преобразовать токены: привести к нижнему регистру, удалить стоп-слова, применить стемминг.

Ключевое следствие: поле типа text хранит в инвертированном индексе токены, а не исходную строку. Именно поэтому term-запрос по text-полю не работает так, как кажется — об этом мы говорили в Типы полей: text, keyword, числа и даты. term ищет точное значение в индексе; если там лежат токены elasticsearch и 8, запрос с "Elasticsearch 8" ничего не найдёт.

Поле keyword хранит строку без анализа — и именно для него term-запрос работает предсказуемо.

Сегменты Lucene: почему индекс нельзя «отредактировать»

Lucene не хранит один монолитный файл. Каждый шард ES содержит набор сегментов — отдельных, неизменяемых (immutable) мини-индексов. Новые документы пишутся в новый сегмент; то, что уже попало в сегмент, не изменяется никогда.

Что происходит при удалении или обновлении?

  • Удаление — документ помечается в служебном файле .del как удалённый, но физически остаётся в сегменте.
  • Обновление — старая версия помечается удалённой, новая пишется в новый сегмент.

Настоящая очистка — слияние сегментов (segment merge): периодически Lucene объединяет несколько мелких сегментов в один крупный, выбрасывая помеченные документы. Для вас этот процесс полностью прозрачен.

flowchart TD A["Новые документы"] --> B["In-memory buffer"] B -->|refresh 1с| S1["Сегмент 1 — неизменяемый"] B -->|refresh 1с| S2["Сегмент 2 — неизменяемый"] B -->|refresh 1с| S3["Сегмент 3 — неизменяемый"] S1 --> M["Слияние — segment merge"] S2 --> M S3 --> M M --> S4["Большой сегмент — помеченные удалены"]
flowchart TD
    A["Новые документы"] --> B["In-memory buffer"]
    B -->|refresh 1с| S1["Сегмент 1 — неизменяемый"]
    B -->|refresh 1с| S2["Сегмент 2 — неизменяемый"]
    B -->|refresh 1с| S3["Сегмент 3 — неизменяемый"]
    S1 --> M["Слияние — segment merge"]
    S2 --> M
    S3 --> M
    M --> S4["Большой сегмент — помеченные удалены"]
Жизненный цикл сегментов Lucene: запись в буфер, refresh, неизменяемые сегменты и их периодическое слияние
Check yourself
Вы выполнили DELETE /products/_doc/2. Документ с ID 2 немедленно исчез из всех сегментов Lucene?

Near real-time: почему документ не виден сразу

Новый документ не попадает мгновенно в сегмент. Сначала он оказывается во временном буфере памяти (in-memory buffer). Раз в ~1 секунду (настраивается через index.refresh_interval) Elasticsearch выполняет refresh: переносит буфер в новый сегмент, и документ становится видимым для поиска.

Именно поэтому Elasticsearch называют near real-time поисковиком: между записью и видимостью есть окно около 1 секунды. Это осознанный компромисс — refresh при каждой записи убил бы пропускную способность.

Если нужна немедленная видимость (например, в интеграционных тестах):

POST /products/_refresh

Или при записи документа:

PUT /products/_doc/1?refresh=true
{
  "title": "Elasticsearch 8"
}
Check yourself
Вы вставили документ и через 100 миллисекунд сделали поисковый запрос — документа нет в результатах. Почему и как это исправить?

Почему полнотекстовый поиск такой быстрый

Три механизма работают вместе:

1. Инвертированный индекс устраняет линейный перебор. Поиск термина — это lookup в специализированной структуре данных, а не сканирование $N$ документов. Для типичного запроса это разница между «за миллисекунды» и «за секунды» при миллионах документов.

2. Операции над отсортированными списками. Списки документов в инвертированном индексе хранятся в отсортированном виде. Пересечение (AND) и объединение (OR) работают алгоритмом слияния — один проход по двум спискам одновременно, без вложенных циклов.

3. Параллельность по сегментам. Запрос выполняется по каждому сегменту независимо и параллельно; результаты мержатся координирующей нодой.

Кроме инвертированного индекса, Lucene строит в каждом сегменте дополнительные структуры:

  • BKD-дерево — для быстрых range-запросов по числам и датам.
  • Doc values — колоночное хранилище значений полей; обеспечивает быструю сортировку и агрегации.

Все эти структуры живут бок о бок внутри каждого сегмента.

Шард = экземпляр Lucene

Из статьи Кластер, ноды, шарды и реплики вы знаете, что шард — единица распределения данных. Добавим внутренний уровень: каждый шард это один экземпляр Lucene со своим набором сегментов, своим инвертированным индексом и своими doc values.

Один индекс ES с тремя primary-шардами — три независимых Lucene-индекса. Поисковый запрос рассылается по всем шардам параллельно, каждый шард ищет в своих сегментах, а координирующая нода собирает и ранжирует итоговые результаты. Именно это устройство позволяет ES горизонтально масштабироваться на терабайты данных без потери скорости поиска.


Quick recall
Что происходит с сегментом Lucene после его создания?
Quick recall
Почему запрос `term: { "title": "Elasticsearch 8" }` часто не находит документ, хотя в title точно записано "Elasticsearch 8"?
Quick recall
Инвертированный индекс устраняет какую проблему реляционных БД при поиске?

См. также