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

Секреты частичного поиска документов через query_string в Elasticsearch. Разбираем анализ текста, экранирование и стратегии индексирования без занудства!

Поиск по частичному номеру документа — распространенная задача в системах управления документами, CRM и ERP-системах. В Elasticsearch для этого существует несколько подходов, но использование query_string требует понимания того, как работает анализ текста, экранирование спецсимволов и выбор правильной стратегии индексирования.

Самое главное помнить: стандартный анализатор (standard analyzer) разбивает номера документов вроде INV-2023-001 на отдельные токены (inv, 2023, 001). Поиск подстроки 2023-001 через query_string по полю с таким анализатором не сработает, так как дефис является разделителем. Для корректного частичного поиска необходимо заранее настроить маппинг (mapping) поля.


Подготовка маппинга (Mapping) — фундамент успеха

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

Стратегия А: Поле keyword + wildcard (Рекомендуемая для точных префиксов/суффиксов)

Тип wildcard (доступен с Elasticsearch 7.9+) оптимизирован для запросов с подстановочными знаками (*, ?) без необходимости использования тяжелых n-gram’ов.

PUT /documents
{
 "mappings": {
 "properties": {
 "doc_number": {
 "type": "wildcard" 
 }
 }
 }
}

Стратегия Б: Поле keyword + ngram / edge_ngram (Для поиска подстроки в середине)

Если нужно искать 2023 внутри INV-2023-001 без указания префикса INV-*, используют n-gram токенизацию на этапе индексирования.

PUT /documents
{
 "settings": {
 "analysis": {
 "filter": {
 "autocomplete_filter": {
 "type": "edge_ngram",
 "min_gram": 2,
 "max_gram": 20
 }
 },
 "analyzer": {
 "autocomplete": {
 "type": "custom",
 "tokenizer": "keyword",
 "filter": ["lowercase", "autocomplete_filter"]
 }
 }
 }
 },
 "mappings": {
 "properties": {
 "doc_number": {
 "type": "text",
 "analyzer": "autocomplete",
 "search_analyzer": "standard",
 "fields": {
 "keyword": { "type": "keyword" }
 }
 }
 }
 }
}

Стратегия В: Мульти-поля (Best of both worlds)

Часто хранят номер и как keyword (для точного совпадения/агрегаций), и как wildcard или text с n-gram (для поиска).

"doc_number": {
 "type": "keyword",
 "fields": {
 "wildcard": { "type": "wildcard" },
 "ngram": { 
 "type": "text", 
 "analyzer": "autocomplete", 
 "search_analyzer": "standard" 
 } }
}

Синтаксис query_string для частичного поиска

Запрос query_string использует синтаксис Lucene. Основные операторы для частичного совпадения:

  • * — заменяет ноль или более символов.
  • ? — заменяет ровно один символ.
  • "...", точная фраза (экранирует спецсимволы внутри).

Пример 1: Поиск по префиксу (начинается на…)

Ищем документы, номер которых начинается на INV-2023.

GET /documents/_search
{
 "query": {
 "query_string": {
 "query": "INV-2023*",
 "fields": ["doc_number.wildcard"], 
 "default_operator": "AND"
 }
 }
}

Пример 2: Поиск по суффиксу (заканчивается на…)

Ищем документы, заканчивающиеся на 001.

Важно: Поиск с ведущей звездочкой (*001) очень ресурсоемок для обычных инвертированных индексов. Тип wildcard справляется с этим лучше, но все равно нагружает CPU. Для частых суффиксных поисков лучше хранить реверсную копию номера.

GET /documents/_search
{
 "query": {
 "query_string": {
 "query": "*001",
 "fields": ["doc_number.wildcard"],
 "allow_leading_wildcard": true 
 }
 }
}

Пример 3: Поиск подстроки в середине (содержит…)

Ищем 2023 в любом месте.

GET /documents/_search
{
 "query": {
 "query_string": {
 "query": "2023",
 "fields": ["doc_number.wildcard"],
 "allow_leading_wildcard": true
 }
 }
}

Пример 4: Маска с неизвестными символами (например, формат INV-YYYY-NNN)

Ищем документы 2023 года с любым номером: INV-2023-??? или INV-2023-*.

GET /documents/_search
{
 "query": {
 "query_string": {
 "query": "INV-2023-???",
 "fields": ["doc_number.wildcard"]
 }
 }}

Критически важные параметры query_string

Параметр Описание Рекомендация
fields Массив полей для поиска. Поддерживает бусты (^) Всегда указывайте явно: ["doc_number.wildcard^2", "doc_number.ngram"]
allow_leading_wildcard Разрешает * или ? в начале строки По умолчанию true. Для wildcard поля работает быстро. Для text/keyword без n-gram — крайне медленно. Ставьте false, если пользователь не должен искать по суффиксу.
analyze_wildcard Пытается проанализировать wildcard-термы через поисковый анализатор Обычно false. Для wildcard типа поля не требуется.
default_operator OR или AND между термами Для номеров документов лучше AND (если пользователь ввел «INV 2023», ищем оба токена).
lenient Игнорирует ошибки формата (например, поиск текста в numeric поле) true, если схема данных нестабильна.
quote_field_suffix Суффикс поля для точного поиска в кавычках Удобно с мульти-полями: ".keyword". Тогда "INV-2023" пойдет в doc_number.keyword, а INV*, в doc_number.wildcard.

