🍣

ベクトル埋め込みを使って「小説家になろう」を検索して、更に多様化する (情報検索・検索技術 Advent Calendar 2022)

2022/12/08に公開

Twitter: @cocomoff / Zenn: @takilog です。本記事は情報検索・検索技術 Advent Calendar 2022の8日目の記事として書きました。

まえがき

普段、メーカーの研究職として様々な雪かき的研究(技術的盆栽かも?)を遂行しているのですが、最近趣味で検索システムの本を読んでいたため、検索技術に個人的に興味を持っています。こちら、大変良い本でした(一部分、ちゃんと読めてないところもありますが…)。

https://twitter.com/cocomoff/status/1555344551371767808

せっかく本を読んだので、何か検索システムのことを考えてAdvent Calendarでも書こうと思ってこちらの記事を書きました。

本職が検索ではないので、真面目なソフトウェア(elasticsearchとか)を使うのが難しく、悩んだ結果、手元で出来ることだけをやります。こういう原理確認は、理解を深めるためにはたまに重要になります。具体的には、このAdvent Calendarの記事では

  • 自然言語分のベクトル埋め込みを使って、検索みたいな行為をしてみる
  • 検索みたいな行為をしたときに、検索結果の多様化をしてみる

という2点に取り組みました。

ベクトル埋め込みを使って検索してみる

ベクトル埋め込み

自然言語文の単語の埋め込みはここ10年ぐらいで高度化している手法群です。膨大な個数存在する単語や語句の情報を(比較的)低次元の実ベクトルに埋め込むことで、扱いやすくなったり、いわゆる機械学習などの手法と相性が良くなったりします。歴史ある手法ではword2vecなどが、最近ではBERTなどの手法を使っていい感じにベクトル埋め込みが計算できます。

ここでは検索システムの実装を考えます。一度単語などをベクトルの世界に持ってくると、クエリー q に対して近傍の文書をK個取得するベクトルの類似度の意味での近傍探索を行うことで、検索っぽいことができそうです(近傍探索も奥が深い分野なので、ここでは触れないことにします。)

ベクトル埋め込みを用いる検索タスクのようなもの

例題として、小説家になろう週間ランキング ぐらいを考えます。このようなものですね。

普段はランキング上位から適当に読んでいます。検索を考えると、例えば「悪徳令嬢」が気になっているとき、適当に「悪徳令嬢」っぽい感じのタイトルの作品を探してきたいという気持ちになるので、これは検索システムの出番ですね!

クエリー q (=悪徳令嬢) のベクトル埋め込みを \mathbf{q} と太字で書いておき、検索システムが対象とする文書のタイトル集合 \mathcal{T}=\{T_1, T_2,\dots, T_M\} について、それぞれのベクトル埋め込みを求めて \{\mathbf{T}_1, \mathbf{T}_2, \dots, \mathbf{T}_M\} とすると、次に読むべき小説家になろう作品(?)をK個出力する検索問題は、何らかのベクトル間距離 d(\cdot, \cdot) が近い作品を K 個取得すれば良いことになります。

実装(概要)

ベクトル埋め込み(雑)

まずベクトル埋め込みが必要です。ナウい HuggingFace をチラ見したり、世の中のテックブログを適当にGoogle検索した結果、こんな感じで日本語文のベクトル埋め込みが取得できることが分かります。世の中のいろんなものを整備してくれる偉人たちに頭が上がりませんね。

早速「悪徳令嬢」のクエリーを埋め込んでみます。

import sentencepiece
from transformers import AlbertTokenizer, AlbertForPreTraining
import torch


if __name__ == '__main__':
    query = '悪徳令嬢'
    tokenizer = AlbertTokenizer.from_pretrained('ALINEAR/albert-japanese-v2')
    model = AlbertForPreTraining.from_pretrained('ALINEAR/albert-japanese-v2')

    input_ids= torch.tensor(tokenizer.encode(query, add_special_tokens=True)).unsqueeze(0) 
    embeddings = torch.mean(model(input_ids, output_hidden_states=True).hidden_states[12], 1)
    print(embeddings.shape)
    print(embeddings[:10])

