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

Чтобы закрыть эту проблему, в Авиасейлс решили внедрить ML-скоринг и ранжировать билеты по вероятности покупки. На практике задача оказалась гораздо сложнее: разные источники данных у аналитиков и бэкенда, training-inference skew, провалы в нефункциональных требованиях и неожиданный рост latency.

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

Авиасейлс — тот самый сервис для покупки дешёвых авиабилетов. В месяц обрабатывается порядка 200 миллионов поисков. И чтобы всё работало, в компании трудится более сотни бэкэнд-разработчиков, в основном пишущих на Go.

Самый дешёвый — не всегда лучший

Рейс Москва — Париж. Прямых билетов нет, приходится выбирать из того, что есть.

Есть два билета с разницей в цене в 500 рублей:

  1. Первый вариант — оптимальный, за 24 694 руб. 

Это перелёт с Turkish Airlines, одна пересадка в Стамбуле днём. Прилетаете в Стамбул, прогуливаетесь по дьюти-фри, пересаживаетесь и летите до Парижа. Приземляетесь в аэропорт Шарль-де-Голль, который находится относительно близко к городу. Удобно и комфортно.

  1. Второй вариант — самый дешёвый, за 24 181 руб.

Здесь две пересадки, в том числе ночные и три рейса разными лоукостерами. Возникают проблемы с багажом и ручной кладью, да и не все аэропорты ночью нормально функционируют. Прилетаете в аэропорт Бове, который расположен очень далеко от Парижа.

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

У такой задачи есть несколько способов решения.

Решение №1. Эвристики

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

«Удобная пересадка» — это не слишком короткое время между рейсами, чтобы не пришлось бежать к новому гейту, сломя голову. Но и не слишком долгое ожидание, когда человеку приходится часами сидеть в аэропорту. Ночные пересадки тоже относятся к неудобным. А пересадки, где нужна виза — это вообще кошмар. Когда нужно перерегистрироваться, ставить штампы, а если не хватает каких-то документов — пользователь поедет домой.

В этом подходе всё выглядит неплохо: есть несколько эвристик — самый быстрый, самый дешёвый, с одной пересадкой, самый удобный и так далее. Но проблемы всё равно остаются:

  1. Направления, где слишком много хороших билетов

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

2. Направления, где всё плохо

Например, рейс из Владивостока до Токио летит минимум 12 часов. На таких выдачах нет билетов с удобной пересадкой в наших терминах, но пользователи всё равно хотят купить билет, который чем-то удобнее остальных билетов на выдаче, и нужно дать им такую возможность.

Так стало понятно, что эвристик недостаточно, поэтому мы пошли дальше и придумали другую систему.

Решение №2. Эвристики + веса

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

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

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

Именно поэтому такое решение оказалось неоптимальным — на этом месте уже напрашивался ML.

Как эта задача попала к бэкендерам 

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

На тот момент у нас было функциональное деление команд на бэкендеров и аналитиков, а выделенной ML-команды вообще не существовало. Поэтому мы просто завели задачу в Jira.

Через какое-то время пришёл аналитик и добавил контекст: «Вот бинарник с моделью, вот пример кода на Go, который вызывает эту модель, и вот SQL-файл».

Я открыл SQL — на первый взгляд всё выглядело адекватно. Но сам факт поставил в тупик: чтобы написать SQL-запрос, нужна таблица, а в нашем поисковом движке никакой реляционной базы данных не было и нет.

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

Как устроен поиск Авиасейлс

Когда пользователь нажимает кнопку «Найти билет», под капотом сервиса запускается сложный процесс.

Мы получаем запрос: направление, даты, параметры. Дальше направляем запрос партнёрам: авиакомпаниям, онлайн-тревел агентствам и другим сервисам, продающим билеты.

В каждом партнёре запускаем живой поиск для конкретного пользователя. Через какое-то время (от 5 до 20 секунд) партнёры начинают возвращать результаты. Они приходят к нам огромными XML-файлами на десятки мегабайт. Эти файлы нужно распарсить: выкинуть мусор, привести данные в порядок и дополнить атрибутами, в которых мы уверены больше, чем сами продавцы (например, тревел-агентства).

Все результаты сохраняем в кэш. Для этого используем Kvrocks — key-value базу данных по типу Redis, но хранящую данные на диске. Такой вариант для нас самый выгодный, иначе пришлось бы держать терабайтные Redis.

