← назад к разделу

Когда интернет-магазин вырастает до миллионов товаров, запрос «найди всё, где есть слово "шоколад"» в обычной базе данных начинает занимать секунды: SQL проходит каждую строку и ищет вхождение. Elasticsearch решает эту задачу за миллисекунды — за счёт особой структуры данных, которая называется инвертированным индексом.

при записи документ разбирается на слова — один раз, заранее документы инвертированный индекс 1: Конфеты шоколадные 2: Шоколад в плитках 3: Печенье с шоколадом анализатор нижний регистр стоп-слова стемминг конфет →[1] шоколад → [1] [1, 2] [1, 2, 3] плитк →[2] печен →[3] поиск: запрос проходит через тот же анализаторзапрос: конфеты анализатор конфет → документ 1одно попадание в словарь вместо перебора миллионов документов

Слова в индекс кладёт анализатор, и он же разбирает поисковый запрос — поэтому «конфеты» и «конфет» встречаются в одной строке индекса. Строка хранит номера документов, так что поиск по слову — это одно обращение к словарю, а не проход по всем документам.

Обязательно

Как работает инвертированный индекс

В обычной базе данных индекс выглядит так: «значение → строка». Например, B-tree на колонке id говорит: «id=42 → вот эта строка таблицы». Это отлично работает для поиска по точным значениям.

Инвертированный индекс устроен наоборот: «слово → список документов». Elasticsearch заранее разбирает каждый документ на отдельные слова (это называется токенизацией) и запоминает, в каких документах каждое слово встретилось.

Собрать такой индекс можно на нескольких десятках строк — разбор на слова и карта «слово → номера документов»:

живой пример

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;

public class InvertedIndexDemo {
    static final Set<String> STOP = Set.of("в", "с", "на", "и", "по");
    static final String[] ENDINGS = {"ные", "ами", "ье", "ом", "ах", "ы", "и", "а", "е", "о"};
    static final Map<String, TreeSet<Integer>> index = new TreeMap<>();

    public static void main(String[] args) {
        add(1, "Конфеты шоколадные");
        add(2, "Шоколад в плитках");
        add(3, "Печенье с шоколадом");

        index.forEach((term, docs) -> System.out.println(term + " -> " + docs));
        System.out.println("запрос «конфеты»  -> " + search("конфеты"));
        System.out.println("запрос «шоколада» -> " + search("шоколада"));
    }

    static void add(int docId, String text) {
        for (String token : analyze(text)) {
            index.computeIfAbsent(token, t -> new TreeSet<>()).add(docId);
        }
    }

    static Set<Integer> search(String query) {
        Set<Integer> found = new TreeSet<>();
        for (String token : analyze(query)) {
            found.addAll(index.getOrDefault(token, new TreeSet<>()));
        }
        return found;
    }

    static List<String> analyze(String text) {
        List<String> tokens = new ArrayList<>();
        for (String word : text.toLowerCase().split("[^\\p{L}\\p{N}]+")) {
            if (!word.isEmpty() && !STOP.contains(word)) {
                tokens.add(stem(word));
            }
        }
        return tokens;
    }

