Почему Elasticsearch возвращает пустые результаты и как это исправить

Секреты настройки Elasticsearch: почему данные есть, а поиск пустой? Исправляем маппинг и анализаторы для идеальной релевантности.

Получение пустого результата (hits.total.value = 0) — одна из самых частых проблем, с которой сталкиваются разработчики, интегрирующие Elasticsearch. Часто причина кроется не в отсутствии данных, а в несовпадении того, как данные проиндексированы, и того, как выполняется запрос.

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


Понимание разницы между text и keyword

Это причина №1 пустых результатов. Elasticsearch имеет два основных типа строковых полей:

  • text — для полнотекстового поиска. Значение проходит через анализатор (токенизируется, приводится к нижнему регистру, удаляются стоп-слова, применяется стемминг). Поиск по этому полю также анализирует запрос.
  • keyword — для точного совпадения, фильтрации, сортировки и агрегаций. Значение сохраняется как есть (без токенизации и изменения регистра).

Типичная ошибка

Вы индексируете email User@Example.COM в поле типа keyword и ищете user@example.com (в другом регистре) или используете match запрос, который анализирует ввод.

Лучшая практика: Multi-fields (Подполя)

Всегда используйте маппинг с подполями для строковых данных, которые могут требовать и точный, и полнотекстовый поиск.

PUT /my_index
{
 "mappings": {
 "properties": {
 "title": {
 "type": "text",
 "analyzer": "standard",
 "fields": {
 "keyword": { "type": "keyword" }, // Для точного совпадения / фильтров
 "suggest": { "type": "completion" } // Для автодополнения (опционально)
 } }, "email": {
 "type": "keyword", // Email обычно не нужен в full-text, только exact
 "normalizer": "lowercase_normalizer" // Важно! См. раздел про нормализаторы
 }
 } }}

Правило: Ищите по field.keyword (через term), если нужно точное совпадение (ID, email, статусы, теги). Ищите по field (через match), если нужен полнотекстовый поиск (названия, описания, статьи).


Несоответствие анализаторов при индексации и поиске

Если вы используете кастомный анализатор (например, с нграмами для автодополнения или специфическим стеммером для русского языка), он обязательно должен применяться и при индексации, и при поиске.

Проблема

Вы создали анализатор russian_ngram для поля title. При индексации «Эластиксерч» превращается в токены: «э», «ел», «ела», «элас»…
Вы делаете запрос match без указания анализатора. Elasticsearch использует standard анализатор по умолчанию для поисковой строки. Токены запроса («эластиксерч») не совпадают с токенами в индексе («эла», «элас»). Результат пуст.

Решение

  1. Явно указывайте анализатор в запросе:
    GET /my_index/_search
    {
     "query": {
     "match": {
     "title": {
     "query": "Эластик",
     "analyzer": "russian_ngram" // Тот же, что при индексации!
     }
     }
     }
    }
  2. Или используйте search_analyzer в маппинге: Укажите отдельный анализатор для поиска (обычно без нграмов, но с тем же стеммером/фильтрами нижнего регистра), а для индексации оставьте нграмы.
    "title": {
     "type": "text",
     "analyzer": "index_ngram_analyzer", // При индексации: нграмы
     "search_analyzer": "search_standard_analyzer" // При поиске: целые слова + стемминг
    }

Использование match вместо term (и наоборот)

Непонимание того, как парсятся запросы, ведет к нулевым хитам.

Тип запроса Анализирует ввод? Для чего нужен Тип поля
match Да Полнотекстовый поиск text
match_phrase Да Поиск точной фразы (порядок слов) text
term Нет (ищет точное значение в инвертированном индексе) Точное совпадение, фильтры keyword, integer, date
terms Нет Точное совпадение с массивом значений keyword

Пример ошибки

Поле status имеет тип keyword со значением "Published".

  • Запрос { "match": { "status": "published" } } -> Анализирует «published» -> ищет токен «published». В индексе лежит «Published» (с большой буквы). Пусто.
  • Запрос { "term": { "status": "Published" } } -> Ищет точно «Published». Нашел.

Совет: Для полей keyword всегда используйте term/terms внутри filter контекста (для кэширования и производительности).


Проблемы с регистром и нормализаторы (Normalizers)

Поля типа keyword чувствительны к регистру. «Moscow» != «moscow».

Решение: Normalizer

Нормализатор — это анализатор без токенизатора. Он применяет фильтры (lowercase, asciifolding) к целому значению.

PUT /my_index
{
 "settings": {
 "analysis": {
 "normalizer": {
 "lowercase_normalizer": {
 "type": "custom",
 "filter": ["lowercase", "asciifolding"] // Убирает акценты: "café" -> "cafe"
 }
 }
 }
 },
 "mappings": {
 "properties": {
 "city": {
 "type": "keyword",
 "normalizer": "lowercase_normalizer"
 }
 }
 }
}

Теперь при индексации «Москва» и «МОСКВА» оба превратятся в «москва». Запрос term: { "city": "москва" } найдет оба варианта.


Оператор AND в match запросе (Слишком строгий поиск)

По умолчанию match использует оператор OR. Запрос «быстрый коричневый лис» найдет документы, содержащие хотя бы одно из слов.

Если вы поставите "operator": "and", документ должен содержать все слова. Опечатка в одном слове или отсутствие стоп-слова в документе дадут пустой результат.

Лучшая практика: minimum_should_match