Параллельно клиент в цикле отправляет запросы: «Есть новые билетики? Нашлись уже, нет?» Если билеты нашлись, мы отвечаем: «Да, показываем». Через секунду приходит новый запрос: «А ещё есть?» — «Вот ещё пришли». И так продолжается, пока мы не скажем, что всё, конечная: опросили всех партнёров, новых билетов больше не будет.

 В итоге:

  • Каждый пользовательский поиск порождает запрос за данными в онлайн-тревел агентствах и авиакомпаниях.

  • Мы получаем самые свежие предложения и цены.

  • У нас нет никакой СУБД, где все эти предложения хранятся и используются для других поисков.

Возвращаемся к SQL-запросу: откуда он взялся? Конечно, от аналитиков, а они работают иначе.

Засучил рукава и пошёл разбираться, как данные попадают в аналитику.

У нас есть Kafka, в которую мы сливаем все данные о билетах от партнёров. Поиск развёрнут в нескольких независимых дата-центрах (и даже в нескольких облаках), там всё это собирается и отправляется в единый кластер аналитики.

Аналитика

В кластере аналитики данные идут по довольно стандартному для индустрии пути и попадают в аналитическое хранилище.

Мы берём сырые JSON, перемапливаем их в более удобные форматы, добавляем дополнительные поля — так, чтобы аналитикам не приходилось джойнить каждый шаг. В итоге они работают с веб-интерфейсом или тем же DataGrip: пишут запросы к табличкам — и всё работает. Именно в таком виде и появился тот самый SQL-запрос.

Как это выглядит по шагам:

  1. Данные попадают в аналитику постфактум.

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

  3. Итоговое представление может сильно отличаться от доменной модели, используемой внутри бэкенд-сервисов.

То есть доменная модель, которая есть в поиске, и табличка в аналитике могут драматично отличаться. И работать с ними одинаково не получится. Здесь начинается недопонимание между аналитиками и разработчиками.

Что делать с фичами

Чтобы модель работала, для каждого поиска нужно собрать вектор признаков и передать его в CatBoost. Простой способ это сделать — сходить в базу данных в аналитическом кластере. Но так не получится: данные появляются там только постфактум, а нам они нужны в момент поиска.

Оставался вариант проследить весь путь данных с момента отправки из поиска до искомого селекта. И попробовать этот же флоу реализовать прямо в бэкенд-приложении.

Нужно было разобраться в том, как работает ETL, как дополняются данные и как они преобразуются и укладываются в финальную таблицу, а потом работать с ними в бэкенд-приложении. Получается, что набор фичей, который используется в Select для обучения, мы формируем аналогично и для инференса в Go.

Вроде должно работать. Я закатал рукава и пошёл делать. Разобрался, как работает ETL. Тогда он был написан на Python:

Прочитал много кода и переписал ту же логику на Go. Всё заработало.

Запуск

Мы реализовали и вывели всю эту логику в продакшен. Взяли признаки билета, подали их в модель, получили для каждого билета prediction. Дальше, когда нашли максимальное значение prediction во всей выдаче, говорим: «Вот этот билет мы рекомендуем больше всего». И поднимаем его наверх под бейджем «Рекомендуемый».

В результате получили хорошие метрики:

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

  • пользователи стали меньше скроллить выдачу;

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

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

Обновление модели

Мы были бэкендерами без серьёзного опыта в ML и вообще без понимания, как устроен этот домен. Не особо думали и о том, как будем развивать модель дальше. Но аналитики довольно быстро пришли и сказали: «Нужно добавлять новые признаки».

Зачем всё это? На самом деле, если подумать, причины очевидны:

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

  • Инфляция. Деньги — один из ключевых признаков билета. Но деньги сегодня и завтра — это разные деньги. Если модель обучилась на том, что «1000 рублей — это много», то без переобучения завтра она не узнает, что это уже «немного».

  • Изменение трендов в тревеле. Появляются новые направления — люди посмотрели рилс или шортс, авиакомпании открыли рейсы под спрос, и распределение покупок на одной выдаче резко меняется.

Всё это называется concept drift — ситуация, когда распределение данных и зависимости между признаками меняются со временем, и модель, обученная на старых данных, перестаёт адекватно работать на новых. Мир динамичен, он не стоит на месте, и важно, чтобы модель это тоже учитывала.

Как обновляем модель

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

Схема выглядела так:

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

  • на этих данных запускают обучение модели;

  • артефакт обучения сохраняют в S3 (или какой-то другой object storage);

  • бэкенд подтягивает новую модель из хранилища и в рантайме подменяет её в памяти.

Именно так мы и сделали, всё запустили, но очень быстро результаты обучения в моменте перестали совпадать с продакшеном.

