投稿頻度の低いユーザーにも光を…！Misskeyサーバーでの新フィード画面の設計と実装

この記事は [Misskey Advent Calendar 2024](https://adventar.org/calendars/10208) 7日目の記事です。
# はじめに
私は、Fediverse こと分散型のSNSサーバー「みすきーすくえあ」を運営しています。このサーバーでは、オープンソースソフトウェア Misskey を、ソースコードを改変しカスタマイズして運用しています。

本記事では、このサーバーの独自機能として、投稿頻度の低いユーザーを優先表示する機能を作ったので、設計の意図と実装方法を紹介したいと思います。
# どんな機能なのか
ユーザー参加型のWebサービスは得てして、少数のヘビーユーザーの投稿がコンテンツの大部分を占め、多数の一般ユーザーの投稿が埋もれてしまうという結果になりやすいです。

報酬のボーナスやTipsの表示により、新規ユーザーや一般ユーザーの定期的な投稿や配信を促す方法もありますが、投稿者が疲弊してしまいやすく、不健康になりやすいです。

また、サービスの利用を継続するに従い、ユーザーフィードの分量が増え、投稿数が控えめだが魅力的なユーザーの投稿を見失いやすくなってしまうため、新しくユーザーをフォローすることをためらったり、当初仲の良かったユーザーとの交流が希薄になってしまい悲しいという問題があります。

X(Twitter)などの多くの商用プラットフォームでは、リコメンデーションアルゴリズムが自動でおすすめするタイムラインを表示することができますが、フォロー中のユーザーの投稿を確実に確認するという目的でつくられていません。関心のある投稿を見逃さないためにお気に入りのユーザーリストを作成する機能もありますが、管理が大変です。

そこで、最近一定期間になされたフォロー中のユーザーの投稿をユーザーごとに整理し、久しぶりに投稿したユーザーを優先して表示するフィード画面を用意すれば、このような問題が解決すると考えました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2519240/d269e229-0fbd-0dbb-88a7-5bd9d6fdcf9e.png)
特定ユーザーで埋まりやすい時系列フィード（左）と、久しぶりに投稿したユーザーを優先して表示するフィード（右）
# 投稿頻度の低いユーザーを特定
 投稿頻度の低いユーザーを特定するには、どのようにすればよいでしょうか。
1. 最近の決められた期間の投稿数をカウントし、数が少ないユーザーを「投稿頻度の低いユーザー」とする。
2. 前回の投稿から時間が空いた投稿を「久しぶりの投稿」とする。
3. ある一定期間に投稿したユーザーを「最近投稿したユーザー」とし、最近投稿したユーザーの中で、その期間より前の投稿が古いユーザーを「投稿頻度の低いユーザー」とする。

時直線を用いて図解すると以下のようになります。

(1)
```
昔 ===================================> 今
 . x .. x ..................... x . x x
 <-------------------------------------> 
 ここの投稿の合計が少ない方が、「投稿頻度の低いユーザー」
 
（x は投稿が行われた時刻を表す。）
 ```

(2) 
```
昔 ===================================> 今
 . x .. x ..................... x . x x
          <--------------------> 
            ここが長い方が、「久しぶりの投稿」
```

(3)
```
昔 ===================================> 今
 . x .. x ..............|....... x . x x
          <------------> 
            ここが長い方が、「投稿頻度の低いユーザー」
            　　　　　　　<--------------->
                          ここの投稿が一つ以上あるユーザーが、「最近投稿したユーザー」
```

久しぶりにログインしたユーザーが投稿を連投することもあります。方式(1)で判定すると、このようなユーザーが「投稿頻度の高いユーザー」と測定されてしまいます。したがって、方式(2)または方式(3)が望ましいということになります。

方式(2)と方式(３)には違いがありますが、方式(３)の方が実装しやすそうだったので、方式(3)を採用することにしました。