実行結果の出力です。真面目に使いなさい(意訳)と怒られている気がしますが、今回は簡単な記事なので目をつぶっておくことにします。出力の中身ですが、とりあえず1本のクエリー q を768次元のベクトルに埋め込めたことが分かります。

> python example.py
Some weights of AlbertForPreTraining were not initialized from the model checkpoint at ALINEAR/albert-japanese-v2 and are newly initialized: ['sop_classifier.classifier.bias', 'sop_classifier.classifier.weight']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.
torch.Size([1, 768])
tensor([ 1.9212,  0.0431, -1.5322,  0.3974, -1.1506, -1.2721,  0.8582, -1.1828,
         0.7689,  1.4410], grad_fn=<SliceBackward0>)

データ(雑)

ここでデータを作ります。先程のランキングページにアクセスし、「異世界〔恋愛〕」から「その他〔その他〕」までの18個のカテゴリについて、表示されている上位5個の作品のタイトルを取得し、90行のタイトルが入ったファイル titles.csv を作成しました。特にクローラを作るほどのことでもないので、手作業でポチポチしました(脳筋)。

ここまでで、クエリー q=\text{悪徳令嬢}と、検索対象となるタイトル群(90作品分)が準備できました。あとは検索するだけですね。ベクトル間の類似度にはコサイン類似度を採用して、上位K=10個の作品をクエリー q=\text{悪徳令嬢} について計算してみます。

全体のコードは省いて、単純に計算する部分だけを書きます。qを埋め込んだベクトルがembed_q、各タイトルを埋め込んだベクトルがembed_titlesに格納されているとして、それぞれのコサイン類似度を処理し、類似度が大きいタイトルをK個出力します。

# コサイン類似度の計算
cos_sim = np.zeros(len(embed_titles))
for j in range(len(embed_titles)):
    embed_j = embed_titles[j]
    dj = torch.cosine_similarity(embed_q, embed_j, dim=1)
    # 雑にtorchを処理しています(真似しちゃだめ)
    cos_sim[j] = dj[0]

# 上位K個取ってくる (argpartition + argsort)
# 参考: https://naoyashiga.hatenablog.com/entry/2017/04/13/224339
K = 10
unsorted_max_indices = np.argpartition(-cos_sim, K)[:K]
y = cos_sim[unsorted_max_indices]
indices = np.argsort(-y)
max_k_indices = unsorted_max_indices[indices]
for j in max_k_indices:
    print(f"{j} {cos_sim[j]:>.3f} {titles[j]}")

これでなろうタイトル検索システムが完成しました(適当すぎて怒られそうです)。

実行結果

早速検索結果を確認してみます。

合ってるのでしょうか…?

よく見ると「令嬢」が引っかかってる雰囲気があったり、女性(魔女、聖女、令嬢、女神)が引っかかってる雰囲気があったり、そこそこかもしれません。4番目はそもそも「悪徳令嬢」がタイトルに入ってますね、1位に出してほしい気もしますが。

> python simplest-search.py
Some weights of AlbertForPreTraining were not initialized from the model checkpoint at ALINEAR/albert-japanese-v2 and are newly initialized: ['sop_classifier.classifier.weight', 'sop_classifier.classifier.bias']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.
87 0.858 大嫌いな令嬢
60 0.826 剣客ウルフ
10 0.817 魔女と傭兵
29 0.797 悪役令嬢にざまぁされた王子のその後
31 0.782 冬嵐記
13 0.771 岩壁のルォ
20 0.754 【超短編】逆行する令嬢の真相
35 0.753 薬屋のひとりごと
28 0.745 魔王と聖女と女神の家族ごっこ
48 0.740 ダンジョン×学園×クズ

完全に余談ですが、筆者は最近「魔女と傭兵」を読んでいたのですが最新作まで追いついてしまったので、著者様はこれからも続きの執筆をよろしくお願いいたします(この記事で一番大事なメッセージです)。

検索結果を多様化してみる

検索結果多様化

これまで試したように、関連度ベース(ここでは関連度=コサイン類似度です)の検索は簡単に実装できそうです(もちろん大規模な文書群にインデクスを貼ってやるのは大変ですが、そのあたりは偉い人がなんとかしてくれるでしょう。ここでは原理の部分だけの話です)。

