Всем привет, меня зовут Иван Елфимов, я Developer Advocate в Островке. До DevRel-ства я 5 лет руководил командой разработки партнёрских интеграций. 

Мы в Островке создаём платформы бронирования тревел-услуг не только для индивидуальных путешественников, но и для корпоративных клиентов и тревел-агентств — наших B2B-партнёров.

У B2B-партнёров может быть много клиентов и бронирований. По каждому бронированию нужна подробная информация — стоимость, комиссия, статус оплаты, кто основной гость и т. д. Всем этим наши партнёры управляют в личном кабинете. Я расскажу вам, как мы подключали в личном кабинете B2B-партнёров умный поиск по заказам. Умный, потому что может подстраиваться под поисковый запрос и иногда даже делать FTS (full-text search, полнотекстовый поиск).

Материала довольно много. Я не буду подробно писать про выбор инструмента поиска, бенчмарки и масштабируемость, а сделаю упор на внедрении, тестировании и поддержке.

Эта статья была бы невозможна без Сергея Рыжова, старшего разработчика в Островке, который проделал всю исследовательскую работу и наладил поиск заказов в личном кабинете B2B-партнёров.

Но перед тем, как начнём, я расскажу вам, зачем нам потребовался умный поиск.

Зачем нам умный поиск

В тревел-индустрии уже давно существует термин «поездка». Им пользуются и профессионалы в тревеле, и обычные путешественники. Поездка — это несколько заказов, объединённых по какому-то условию, например: бронирование отеля, билеты на самолёт или поезд, трансфер от аэропорта или вокзала до отеля, или аренда автомобиля. Заказов и услуг в поездке может быть и 1, и 10, и 20.

Наши пользователи хотят объединять заказы в группы и давать им названия: «Отпуск в мае» или «2023-04-01/05 Сидоров, Ростов-на-Дону». Раньше в интерфейсе можно было только условно объединить заказы, указывая специальный номер или комментарий. Это неудобно, потому что список заказов остаётся плоским и можно ошибиться при редактировании названия группы. Поэтому мы решили изменить интерфейс личного кабинета партнёров — группировать и отображать заказы по поездкам.

И ещё вместе с редизайном мы решили закрыть давнюю потребность партнёров — возможность искать заказы по любым терминам, а не только фильтрами по отдельным полям.

Старый дизайн:

Список заказов в личном кабинете партнёра
Список заказов в личном кабинете партнёра

Новый дизайн:

Список поездок в личном кабинете партнёра
Список поездок в личном кабинете партнёра

Выбор инструмента умного поиска

Добавление новых фильтров по отдельным полям у нас уже поставлено на поток, и мы можем доставить новый фильтр за пару часов. Но мы не умеем делать настоящий полнотекстовый поиск. Например, поиск по «сидров» не найдёт гостя с именем «Петр Сидоров». А нам хотелось бы иметь такую возможность.

В Островке есть опыт подключения FTS на PostgreSQL и ElasticSearch. FTS на PostgreSQL уступал в скорости ElasticSearch на наших бенчмарках. Да и без FTS запрос с 30 WHERE OR условиями в PostgreSQL был медленнее, чем аналогичный в ElasticSearch.

Мы любим экспериментировать с новыми технологиями. Помимо ElasticSearch и PostgreSQL, мы посмотрели на MeiliSearch. MeiliSearch — это новый модный молодёжный инструмент для полнотекстового поиска, написанный на Rust. У нас не было экспертизы в компании с MeiliSearch. Мы изучили документацию и решили его не использовать, потому что:

Если мы умеем пользоваться каким-то инструментом и он работает, то предпочитаем его современным, модным и молодёжным. Нам нравится принцип Choose Boring Technology. В итоге мы выбрали ElasticSearch.

В ElasticSearch поиск по отдельным полям и точному совпадению называется filter context. А полнотекстовый поиск — это query context. Сейчас нам достаточно организовать поиск по filter context. И когда дело дойдёт до полнотекстового, нам не придётся искать новый инструмент. Дальше я буду часто писать про индексы и документы. Индекс — это место, где можно найти документ. Документ — это сериализованный объект, который нужно найти.

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

  • как доставлять данные до поискового инструмента;

  • как перестраивать индекс полностью и частично;

  • как проверить, что индекс актуален;

  • как тестировать полнотекстовый поиск;

  • что делать, если ElasitcSearch отвалится;

  • как обойти специфичные для наших инструментов ограничения.