    static String stem(String word) {
        for (String ending : ENDINGS) {
            if (word.endsWith(ending) && word.length() - ending.length() >= 4) {
                return word.substring(0, word.length() - ending.length());
            }
        }
        return word;
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"fmt"
	"maps"
	"regexp"
	"slices"
	"strings"
	"unicode/utf8"
)

var stop = map[string]bool{"в": true, "с": true, "на": true, "и": true, "по": true}
var endings = []string{"ные", "ами", "ье", "ом", "ах", "ы", "и", "а", "е", "о"}
var index = map[string][]int{}
var splitter = regexp.MustCompile(`[^\p{L}\p{N}]+`)

func add(docID int, text string) {
	for _, token := range analyze(text) {
		if !slices.Contains(index[token], docID) {
			index[token] = append(index[token], docID)
			slices.Sort(index[token])
		}
	}
}

func search(query string) []int {
	var found []int
	for _, token := range analyze(query) {
		for _, id := range index[token] {
			if !slices.Contains(found, id) {
				found = append(found, id)
			}
		}
	}
	slices.Sort(found)
	return found
}

func analyze(text string) []string {
	var tokens []string
	for _, word := range splitter.Split(strings.ToLower(text), -1) {
		if word != "" && !stop[word] {
			tokens = append(tokens, stem(word))
		}
	}
	return tokens
}

func stem(word string) string {
	for _, ending := range endings {
		if strings.HasSuffix(word, ending) && utf8.RuneCountInString(word)-utf8.RuneCountInString(ending) >= 4 {
			return strings.TrimSuffix(word, ending)
		}
	}
	return word
}

func show(ids []int) string {
	parts := make([]string, len(ids))
	for i, id := range ids {
		parts[i] = fmt.Sprint(id)
	}
	return "[" + strings.Join(parts, ", ") + "]"
}

func main() {
	add(1, "Конфеты шоколадные")
	add(2, "Шоколад в плитках")
	add(3, "Печенье с шоколадом")

	for _, term := range slices.Sorted(maps.Keys(index)) {
		fmt.Println(term, "->", show(index[term]))
	}
	fmt.Println("запрос «конфеты»  ->", show(search("конфеты")))
	fmt.Println("запрос «шоколада» ->", show(search("шоколада")))
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const STOP = new Set(['в', 'с', 'на', 'и', 'по']);
const ENDINGS = ['ные', 'ами', 'ье', 'ом', 'ах', 'ы', 'и', 'а', 'е', 'о'];
const index = new Map();

function add(docId, text) {
  for (const token of analyze(text)) {
    if (!index.has(token)) index.set(token, new Set());
    index.get(token).add(docId);
  }
}

function search(query) {
  const found = new Set();
  for (const token of analyze(query)) for (const id of index.get(token) ?? []) found.add(id);
  return found;
}

function analyze(text) {
  return text.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w !== '' && !STOP.has(w)).map(stem);
}

function stem(word) {
  for (const ending of ENDINGS) {
    if (word.endsWith(ending) && word.length - ending.length >= 4) return word.slice(0, -ending.length);
  }
  return word;
}

const show = (ids) => '[' + [...ids].sort((a, b) => a - b).join(', ') + ']';

add(1, 'Конфеты шоколадные');
add(2, 'Шоколад в плитках');
add(3, 'Печенье с шоколадом');

for (const term of [...index.keys()].sort()) console.log(`${term} -> ${show(index.get(term))}`);
console.log('запрос «конфеты»  -> ' + show(search('конфеты')));
console.log('запрос «шоколада» -> ' + show(search('шоколада')));
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

import re

STOP = {"в", "с", "на", "и", "по"}
ENDINGS = ["ные", "ами", "ье", "ом", "ах", "ы", "и", "а", "е", "о"]
index: dict[str, set[int]] = {}


def add(doc_id: int, text: str) -> None:
    for token in analyze(text):
        index.setdefault(token, set()).add(doc_id)


def search(query: str) -> set[int]:
    found: set[int] = set()
    for token in analyze(query):
        found |= index.get(token, set())
    return found


def analyze(text: str) -> list[str]:
    return [stem(w) for w in re.split(r"[^\w]+", text.lower()) if w and w not in STOP]


def stem(word: str) -> str:
    for ending in ENDINGS:
        if word.endswith(ending) and len(word) - len(ending) >= 4:
            return word[: -len(ending)]
    return word


def show(ids: set[int]) -> str:
    return "[" + ", ".join(str(i) for i in sorted(ids)) + "]"


add(1, "Конфеты шоколадные")
add(2, "Шоколад в плитках")
add(3, "Печенье с шоколадом")

for term in sorted(index):
    print(f"{term} -> {show(index[term])}")
print("запрос «конфеты»  -> " + show(search("конфеты")))
print("запрос «шоколада» -> " + show(search("шоколада")))
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Запрос «найди шоколад» — это один поиск по словарю: сразу находим [1, 2, 3], без перебора миллионов строк. И все три формы слова оказались в одной строке индекса.

Цена: индекс строится при каждой записи — документ надо разобрать на слова и разложить по строкам словаря, и это дороже, чем просто положить строку в таблицу. Зато поиск по тексту становится несравнимо быстрее.

Elasticsearch построен поверх Apache Lucene — той же библиотеки, что лежит в основе Solr. У PostgreSQL полнотекстовый поиск свой собственный и с Lucene никак не связан, хотя устроен на той же идее инвертированного индекса. Elasticsearch добавляет к Lucene два слоя: кластеризацию (несколько машин, шарды, репликация) и REST API для работы с данными через JSON.

Документ и индекс

Единица хранения здесь не строка, а документ: JSON-объект с уникальным _id, аналог строки в SQL. Документы одной структуры складывают в индекс, аналог таблицы, и именно по индексу идёт поиск.

# Сохранить документ с конкретным id
PUT /products/_doc/3
{
  "name": "Конфеты",
  "category_id": 1,
  "price": 150
}

# Сохранить документ, id генерирует ES
POST /products/_doc
{ "name": "Печенье", "price": 80 }

Как запустить у себя и на чём это работает

Всё, что ниже, нужно пробовать руками, и первый вопрос читателя: где. Локально Elasticsearch поднимают одной службой в docker compose:

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.15.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - ES_JAVA_OPTS=-Xms1g -Xmx1g
    ports: ["9200:9200"]
  kibana:
    image: docker.elastic.co/kibana/kibana:8.15.0
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    ports: ["5601:5601"]

discovery.type=single-node отключает поиск соседей по кластеру, иначе узел ждёт кворума и не стартует. Безопасность с версии 8.0 включена по умолчанию: пароль пользователя elastic, HTTPS с самоподписанным сертификатом и токен для подключения Kibana; для локального стенда её выключают, как выше, а на любом общем стенде оставляют, и тогда curl ходит с -u elastic:пароль и --cacert. Память задают явно: без ES_JAVA_OPTS узел возьмёт половину памяти машины.

Запросы из статей удобнее всего выполнять в Kibana, раздел Dev Tools: там консоль, куда вставляют PUT /products {...} как есть, с подсветкой и автодополнением. Без Kibana то же самое делает curl:

curl -X GET 'localhost:9200/_cat/indices?v'
curl -X POST 'localhost:9200/products/_search' -H 'Content-Type: application/json' -d '{"query":{"match":{"name":"кроссовки"}}}'

Про то, что именно вы запускаете. Elasticsearch с 2021 года распространяется не под Apache 2.0, а под двойной лицензией SSPL и Elastic License 2.0, а с версии 8.16 к ним добавлена AGPL; для использования внутри компании это ничего не меняет, а вот предлагать его как сервис клиентам нельзя. В ответ на смену лицензии AWS сделал форк с версии 7.10, OpenSearch, под Apache 2.0: те же индексы и почти тот же API, но своя ветка развития, свои клиенты (opensearch-java) и своя панель вместо Kibana. Управляемые сервисы у облаков это чаще именно OpenSearch. Для этих статей разницы почти нет, а вот при выборе клиента она есть: библиотеки у двух систем разные и не взаимозаменяемы — elasticsearch-py и opensearch-py в Python, go-elasticsearch и opensearch-go в Go, @elastic/elasticsearch и @opensearch-project/opensearch в Node, Spring Data Elasticsearch и Spring Data OpenSearch в Java.

Где выполнять примеры

Все примеры в этом разделе записаны в том виде, в каком их набирают в консоли разработчика Kibana: строка запроса (PUT /products) и под ней тело в JSON. Это не команда оболочки — в терминал её так не вставить.

Выполнять их можно двумя способами. Первый: поднять Elasticsearch и Kibana рядом и работать в консоли разработчика — она подставляет адрес и заголовки сама, умеет автодополнение и форматирование.

# docker-compose.yml для локальной пробы
services:
  es:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.15.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - ES_JAVA_OPTS=-Xms1g -Xmx1g
    ports: ["9200:9200"]
  kibana:
    image: docker.elastic.co/kibana/kibana:8.15.0
    environment:
      - ELASTICSEARCH_HOSTS=http://es:9200
    ports: ["5601:5601"]

Второй способ — тот же запрос обычным HTTP-клиентом, и тогда он выглядит так:

curl -X PUT 'http://localhost:9200/products' \
  -H 'Content-Type: application/json' \
  -d '{ "settings": { "number_of_shards": 1 } }'

То есть строка PUT /products из примера превращается в метод и адрес, а тело — в -d. Дальше в разделе используется первая, короткая запись: она компактнее и это тот вид, в котором примеры Elasticsearch встречаются в документации и в чужих статьях.

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

Почему документ виден не сразу

Внутри каждого шарда (о шардах — ниже) данные хранятся в неизменяемых сегментах — файлах на диске. Записать документ «прямо в сегмент» нельзя: сегмент закрыт. Вместо этого новые документы сначала накапливаются в памяти.

В памяти — но не только: параллельно каждый документ дописывается в журнал на диске (его называют translog). Поэтому та самая секунда до появления документа в поиске — это не окно потери данных: если узел выключат, при старте он проиграет журнал и вернёт всё, что успел принять.

Каждую секунду Elasticsearch выполняет refresh: сбрасывает буфер в новый сегмент, и этот сегмент становится видим для поиска. Именно поэтому ES называют near-real-time (почти реальное время): документ, записанный прямо сейчас, появится в результатах поиска через ~1 секунду.

документ принят узлом буфер в памяти и запись в translog refresh раз в секунду новый сегмент документ виден поиску merge сегменты сливаются

Путь документа от приёма до слияния: в поиске он появляется не в момент записи, а после ближайшего refresh, и всё это время его страхует журнал на диске.

Оговорка для ES 7 и новее: если refresh_interval не задан явно и по индексу 30 секунд не было поиска (index.search.idle.after), фоновый refresh останавливается до первого же поискового запроса.

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

При массовой загрузке данных refresh лучше временно отключить:

# Отключить автоматический refresh
PUT /products/_settings
{ "index": { "refresh_interval": "-1" } }

# ... загрузить данные ...

# Вернуть и обновить вручную
PUT /products/_settings
{ "index": { "refresh_interval": "1s" } }
POST /products/_refresh

Это даёт 5–10-кратное ускорение при начальной загрузке миллионов документов.

Ещё одно следствие: обновление документа — это не правка на месте. ES помечает старую версию как удалённую и создаёт новый сегмент с новой версией. Старые сегменты периодически сливаются в большие (это называется merge), и только тогда удалённые документы физически исчезают.

Кластер: как данные распределяются по машинам

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

В кластере есть несколько типов узлов:

  • Master-узлы — следят за состоянием кластера: какие индексы существуют, где лежат шарды, какие узлы живы. Для надёжности нужно минимум три master-узла (чтобы при потере одного оставшиеся два могли выбрать нового лидера).
  • Data-узлы — хранят данные и выполняют поиск. Именно их надо масштабировать, когда данных становится больше.
  • Coordinating-узлы — принимают запросы от приложения, рассылают их по data-узлам, собирают и возвращают результат. По умолчанию любой узел может выполнять эту роль.

Маленький кластер обычно совмещает роли: три машины работают и как master, и как data. При росте нагрузки master-роль выносят на отдельные машины.

Шарды и реплики

Индекс разрезан на primary-шарды — части, которые распределены по разным data-узлам. Число primary-шардов задаётся при создании индекса и на лету не меняется. Изменить его всё-таки можно, но только отдельной операцией и с ограничениями: _split увеличивает число шардов кратно, _shrink уменьшает, и оба требуют перевести индекс в режим только для чтения. У _shrink условие ещё жёстче: все шарды индекса должны сначала оказаться на одном узле, иначе операция просто не начнётся. Поэтому число шардов лучше прикинуть заранее.

Каждый primary-шард имеет реплики — точные копии, которые живут на других узлах. Реплик может быть несколько, и их число можно менять в любой момент.

PUT /products
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1
  }
}

