Как спроектировать индекс и использовать query_string для поиска телефонных номеров

Умный поиск по базе номеров: по частям, без кодов и с ошибками. Забудьте о точном совпадении — находите контакты мгновенно.

В современных проектах, где хранится телефонный справочник (например, база контактов «гадалок», где каждый клиент может искать по части номера), обычный term-поиск оказывается недостаточным. Нужно уметь находить записи по:

  • частичному номеру (например, 2354 в +7 (495) -35-46-79);
  • номеру без префикса (без +7 или 8);
  • номеру с небольшими опечатками (фаззи‑поиск);
  • разным форматам записи (пробелы, скобки, дефисы).

В этой статье мы разберём, как правильно спроектировать индекс и какие возможности query_string использовать, чтобы обеспечить быстрый и точный поиск.

Планирование схемы индекса

Хранение телефона как строки

Телефонный номер – это не число, а набор символов, поэтому в маппинге его следует объявлять типом text (или keyword в сочетании с дополнительными анализаторами). Пример базового маппинга:

{
 "mappings": {
 "properties": {
 "name": {
 "type": "text"
 },
 "phone_raw": {
 "type": "keyword"
 },
 "phone": {
 "type": "text",
 "analyzer": "phone_analyzer",
 "search_analyzer": "phone_search_analyzer"
 }
 }
 }
}

Поле phone_raw хранит оригинальное значение (для вывода), а phone – нормализованное и готовое к поиску.

Анализатор phone_analyzer

Самый надёжный способ – создать кастомный анализатор, который:

  1. Удаляет все нецифровые символы (пробелы, скобки, дефисы, «+», «8», «7»).
  2. Разбивает номер на n‑gram‑ы (обычно edge_ngram от 3 до ) – это даёт возможность искать по началу и по любой части.
  3. Опционально добавляет reverse токен, чтобы поддерживать поиск по окончанию;

Пример конфигурации:

{
 "settings": {
 "analysis": {
 "filter": {
 "phone_edge_ngram": {
 "type": "edge_ngram",
 "min_gram": 3,
 "max_gram": 7
 },
 "phone_reverse": {
 "type": "reverse"
 } },
 "char_filter": {
 "phone_digits": {
 "type": "pattern_replace",
 "pattern": "[^0-9]",
 "replacement": ""
 } },
 "analyzer": {
 "phone_analyzer": {
 "type": "custom",
 "char_filter": ["phone_digits"],
 "tokenizer": "standard",
 "filter": ["lowercase", "phone_edge_ngram"]
 }, "phone_search_analyzer": {
 "type": "custom",
 "char_filter": ["phone_digits"],
 "tokenizer": "standard",
 "filter": ["lowercase"]
 }, "phone_reverse_analyzer": {
 "type": "custom",
 "char_filter": ["phone_digits"],
 "tokenizer": "standard",
 "filter": ["lowercase", "phone_reverse", "phone_edge_ngram"]
 }
 }
 }
 }
}

В результате в индексе будет два токена: обычный (для поиска с начала) и обратный (для поиска с конца).

Техники query_string для частичных номеров

Запрос query_string позволяет использовать гибкий синтаксис, включающий:

  • wildcard‑символы * и ?;
  • фаззи‑поиск ~;
  • операторы AND, OR, NOT;
  • поиск по нескольким полям через fields;
  • префиксный поиск field:prefix*;

Поиск по началу номера

Если пользователь вводит первые цифры, достаточно использовать префиксный запрос:

GET contacts/_search
{
 "query": {
 "query_string": {
 "default_field": "phone",
 "query": "2354*"
 }
 }
}

Благодаря edge_ngram такой запрос будет находить любые номера, где 2354 находится в любой позиции (не только в начале).

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

Для «серединного» поиска удобно использовать wildcard‑запрос с обеих сторон:

GET contacts/_search
{
 "query": {
 "query_string": {
 "default_field": "phone",
 "query": "2354"
 } }
}

Впрочем, такой запрос может быть тяжёлым, если в индексе миллионы записей. Чтобы ускорить его, рекомендуется добавить reverse‑анализатор и искать через phone_rev:

GET contacts/_search
{
 "query": {
 "bool": {
 "should": [
 {
 "query_string": {
 "default_field": "phone",
 "query": "2354"
 }
 },
 {
 "query_string": {
 "default_field": "phone_rev",
 "query": "4352"
 }
 }
 ]
 }
 }}

Поиск без префикса (7/8/+7)

Поскольку в char_filter мы удаляем все нецифровые символы, номер +7 (495) -35‑46‑79 будет проиндексирован как 74952354679. Чтобы пользователь мог вводить 4952354 или просто 2354, достаточно использовать wildcard‑запрос без привязки к началу:

GET contacts/_search
{
 "query": {
 "query_string": {
 "default_field": "phone",
 "query": "4952354"
 }
 }}

Фаззи‑поиск (небольшие опечатки)

Если клиент иногда ошибается в наборе цифр, можно добавить ~ с указанием расстояния Левенштейна:

GET contacts/_search
{
 "query": {
 "query_string": {
 "default_field": "phone",
 "query": "2354~1"
 }
 }
}

Здесь ~1 означает «разрешить одну замену, вставку или удаление». Для телефонных номеров обычно достаточно ~1 или ~2.