На нашем опыте все эти моменты занимают больше времени, чем выбор инструмента и кодирование. И мы считаем, что внедрение любой системы умного поиска столкнётся с этими проблемами. О них и расскажем.

Формирование и доставка данных

У нас есть класс, который описывает документ — OrderDocument. В нём около 80 полей. Некоторые поля вложенные. Я не могу перечислить все поля и структуры данных из-за NDA, зато могу показать соотношение количества типов полей:

  • Text+Keyword (Substring+Exact match) – 35;

  • Date – 22;

  • Text+Keyword (Enums) – 12;

  • Boolean – 5;

  • Long – 5;

  • Short – 2;

  • Float – 1;

Мы используем пост-сейв сигналы, которые запускают фоновые задачи. В фоновых задачах формируются OrderDocument для каждого заказа. Нам нужны фоновые задачи, потому что сбор данных — это тяжелая операция из-за JOIN-ов. 

Сформированные данные сериализуются и отправляются в ElasticSearch стандартными механизмами библиотеки elasticsearch.

Данные собрали. Теперь нужно их стабильно отправлять. Причём для миллионов заказов.

Построение и обновление индекса

Мы сделали мегаскрипт, «чтобы править всеми» — run_elastic_sync. У него несколько режимов работы:

  • отправить в ElasticSearch все заказы конкретного партнёра;

  • отправить в ElasticSearch заказы, созданные в определённый период;

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

Мегаскрипт — это всего лишь способ для передачи параметров запуска в ContractSyncToElasticLogic.

./manage.py run_elastic_sync --contract_id=1234
ContractSyncToElasticLogic().run(contract_id=1234)

Скрипт может работать и по дате, и по времени. Чисто теоретически, мы могли бы указать datetime_from c начала времён и полностью перестроить индекс. Полное обновление индекса может пригодиться, когда мы впервые его заполняем или если всё сломалось и нужно накатить заново. Но на практике мы можем допустить, что в первую очередь партнёрам нужна возможность поиска по заказам за последний год. Да и включать полнотекстовый поиск мы собираемся плавно, по сегментам партнёров. Поэтому необходимость в полном обновлении индекса отпадает.

Добавление нового поля в индекс идёт по такому же сценарию. Разница только в том, что мы делаем UPSERT документа. То есть мы одним мегаскриптом закрыли все требования по построению и перестроению индекса. Но остаётся проблема — как понять, что индекс актуален?

Проверка актуальности индекса

Мы планируем написать ещё один скрипт — check_es_consistency. Вот что он будет делать:

  • выгружать заказы, созданные за последний час;

  • собирать OrderDocument;

  • выгружать документ из ElasticSearch;

  • сравнивать значения полей;

  • обновлять данные в индексе или создавать новую запись в индексе;

  • отправлять метрики.

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

А если повесим оповещения на метрики, то сможем реагировать на расхождения данных в БД и индексе.

Тестирование

Мы покрыли полнотекстовый поиск тестами. В каждом тесте мы старались воспроизвести работу реальной системы. В setUp создаём записи в БД с помощью factoryboy. Сохранение моделей вызывает post-save сигналы. В post-save-ах создаются документы и отправляются в ElasticSearch.

Инстанс ElasticSearch запускается в GitLab CI в виде сервиса вместе с PosgreSQL и Redis. Мы практически не меняли параметров запуска, вот как они выглядят:

tests:
  stage: test
  services:
    - name: postgres:13.4-alpine
      alias: postgres-partner
    - name: redis:latest
      alias: redis-partner
    - name: elasticsearch:7.17.12
      alias: elasticsearch-partner
      command: [
        "bin/elasticsearch",
        "-Ebootstrap.memory_lock=true",
        "-Ediscovery.type=single-node",
        "-Ecluster.max_shards_per_node=3000",
        "-Ehttp.host=0.0.0.0",
        "-Etransport.host=127.0.0.1",
        "-Expack.security.enabled=false",
      ]

ElasticSearch может потреблять много ресурсов. Если у runner-ов мало памяти, то можно схватить ошибки с текстом failed to establish a new connection. Такие ошибки решаются настройкой ElasticSearch переменными среды, которые настраивают потребление ресурсов виртуальной машины Java, например, ES_JAVA_OPTS=-Xms256m -Xmx1g.

Что, если FTS сломается?

