Tech Blog

視聴履歴の「重心ベクトル」で、おすすめを作る — 履歴ゼロの会員でも空にしない3段構え

Recommendation embedding パーソナライズ Cold Start

はじめに

映像・音楽を扱う自作の総合プラットフォームに、会員向けの「おすすめ」を付けた。人気順ではなく、その人の視聴・貸出履歴から選ぶパーソナライズ推薦だ。

しかも、意味検索のときに作った索引をそのまま使い回している。専用のベクトル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もモデルも無しに三つ目の顔を見せた。パーソナライズと聞くと大掛かりな基盤を想像しがちだが、手元にある「意味の近さ」を測る仕組みに、履歴の平均という一手を足すだけで立ち上がる。

そして、質の高い一段だけで完成させないこと。①が効かない会員のために②③を敷いておく。理想の推薦と、誰の画面も空にしない約束を、両方同時に持つ——それが、この機能で選んだ形だった。


関連する記事

気軽にメッセージください

仕事の依頼、案件紹介、ご感想・ご質問なんでもお待ちしております。 高い志をもった同士の皆様と繋がることを切に願っております。 これからも人生を掛けたチャレンジを続けていきます。 何卒よろしくお願い致します。