Перейти к содержанию
Innovialabs

Как сделать полнотекстовый поиск на сайте с Elasticsearch

Практика4 мин

Коротко

Создайте индекс с отдельными полями под каждый язык и правильными анализаторами, синхронизируйте его с основной базой через события или периодическую переиндексацию, а для опечаток и подсказок используйте fuzziness и search_as_you_type.

Как это устроено коротко

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

Шаг 1. Индекс и анализаторы

Храните каждый язык в своём поле — так у каждого будет правильный анализатор.

  • Русский: встроенные стоп-слова и стеммер для русского отбрасывают окончания («телефоны» находит «телефон»). Буквы ё и е полезно приравнять.
  • Узбекский: встроенного анализатора нет. Практичная основа — свой анализатор, который приводит текст к нижнему регистру и удаляет разные апострофы (‘ ’ ʻ ʼ ' и обратный апостроф), потому что пользователи пишут o‘zbek, o'zbek и ozbek вперемешку.
PUT /products
{
  "settings": {
    "analysis": {
      "char_filter": {
        "uz_apostrophes": {
          "type": "pattern_replace",
          "pattern": "[‘’ʻʼ'`]",
          "replacement": ""
        },
        "ru_yo": { "type": "mapping", "mappings": ["ё => е", "Ё => Е"] }
      },
      "analyzer": {
        "uz_text": {
          "type": "custom",
          "char_filter": ["uz_apostrophes"],
          "tokenizer": "standard",
          "filter": ["lowercase"]
        },
        "ru_text": {
          "type": "custom",
          "char_filter": ["ru_yo"],
          "tokenizer": "standard",
          "filter": ["lowercase", "russian_stop", "russian_stemmer"]
        }
      },
      "filter": {
        "russian_stop": { "type": "stop", "stopwords": "_russian_" },
        "russian_stemmer": { "type": "stemmer", "language": "russian" }
      }
    }
  },
  "mappings": {
    "properties": {
      "title_ru": {
        "type": "text", "analyzer": "ru_text",
        "fields": { "suggest": { "type": "search_as_you_type", "analyzer": "ru_text" } }
      },
      "title_uz": {
        "type": "text", "analyzer": "uz_text",
        "fields": { "suggest": { "type": "search_as_you_type", "analyzer": "uz_text" } }
      },
      "description_ru": { "type": "text", "analyzer": "ru_text" },
      "description_uz": { "type": "text", "analyzer": "uz_text" },
      "category": { "type": "keyword" },
      "price": { "type": "integer" },
      "updated_at": { "type": "date" }
    }
  }
}

Узбекский — агглютинативный язык: без стеммера «kitoblar» и «kitob» — разные токены. Частично это закрывают исправление опечаток и поиск по префиксу (ниже). Проверяйте на реальных запросах пользователей, а разбор на токены смотрите через API _analyze.

Тип keyword — для полей, по которым фильтруете и считаете агрегаты (категория, бренд, статус), text — для полей, где ищете.

Шаг 2. Синхронизация с основной базой

ПодходКак работаетПлюсы и минусы
Периодическая переиндексацияЗадача выбирает строки, у которых изменился updated_at, и отправляет их через Bulk APIПросто; поиск отстаёт на интервал задачи
События из приложенияПосле сохранения приложение кладёт событие в очередь, воркер обновляет индексПочти в реальном времени; нужны очередь и повторы
Change data captureИнструмент читает журнал изменений базы и передаёт измененияЛовит все изменения; больше инфраструктуры

Практические правила:

  • Отправляйте данные пачками через Bulk API, а не по одному документу.
  • Явно обрабатывайте удаления, например через колонку deleted_at.
  • Читайте и пишите через алиас индекса. Для полной пересборки создайте новый индекс, заполните его и переключите алиас без простоя.

Шаг 3. Устойчивость к опечаткам

fuzziness: "AUTO" допускает небольшое число исправлений символов в зависимости от длины слова.

GET /products/_search
{
  "query": {
    "multi_match": {
      "query": "телфон samsung",
      "fields": ["title_ru^3", "title_uz^3", "description_ru", "description_uz"],
      "fuzziness": "AUTO",
      "prefix_length": 1
    }
  }
}

prefix_length: 1 требует совпадения первой буквы — меньше шума и быстрее запрос. Буст ^3 поднимает совпадения в названии выше совпадений в описании.

Шаг 4. Автодополнение

Поле search_as_you_type создаёт служебные подполя для поиска по префиксу. Запрашивайте их через bool_prefix:

GET /products/_search
{
  "size": 5,
  "_source": ["title_ru", "title_uz"],
  "query": {
    "multi_match": {
      "query": "смартф",
      "type": "bool_prefix",
      "fields": [
        "title_ru.suggest", "title_ru.suggest._2gram", "title_ru.suggest._3gram",
        "title_uz.suggest", "title_uz.suggest._2gram", "title_uz.suggest._3gram"
      ]
    }
  }
}

На фронтенде отправляйте запрос с небольшим debounce и отменяйте устаревшие запросы.

Частые ошибки

  • Одно поле text со стандартным анализатором для всех языков.
  • Доступ к Elasticsearch напрямую из браузера. Держите его за бэкендом и никогда не открывайте в интернет без аутентификации.
  • Попытка поменять маппинг на месте. Большинство изменений маппинга требует переиндексации в новый индекс.

Частые вопросы

Для каталога или блога среднего размера часто хватает полнотекстового поиска PostgreSQL с триграммами. Elasticsearch окупается при сложной настройке релевантности, тяжёлом фасетном поиске или высокой поисковой нагрузке.

Поможем с этим

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

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.