視聴履歴の「重心ベクトル」で、おすすめを作る — 履歴ゼロの会員でも空にしない3段構え
はじめに
映像・音楽を扱う自作の総合プラットフォームに、会員向けの「おすすめ」を付けた。人気順ではなく、その人の視聴・貸出履歴から選ぶパーソナライズ推薦だ。
しかも、意味検索のときに作った索引をそのまま使い回している。専用のベクトルDBを足さずに意味検索を実装した話は 「字面」でなく「意味」で探す に書いた。この記事は、同じ索引を「おすすめ」に転用し、かつ履歴の無い会員でも画面を空にしないための3段構えの話だ。
実際の画面はこうだ。会員のおすすめ一覧で、各作品に付く緑の「あなた向け」は、本人の履歴から選ばれた(人気順フォールバックではない)ことを示している。

「おすすめ」は、何を根拠に出すのか
「あなたへのおすすめ」と銘打った棚は多い。だが素朴に作ると、中身はただの人気順だったりする。人気順は簡単で、誰にでも同じものを出せる。しかしそれは、“あなた”を一切見ていない。
やりたいのは、本人が実際に観た・借りた作品から好みを推し量って、まだ観ていない作品を薦めることだ。問題は「履歴から好みをどう表すか」——ここでつまずくと、結局はカテゴリ一致のような素朴なルールに戻ってしまう。
【人気順】 全員に同じ棚 … 簡単だが "あなた" を見ていない
【パーソナライズ】本人の履歴から選ぶ … "あなた" を見る。but 好みをどう数値化する?
観た作品の「平均ベクトル」が、その人の好みになる
ここで、意味検索のために作った索引が効いてくる。全作品はすでに 意味ベクトル(作品の説明文を数百次元の数値の並びに変換したもの。意味が近い作品どうしは近い場所に来る)になっていて、起動時からメモリに載っている。
好みの表し方はシンプルだ。本人が観た作品のベクトルを全部足して、平均する。 この平均ベクトルを「重心」と呼ぶ。重心は、その人が観てきた作品たちの”真ん中”にあたる一点で、その人の好みの中心を意味する。あとは、この重心に近い未視聴作品を返せばいい。索引に対して「重心に近い順」を尋ねるだけだ。
観た作品を意味ベクトルにして平均する = 好みの「重心」
作品A ●
作品B ● ─▶ 平均(重心)─▶ ★ あなたの好みの中心
作品C ●
│
└─▶ 重心に近い"未視聴"作品を返す = おすすめ
コードにすると、重心を作る部分はただの平均だ。索引に無い作品(ベクトル未生成のもの)は黙って飛ばし、一件も残らなければ空を返す——空が返ったら、呼び出し側は「embeddingでは出せない」と判断して次の手に移る。
// 観た作品群の embedding の平均(重心)=その人の好みベクトル
List<Double> profileVector(List<Integer> filmIds) {
double[] sum = null;
int count = 0;
for (int filmId : filmIds) {
List<Double> v = getVector(filmId);
if (v == null) continue; // 未生成の作品は飛ばす
if (sum == null) sum = new double[v.size()];
for (int i = 0; i < sum.length; i++) sum[i] += v.get(i);
count++;
}
if (count == 0) return List.of(); // 一件も無い → 空(=次の手へ退化の合図)
List<Double> centroid = new ArrayList<>();
for (double s : sum) centroid.add(s / count);
return centroid;
}
重心さえ作れれば、あとは意味検索とまったく同じ topSimilar(クエリベクトルに近い順に返す)に渡すだけ。クエリ文の代わりに「あなたの重心」を入れる——それがパーソナライズだ。
重心を作れない会員が、必ずいる
ここに落とし穴がある。重心は、履歴がある人にしか作れない。
- 登録したての新規会員は、履歴がゼロだ(コールドスタート=履歴が無く、その人向けの手がかりがまだ無い状態)。
- 履歴はあっても、その作品のベクトルがまだ生成されていないこともある。
こういう会員に、embeddingパーソナライズは何も返せない。では空の「おすすめ」を出すのか? それは最悪だ。登録して最初に開いた棚が空っぽなら、その会員はもう二度と開かない。
だから「出せなければ、質は落ちても別の手で必ず何か出す」——3段フォールバックにした。フォールバックとは、上の手が使えないとき下の手へ退化して切り替えること。上から順に試し、出せた時点で止める。
GET /api/me/recommendations
│
▼
① embedding:履歴の重心に近い未視聴作品
│ 出せた ─────────────▶ 返す(method="embedding")
│
└─ 履歴ゼロ/embedding未生成 → 退化
│
▼
② taste_tag:好みタグのルールで選ぶ
│ (同カテゴリ+共有タグ数で並べる)
│ 出せた ─────────────▶ 返す(method="taste_tag")
│
└─ 履歴そのものが無い → 退化
│
▼
③ popularity:人気順(全員向けの最後の砦)
必ず ────────────▶ 返す(method="popularity")
3段目の人気順は”あなた”を見ていないが、空の画面よりはるかにいい。品質の理想(①)と、必ず何か返す責任(③)を、退化の階段でつないでいる。
もう一つ、地味だが効く工夫を入れた。レスポンスに method(“embedding” / “taste_tag” / “popularity”)という内部指標を付けている。これで**「今どの段でおすすめを出したか」を後から測れる**。①がどれだけ発火しているか=パーソナライズが実際に効いている会員の割合が分かる。作りっぱなしにせず、質を数字で見張るための足場だ。
そして①で返すときは、重心に近い順に取ったあと、すでに観た作品を必ず外す。観た映画をまた薦めても意味がない。除外で件数が目減りするぶんを見込んで、目標より多めに取ってから絞る——このあたりの「意味が近いだけの余計なものを出口で落とす」話は ベクトル検索は「意味が近い」を勝手に混ぜてくる に書いた。おすすめの棚(映像)に音楽CDが紛れ込むのを外すのも、同じ理由だ。
一つの索引が、三つの顔を持つ
作ってみて一番よかったのは、検索のために用意した索引が、そのまま「おすすめ」にもなったことだ。
- クエリ文を入れれば → 意味検索
- ある作品を入れれば → 「この作品に似た作品」
- 履歴の重心を入れれば → 「あなたへのおすすめ」
入口が違うだけで、中で走るのは同じ topSimilar 一つ。意味検索を軽く実装したその索引が、追加のDBもモデルも無しに三つ目の顔を見せた。パーソナライズと聞くと大掛かりな基盤を想像しがちだが、手元にある「意味の近さ」を測る仕組みに、履歴の平均という一手を足すだけで立ち上がる。
そして、質の高い一段だけで完成させないこと。①が効かない会員のために②③を敷いておく。理想の推薦と、誰の画面も空にしない約束を、両方同時に持つ——それが、この機能で選んだ形だった。
関連する記事
- この「おすすめ」が転用している意味ベクトルの索引そのものは 「字面」でなく「意味」で探す — 専用のベクトルDBを足さずに意味検索を実装する に書いた。
- 重心に近いだけの別カテゴリ(映像に混じる音楽CDなど)を出口で落とす話は ベクトル検索は「意味が近い」を勝手に混ぜてくる — 種類の違うカタログが推薦に紛れ込む罠 にまとめた。
- このプラットフォーム全体の構想は DVDレンタルからDMM型プラットフォームへ にある。