Дополнение · X-алгоритм · страница 7 из 11
Фильтрация: двадцать девять поводов не показать пост
Ранжирование решает, в каком порядке. Фильтры решают, попадёт ли пост в ленту вообще. Их двадцать девять, они идут строго по очереди, и порядок задан не вкусом, а стоимостью. Разбираем, почему одни стоят до скоринга, а другие после, зачем на одну задачу три независимых механизма и как из-за фильтров лента может не набраться.
Коротко
- Фильтры идут строго по очереди, и порядок содержательный: сначала дешёвые и массовые, потом дорогие и точечные.
- Граница проходит по отбору топ-50. Всё, что требует похода в другой сервис по паре «пост и зритель», стоит после — там объектов на порядок меньше.
- Три независимых механизма против повторного показа: фильтр Блума в гидраторах и два фильтра из разных журналов. Это прямое признание того, что запись показов ненадёжна.
- Отбирают 50 постов, а показывают 35. Запас существует потому, что после отбора работают ещё три фильтра и заранее неизвестно, сколько они выбросят.
- Есть фильтр, который выбрасывает посты намеренно и без причины — детерминированный холдаут инвентаря. Это инструмент измерения, а не фильтрации.
1. Порядок и его логика
Восемнадцать фильтров работают до скоринга, три — после отбора, остальные встроены в другие стадии. Вот полный список до скоринга, в том порядке, в каком они стоят в коде.
| # | Фильтр | Что выбрасывает | Стоимость |
|---|---|---|---|
| 1 | DropDuplicatesFilter | Один и тот же пост, вернувшийся из нескольких источников | множество в памяти |
| 2 | CoreDataHydrationFilter | Посты, у которых не загрузились текст или автор | проверка поля |
| 3 | AgeFilter | Старше 48 часов | арифметика над идентификатором |
| 4 | SelfTweetFilter | Собственные посты зрителя | сравнение чисел |
| 5 | OONRetweetReplyFilter | Чужие ответы и репосты, а также ответы с потерянным родителем | проверка полей |
| 6 | OONNsfwSimclustersFilter | Взрослый контент из кластерного источника для неподписанных | проверка флага |
| 7 | RetweetDeduplicationFilter | Повторные репосты одного и того же поста | множество в памяти |
| 8 | IneligibleSubscriptionFilter | Платный контент без подписки | проверка флага |
| 9–11 | PreviouslySeen…, PreviouslyServed… | Уже показанное — из трёх разных журналов | множества, полученные при гидратации |
| 12 | ViewerMutedKeywordFilter | Совпадение со скрытыми словами | поиск по строке |
| 13 | AuthorSocialgraphFilter | Блокировки и скрытые аккаунты | множества из гидратации |
| 14 | Brazil2026ElectionFilter | Аккаунты по требованию избирательного суда Бразилии | проверка по списку |
| 15–16 | VideoFilter, TopicIdsFilter | Не тот тип контента для этого запроса | проверка флага |
| 17 | NewUserMinEngagementFilter | Для совсем новых аккаунтов — слабо вовлекающие чужие посты | сравнение чисел |
| 18 | InventoryHoldoutFilter | Заданный процент постов, детерминированно | хеш |
Посмотрите на последний столбец: ни один фильтр до скоринга не ходит в другой сервис. Всё, что им нужно, либо уже лежит в кандидате после гидратации, либо считается арифметикой. Именно поэтому их можно позволить себе восемнадцать штук на тысячах кандидатов.
Три соображения, и все три видны в списке.
Первое: дедупликация идёт первой. Источники работают параллельно и не знают друг о друге, поэтому один и тот же пост легко приходит трижды. Отбросив дубликаты сразу, мы уменьшаем работу для всех семнадцати следующих фильтров. Дедупликация — самая выгодная позиция в очереди.
Второе: возраст стоит третьим. Он выбрасывает самую большую долю — всё старше 48 часов, — и делает это практически бесплатно, потому что время создания зашито в идентификатор поста и не требует никаких данных. Правило простое: максимум отсева на единицу стоимости — вперёд.
Третье: то, что зависит от запроса, — ближе к концу. Фильтры видео и тем срабатывают только для части запросов, а холдаут по умолчанию выключен вовсе. Ставить их первыми смысла нет: чаще всего они не делают ничего.
Это ровно тот принцип упорядочивания фильтров, который мы обсуждали в главе «Порядок фильтрации». Здесь он виден построчно.
2. Воронка целиком
Соберём числа из конфигурации: источники отдают до 1200, 1000, 800 и 200 постов, порог скоринга — 2800, отбирается топ-50, на экран уходит 35.
- Выключите три источника из четырёх. Итог почти не изменится: кандидатов всё равно остаётся больше пятидесяти, и лишние просто не доходят до отбора. Количество источников влияет на качество, а не на наполняемость.
- А теперь тяните ползунок жёсткости фильтров после отбора. При множителе около 3 остаётся меньше 35 постов, и лента не набирается. Вот это влияет напрямую.
- Кликните по строке «старше 48 часов» и посмотрите, насколько шире станет воронка. Один фильтр выбрасывает пятую часть всего.
- Обратите внимание на три строки про «уже показывали» подряд — про них следующий раздел.
Что сказать на собесе: «Фильтрация после ранжирования обязана закладываться в размер отбора. Если отбирать ровно столько, сколько показываем, любой всплеск отсева на последней стадии оставит пользователя с неполным экраном».
3. Три механизма против одного повтора
Самое заметное дублирование в системе. Против повторного показа работают одновременно:
- Фильтр Блума, приезжающий вместе с запросом на стадии гидратации;
PreviouslySeenPostsFilter— основной журнал показов;PreviouslySeenPostsBackupFilter— резервный журнал;- плюс
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%.
Правило
Каждое правило после ранжирования — налог на размер отбора.
Что не делают фильтры
Ни один до скоринга не ходит в другой сервис. Иначе их не было бы восемнадцати.
Первоисточники
- home-mixer/filters/ — все двадцать девять фильтров.
- inventory_holdout_filter.rs — детерминированный холдаут и его хеш.
- params/config.rs — константы: возраст поста, размер отбора, размер ответа.
- phoenix_candidate_pipeline.rs — порядок фильтров в пайплайне.
- Курс: глава «Порядок фильтрации» о фильтрации и её месте в воронке, там же о фильтре Блума, глава «Exploration на практике» об экспериментах.