一方で課題もいろいろありそうです。例えばクエリー q の埋め込みベクトル \mathbf{q} に似ている2つのタイトル文 T_1, T_2 の埋め込みベクトル \mathbf{T}_1, \mathbf{T}_2 は、ちょっと考えると、お互い似ている気がしてきます。当たり前ですが、今の例題の場合だと、どっちも「悪徳令嬢」っぽいタイトルになっているはずなので、お互いに似ていると期待されるわけですね。

つまり、悪徳令嬢の作品をひたすら読みたいときは関連度だけを用いた検索システムで十分に機能すると期待されますが、実際「悪徳令嬢以外の作品も読みたいな〜〜〜〜」みたいな邪な気持ちがあるときには、関連度だけを使った検索システムは広がりがなさそうです。

このような検索結果の多様性に関する技術群は 検索結果多様化 (Search Result Diversification) という技術として、いろんな研究がされています。Foundations and Trends(R) in Information Retrievalでも、Search Result Diversificationについてはまとまった文書がありますので、大事な技術であることが分かります。

https://www.nowpublishers.com/article/Details/INR-040

アブストをコピペしておきます。それっぽいことが書いてありますね!(投げっぱなし)。飲むのが面倒な人は適当にDeepLにコピペしてください。

Ranking in information retrieval has been traditionally approached as a pursuit of relevant information, under the assumption that the users’ information needs are unambiguously conveyed by their submitted queries. Nevertheless, as an inherently limited representation of a more complex information need, every query can arguably be considered ambiguous to some extent. In order to tackle query ambiguity, search result diversification approaches have recently been proposed to produce rankings aimed to satisfy the multiple possible information needs underlying a query. In this survey, we review the published literature on search result diversification. In particular, we discuss the motivations for diversifying the search results for an ambiguous query and provide a formal definition of the search result diversification problem. In addition, we describe the most successful approaches in the literature for producing and evaluating diversity in multiple search domains. Finally, we also discuss recent advances as well as open research directions in the field of search result diversification.

MMR (Maximal Marginal Relevance) を用いた検索結果多様化

MMRは、検索結果として出力するアイテムを1つずつ計算していくタイプの検索結果多様化タスクに対して適用できる古典的な(1998年)手法です。しかし、考え方はわかりやすく、侮れなくいい感じに作用します。解説ブログもあります。

https://yolo-kiyoshi.com/2020/05/08/post-1781/

https://hazm.at/mox/machine-learning/natural-language-processing/summarization/mmr/index.html

真面目な解説は解説ブログや論文に丸投げするとして、MMRの基本的な考え方は「逐次的にアイテムを選択するタイプの仕組みにおいて、目的関数式として2つの項を考慮し、次に検索結果に挿入するアイテムを選択する」というものです。式ですが

MMR := \arg\max_{D_i \in R\setminus S} \left[ \lambda \mathrm{sim}(D_i, q) - (1 - \lambda) \max_{D_j\in S} \mathrm{sim}(D_i, D_j)\right]

というものです。

気持ちだけ簡単に解説しておきます。

  • あるアイテムD_iを選択するとき、全体の候補Rの中から、既に選んだものSを取り除いた範囲R\setminus Sから評価値が良いものを選びます。
  • 1つ目の評価値はクエリー q との類似度で、これは関連度ベースの検索を行っていることを意味します。
  • マイナスではじまる2つ目の項は「関連度スコアを減らしたるで〜〜〜〜」という気持ちです。
    • 式を見ると、既に選んだSの中のD_jについて、次に選ぶD_iとの類似度を測定しています。
    • コサイン類似度をイメージすると「これまでに選んだものと似ている場合には、検索の関連度スコアを下げたるで〜〜〜〜」という気持ちです。

こういう気持ちに従って1つずつ検索結果に含めるアイテムを選ぶことで、結果として多様化された検索結果が計算されることが期待されます。わかりやすいですね!

実装(雑)

さて、実装です。