Вместо жесткого AND используйте процентное совпадение. Это делает поиск устойчивым к опечаткам и лишним словам.

GET /articles/_search
{
 "query": {
 "match": {
 "content": {
 "query": "быстрый коричневый лис прыгает",
 "minimum_should_match": "75%" // Найдет, если совпало 3 из 4 значимых слов
 } }
 }
}

Игнорирование несуществующих полей и lenient параметр

Если вы ищете по полю, которого нет в маппинге (или оно динамически не создано), Elasticsearch по умолчанию выдаст ошибку 400 Bad Request, а не пустой результат. Но если вы используете мульти-поиск или шаблоны, где поля могут отсутствовать, добавьте параметр.

GET /_search
{
 "query": {
 "multi_match": {
 "query": "текст",
 "fields": ["title", "description", "tags"],
 "lenient": true // Игнорирует ошибки, если поля нет или типы не совпадают
 }
 }
}

Проблемы с вложенными объектами (nested)

Если у вас массив объектов:

{
 "actors": [
 { "first": "Keanu", "last": "Reeves" },
 { "first": "Carrie-Anne", "last": "Moss" }
 ]
}

И маппинг "actors": { "type": "object" } (по умолчанию), то Elasticsearch «выпрямит» это в:

{ "actors.first": ["Keanu", "Carrie-Anne"], "actors.last": ["Reeves", "Moss"] }

Запрос «Keanu Moss» найдет этот документ (ложное срабатывание), а запрос «Keanu Reeves» тоже найдет. Но если вы ищете точное сочетание, стандартный object не сработает.

Решение: Тип nested

В маппинге укажите "type": "nested". При поиске используйте nested запрос:

GET /movies/_search
{ "query": {
 "nested": {
 "path": "actors",
 "query": {
 "bool": {
 "must": [
 { "match": { "actors.first": "Keanu" }},
 { "match": { "actors;last": "Reeves" }}
 ]
 }
 }
 }
 }}

Без nested запроса поиск по вложенным полям часто дает неожиданные (или пустые, если логика сложная) результаты.


Пейджинг и track_total_hits

Иногда результаты есть, но вы их не видите.

  • По умолчанию Elasticsearch возвращает только первые 10 хитов (size: 10).
  • Если track_total_hits = 10000 (дефолт в новых версиях), и совпадений 15000, в ответе будет "total": { "value": 10000, "relation": "gte" }.

Всегда явно указывайте size и from (или используйте search_after для глубокого пейджинга) и проверяйте hits.total.value в ответе, а не длину массива hits.hits.


Кэш и «свежесть» данных (Refresh Interval)

Elasticsearch — near real-time (почти в реальном времени). По умолчанию refresh_interval: "1s".

Сценарий: Вы проиндексировали документ -> сразу сделали поиск -> пусто.

Решения:

  1. Дождаться 1 секунды (обычно достаточно).
  2. Использовать ?refresh=wait_for при индексации (ждет рефреша, замедляет запись).
  3. Использовать ?refresh=true (форсирует рефреш сразу, очень нагружает кластер, не для продакшн-объема).

Диагностика: API Validate и Explain

Если запрос возвращает пусто, не гадайте, используйте встроенные инструменты.

Validate API (Проверка синтаксиса и понимания запроса)

GET /my_index/_validate/query?explain=true
{
 "query": {
 "match": { "title": "elasticsearch" }
 }
}

В ответе поле valid: true и explanation покажет, во что Elasticsearch преобразовал ваш запрос (какие токены ищет, какие поля затрагивает). Если вы видите ConstantScore(:) или странные токены — проблема в анализаторе.

Explain API (Почему конкретный документ не попал/попал)

Если вы знаете ID документа, который должен был найтись:

GET /my_index/_explain/DOC_ID
{
 "query": { ;.. ваш запрос ... }
}

Ответ покажет детальное расчет скоринг (score) для каждого куска запроса. Вы увидите, какое условие дало score 0 и отсеяло документ.


Чек-лист: Почему поиск вернул 0 результатов

  1. Тип поля: Ищете по keyword через match? -> Используйте term или ищите по text подполю.
  2. Регистр: Поле keyword без нормализатора? -> Добавьте normalizer: lowercase.
  3. Анализатор: Кастомный анализатор при индексации, но стандартный при поиске? -> Укажите search_analyzer или analyzer в запросе.
  4. Оператор: Слишком строгий operator: "and"? -> Используйте minimum_should_match.
  5. Опечатки: Пользователь ошибся? -> Добавьте fuzziness: "AUTO" в match запрос.
  6. Nested: Ищете по массиву объектов? -> Проверьте маппинг на nested и используйте nested запрос.
  7. Свежесть: Только что записали? -> Подождите рефреш или используйте refresh=wait_for.
  8. Права доступа: (Security) У пользователя есть права на чтение индекса/полей? (Document Level Security / Field Level Security).

Избегание пустых результатов в Elasticsearch — это вопрос правильного проектирования маппинга (schema design) и понимания конвейера анализа (analysis chain).

Главные правила:

  • Всегда используйте multi-fields (text + keyword).
  • Синхронизируйте анализаторы индексации и поиска (search_analyzer).
  • Используйте normalizer для полей точного совпадения;
  • Применяйте minimum_should_match и fuzziness для устойчивости к ошибкам пользователя.
  • Диагностируйте через _validate/query?explain и _explain.

Следуя этим практикам, вы сделаете поиск предсказуемым, быстрым и удобным для конечных пользователей.