Диалоги выглядели так: «Вы неправильно сделали!» — «Нет, это вы неправильно сделали!». Мы прошли все стадии — отрицание, гнев, торг. В конце концов пришли к принятию: проблема есть, надо разбираться. Оказалось, что мы сильно разъехались в расчёте фичей.

В аналитике признаки билета считались по одному флоу, а в бэкенде — по другому.

Например, с фичей is_round_trip. Для аналитиков round trip включал сложные маршруты с несколькими перелётами, но возвращением в гоод первого вылета, вроде Москва → Стамбул → Париж → Стамбул → Москва. А в бэкенде под round trip понимали ровно дваперелёта туда и обратно.

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

Мы начали расследование: брали эталонные данные, считали признаки по аналитическому флоу на Python, по нашему на Go, пытались заметчить, рисовали схемы. Но всё это толком не работало.

В какой-то момент задались вопросом: «Мы реально такие тупые? Почему у нас всё так сложно?» Но дело оказалось не только в нас. Эта проблема известна в индустрии и называется training–inference skew (или feature skew). Суть простая: у вас есть обучение, есть инференс — и они не совпадают. Вот несколько вариантов, что с этим делать: 

  • Feature Store. Модный подход: магазин фичей, где вы с помощью DSL описываете feature extraction, а он генерирует код или решает задачу другим способом.

  • Validation. Один из способов — валидировать результаты: сначала проверить одно, потом другое и сравнить. Минус в том, что в таком подходе проблема у вас уже «на руках». Вы знаете, что расхождение есть, но дальше придётся вручную дебажить и разбираться.

  • Monitoring. Ещё один способ — смотреть на частотные распределения. Особенно когда на обучении у нас в среднем бронирует билет 1,5 человека, а на инференсе вдруг оказалось 0,5.

Мы подумали, что все эти подходы слишком сложные и решили действовать по-другому.

Бэкенд как источник правды

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

Мешает только то, что данные по пути изменились. Что, если мы возьмём все фичи, которые уже считаются на бэкенде, векторы признаков и запушим их в Kafka? Дальше они попадут в object storage, и мы сможем крутить обучение на этих же данных.

Так и сделали. Дропнули старое, сделанное на Python, и оставили только бэкендовую имплементацию. Начали слать данные в Kafka с минимальными преобразованиями формата, потому что на бэкенде удобнее работать с JSON, а хранить данные эффективнее в Parquet. Запустились, стало работать.

Плюсы подхода:

  • Метрики на инференсе и обучении наконец-то сошлись. Мы начали выполнять функциональные требования: предсказания стали корректными, и система заработала как надо.

  • Код пишется только в одном месте. Чтобы добавить новую фичу, достаточно сделать это один раз.

Минус тоже есть:

  • При добавлении новой фичи в прод нужно ждать неделю.

Фича попадает в стрим только после того, как мы взяли срез реальных данных с бэкенда, а цикл — семь дней. Если бы мы использовали исторические данные, можно было бы обойтись без ожидания. Но решили, что отсрочка нас устроит: новые фичи добавляются не так часто.

Казалось бы, всё должно заработать. Но, как обычно, нашлось ещё одно «но».

Нефункциональные требования

С функциональными требованиями мы справились, но остались нефункциональные, с которыми возникли проблемы.

Во-первых, рухнуло latency наших сервисов при чтении данных о билетах. Чтобы понять, почему так произошло, объясню, как вообще устроен процесс.

Есть Партнёр 1, он отдаёт нам два билета A и Б по разным ценам. Если другие партнёры не ответили, то на выдаче будут только эти два билета.

Теперь добавим Партнёра 2, который тоже продаёт билет Б, но по другой цене. В этом случае происходит мерджинг предложений: на выдаче мы покажем один и тот же билет Б, но сразу по двум ценам от разных агентов.

В этом месте нам пришлось встроить сервис Prediction, слепляющий из разных кусков данных одну выдачу и отдающий её наружу.

Дело в том, что в списке фичей есть так называемые контекстные (агрегатные) фичи.  Они зависят не только от характеристик самого билета прямо сейчас, а ещё от характеристик других объектов. В нашем случае это:

  • отношение цены текущего билета к минимальной цене на поиске прямо сейчас;

  • наличие на выдаче других билетов с багажом.

Единственная точка, где у нас есть вся информация одновременно — это запрос на чтение от клиента. Потому что все ответы от партнёров мы всегда обрабатывали параллельно. Это была наша фишка, нам нравилось, что несколько независимых пайплайнов на каждого из партнёров обрабатывают билеты, складывают их в кэш под отдельными ключами, и только потом мы собирали их в одну выдачу. Из-за того, что нам нужно, чтобы в пайплайне появлялось знание о других результатах, мы это сделали именно в этом сервисе.