上の式では\mathrm{sim}というものが「クエリ-候補 (1項目)」と「候補-候補 (2項目)] の両方に出てきますが、ここでは議論と実装を簡単にするために、どちらもコサイン類似度にしてしまいます。

後はK個選ぶ際に、

  • 1つ目のアイテムは、関連度スコアが最大のものを選びます(2項目は S=\emptyset のため評価しない)
  • 2つ目以降のアイテムは、関連度スコアを「これまでに選んだもの」に対して2項目で調整して、順番に選びます

という感じで、1つずつなろう作品を選択していけば、パラメータ \lambda (あまり真面目に考えてなかったですが、たぶん 0 < \lambda < 1 でしょうか)に対して多様化されたはずの結果が取得できるはずです。実装はgithubにおいておきました。今回は遊びシステム(酔っ払いが書いたコード)なので実装は雑ですので、真面目に参考にはしないでください。

計算部分のみ抜粋しておきます。

# 1アイテム目: 最大値のもの
k = np.argmax(cos_sim)
results.append(k)


# 2アイテム目 ~ K=10アイテム目
for i in range(1, K):
    new_sims = np.zeros_like(cos_sim)

    # 既に選択したアイテムは選択しないので、-infにしておく
    new_sims[:] = param_lambda * cos_sim[:]
    new_sims[results] = -np.float64("inf")

    # 雑な実装: 各アイテム D_i について、これまで選んだアイテムとのsimで最大値を
    # (1 - λ) を付けて引いて調整する
    for j in range(N):
        # アイテム j とこれまでに選んだアイテム (results) との類似度の最大値
        max_j = max([cos_sim_mat[j, s] for s in results])
        new_sims[j] -= (1 - param_lambda) * max_j

    # スコアを調整した上で選ぶ。これをK個まで繰り返す。
    k = np.argmax(new_sims)
    results.append(k)

良さそうですね(本当か…?)。

多様化した悪徳令嬢作品

結果を見てみます。

パラメータ \lambda = 1 は関連度スコアが高いものを K 個選ぶ先ほどの結果に一致しますので、\lambda を徐々に大きくしていって、結果の変遷を見てみましょう。ここではタイトルを並べたランキングで見てみますね。本当はきれいに可視化したかったのですが、手抜きで申し訳ないです。

タイトルの横には通常の関連度スコア(\mathrm{sim}(D_i, q) です)を書いています。

lambad = 1.0 の結果 (普通のTop-Kと同じ)

上の計算結果と同じものです(スコアの大きい順に並んでいます)。

87 0.858 大嫌いな令嬢
60 0.826 剣客ウルフ
10 0.817 魔女と傭兵
29 0.797 悪役令嬢にざまぁされた王子のその後
31 0.782 冬嵐記
13 0.771 岩壁のルォ
20 0.754 【超短編】逆行する令嬢の真相
35 0.753 薬屋のひとりごと
28 0.745 魔王と聖女と女神の家族ごっこ
48 0.740 ダンジョン×学園×クズ

\lambda = 0.8 の結果

ちょっとだけペナルティがかかった0.8です。

上位はそこそこ強いですが、新しくどこからともなく「私掠宇宙船から始める特務軍歴」と「異世界の名探偵、美しき令嬢助手と共に「村人連続石化事件」に挑む」が入ってきました。

\lambda = 1.0のときにいた上位 K 個の下のほうが関連度スコアがこれられと似ているので、ペナルティでいい感じに入れ替わった様子が見て取れます。

87 0.858 大嫌いな令嬢
60 0.826 剣客ウルフ
10 0.817 魔女と傭兵
29 0.797 悪役令嬢にざまぁされた王子のその後
31 0.782 冬嵐記
13 0.771 岩壁のルォ
35 0.753 薬屋のひとりごと
20 0.754 【超短編】逆行する令嬢の真相
55 0.730 私掠宇宙船から始める特務軍歴
39 0.730 異世界の名探偵、美しき令嬢助手と共に「村人連続石化事件」に挑む

\lambda = 0.6 の結果

上位が引き続き結構強いですが、「ダンジョン×学園×クズ」が復活してきたり、「岩壁のルォ」と「冬嵐記」のランキングが大きく移動したり、また「離縁希望の側室と王の寵愛」が新しくやってきました。いい感じに多様化されていると言ってもいいかもしれません。