Экранирование спецсимволов (Security & Correctness)

Синтаксис query_string полон зарезервированных символов: + ⎯ = && || > < ! ( ) { } [ ] ^ " ~ * ? : /.

Если номер документа содержит их (например, INV/2023/001 или DOC#123), запрос сломается или вернет неверный результат.

Правила экранирования:

  1. Используйте обратный слеш перед спецсимволом: INV/2023/001*.
  2. Или оберните весь терм в кавычки для точного совпадения: "INV/2023/001" (но тогда wildcard внутри кавычек не сработает).
  3. Лучшая практика: Экранируйте пользовательский ввод на стороне приложения (бэкенда) перед подстановкой в JSON.
// Пример на Python (elasticsearch-dsl / low level client)
from elasticsearch.helpers import escape_query_string

user_input = "INV/2023/001" # Пользователь ввел слеш
safe_query = escape_query_string(user_input) + "" 

Внимание: Если вы используете quote_field_suffix, то запрос в кавычках "INV/2023" автоматически перенаправится на поле .keyword, где спецсимволы не имеют силы, и экранирование не требуется для точного совпадения.


Альтернативы query_string (Когда не использовать)

Несмотря на гибкость, query_string имеет недостатки: сложный парсинг, риск ошибок синтаксиса (400 Bad Request), сложность тюнинга релевантности.

Альтернатива 1: wildcard query (Структурированный, безопаснее)

Не парсит синтаксис Lucene, просто ищет по маске. Идеален для поля типа wildcard.

GET /documents/_search
{
 "query": {
 "wildcard": {
 "doc_number.wildcard": {
 "value": "INV-2023-*",
 "case_insensitive": true
 }
 }
 }}

Альтернатива 2: match_phrase_prefix (Для n-gram полей)

Если вы используете стратегию с edge_ngram, этот запрос работает очень быстро и предсказуемо.

GET /documents/_search
{
 "query": {
 "match_phrase_prefix": {
 "doc_number.ngram": "INV-2023"
 }
 }
}

Альтернатива 3: regexp query (Для сложных масок)

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

GET /documents/_search
{
 "query": {
 "regexp": {
 "doc_number.keyword": {
 "value": "INV-2023-\d{3}",
 "flags": "ALL"
 }
 }
 }
}

Производительность и Best Practices

  1. Избегайте ведущих wildcard (*term) на обычных keyword/text полях. Это заставляет Elasticsearch сканировать весь индекс (Full Index Scan). Используйте тип wildcard или реверсные токены (reverse token filter).
  2. Ограничивайте длину минимального префикса. Не позволяйте пользователю искать * или ??. Валидируйте ввод: минимум 3- перед первой звездочкой.
  3. Используйте query_string только для «умного» поискового бара. Если у вас отдельные поля «Префикс», «Год», «Номер» — собирайте bool запрос из term/wildcard программно. Это быстрее и безопаснее.
  4. Кешируйте частые запросы. Wildcard-запросы не кешируются стандартным query cache. Рассмотрите использование index.prefixes на keyword поле (для префиксов до определенной длины) вместо n-gram/wildcard типа, если память позволяет.
  5. Мониторьте search.slowlog. Запросы с leading_wildcard часто попадают в медленные.

  • [ ] Маппинг: Поле doc_number имеет подполе wildcard (тип wildcard) или ngram (тип text с edge_ngram).
  • [ ] Валидация ввода: На бэкенде проверяем длину префикса (min ), экранируем спецсимволы (+ ⎯ = && || > < ! ( ) { } [ ] ^ " ~ * ? : /).
  • [ ] Формирование запроса:
    • Если нужен префикс: query_string: { query: "PREFIX*", fields: ["doc_number.wildcard"], allow_leading_wildcard: false }.
    • Если нужна подстрока/суффикс: allow_leading_wildcard: true (только на поле wildcard!).
    • Лучше: используйте wildcard query вместо query_string для программных запросов.
  • [ ] Тестирование: Проверьте на датасете 1М+ доков. Запрос должен выполняться < 50-100мс (p99).
  • [ ] Фоллбэк: Если пользователь ввел «мусор» (только спецсимволы), возвращайте понятную ошибку, а не 400 от ES.

Правильная архитектура — это заранее подготовленный маппинг под тип wildcard и использование структурированного wildcard query вместо парсинга строки query_string везде, где это возможно. Оставьте query_string только для единой строки глобального поиска, где пользователь ожидает синтаксис Google.