Зачем это нужно:

  • Масштабирование чтения: запросы распределяются между primary и репликами. Больше реплик — больше параллельных читателей.
  • Отказоустойчивость: если узел с primary-шардом упал, одна из реплик автоматически становится новым primary.

При записи документа Elasticsearch вычисляет, в какой шард он попадёт: шард = hash(routing) % число_primary_шардов, где routing по умолчанию равен _id. Значение routing можно задать и самому — тогда все документы одного заказа или одного пользователя лягут в один шард, и запрос по ним не пойдёт опрашивать весь кластер. Запись идёт в primary, который синхронно передаёт её репликам.

документ индекс products hash(_id) % 3 шард 0 узел A, копия B шард 1 узел B, копия C шард 2 узел C, копия A

Куда попадает документ: номер шарда считается из routing, шард живёт на своём узле, а его копия на соседнем, поэтому падение одного узла не уносит данные.

Хороший размер одного шарда — от 10 до 50 ГБ. Слишком маленькие шарды создают лишние накладные расходы, слишком большие — замедляют поиск и обслуживание.

Маппинг: схема индекса

Маппинг описывает структуру документа: какой тип у каждого поля и как его индексировать.

Динамический маппинг работает из коробки: Elasticsearch сам определяет тип при первой записи. Строка "price": "150" становится полем типа text, число "price": 150 — типом long. Удобно для экспериментов, но опасно в промышленной эксплуатации: если первый документ задал неправильный тип, все последующие будут приводиться к нему, и запросы начнут давать неожиданные результаты.