87 0.858 大嫌いな令嬢
31 0.782 冬嵐記
60 0.826 剣客ウルフ
35 0.753 薬屋のひとりごと
29 0.797 悪役令嬢にざまぁされた王子のその後
4 0.722 離縁希望の側室と王の寵愛
55 0.730 私掠宇宙船から始める特務軍歴
39 0.730 異世界の名探偵、美しき令嬢助手と共に「村人連続石化事件」に挑む
48 0.740 ダンジョン×学園×クズ
13 0.771 岩壁のルォ

\lambda = 0.4 の結果

このあたりになると関連度スコアよりも多様化が重くなってきますね。もはや全然違うランキングになっています。「ダンジョン×学園×クズ」が微妙にしぶといのは何なんでしょうか。

悪徳令嬢っぽいので言うと、「婚約破棄された娘は死んだ婚約者に復讐をする」がいい感じに出てきましたね(※これは類似度ベクトルやfine-tuningしていない埋め込みがダメなのもあると思いますが、まぁいいでしょう)。

87 0.858 大嫌いな令嬢
31 0.782 冬嵐記
72 0.439 金の斧と銀の斧と妻の頭をかち割った鉄の斧
85 0.559 婚約破棄された娘は死んだ婚約者に復讐をする
53 0.588 New Eternity Online -PSだけで往く新世界-
79 0.595 星座の瞳は、情熱の色
62 0.523 【鑑定スキルなしの錬金術師物語】色彩能力者の錬金術師
32 0.661 三成、かがり火を消させず
48 0.740 ダンジョン×学園×クズ

\lambda = 0.2 の結果

更に関連度スコアの重みを下げてみます。もはや謎のランキングですね(もちろん、1位はずっと一緒ですが)。

新しく「職務放棄による被害状況について」「星座の瞳は、情熱の色」「量子力学的きびだんご」などが出てきました。発掘した感じがありますね。たまにはいいかもしれません。

87 0.858 大嫌いな令嬢
72 0.439 金の斧と銀の斧と妻の頭をかち割った鉄の斧
62 0.523 【鑑定スキルなしの錬金術師物語】色彩能力者の錬金術師
85 0.559 婚約破棄された娘は死んだ婚約者に復讐をする
3 0.586 職務放棄による被害状況について
79 0.595 星座の瞳は、情熱の色
80 0.507 「ラノベの打ち切りは3日で決まる」3日で6割の法則
53 0.588 New Eternity Online -PSだけで往く新世界-
71 0.583 量子力学的きびだんご
25 0.487 開発者を大事にしない国は滅びるのです。常識でしょう?

\lambda = 0.0 の結果

最後に開き直って関連度スコアさようならしてみた結果です。

カオスですね。「開発者を大事にしない国は滅びるのです。常識でしょう?」が気になりますね。

87 0.858 大嫌いな令嬢
72 0.439 金の斧と銀の斧と妻の頭をかち割った鉄の斧
62 0.523 【鑑定スキルなしの錬金術師物語】色彩能力者の錬金術師
85 0.559 婚約破棄された娘は死んだ婚約者に復讐をする
80 0.507 「ラノベの打ち切りは3日で決まる」3日で6割の法則
3 0.586 職務放棄による被害状況について
79 0.595 星座の瞳は、情熱の色
25 0.487 開発者を大事にしない国は滅びるのです。常識でしょう?
53 0.588 New Eternity Online -PSだけで往く新世界-
71 0.583 量子力学的きびだんご

\lambda を変化させていろいろ計算した結果、MMRの動作の雰囲気が分かった気がします。

やっぱり、理解のために自分で書いてみることは大事ですね!

まとめ

「検索」の行為について考えてみると、いろいろ学びがあるなと思いました。冒頭に上げた検索システムの本はもっと真面目なテクニックがちゃんと説明されているのですが、とても勉強になりました。

この記事で使っているコードはこちらにあります。

https://github.com/cocomoff/Sandbox-NLP-Search-Narou

Discussion