Конечно, замедлились все запросы на чтение: тормозить стало и применение фильтров, и скролл. К тому же довольно сильно: latency выросло примерно в два раза. Нужно было думать, что делать дальше.

Оптимизации

Самое простое решение для бэкенд-разработчика — перефигачить байтики в другие байтики.

Мы начали менять форматы взаимодействия между сервисами: вместо JSON попробовали MessagePack или Protobuf. Что-то стало лучше, но не сильно. Данных всё равно слишком много.

Дальше мы попробовали встроить вызов библиотеки CatBoost в основной сервис с отдачей результата клиентам. Логика простая: если мы избавимся от лишних сетевых походов и сериализации данных (а на каждый запрос нужно было поскорить по 10−20 тысяч объектов), должно стать быстрее. На практике снова оказалось не всё гладко.

CatBoost написан на C++, а наш поисковый движок — на Go. Чтобы связать библиотеку на C++ и рантайм Go, нужен мостик — cgo, но он работает неэффективно. А ещё из-за разного представления строк в памяти в двух рантаймах нам приходилось копировать туда-сюда десятки тысяч объектов на каждый запрос. И в конце концов мы получили какой-то прирост производительности, хотя относительно стартовой точки всё равно мало продвинулись.

Решение

В итоге мы пошли к аналитикам и сказали: «У нас серьёзная проблема, давайте думать, что делать». Почему-то раньше этого разговора не случилось.

И тут аналитики удивили: «А если эти фичи так мешают, давайте их просто дропнем. Заменим на другие, которые не зависят от контекста». — «А что, серьёзно, так можно было?» — «Ну да, смотрите графики: метрики чуть-чуть просядут, но в целом всё нормально. Мы просто не знали, что у вас такие сложности».

Тут мы подумали — ого, вон оно как!

Отказавшись от контекстных фичей, мы смогли встроить предсказания прямо в наш параллельный пайплайн. Теперь на каждый ответ от партнёра сразу навешивался prediction.

А партнёры отвечают достаточно долго — в среднем около 14 секунд. Поэтому дополнительные ~70 мс на пайплайн ни на что не влияют. Это не UX-кейс, где пользователь кликает и ждёт мгновенного отклика. Здесь всё и так «медленно», и эти миллисекунды просто теряются на фоне общего времени ответа.

Поэтому мы перенесли пайплайн и всё стало хорошо.

Выводы

ML в продакшене — это не просто «ещё один сервис». Мы сначала думали: есть CatBoost, мы его вызовем из нового сервиса — и всё заработает. На деле оказалось, что это вызов всей архитектуре: приходится пересматривать подходы и понимать, как всё устроено и пробовать внедрить.

У аналитиков и бэкенда разная правда. У аналитиков — это ROC AUC, у бэкенда — p99 latency.

ML — это командный спорт. Есть бэкендеры, которые не очень глубоко понимают machine learning, особенно если раньше с такими задачами не сталкивались. Есть аналитики, которые привыкли видеть данные в таблице БД, а не в том виде, как они реально текут во времени в сервисах. Поэтому важно проговаривать, что у нас всё работает иначе, и предлагать искать решения вместе.

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

Скрытый текст

А чтобы больше узнать о работе с высоконагруженными системами и обменяться опытом с ведущими специалистами отрасли, приходите на HighLoad++ в Москве 6-7 ноября! Принять участие можно как очно, так и в онлайн-формате. До новых встреч!

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


  1. Kelbon
    24.10.2025 11:24

    ML здесь это прям перебор. Обычно это решается фильтрами, которые уже давно были - например без ночной пересадки, или количество пересадок до N

    Если уж идти в какую-то оценку, то по-моему здесь её можно написать на ифах, без всякого мл, потому что правила очень простые. У билета есть количество пересадок, время начала путешествия и конца.

    По пересадкам:

    • короткая пересадка? Плохо

    • долгая пересадка? Плохо

    • ночная пересадка? Ещё хуже

    Есть ещё гениальные невозможные пересадки, типа в Москве из одного аэропорта в другой за 2 часа нужно переехать

    По времени посадки/прилёта очевидно днём лучше, вечером на поезде тоже вполне пойдёт, а вот в 3 часа ночи - неочень

    В итоге у каждого билета появляется некое количество баллов вплоть до 100 и балл оценивается скажем в 100 рублей, так что можно сортировать комбинировано по общей "цене", где цена это плата в рублях + условная "плата за неудобство" в баллах