Дополнение · X-алгоритм · страница 3 из 11
Источники: откуда вообще берутся посты
Прежде чем что-то ранжировать, надо это что-то найти. В ленте семь источников кандидатов, и они устроены принципиально по-разному: один держит посты в оперативной памяти, другой ищет ближайших соседей в векторном пространстве, третий опирается на кластеризацию всего социального графа. Разбираем, зачем их столько и почему ни один не заменяет остальные.
Коротко
- Главное деление — по подпискам. Посты тех, на кого вы подписаны, ищутся принципиально иначе, чем посты тех, на кого не подписаны.
- Thunder держит свежие посты прямо в оперативной памяти, разложенные по авторам. Никакого поиска: получить посты подписок — это пройтись по списку подписок и взять готовые очереди.
- SimClusters кластеризует социальный граф на 145 тысяч сообществ разреженной бинарной факторизацией, а потом ищет кандидатов в пространстве кластеров.
- У каждого источника свой лимит: retrieval отдаёт до 1000 постов, Thunder — до 1200, остальные меньше. Лимиты — это распределение бюджета между способами поиска.
- Ни один источник не заменяет остальные, потому что каждый видит своё подмножество и слеп к остальному. Это тот же аргумент, что и в многомодальном поиске.
1. Почему источников несколько
Вопрос законный: если есть обученная модель, которая умеет находить релевантные посты, зачем к ней что-то ещё?
Ответ проще, чем кажется: у каждого способа поиска своя слепая зона, и они не пересекаются.
| Источник | Что находит | Чего не видит |
|---|---|---|
| Thunder | Всё, что опубликовали ваши подписки за последние часы | Вообще ничего за пределами подписок |
| Retrieval-модель | Посты, похожие на то, что вам заходило | Совсем свежие посты, не попавшие в индекс; темы, по которым у вас нет истории |
| SimClusters | Посты, популярные в сообществах, к которым вы принадлежите | Нишевое и новое: кластеры пересчитываются раз в неделю |
| Тематические источники | Посты по темам, на которые вы подписались явно | Всё, что вне названных тем |
| Кеш отранжированного | То, что уже посчитали в прошлый раз | Всё новое с момента кеширования |
Особенно показательна вторая строка. Модель retrieval прекрасна, но её индекс, как мы разбирали на странице про retrieval, обновляется только при сохранении чекпоинта. Пост, опубликованный пять минут назад, в нём физически отсутствует — а именно такие посты и составляют ленту соцсети.
в главе «Виды кандидатогенераторов» мы говорили, что кандидатогенераторы комбинируют не от бедности, а потому что каждый оптимизирован под свой тип связи: коллаборативный ловит «похожие люди смотрели», контентный — «похоже по содержанию», популярный — «это просто хорошо».
Здесь то же самое, но ось другая — по свежести и по типу связи одновременно. Thunder закрывает «свежее и от знакомых», retrieval — «похожее на мои интересы», SimClusters — «популярное в моём сообществе». Убери любой, и появится класс постов, который система перестанет находить в принципе.
2. Thunder: посты подписок в оперативной памяти
Самый прямолинейный и самый поучительный источник. Задача: по списку подписок отдать их свежие посты. Решение: не искать вовсе, а держать всё нужное в памяти, уже разложенным по авторам.
Что именно лежит в памяти
pub struct PostStore {
posts: Arc<DashMap<i64, Arc<CompactPost>>>,
original_posts_by_user: Arc<DashMap<i64, VecDeque<TinyPost>>>,
secondary_posts_by_user: Arc<DashMap<i64, VecDeque<TinyPost>>>,
video_posts_by_user: Arc<DashMap<i64, VecDeque<TinyPost>>>,
deleted_posts: Arc<DashMap<i64, bool>>,
retention_seconds: u64,
request_timeout: Duration,
}
Фрагмент из post_store.rs · код X, Apache 2.0, коммит 28e414f
Пять структур, и каждая объяснима.
posts— сам пост по идентификатору. Обратите внимание на тип:CompactPost— это структура из одиннадцати полей фиксированного размера, без текста. Идентификаторы, время, флаги «репост», «ответ», «есть видео».- Три раздельных индекса по автору: оригинальные посты, вторичные (ответы и репосты) и видео. В каждом — очередь из
TinyPost, а это всего два поля: идентификатор и время создания. deleted_posts— надгробия для удалённого.
Казалось бы, храни один список на автора и фильтруй при выдаче. Но тогда, чтобы отдать десять оригинальных постов активного автора, пришлось бы прочитать сотню его ответов и отбросить их. При тысяче подписок это тысяча лишних проходов на каждый запрос.
Раздельные индексы превращают фильтрацию в выбор структуры данных. Нужны только оригинальные — берём одну очередь. Нужно видео — другую. Работа, которой не происходит, — самая дешёвая работа.
Это ровно тот приём, который в главе «Порядок фильтрации» назывался «фильтровать на уровне индекса, а не после выборки». Здесь видно, во что он выливается на практике: не в условие в коде, а в решение о раскладке данных.
TinyPost — это два восьмибайтовых целых, то есть 16 байт. CompactPost — восемь целых и три флага, порядка 72 байт с выравниванием.
Прикинем: пусть в системе 500 миллионов постов за окно хранения. Основная карта — около 36 гигабайт, индексы по авторам — ещё по 8 гигабайт на каждый. Много, но это реально для одного парка машин, и на порядки быстрее любого обращения к диску.
А теперь представим, что мы решили хранить и текст. Средний пост — сотни байт, и оценка вырастает в разы, до сотен гигабайт. Именно поэтому текста в структуре нет: Thunder отвечает на вопрос «какие посты», а не «что в них написано». Текст дотягивается позже, гидратором, и только для тех кандидатов, которые дожили до этой стадии.
Как это наполняется
Thunder подписан на поток событий Kafka: опубликован пост, удалён пост, изменилась видимость. Никаких периодических выгрузок — данные приходят непрерывно, и задержка между публикацией и появлением поста в памяти измеряется секундами.
Старое вычищается по времени хранения: есть фоновая задача trim_old_posts, которая обходит очереди и выбрасывает то, что вышло за окно. Плюс sort_all_user_posts — периодическая пересортировка, потому что события Kafka приходят не строго по порядку.
в главе «Два контура и лямбда-архитектура» мы разбирали разделение на офлайн-контур (тяжёлый, батчевый, точный) и онлайн-контур (лёгкий, потоковый, свежий). Thunder — образцовый онлайн-контур: он ничего не вычисляет, он просто помнит недавнее и умеет быстро отдать по ключу.
Обратите внимание на разделение труда. Thunder знает, какие посты вообще существуют у ваших подписок прямо сейчас. Он ничего не знает о том, какие из них хорошие, — это работа ранжирования. Каждая система решает ровно одну задачу, и именно поэтому обе получаются простыми.
3. SimClusters: кластеризация социального графа
Самый старый и самый концептуально интересный источник. Идея: если разбить пользователей на сообщества по тому, кто за кем следит и кто с кем взаимодействует, то «интересное для вас» можно искать в пространстве сообществ, а не в пространстве постов.
В названиях датасетов зашита конфигурация модели: Model20M145K2020 — 20 миллионов пользователей раскладываются по 145 тысячам кластеров.
Разреженная бинарная факторизация
В коде метод называется SBF, и в его основе лежит SparseBinaryMatrix. Разберём, чем это отличается от привычной матричной факторизации из главы «Матричная факторизация».
| Обычная факторизация | Разреженная бинарная | |
|---|---|---|
| Значения факторов | вещественные числа | нули и единицы |
| Сколько ненулевых | все \(d\) координат | немного из 145 тысяч |
| Смысл координаты | непонятен | конкретное сообщество |
| Что раскладывается | матрица взаимодействий | граф похожести аккаунтов |
Ключевое отличие — интерпретируемость. Координата обычного эмбеддинга не означает ничего; про неё нельзя сказать «это про астрономию». Кластер SimClusters означает конкретное сообщество, у него есть состав, его можно посмотреть глазами, ему можно дать имя, на него можно пожаловаться.
Второе отличие — разреженность. Аккаунт принадлежит нескольким сообществам из 145 тысяч, а не имеет 145 тысяч слабых весов. Это даёт и экономию, и инвертированный индекс: по сообществу можно быстро получить всех, кто в него входит.
Вопрос естественный: SimClusters — технология прошлого десятилетия, а рядом стоит трансформер. Три причины, по которым её не выкинули.
- Она видит другое. Retrieval обучен на ваших лайках и находит похожее на то, что вам заходило. SimClusters опирается на структуру социального графа: сообщество, к которому вы принадлежите, определяется тем, на кого вы подписаны, а не тем, что вы лайкали. Это разные сигналы, и они расходятся чаще, чем кажется.
- Она объяснима. «Этот пост популярен в сообществе, к которому вы относитесь» — фраза, которую можно показать пользователю и предъявить регулятору. «Скалярное произведение эмбеддингов равно 0.83» — нельзя.
- Она устойчива. Кластеры пересчитываются раз в неделю и меняются медленно. Модель переобучается чаще и может резко изменить поведение. Иметь в системе источник, который двигается медленно и предсказуемо, — это страховка.
Заодно это иллюстрация мысли из главы «Как проектировать систему с нуля»: в проде рядом живут технологии разных поколений, и это нормально. Старое не выкидывают, пока оно закрывает свою нишу.
4. Лимиты источников: распределение бюджета
У каждого источника в конфигурации стоит потолок на число возвращаемых постов.
| Источник | Максимум постов | Комментарий |
|---|---|---|
| Thunder | 1200 | Больше всех: посты подписок дёшевы и отдаются из памяти |
| Phoenix retrieval | 1000 | Основной источник постов за пределами подписок |
| Tweet Mixer | 800 | |
| Phoenix MOE | 200 | Отдельная разновидность retrieval |
Эти числа — не технические ограничения, а продуктовое решение, выраженное в конфиге. Они задают, из какого материала система вообще может собрать ленту.
Прикиньте пропорцию: 1200 постов от подписок против примерно 2000 из всех прочих источников. Если бы Thunder отдавал 5000, а retrieval 200, лента была бы принципиально другой — почти целиком из подписок, — при абсолютно тех же весах ранжирования и той же модели.
Когда спрашивают «почему лента показывает мне столько незнакомых аккаунтов», инстинктивно смотрят на веса действий и на дисконт за пределы подписок. Но лимиты источников влияют на состав ленты раньше и сильнее: то, что не попало в кандидаты, не может быть отранжировано никак.
Это общее правило многостадийных систем из главы «Многостадийная воронка»: потолок задаёт первая стадия. Виджет воронки там показывает то же самое численно — сквозная полнота есть произведение полнот стадий, и потери кандидатогенерации не отыгрывает никакой ранкер.
5. Кеш отранжированного как источник
Отдельного внимания заслуживает CachedPostsSource. Он возвращает посты, уже отранжированные в предыдущем запросе.
Это прямое следствие изоляции кандидатов: раз скор поста зависит только от пары «пользователь и пост», а не от того, кто ещё был в батче, — его можно сохранить и переиспользовать. Дизайнерское решение в модели напрямую превратилось в источник кандидатов в пайплайне.
Зачем: человек листает ленту, и каждый следующий экран — новый запрос. Пересчитывать модель на тех же кандидатах бессмысленно. Кеш позволяет отдать продолжение мгновенно и потратить бюджет на поиск нового.
Заметьте, как это связано с округлением возраста поста до часа из ранжирующей модели: без округления признак менялся бы каждую минуту, и закэшированный скор устаревал бы немедленно. Три решения в трёх разных местах системы — маска внимания, гранулярность признака и источник-кеш — работают на одну цель.
Частые ошибки и подводные камни
- Считать, что нейросетевой retrieval делает остальные источники ненужными. Его индекс обновляется при сохранении чекпоинта, поэтому совсем свежих постов в нём нет. А в ленте соцсети именно они и составляют основной материал.
- Думать, что Thunder — это база данных. Он не отвечает на произвольные запросы. Он умеет одно: по автору отдать его недавние посты. Всё остальное вынесено наружу.
- Хранить текст там, где нужны только идентификаторы. В структуре Thunder текста нет вовсе — это разница между десятками и сотнями гигабайт памяти.
- Не замечать лимиты источников. Состав ленты определяется ими раньше, чем весами: непопавший кандидат не может быть отранжирован никак.
- Считать SimClusters устаревшим. Он опирается на структуру графа, а не на историю лайков; даёт объяснимость и меняется медленно. Это отдельная ниша, а не отставшая версия retrieval.
Вопросы с собеседований
Зачем в системе несколько кандидатогенераторов, если один из них — обученная модель?
У каждого своя слепая зона. Модель retrieval ищет похожее на историю пользователя, но её индекс обновляется только при сохранении чекпоинта — совсем свежих постов там нет. Источник постов подписок видит только подписки. Кластерный источник опирается на структуру графа и не знает про новое, потому что кластеры пересчитываются раз в неделю.
Убрав любой, получаем класс постов, который система перестаёт находить в принципе. Это ровно тот довод про многомодальный поиск, что и в главе «Виды кандидатогенераторов»: разные генераторы оптимизированы под разные типы связи, и объединение покрывает больше, чем лучший из них.
Как отдавать посты подписок за единицы миллисекунд?
Не искать, а держать в памяти, разложенным по авторам. В разобранной системе это отдельный сервис: карта «идентификатор поста → компактная запись» плюс индексы «автор → очередь его недавних постов». Запрос сводится к обходу списка подписок и чтению готовых очередей.
Три решения делают это возможным. Никакого текста — только идентификаторы, время и флаги, иначе память вырастет на порядок. Окно хранения — старое вычищается фоновой задачей. Раздельные индексы для оригинальных постов, ответов с репостами и видео — чтобы фильтрация была выбором структуры, а не проходом с отбрасыванием.
Наполняется это потоком событий, а не периодической выгрузкой: задержка от публикации до появления в памяти — секунды.
Что такое SimClusters и чем он отличается от матричной факторизации?
Это кластеризация социального графа: 20 миллионов заметных аккаунтов раскладываются по 145 тысячам сообществ разреженной бинарной факторизацией графа похожести. Автор получает набор сообществ, за которые он известен; пользователь — набор сообществ, которыми интересуется, как сумму по тем, на кого подписан; пост — вектор над сообществами.
Отличия от обычной факторизации: значения бинарные, а не вещественные; ненулевых координат мало; и главное — координата интерпретируема, это конкретное сообщество с составом, которое можно посмотреть глазами. Плюс раскладывается не матрица взаимодействий, а граф похожести аккаунтов.
Зачем держать в системе источник, пересчитываемый раз в неделю?
Ради устойчивости и объяснимости. Медленно меняющийся источник — страховка на случай, когда модель после переобучения начинает вести себя неожиданно: часть ленты остаётся предсказуемой.
Объяснимость важна не меньше: «этот пост популярен в сообществе, к которому вы относитесь» можно показать пользователю и предъявить регулятору, а «скалярное произведение эмбеддингов 0.83» — нельзя.
Цена — недельная задержка: новый аккаунт попадёт в кластеры не сразу. Поэтому источник и не единственный.
Можно ли переиспользовать посчитанный скор при следующем запросе?
Только если скор зависит от пары «пользователь и пост» и ни от чего больше. В разобранной системе это обеспечено маской внимания: кандидаты не видят друг друга, поэтому скор не зависит от состава батча. Благодаря этому в пайплайне есть отдельный источник, отдающий уже отранжированные посты из кеша.
Второе условие — признаки не должны меняться быстрее, чем живёт кеш. Поэтому возраст поста подаётся модели с гранулярностью в час: непрерывно растущее значение обесценивало бы кеш каждую минуту.
Практический смысл: человек листает ленту, каждый экран — новый запрос, и пересчитывать модель на тех же кандидатах бессмысленно.
Шпаргалка одним экраном
Главное деление
Внутри подписок и за их пределами. Ищутся принципиально разными способами.
Thunder
Свежие посты подписок в оперативной памяти, разложенные по авторам. Поток из Kafka.
Компактность
Без текста: 11 полей фиксированного размера. Текст дотягивается позже и только выжившим.
Три индекса
Оригинальные, вторичные, видео. Фильтрация как выбор структуры данных.
SimClusters
20 млн аккаунтов → 145 тыс. сообществ разреженной бинарной факторизацией графа.
KnownFor / InterestedIn
Автор известен за сообщества; интересы пользователя — сумма по подпискам.
Лимиты
Thunder 1200, retrieval 1000, Tweet Mixer 800, MOE 200. Это продуктовое решение.
Кеш как источник
Возможен только благодаря изоляции кандидатов в модели.
Почему не один источник
У каждого своя слепая зона: свежесть, история, структура графа.
Первоисточники
- thunder/posts/post_store.rs — структуры в памяти, индексы по авторам, вычистка старого.
- UpdateKnownForSBFRunner.scala — построение сообществ разреженной бинарной факторизацией.
- InterestedInFromKnownFor.scala — интересы пользователя из сообществ авторов, недельный пересчёт.
- home-mixer/sources/ — подключение всех источников к ленте.
- Курс: глава «Виды кандидатогенераторов» о видах кандидатогенераторов, глава «Матричная факторизация» о матричной факторизации, глава «Два контура и лямбда-архитектура» о двух контурах.