Комбинация полей

В справочнике часто нужны одновременные запросы по имени и номеру. Для этого указываем список полей через параметр fields:

GET contacts/_search
{
 "query": {
 "query_string": {
 "fields": ["name^2", "phone"],
 "query": "Иван* 2354*"
 }
 }
}

Знак ^2 повышает вес совпадения по полю name в два раза.

Практические рекомендации по производительности

  • Избегайте ведущих wildcard‑символов (*123) без обратного индекса – они заставляют Elasticsearch сканировать все термины;
  • Для поиска «по любой части» используйте edge_ngram + reverse‑анализатор, как описано выше;
  • Ограничьте max_expansions в wildcard‑запросах, если работаете с большими объёмами.
  • Если нужен поиск по международному формату, храните отдельное поле phone_normalized без префикса 7 и создавайте alias‑поле.
  • Регулярные выражения (regexp) работают медленно; используйте их только в редких случаях, когда нужен поиск по шаблону вроде ^7[0-9]{9}$.

Полный пример создания индекса и запросов

Создание индекса

PUT /gadalka_contacts
{
 "settings": {
 "analysis": {
 "char_filter": {
 "digits_only": {
 "type": "pattern_replace",
 "pattern": "[^0-9]",
 "replacement": ""
 }
 },
 "filter": {
 "phone_edge_ngram": {
 "type": "edge_ngram",
 "min_gram": 3,
 "max_gram": 7
 },
 "reverse": {
 "type": "reverse"
 }
 },
 "analyzer": {
 "phone_analyzer": {
 "type": "custom",
 "char_filter": ["digits_only"],
 "tokenizer": "standard",
 "filter": ["lowercase", "phone_edge_ngram"]
 },
 "phone_search_analyzer": {
 "type": "custom",
 "char_filter": ["digits_only"],
 "tokenizer": "standard",
 "filter": ["lowercase"]
 },
 "phone_rev_analyzer": {
 "type": "custom",
 "char_filter": ["digits_only"],
 "tokenizer": "standard",
 "filter": ["lowercase", "reverse", "phone_edge_ngram"]
 }
 }
 }
 },
 "mappings": {
 "properties": {
 "name": {
 "type": "text",
 "analyzer": "russian"
 },
 "phone_raw": {
 "type": "keyword"
 },
 "phone": {
 "type": "text",
 "analyzer": "phone_analyzer",
 "search_analyzer": "phone_search_analyzer"
 },
 "phone_rev": {
 "type": "text",
 "analyzer": "phone_rev_analyzer",
 "search_analyzer": "phone_search_analyzer"
 }
 }
 }
}

Индексация примеров

POST /gadalka_contacts/_doc
{ "name": "Алексей Петров",
 "phone_raw": "+7 (495) -35-46-79",
 "phone": "+7 (495) -35-46-79",
 "phone_rev": "+7 (495) -35-46-79"
}

POST /gadalka_contacts/_doc
{
 "name": "Марина Сидорова",
 "phone_raw": "###-###-####",
 "phone": "###-###-####",
 "phone_rev": "###-###-####"
}

Примеры запросов

  1. Поиск по части «2354» (любой позиции):
    GET /gadalka_contacts/_search
    {
     "query": {
     "bool": {
     "should": [
     { "query_string": { "default_field": "phone", "query": "2354" } },
     { "query_string": { "default_field": "phone_rev", "query": "4352" } }
     ]
     }
     }
    }
  2. Поиск с ошибкой в одной цифре:
    GET /gadalka_contacts/_search
    {
     "query": {
     "query_string": {
     "default_field": "phone",
     "query": "2354~1"
     }
     }
    }
    
  3. Поиск по имени и номеру одновременно:
    GET /gadalka_contacts/_search
    {
     "query": {
     "query_string": {
     "fields": ["name^3", "phone"],
     "query": "Марина 9215*"
     } }
    }
    

Ошибки, которые часто встречаются

  • Не задали search_analyzer – запросы проходят через стандартный анализатор и «теряют» цифры.
  • Слишком маленькие min_gram (например, 1) приводят к огромному числу токенов и падению производительности.
  • Использование regexp без ограничения длины может привести к TooManyClausesException.
  • Запросы вида *123 без обратного индекса заставляют Elasticsearch сканировать весь инвертированный список терминов.

Для поиска по частичным номерам телефонов (в т.ч. в «справочнике гадалок») оптимальный набор техник выглядит так:

  1. Создать кастомный анализатор, который оставляет только цифры и генерирует edge_ngram токены.
  2. Добавить reverse‑анализатор, чтобы ускорить поиск по окончанию.
  3. Использовать query_string с wildcard‑символами * и, при необходимости, фаззи‑поиском ~.
  4. Комбинировать поиск по имени и номеру через параметр fields, задавая веса.
  5. Следить за производительностью: ограничивать max_gram, избегать ведущих * и использовать bool/should вместо одного огромного wildcard‑запроса.

Следуя этим рекомендациям, вы получите быстрый, гибкий и «чувствительный» к ошибкам поиск по телефонным номерам, который будет удобен как для операторов колл‑центра, так и для клиентов, ищущих «гадалку» по части номера.


Статья написана в 2026 году, основываясь на актуальных практиках Elasticsearch 8.x и опыте решения задач поиска телефонных справочников.