RecSys · учебник
Тренажёр Виджеты Повторение О проекте Все главы ← Скоринг Обзор

Дополнение · X-алгоритм · страница 7 из 11

Фильтрация: двадцать девять поводов не показать пост

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

Коротко

  • Фильтры идут строго по очереди, и порядок содержательный: сначала дешёвые и массовые, потом дорогие и точечные.
  • Граница проходит по отбору топ-50. Всё, что требует похода в другой сервис по паре «пост и зритель», стоит после — там объектов на порядок меньше.
  • Три независимых механизма против повторного показа: фильтр Блума в гидраторах и два фильтра из разных журналов. Это прямое признание того, что запись показов ненадёжна.
  • Отбирают 50 постов, а показывают 35. Запас существует потому, что после отбора работают ещё три фильтра и заранее неизвестно, сколько они выбросят.
  • Есть фильтр, который выбрасывает посты намеренно и без причины — детерминированный холдаут инвентаря. Это инструмент измерения, а не фильтрации.

1. Порядок и его логика

Восемнадцать фильтров работают до скоринга, три — после отбора, остальные встроены в другие стадии. Вот полный список до скоринга, в том порядке, в каком они стоят в коде.

#ФильтрЧто выбрасываетСтоимость
1DropDuplicatesFilterОдин и тот же пост, вернувшийся из нескольких источниковмножество в памяти
2CoreDataHydrationFilterПосты, у которых не загрузились текст или авторпроверка поля
3AgeFilterСтарше 48 часоварифметика над идентификатором
4SelfTweetFilterСобственные посты зрителясравнение чисел
5OONRetweetReplyFilterЧужие ответы и репосты, а также ответы с потерянным родителемпроверка полей
6OONNsfwSimclustersFilterВзрослый контент из кластерного источника для неподписанныхпроверка флага
7RetweetDeduplicationFilterПовторные репосты одного и того же постамножество в памяти
8IneligibleSubscriptionFilterПлатный контент без подпискипроверка флага
9–11PreviouslySeen…, PreviouslyServed…Уже показанное — из трёх разных журналовмножества, полученные при гидратации
12ViewerMutedKeywordFilterСовпадение со скрытыми словамипоиск по строке
13AuthorSocialgraphFilterБлокировки и скрытые аккаунтымножества из гидратации
14Brazil2026ElectionFilterАккаунты по требованию избирательного суда Бразилиипроверка по списку
15–16VideoFilter, TopicIdsFilterНе тот тип контента для этого запросапроверка флага
17NewUserMinEngagementFilterДля совсем новых аккаунтов — слабо вовлекающие чужие постысравнение чисел
18InventoryHoldoutFilterЗаданный процент постов, детерминированнохеш

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

Почему порядок именно такой

Три соображения, и все три видны в списке.

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

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

Третье: то, что зависит от запроса, — ближе к концу. Фильтры видео и тем срабатывают только для части запросов, а холдаут по умолчанию выключен вовсе. Ставить их первыми смысла нет: чаще всего они не делают ничего.

Это ровно тот принцип упорядочивания фильтров, который мы обсуждали в главе «Порядок фильтрации». Здесь он виден построчно.

2. Воронка целиком

Соберём числа из конфигурации: источники отдают до 1200, 1000, 800 и 200 постов, порог скоринга — 2800, отбирается топ-50, на экран уходит 35.

Что здесь надо увидеть
  1. Выключите три источника из четырёх. Итог почти не изменится: кандидатов всё равно остаётся больше пятидесяти, и лишние просто не доходят до отбора. Количество источников влияет на качество, а не на наполняемость.
  2. А теперь тяните ползунок жёсткости фильтров после отбора. При множителе около 3 остаётся меньше 35 постов, и лента не набирается. Вот это влияет напрямую.
  3. Кликните по строке «старше 48 часов» и посмотрите, насколько шире станет воронка. Один фильтр выбрасывает пятую часть всего.
  4. Обратите внимание на три строки про «уже показывали» подряд — про них следующий раздел.

Что сказать на собесе: «Фильтрация после ранжирования обязана закладываться в размер отбора. Если отбирать ровно столько, сколько показываем, любой всплеск отсева на последней стадии оставит пользователя с неполным экраном».

3. Три механизма против одного повтора

Самое заметное дублирование в системе. Против повторного показа работают одновременно:

  1. Фильтр Блума, приезжающий вместе с запросом на стадии гидратации;
  2. PreviouslySeenPostsFilter — основной журнал показов;
  3. PreviouslySeenPostsBackupFilter — резервный журнал;
  4. плюс PreviouslyServedPostsFilter — то, что уже отдавали в этой сессии скролла.

Плюс отдельно: источнику постов подписок список уже показанного передаётся внутрь, и он их не возвращает вовсе.

Это не избыточность, а признание ненадёжности

Вспомним, как записываются показы. На странице про пайплайн мы видели, что это побочный эффект: запускается после отправки ответа, в фоне, и его результат не проверяется — в коде буквально let _ = join_all(futures).await.

Значит, запись показа может не произойти. Сервис перезапустился, очередь переполнилась, запрос к хранилищу отвалился — пользователь пост увидел, а система об этом не узнала. И при следующем запросе честно покажет его снова.

Отсюда стратегия: несколько независимых механизмов, у каждого свой путь данных. Фильтр Блума приезжает с запросом, журналы читаются из разных хранилищ, сессионный список живёт в самом запросе. Чтобы дубликат прорвался, должны отказать все сразу.

Цена — лишняя работа и лишние килобайты в каждом запросе. Но повторный показ пользователь замечает мгновенно и раздражается сильно, а лишний фильтр не стоит почти ничего. Размен очевиден.