Мы оставили возможность отключения поиска на ElasticSearch. Если эластик ломается — переключаемся на PostgreSQL. В движке на PostgreSQL оставили поиск только по нескольким ключевым полям через ILIKE. А пользователю покажем, что с поиском проблемы. Так мы реализуем принцип Graceful Degradation.

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

Пагинация сгруппированных заказов

Пользователям удобно видеть заказы сгруппированными в поездки. Но то, что удобно пользователю, не всегда удобно организовать в коде. Новый дизайн предполагает, что мы показываем 10 поездок на странице, сколько бы ни было в них заказов. Проблема в том, что мы строим индекс по заказам, а не по поездкам. В каждом документе заказа есть вложенная структура поездки. ElasticSearch не умеет делать пагинацию по вложенной структуре. Мы используем aggregation pipeline, чтобы обойти это ограничение. Вот как это работает.

Есть конфиг агрегации:

"aggs": {
    "unique_trips": {
      "terms": {
        "field": "trip_id",
        "size": 65536,
        "order": { "agg_ordering": "asc" }
      },
      "aggs": {
        "agg_ordering": {
          "min": { "field": "created_at" }
        },
        "trips_bucket_sort": {
          "bucket_sort": {
            "sort": [
              { "agg_ordering": { "order": "asc" } }
            ],
            "size": 10,
            "from": 0
          }
        }
      }
    }
  },

Группируем заказы по поездкам и находим их количество. Если переписать эту агрегацию на псевдо-SQL, то получится так:

SELECT * FROM (
    SELECT id FROM orders GROUP BY trip_id
) as unique_trips LIMIT 65536

Магическое число 65536 — это просто достаточно большое количество поездок, с которыми партнёр работает в личном кабинете. Если не указать size, то будет использовано 10. Мы написали отдельный тест для того, чтобы не забывать держать size достаточно большим, ведь иначе в личном кабинете партнёра всегда будет одна страница в выдаче.

Схема работы агрегации и сортировки
Схема работы агрегации и сортировки

Post-save сигналы

Изменения по заказам отправляются в ElasticSearch на post-save сигналах и фоновых задачах. Post-save сигналы некоторые считают плохой практикой, потому что за ними сложно уследить и непонятно, в какой последовательности они выполняются. Но мы привыкли с ними работать, а рефакторинг логики сохранения заказов занял бы слишком много времени.

Ещё мы рассматривали вариант с логической репликацией и отправку данных напрямую из базы данных. На нашей практике логическая репликация в PostgreSQL была нестабильной. Да и нам пришлось бы поднимать ещё один инструмент, который бы формировал данные для ElasticSearch.

Мультиязычный полнотекстовый поиск

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

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

  • ElasticSearch multi-field;

  • ElasticSearch multi-index;

  • сторонний сервис;

  • доработки на фронтенде.

Multi-field значит, что для каждого языка появляется своё поле. Мы поддерживаем 21 язык и каждый год добавляем несколько новых. Для каждого нового языка нужно будет обновить весь индекс, а это неудобно.

{
  "mappings": {
    "properties": {
      "contents": {
        "properties": {
          "city_en": {
            "type": "text", "analyzer": "english"
          },
          "city_ru": {
            "type": "text", "analyzer": "russian"
          },
          "city_de": {
            "type": "text", "analyzer": "german_custom"
          },
          ...

Multi-index значит, что для каждого языка появляется свой индекс: orders-ru, orders-en и т. д. Недостаток — дублирование данных.

Сторонний сервис предполагает, что мы куда-то отправим поисковый запрос, а в ответ получим потенциальный набор отелей, стран и городов. Недостаток — низкая точность поиска.

Доработки на фронтенде — это когда мы добавим интерфейс, похожий на поиск Merge Request в GitLab. Вместе с полнотекстовым поиском пользователь сможет выбирать параметр, по которому ищет.

Пример работы поисковой строки GitLab
Пример работы поисковой строки GitLab

Выводы

Каждый сценарий FTS специфичен. Сложность умного поиска — в обслуживании индекса, а не в самом инструменте. В индустрии мало отлаженных подходов к мультиязычному FTS.

Можно вполне хорошо жить и без навороченных инструментов поиска. Старый дизайн и подход с фильтрами по отдельным полям существует последние 8 лет. Поддерживать его или делать что-то новое — выбирает каждый сам для себя.

В следующий раз расскажем, как мы добавили поиск на нескольких языках. Не пропустите!

Комментарии (0)