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

Хронологическая лента выглядела мёртвой: пачки постов от одного канала и ничего нового при обновлении. Как это починили двумя простыми правилами, как выбрали окно свежести замером на живых данных и какой баг курсора ломал «загрузить ещё».

Лента в ЧатЯдре была строго хронологической: ORDER BY id DESC LIMIT 20. Потянул вниз, чтобы обновить, — те же посты. Ощущение, что ничего не происходит, хотя контента хватало: 1130 публичных постов, 258 за последнюю неделю.

Диагноз по данным, а не по ощущениям

Разбор живых данных показал две вещи. Во-первых, ленту почти целиком заполняли каналы-агрегаторы: «Интересное», «Новости», «Кухня», «Юмор», «Дом и дача», «Спорт», сотни постов в месяц каждый. Во-вторых, и это ключ, посты шли пачками по автору: канал публикует пять записей одной минутой, и первый экран выглядел как «5 × Интересное, 3 × Спорт, 3 × Новости». Это и читалось как «нет разнообразия».

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

  1. Непросмотренное вперёд.
  2. Расшивка пачек: не больше двух постов подряд от одного автора.
  3. Лёгкое ранжирование по свежести, реакциям и комментариям.

Первые два сделаны за день. Третий пока не понадобился.

Уровень 1: непросмотренное вперёд

Фундамент уже был: таблица просмотров post_views, заведённая для счётчика «сколько человек видели». Запрос ленты стал двухступенчатым: сначала посты, которых человек не видел, потом всё остальное, внутри каждой группы новое первым.

CASE WHEN NOT seen_by_me AND is_fresh THEN 0 ELSE 1 END AS rank
…
ORDER BY rank ASC, id DESC

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

Первый баг нашёлся сразу: пост нового человека не находился в ленте. Карточка поста одна и та же в ленте и в профиле, и при показе она отмечает просмотр. Достаточно было заглянуть в чужой профиль, чтобы его запись исчезла из ленты, ни разу в ней не показавшись. Разделили: флаг in_feed в таблице просмотров; порядок двигают только просмотры из ленты, а счётчик «сколько видели» считается везде.

Окно свежести: замер, а не прикидка

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

Значит, наверх должно подниматься не всё непрочитанное, а только свежее. Сколько дней считать свежими, решили замером на живой ленте: 1163 непрочитанных против 277 прочитанных.

Окно Чем открывается лента
3, 5, 7 дней записями за вчера
14 дней записями двухнедельной давности

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

const UNSEEN_WINDOW_DAYS = 7;

Уровень 2: не больше двух подряд от автора

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

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

Баг, который не был виден с экрана

Через неделю после выката выяснилось, что «загрузить ещё» в ленте не работало ни разу. Курсор приоритет:id разбирался в булево значение seen: true/false, и оно уходило в запрос на сравнение с колонкой rank, которая целочисленная. Postgres пытался прочитать слово «true» как число и отбрасывал запрос целиком. На экране просто ничего не догружалось; ошибка была видна только в журнале сервера. Исправление — одна строка: возвращать число.

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

Попутные находки

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

Оба бага нашлись только потому, что ленту стали читать глазами после каждого изменения, а не только тестами.