И главная производственная ловушка: индекс не обязательно создаёте вы руками. Записали документ в несуществующий индекс — Elasticsearch заведёт его сам и тут же зафиксирует типы по первому документу. То же самое делает клиентский код при старте приложения, если ему это разрешено. Переиграть потом нельзя — только новый индекс и _reindex.

Поэтому на рабочих индексах всегда задают явный маппинг:

PUT /products
{
  "mappings": {
    "properties": {
      "name":        { "type": "text", "analyzer": "russian" },
      "category_id": { "type": "long" },
      "price":       { "type": "scaled_float", "scaling_factor": 100 },
      "tags":        { "type": "keyword" },
      "created_at":  { "type": "date" }
    }
  }
}

Главные типы полей:

  • text — строка для полнотекстового поиска. Разбивается на слова, хранится в инвертированном индексе. Для описаний и имён.
  • keyword — строка как есть, без разбивки. Для категорий, статусов, тегов — всего, где нужен точный поиск или сортировка.
  • text + keyword вместе — частый приём: одно поле доступно и для полнотекстового поиска, и для точного совпадения.
  • long, integer, float, scaled_float — числа.
  • date — дата в формате ISO 8601.
  • boolean — true/false.
  • geo_point — координаты для географических запросов.
  • dense_vector — вектор для семантического поиска: сам тип есть с ES 7, приближённый поиск ближайших соседей — с ES 8.

Важное ограничение: тип поля нельзя изменить после создания индекса. Если ошиблись — нужно создать новый индекс с правильным маппингом и перенести в него данные через API _reindex.

Откуда берётся документ в ответе и чем платят агрегации

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

_source — исходный JSON документа, сохранённый целиком отдельным блоком. Именно его вы видите в ответе, из него работает подсветка совпадений и переиндексация. Отключить его можно (_source: { enabled: false }), и тогда индекс станет меньше, но вы потеряете и выдачу документов, и возможность переиндексировать без перезаливки из источника — поэтому так почти никогда не делают.

Инвертированный индекс (index: true, по умолчанию) — то, по чему ищут. Полю, по которому никогда не ищут, а только отдают в ответе, индекс не нужен: index: false экономит место и время индексации.

doc_values — колоночное хранение значений поля, по которому работают сортировка, агрегации и скрипты. Это отдельная структура: искать по ней нельзя, зато читать значения всех документов подряд — быстро. Включена по умолчанию для всех типов, кроме анализируемого текста; отключают её (doc_values: false) полям, по которым точно не сортируют и не агрегируют.