4. Фильтр, который выбрасывает посты просто так

Самый необычный из двадцати девяти. InventoryHoldoutFilter удаляет заданный процент постов — не плохих, не старых, не запрещённых, а просто процент.

fn holdout_bucket(post_id: u64, viewer_id: u64) -> u64 {
    let mut z = post_id
        .wrapping_mul(0x9E37_79B9_7F4A_7C15)
        .wrapping_add(viewer_id.rotate_left(32).wrapping_mul(0xD1B5_4A32_D192_ED03))
        .wrapping_add(0x9E37_79B9_7F4A_7C15);
    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
    z ^= z >> 31;
    z % 100
}

fn is_held_out(post_id: u64, viewer_id: u64, percent: u32) -> bool {
    Self::holdout_bucket(post_id, viewer_id) < percent as u64
}

Фрагмент из inventory_holdout_filter.rs · код X, Apache 2.0, коммит 28e414f

Разберём. Из пары «идентификатор поста и идентификатор зрителя» хешированием получается число от 0 до 99. Если оно меньше заданного процента — пост выбрасывается. Проценты задаются отдельно для оригинальных постов, ответов и репостов; по умолчанию все нули, то есть механизм выключен.

Зачем это нужно: измерение ценности инвентаря

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

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

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

Это тот же приём, что мы разбирали в главе «Exploration на практике»: чтобы измерить ценность механизма, его выключают части трафика. Необычно здесь то, что единица рандомизации — не пользователь, а пара «пользователь и объект». Это позволяет мерить ценность типа инвентаря, а не фичи.

5. Фильтры после отбора

После сортировки и взятия топ-50 работают ещё три фильтра, и они принципиально дороже.

ФильтрЧто делаетПочему после отбора
VFFilterУбирает посты, по которым сервис видимости ответил «не показывать»Обращение к отдельному сервису по каждой паре «пост и зритель»
AncillaryVFFilterУбирает посты, у которых скрыт родитель, цитируемый или репостнутый постТребует результатов предыдущего фильтра
DedupConversationFilterСхлопывает несколько веток одного разговора в однуИмеет смысл только на итоговом порядке

Первая строка — главная. Спросить сервис видимости про 2800 кандидатов и про 50 отобранных — это разница в пятьдесят раз по числу запросов. Отсюда и решение поставить его после сортировки, разобранное на странице обзора.

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

Отсюда и берётся запас в пятнадцать позиций

Отбирается 50, показывается 35. Разница — не округление, а вычисленный запас: три фильтра после отбора выбрасывают неизвестное заранее количество, и если запаса не хватит, пользователь получит неполный экран.

Виджет выше показывает, где проходит граница: при обычной жёсткости остаётся около 44 постов, при утроенной — уже меньше 35. То есть система рассчитана примерно на двукратный запас прочности по отсеву на последней стадии.

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

Частые ошибки и подводные камни

На чём спотыкаются
  • Ставить дорогие фильтры до ранжирования. Обращение к другому сервису по паре «объект и пользователь» должно идти после отбора: там объектов на порядок меньше.
  • Отбирать ровно столько, сколько показываешь. Любой отсев после отбора оставит экран неполным. Запас обязателен и должен считаться, а не угадываться.
  • Считать тройную защиту от повторов паранойей. Запись показов — побочный эффект, чей результат не проверяется. Один механизм на такой задаче ненадёжен по построению.
  • Ставить редко срабатывающие фильтры в начало очереди. Впереди должны быть те, у кого максимум отсева на единицу стоимости.
  • Путать холдаут инвентаря с фильтром. Он не защищает пользователя ни от чего — это инструмент причинного измерения, случайно живущий в списке фильтров.
  • Забывать про дедупликацию между источниками. Источники работают параллельно и не знают друг о друге; без первого фильтра один пост уезжает дальше в трёх экземплярах.

Вопросы с собеседований

В каком порядке ставить фильтры в рекомендательном пайплайне?

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

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

Отдельная граница — отбор топ-K. Всё, что требует запроса по паре «объект и пользователь», ставится после него: разница между тысячами кандидатов и десятками отобранных даёт экономию в десятки раз.

Почему отбирают больше постов, чем показывают?

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

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

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

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

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

Как измерить, сколько ценности приносит определённый тип контента в ленте?

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

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

Необычно здесь то, что единица рандомизации — пара «пользователь и объект», а не пользователь. Это позволяет мерить ценность типа инвентаря, а не функциональности.

Фильтр по возрасту выбрасывает всё старше 48 часов. Не теряем ли мы хорошее?

Теряем, и осознанно. Это продуктовое решение о том, что такое лента новостей: контент старше двух суток в ней неуместен, каким бы хорошим он ни был.

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

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

Шпаргалка одним экраном

Сколько

29 фильтров: 18 до скоринга, 3 после отбора, остальные встроены в другие стадии.

Порядок

Дедупликация → дешёвое с большим отсевом → зависящее от запроса → холдаут.

Граница

Отбор топ-50. После него — только то, что ходит в другие сервисы.

Возраст

48 часов. Почти бесплатно: время зашито в идентификатор поста.

Против повторов

Фильтр Блума + два журнала + сессионный список. Запись показов ненадёжна.

Запас

Отбирают 50, показывают 35. Разница — плата за фильтры после отбора.

Холдаут

Хеш пары «пост и зритель» → 0..99. Детерминированный, персональный, по умолчанию 0%.

Правило

Каждое правило после ранжирования — налог на размер отбора.

Что не делают фильтры

Ни один до скоринга не ходит в другой сервис. Иначе их не было бы восемнадцати.

Первоисточники