# 表示数の制限
投稿頻度の低いユーザーの最近の投稿を一覧表示するフィードで、大量の投稿が表示されてしまい確認が大変になると元も子もないので、ユーザーごとに最近の投稿を最大3件表示するようにしました。あ、最近この人投稿したのね～と気になったら、プロフィールページに行き読むことを想定しています。（SNSってみんなそういう風に使っていますよね？）

# 実装
どういうSQLコマンドを発行すればよいかなと、SQLコマンド上で検討して作成したため、長いSQL文を投げる形の実装にしました。

Misskeyの現在の実装では、時刻のかわりとしてIDを用いています。「最近の投稿」とみなす区間の区切れ目を`ps.anchorId`として与えています。

```javascript
const updatedUsers = await this.db.query(`SELECT c."userId" as user, d.m as last FROM ( SELECT DISTINCT ON (f."followeeId") f."followeeId" AS "userId" FROM "following" f JOIN "note" n ON n."userId" = f."followeeId" WHERE f."followerId" = $1 AND n."id" > $2 AND n."visibility" <> 'specified' AND n."renoteId" IS NULL AND n."replyId" IS NULL ORDER BY f."followeeId", n."id" DESC) AS c LEFT JOIN LATERAL ( SELECT "id" AS m FROM "note" WHERE "userId" = c."userId" AND "id" <= $2 AND note."visibility" <> 'specified' AND note."renoteId" IS NULL AND note."replyId" IS NULL ORDER BY "id" DESC LIMIT 1) AS d ON true ORDER BY d.m ASC NULLS FIRST OFFSET $3 LIMIT $4`, [ me.id, ps.anchorId, ps.offset, ps.limit ]);
		return await Promise.all(updatedUsers.map(async (row) => {
			const userId = row.user;
			const query = this.notesRepository.createQueryBuilder('note').innerJoinAndSelect('note.user', 'user')

			this.queryService.generateVisibilityQuery(query, me);
			this.queryService.generateMutedUserQuery(query, me);
			this.queryService.generateBlockedUserQuery(query, me);
			this.queryService.generateMutedUserRenotesQueryForNotes(query, me);

			query.andWhere('note.renoteId IS NULL');
			query.andWhere('note.replyId IS NULL');
			query.andWhere('note.userId = :userId', { userId });
			query.andWhere('note.id > :anchorId', { anchorId: ps.anchorId });
			query.orderBy('note.id', 'DESC');
			query.limit(3);

			return { id: userId, notes: await query.getMany(), last: row.last };
```
最初のサブクエリ `( SELECT DISTINCT ON ... ) AS c` では、`anchorId`より新しい投稿のあるフォロー中のユーザーIDを取得し、二つ目のサブクエリ `( SELECT "id" ... ) AS d` では、`anchorId`より古い投稿の中で一番新しい投稿を取得しています。このようなクエリを書くときには、lateral joinを使用すると書きやすいです。

このクエリをタイムアウトせずに実行するためには、投稿ユーザーひとりひとりに対し、別々の平衡木インデックスが必要です。そのような複合インデックスは、以下のようなコマンドで作成できます。
```
CREATE INDEX idx_per_user_notes ON public.note USING btree ("userId", id)
```
まず初めに本番環境（もしくはその複製環境）でインデックスを作成し、実行することになるSQL文をEXPLAINコマンドで解析します。idx_per_user_notesが確かに使われ、タイムアウト時間内に実行できることを確認して、ソースコードに反映し、デプロイしたら完了です。
# おわりに
クライアントアプリのみでこのような機能を実装しようとすると、フォローしているユーザー全員の最近の投稿すべてを同期する必要があり、通信量やストレージの負担が大きくなってしまいます。サーバーへの機能追加が気軽にできるオープンソースソフトウェアの長所を感じられる機能追加の経験になりました。
# ソースコード
https://github.com/yuriha-chan/misskey/tree/following-activities