Отсюда практические выводы. Сортировка и фасеты по текстовому полю не работают вовсе — для этого держат его копию типа keyword (в стандартном отображении это подполе .keyword). Агрегации читают не индекс, а колоночные значения, и потому требуют памяти пропорционально числу уникальных значений — отсюда и их способность положить узел. И отображение поля — это выбор между тремя ролями: «искать», «сортировать и считать», «отдавать в ответе», — а не единый переключатель.

Массив объектов: главная ловушка отображения

Ошибка, которую делают почти все, и находится она поздно. Поле с массивом объектов по умолчанию схлопывается: Elasticsearch не хранит, какое значение из какого элемента. Для документа

{ "variants": [ { "color": "красный", "size": 40 }, { "color": "синий", "size": 42 } ] }

индекс запомнит только, что цвета — «красный» и «синий», а размеры — 40 и 42. Поэтому запрос «красный и размер 42» этот документ найдёт, хотя такого варианта нет.

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

{ "mappings": { "properties": {
    "variants": { "type": "nested", "properties": {
        "color": { "type": "keyword" }, "size": { "type": "integer" } } } } } }

Запрашивать такие поля надо особым образом (nested-запросом), а не обычным условием — иначе они просто не найдутся. Цена: документов внутри индекса становится больше (по одному на элемент), запросы дороже, обновление одного элемента переписывает весь документ. Поэтому nested берут там, где комбинация полей внутри элемента действительно важна, а не «на всякий случай».

Сколько шардов и что с лицензией

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

Лицензия. Факт, который влияет на выбор стека: с версии 7.11 Elasticsearch больше не под свободной лицензией Apache 2.0 — он распространяется под SSPL и собственной лицензией Elastic. В ответ на это появился форк OpenSearch (под Apache 2.0), который поддерживают другие вендоры и который предлагают часть облаков. Совместимость между ними есть на уровне основных возможностей, но с версиями они уже разошлись, и клиентские библиотеки различаются.

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

Анализаторы: как текст превращается в слова

Когда Elasticsearch индексирует поле типа text, он не просто разбивает строку по пробелам. Текст проходит через анализатор — цепочку из трёх шагов:

  1. Character filters — предварительная обработка: убрать HTML-теги, заменить символы.
  2. Tokenizer — разбить на слова (токены).
  3. Token filters — обработать каждое слово: привести к нижнему регистру, убрать стоп-слова, привести к корневой форме (стемминг).
Исходный текст: "Конфеты «Шоколадные» 150г"
После tokenizer: ["Конфеты", "Шоколадные", "150г"]
После lowercase:  ["конфеты", "шоколадные", "150г"]
После стемминга:  ["конфет", "шоколадн", "150г"]

Те же шаги применяются к поисковому запросу — как в примере выше, где analyze работала и при записи, и при поиске. Пользователь вводит «конфеты» — ES применяет стемминг, получает «конфет» и ищет именно это в инвертированном индексе. Поэтому запрос «конфеты» найдёт и «конфета», и «конфет» — все формы одного слова.

Встроенные анализаторы:

  • standard — разбивка по словам + нижний регистр. Хорошо для латиницы, но без стемминга.
  • russian — стемминг + русские стоп-слова («в», «на», «с» и другие). Использовать для русскоязычных полей.
  • english — то же для английского.
  • keyword — не разбивает: всё поле = один токен. Применяется автоматически к полям типа keyword.

Для автодополнения (когда пользователь набирает «конф», а система уже предлагает «конфеты») используют edge_ngram: при индексации слово разбивается на префиксы — к, ко, кон, конф, конфе, конфет, конфеты. Поиск по «конф» мгновенно находит все слова с таким началом.

Для специфических задач составляют пользовательский анализатор:

PUT /products
{
  "settings": {
    "analysis": {
      "filter": {
        "russian_stop": { "type": "stop", "stopwords": "_russian_" },
        "russian_stemmer": { "type": "stemmer", "language": "russian" }
      },
      "analyzer": {
        "ru_text": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "russian_stop", "russian_stemmer"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": { "type": "text", "analyzer": "ru_text" }
    }
  }
}
Дополнительно: при первом чтении можно пропустить

Глубже: массивы объектов: nested и joinрасширенное

Классическая ошибка моделирования выглядит так. Товар хранит варианты: variants: [{color: "red", size: 41}, {color: "blue", size: 42}]. Запрос «красный и 42-й размер» находит этот товар, хотя красного 42-го у него нет. Причина в том, что Elasticsearch хранит массив объектов не как список объектов, а как набор плоских полей: variants.color: [red, blue], variants.size: [41, 42]. Связь между цветом и размером внутри одного варианта потеряна, и условие «red» и «42» выполняется по разным элементам.

Первое решение это тип nested: каждый объект массива индексируется отдельным скрытым документом рядом с родителем, и запрос по нему явно говорит, что оба условия должны совпасть внутри одного объекта:

PUT /products
{ "mappings": { "properties": { "variants": { "type": "nested" } } } }

POST /products/_search
{ "query": { "nested": { "path": "variants", "query": { "bool": { "must": [
  { "term": { "variants.color": "red" } },
  { "term": { "variants.size": 42 } }
] } } } } }

Цена: каждый вложенный объект это документ в индексе, товар с сотней вариантов это сто один документ; обновление одного варианта переиндексирует товар целиком; обычный запрос по variants.color без обёртки nested вложенные поля не видит. Для нескольких вариантов на товар это нормальная плата, для тысяч вложенных объектов нет.

Второе решение, тип join, делает родителя и детей отдельными документами с отношением между ними: варианты живут своими документами, обновляются по одному, а запрос has_child находит товар, у которого есть подходящий ребёнок. Плата другая: родитель и дети обязаны лежать в одном шарде, join в индексе может быть только один, а запросы с ним заметно медленнее обычных. Берут, когда детей много и они меняются чаще родителя, например комментарии к статье.

Третье решение обходится без обоих: денормализовать наоборот, документ на вариант с повторением полей товара. Поиск становится плоским и быстрым, а витрина группирует результаты по product_id через агрегацию terms или collapse. Для каталога с фильтрами это чаще всего лучший вариант, потому что фильтруют и сортируют именно варианты (цена, размер), а не товар.

Коротко

  • Elasticsearch хранит данные в инвертированном индексе: слово → список документов. Это делает полнотекстовый поиск быстрым даже по миллионам записей.
  • ES работает в режиме near-real-time: новый документ виден в поиске через ~1 секунду после записи, не мгновенно. Обновление документа — это новая версия, а не правка на месте; старая уходит при следующем слиянии сегментов.
  • Индекс делится на primary-шарды (с версии 7 по умолчанию один; меняются только через _split, _shrink или переиндексацию) и реплики (меняются в любой момент): шарды держат в пределах десятков гигабайт, реплики ускоряют чтение и дают отказоустойчивость.
  • Маппинг — схема индекса. Его задают явно до первой записи; тип поля нельзя изменить без пересоздания индекса и _reindex.
  • Анализатор разбивает текст на токены при индексации и при поиске — поэтому запросы находят разные формы одного слова. Для русского текста берут анализатор russian или свой с Russian Stemmer.
  • Локально: docker compose с discovery.type=single-node, запросы в Kibana Dev Tools или curl; с 8.0 безопасность включена; OpenSearch это форк с 7.10 под Apache 2.0 со своими клиентами.
  • Массив объектов хранится плоско, и «красный И 42-й» находит не тот товар: nested хранит объекты скрытыми документами, join делает детей отдельными документами, а часто проще документ на вариант.
  • У поля три роли, и ими управляют отдельно: _source даёт документ в ответе, index — поиск, doc_values — сортировку и агрегации; по анализируемому тексту сортировать нельзя, для этого держат подполе keyword.

Что почитать дальше

  • Query DSL и relevance scoring — как формулировать запросы к индексу.
  • Клиенты Elasticsearch — подключение на примере Java; официальные клиенты для Go (go-elasticsearch), Node (@elastic/elasticsearch) и Python (elasticsearch) устроены так же: один клиент на приложение, запросы тем же JSON.
  • Operations — управление индексами, снапшоты, мониторинг.
  • PostgreSQL FTS или Elasticsearch — когда отдельный движок не нужен.