Elasticsearch: полнотекстовый поиск, индексирование и масштабирование без магии
Elasticsearch без магии: полнотекстовый поиск, индексирование и масштабирование
Elasticsearch часто называют «быстрой поисковой системой в реальном времени». На практике полезнее думать о нём как о распределённом поисково-аналитическом движке поверх Apache Lucene. Он принимает JSON-документы через REST API, раскладывает текст по индексам, строит обратный индекс и даёт Query DSL для полнотекстового поиска, фильтрации и агрегаций.
Когда LIKE уже мешает
Для маленького каталога запрос вроде ILIKE ’%iphone%’ может быть нормальным решением. Но он быстро начинает мешать, когда появляются требования:
— искать по нескольким полям с разным весом;- учитывать формы слов: «телефон», «телефоны», "телефона«;- ранжировать документы, а не просто возвращать совпавшие строки;- фильтровать по цене, категории и наличию одновременно с поиском;- строить фасеты по брендам, категориям и другим признакам;- выдерживать рост данных и нагрузки на чтение.
Elasticsearch не заменяет транзакционную СУБД. В нормальной схеме PostgreSQL, MySQL или другая основная база остаётся источником истины, а Elasticsearch работает как поисковый индекс, который можно пересобрать из первичных данных.
Базовые сущности
Данные в Elasticsearch хранятся как JSON-документы. В этой модели:
— индекс — логическая коллекция документов, например products;- документ — JSON-объект;- поле — конкретный атрибут документа: title, price, brand;- mapping — схема индекса: типы полей и правила анализа текста.
Одна из частых ошибок — полностью положиться на автоматический mapping. Elasticsearch умеет угадывать типы, но в поиске такие догадки быстро превращаются в сюрпризы. Например, brand обычно нужен для фильтров и агрегаций, а title — для полнотекстового поиска. Это разные режимы хранения.
Ключевое различие:
— text анализирует строку и разбивает её на токены. Подходит для полнотекстового поиска по названию, описанию или статье.- keyword хранит значение как цельный термин. Подходит для фильтров, сортировок, агрегаций и точных совпадений.
Для товара Apple iPhone 15 Pro поле title типа text будет разобрано на токены вроде apple, iphone, 15, pro. А поле brand типа keyword останется цельным значением Apple.
Минимальный сценарий для каталога
Для каталога товаров обычно делают так:
1. title и description индексируют как text и пропускают через анализатор.2. brand, category и id хранят как keyword, чтобы использовать в точных фильтрах и агрегациях.3. В multi_match повышают вес title, например title^3, потому что совпадение в названии важнее совпадения в описании.4. filter используют для категории, наличия и цены: он не влияет на текстовую релевантность, а только ограничивает набор документов.5. aggs считают фасеты, например количество найденных товаров по брендам.
Такой сценарий похож на поиск в интернет-магазине: пользователь вводит запрос, а интерфейс рядом показывает фильтры по бренду, цене, наличию и категории.
Что делает анализатор
Полнотекстовый поиск начинается не с запроса, а с анализа текста при индексировании.
Анализатор состоит из трёх этапов:
1. Character filters меняют исходную строку до токенизации, например убирают HTML.2. Tokenizer разбивает строку на токены.3. Token filters нормализуют токены: приводят к нижнему регистру, удаляют стоп-слова, применяют стемминг.
Например, строка «Смартфоны с быстрыми процессорами» превращается в набор нормализованных терминов. Поэтому запрос «смартфон процессор» может найти документ, где были слова «смартфоны» и «процессорами».
Компромисс простой: чем активнее нормализация, тем выше шанс найти полезный документ по другой форме слова. Но вместе с этим растёт риск объединить слова, которые пользователь считал разными. Для русского языка это особенно заметно: стемминг помогает, но не заменяет полноценную морфологию и доменные словари.
Обратный индекс
Реляционная таблица обычно отвечает на вопрос: «какие поля есть у документа?». Поисковому движку чаще нужен обратный вопрос: «в каких документах встречается термин?».
Упрощённо обратный индекс может выглядеть так:
— iphone → sku-1001;- pro → sku-1001;- смартфон → sku-1001, sku-1002;- android → sku-1002.
Когда приходит запрос «смартфон pro», Elasticsearch не сканирует все JSON-документы. Он смотрит posting lists для терминов, находит кандидатов и считает релевантность.
Для полнотекстового ранжирования по умолчанию используется BM25, реализованный в Lucene. Он учитывает:
— сколько раз термин встречается в документе;- насколько редок термин во всей коллекции;- насколько длинное поле относительно среднего;- какие поля участвовали в запросе и какие boost-коэффициенты заданы.
Из-за этого документ с pro в title может оказаться выше документа, где pro встречается только в длинном описании. Никакой магии здесь нет: это вычисляемая оценка по статистике индекса и параметрам запроса.
Query DSL: поиск отдельно, фильтрация отдельно
Elasticsearch Query DSL — JSON-язык запросов. Удобнее всего читать его как дерево решений.
Типовой запрос для каталога делится на две части:
— query отвечает за полнотекстовый поиск и _score;- filter отбрасывает документы по точным условиям и обычно кэшируется эффективнее.
Fuzziness может помочь при небольших опечатках, например в запросе iphon вместо iphone. Но бесплатным его назвать нельзя: Elasticsearch расширяет запрос до близких вариантов термина, из-за чего растёт нагрузка и могут появляться странные совпадения на коротких словах. Лучше включать fuzziness только там, где пользователь действительно ждёт «поиск с исправлением».
Автодополнение
Автодополнение часто пытаются сделать тем же match, что и обычный поиск. Это плохо работает, потому что пользователь вводит префикс, а обычный анализатор рассчитан на полные токены.
Один из вариантов — edge_ngram. При индексировании слово режется на префиксы: для iphone появятся ip, iph, ipho, iphon, iphone.
Плата за такой подход — больший индекс, потому что вместо одного токена хранится несколько префиксов. Для небольшого каталога это обычно приемлемо. Для десятков миллионов документов уже приходится считать размер индекса, частоту обновлений и требования к latency.
Шарды и реплики
Индекс физически делится на primary shards. Каждый шард — самостоятельный Lucene-индекс. Elasticsearch распределяет шарды по узлам кластера.
Replica shards — копии primary-шардов. Они нужны для отказоустойчивости и увеличения пропускной способности чтения.
Основные сущности:
— primary shard хранит часть данных индекса;- replica shard копирует primary shard и обслуживает чтение;- node — процесс Elasticsearch, на котором размещаются шарды;- cluster — набор узлов с общим состоянием.
При поиске запрос рассылается на шарды. Каждый шард возвращает локальные top-N результаты, после чего coordinating node объединяет их в общий ответ.
Горизонтальное масштабирование не бесплатное: чем больше шардов, тем больше служебных структур, сетевых пересылок и работы на координацию. Поэтому идея «сразу поставить много шардов на будущее» часто плохая. Число primary-шардов нельзя просто уменьшить у существующего индекса без переиндексации или shrink-операций.
Near real-time
Elasticsearch часто описывают как систему реального времени, но точнее говорить near real-time.
После индексирования документ не обязан мгновенно стать доступным для поиска. Elasticsearch периодически делает refresh: открывает новые сегменты Lucene для поиска. По умолчанию refresh interval обычно составляет около одной секунды для активно используемых индексов, но это настройка, а не гарантия строгой синхронности.
В тестах можно вручную вызвать _refresh, чтобы документ сразу появился в поиске. В продакшене делать так после каждого документа нельзя: refresh дорогой, увеличивает нагрузку и создаёт больше мелких сегментов. Если нужен сценарий «записали товар и сразу нашли его», можно использовать refresh=wait_for. Если важнее скорость записи логов, refresh interval часто увеличивают.
Алгоритм внедрения
Практический путь внедрения Elasticsearch обычно такой:
1. Определить источник истины. Например, товары живут в PostgreSQL, а Elasticsearch получает проекцию для поиска.2. Описать поисковые сценарии: обычный поиск, фильтры, сортировки, фасеты, автодополнение, опечатки.3. Спроектировать mapping: text, keyword, числовые типы, даты, boolean, multi-field.4. Настроить анализаторы: lowercase, стоп-слова, стемминг, синонимы, доменные термины.5. Загружать данные батчами через Bulk API, а не одиночными POST _doc.6. Сравнивать выдачу с ручной оценкой на реальных запросах.7. Настраивать релевантность: boosts, multi_match type, fuzziness, synonyms, фильтры.8. Планировать эксплуатацию: heap, размер шардов, refresh, merge, latency, диск и скорость индексации.
Главная мысль: Elasticsearch полезен не потому, что «магически ищет», а потому что даёт правильные структуры данных и инструменты для поиска. Он анализирует текст, строит обратный индекс, ранжирует документы и распределяет нагрузку по шардам. Но качество результата всё равно зависит от mapping, анализаторов, запросов и дисциплины эксплуатации.