なんかさ、最近の検索って賢いようで賢くない時ない? 例えば「LLMの評価方法」とかで調べると、タイトルは微妙に違うのに、中身がほとんど同じ記事がずらーっと出てきたりして。「もうその話は分かったよ!」って心の中でツッコミたくなる感じ。これ、あるあるだよね?
実はこれ、ベクトル検索みたいな「似てるものを見つける」技術のちょっとした副作用なんだよね。一番似てるものを見つけるのは得意だけど、似すぎてるものを延々と見せられちゃう。せっかく検索しても、同じ情報ばっかりスクロールして時間を無駄にするのは、正直しんどい。
そこで出てくるのが、今日話したい「Maximal Marginal Relevance」、略してMMRっていう考え方。こいつがマジでいい仕事するんだ。
TL;DR 多様性こそが新しい価値を生む
一言で言うと、MMRは検索結果の中から「クエリには関連してるけど、すでに見つかった結果とはちょっと違う、新しい情報」を賢く選んでくれるフィルターみたいなもの。つまり、ただ関連性が高い順に並べるんじゃなくて、「関連性」と「多様性」のバランスを取ってくれるわけ。
- まず、いつも通りベクトル検索で、クエリに最も関連性の高い候補(トップk個)をざっと集めてくる。
- 次に、その候補の中からMMRが再ランキングを開始。クエリとの関連性を見つつ、「すでに選んだアイテムと似すぎてないか?」をチェックして、結果を並べ替える。
だから、最初のベクトル検索で「的を射た」候補群が手に入って、その後のMMRで「飽きさせない」最終結果が出来上がるって感じ。最高じゃない?
そもそもMMRって、どういう仕組みなの?
MMRがやろうとしてることは、結果の「冗長性(ダブり)」を減らしつつ、「関連性」はキープすること。これを実現するために、2つのモノサシを同時に使うんだ。
- 関連性(Relevance): このアイテムは、私の検索クエリにどれくらいマッチしてる?
- 多様性(Diversity): このアイテムは、すでに選んだ他のアイテムとどれくらい違う?
これ、レストランのコース料理を考えるのにちょっと似てるかも。コースのテーマが「きのこ」だとしても、前菜からデザートまで全部しいたけだったら、いくら美味しくても飽きるでしょ?(笑) ポルチーニのリゾットとか、マッシュルームのポタージュとか、トリュフ塩のポテトとか、そういう「バリエーション」が欲しいわけ。MMRは、その優秀なシェフみたいな役割で、ちゃんとテーマに沿いつつも、驚きのある一皿(情報)を提供してくれるんだ。
技術的な話をすると、MMRは候補となる各ドキュメントを評価するときに、クエリとの類似度(関連性)と、すでに選択済みのドキュメントとの類似度(冗長性)を計算する。そして、この2つのスコアをλ(ラムダ)っていうパラメータでいい感じに混ぜ合わせて、次に追加するアイテムを決めるんだよね。
数式で書くとこんな感じになるけど、ビビらないで。やってることはシンプルだから。
MMR = arg max_{d_i ∈ C \ R} [ λ * Sim₁(d_i, q) - (1 - λ) * max_{d_j ∈ S} Sim₂(d_i, d_j) ]
めっちゃざっくり言うと…
- Sim₁: 候補のドキュメントが、検索クエリとどれだけ似てるか(これが高いと嬉しい)。
- Sim₂: 候補のドキュメントが、すでに選んだドキュメントとどれだけ似てるか(これは低いほうが嬉しい)。
- λ (ラムダ): この2つのどっちを重視するかの調整ツマミ。0から1の間で設定する。
このλの調整が結構キモで…
- λ = 1 にすると、完全に「関連性」だけを見る。つまり、普通のベクトル検索と同じ結果になる。
- λ = 0 にすると、今度は完全に「多様性」だけ。クエリとは関係なく、とにかく互いに似ていないものが選ばれる。これはこれでカオス(笑)。
- λ = 0.5 あたりが、両方のバランスを取る一般的なスタート地点かな。
あ、どっちがどっちだっけな…そうそう、λが1に近いと「関連性」重視で、0に近いと「多様性」重視。例えば、ECサイトで特定の商品を探してるユーザーにはλを高めに設定して関連商品をしっかり見せるとか、ニュースアプリでいろんな視点の記事を読ませたいならλを低めにするとか、そういう使い分けができるわけだ。便利だよね。
言葉で言われてもピンとこないから、比べてみよう
理屈は分かったけど、じゃあ実際どれくらい結果が変わるの?って思うよね。そこで、簡単な比較表を作ってみた。例えば「面白いホラー映画」で検索した場合を想像してみて。
| 検索クエリ | 普通のベクトル検索の結果 | MMRを使った検索の結果 |
|---|---|---|
| 「面白いホラー映画」 | 1. 『シャイニング』 2. 『エクソシスト』 3. 『13日の金曜日』 4. 『IT/イット』 → 全部超有名なド定番じゃん!もちろん名作だけど、もう知ってるよ!(笑) ってなる感じ。 |
1. 『シャイニング』 2. 『ヘレディタリー/継承』 3. 『ゲット・アウト』 4. 『ミッドサマー』 → お、『シャイニング』は来たけど、次は雰囲気の違う『ヘレディタリー』か!で、『ゲット・アウト』?サスペンス寄り?この組み合わせは面白いね。新しい発見がありそう。 |
| 「AIの最新動向」 | 1. GPT-4とは? 2. 大規模言語モデルGPT-4の解説 3. OpenAIのGPT-4を徹底分析 4. GPT-4の仕組みと応用 → うーん、全部GPT-4の話…。もちろん重要だけど、他のAIの話題はどこ…? |
1. GPT-4とは? 2. 画像生成AI「Stable Diffusion 3」の進化 3. GoogleのGemini 1.5 Proのすごいところ 4. オープンソースLLM「Llama 3」の性能 → そうそう、これこれ!GPTの話も押さえつつ、画像生成とかGoogleの動向とか、幅広く知りたいんだよ。 |
どう?全然違うでしょ?普通の検索は「正解」を一つ見つけたらその周辺ばっかり集めてきちゃうけど、MMRは「いろんな角度からの正解」を探しにいってくれる感じが伝わるかな。
じゃ、実際にQdrantでやってみようか
ここからは、実際にコードを書いて映画のおすすめチャットボットを作る流れでMMRを試してみよう。使うのはベクトルデータベースの「Qdrant」。こいつがMMRをネイティブでサポートしてくれてるから、実装がめちゃくちゃ楽なんだ。
ちなみに、こういうベクトル検索の技術って、海外のドキュメント(例えばQdrantの公式ドキュメント)は理論や数学的な背景からしっかり解説してくれることが多い。一方で、日本の技術ブログ(例えばZennやQiita)を見ると、具体的なサービス(例えばメルカリの検索改善とか)への応用例や、日本語特有のテキスト処理の工夫に焦点を当てた記事が多い印象。どっちも面白いから、両方見てみると理解が深まるよ。
ステップ1: まずは準備運動
最初に必要なライブラリをインストール。qdrant-clientは当然として、fastembedが個人的に好き。PyTorchみたいな重たいライブラリに依存してないから、環境構築が軽くて済むのがいいんだよね。
!pip install qdrant-client fastembed
!pip install langgraph langchain-community
!pip install langchain-sambanova
今回はLLMのオーケストレーションにLangGraphを使ってみる。状態管理がしやすくていい感じ。
ステップ2: データの読み込みと前処理
データはKaggleにあるIMDBのトップ250映画データセットを使う。映画の名前、ジャンル、キャッチコピーとかを全部くっつけて「summary」っていう1つのテキストフィールドを作っちゃう。これがベクトル化の元ネタになる。
import pandas as pd
from langchain_community.document_loaders import DataFrameLoader
data = pd.read_csv("movies.csv")
data['summary'] = data[['name', 'genre', 'tagline', 'writers', 'casts']].astype(str).agg(' '.join, axis=1)
df = data[['summary','rank','name','certificate']]
loader = DataFrameLoader(df, page_content_column="summary")
docs = loader.load()
ステップ3: EmbeddingモデルとQdrantクライアントのセットアップ
テキストをベクトルに変えるためのモデルを初期化。今回は英語のデータなのでBAAI/bge-base-en-v1.5を使うけど、もし日本語でやるなら、東北大学とかrinna社が出してる日本語に強いモデルを選ぶ必要がある。このモデル選びが検索精度に直結するから、実はすごく大事なポイント。
from fastembed import TextEmbedding
embedding_model = TextEmbedding("BAAI/bge-base-en-v1.5")
次にQdrantの設定。ここは各自Qdrantのクラウドでアカウント作って、クラスタのURLとAPIキーを取得してきてね。コピペするだけだから簡単。
from qdrant_client import QdrantClient, models
collection_name = "recommendations"
client = QdrantClient(
url="YOUR_QDRANT_CLOUD_URL",
api_key="YOUR_API_KEY"
)
client.create_collection(
collection_name=collection_name,
vectors_config=models.VectorParams(size=768, distance=models.Distance.COSINE),
on_disk_payload=True,
)
ステップ4: データのベクトル化とインデックス作成
さっき作った映画のドキュメントを一つずつベクトルに変換して、メタデータ(ランクとか映画名)と一緒にQdrantに放り込む。これが「インデックス作成」っていう工程。これで検索できる準備が整う。
from qdrant_client.models import PointStruct
points = []
for i, doc in enumerate(docs):
vector = list(embedding_model.embed([doc.page_content]))[0]
points.append(PointStruct(
id=i,
vector=vector,
payload=doc.metadata
))
operation_info = client.upsert(
collection_name=collection_name,
points=points
)
ステップ5: MMRを使った検索ロジックの定義
いよいよ本番。Qdrantで検索する関数を定義する。見て、query_pointsメソッドの中でmmr=models.Mmr(...)って指定するだけ。これだけでMMRが有効になる。簡単すぎて笑っちゃうレベル。
def search_movies_by_text(query_text, limit=5):
query_vector = list(embedding_model.embed([query_text]))[0]
results = client.query_points(
collection_name=collection_name,
query=models.NearestQuery(
nearest=query_vector,
mmr=models.Mmr(
diversity=0.5, # 多様性の強さ(0〜1)
candidates_limit=20 # MMRを適用する前の候補数
)
),
limit=limit,
with_payload=True,
)
return results
diversityがさっき話したλ(ラムダ)の値。candidates_limitは、最初に何件の候補を取ってきてからMMRで並べ替えるかを決めるパラメータ。この値を大きくすると、より多様な結果を得られる可能性があるけど、その分計算コストはちょっと上がる。
ステップ6: LangGraphでチャットボットのロジックを組む
あとは検索結果をLLMに渡して、自然な会話文を生成させるだけ。LangGraphを使うと、「検索ノード」→「生成ノード」みたいな処理の流れをグラフとして定義できるから、コードがすごくクリーンになる。
まず、検索結果を整形するノード。
def search_node(state: MovieSearchState):
"""映画を検索するノード"""
results = search_movies_by_text(state["query"], limit=5)
search_data = []
for result in results.points:
search_data.append({
"score": result.score,
"name": result.payload.get('name', 'Unknown'),
"rank": result.payload.get('rank', 'N/A'),
"certificate": result.payload.get('certificate', 'N/A')
})
return {"search_results": search_data}
次に、その結果を使ってLLMに応答を生成させるノード。
def generate_node(state: MovieSearchState):
"""LLMの応答を生成するノード"""
movies_text = "\\n".join([
f"- {movie['name']} (Rank: {movie['rank']}, Rating: {movie['certificate']}, Score: {movie['score']:.3f})"
for movie in state["search_results"]
])
prompt = f"""ユーザーのクエリ: "{state['query']}"
これらの映画のトップマッチは以下の通りです:
{movies_text}
これらの映画をおすすめする、簡潔で会話的な応答を提供してください。
"""
response = llm.invoke(prompt)
return {"response": response.content}
最後に、これらのノードをつなぎ合わせてグラフをコンパイルすれば完成!
workflow = StateGraph(MovieSearchState)
workflow.add_node("search", search_node)
workflow.add_node("generate", generate_node)
workflow.add_edge(START, "search")
workflow.add_edge("search", "generate")
workflow.add_edge("generate", END)
graph = workflow.compile()
# 実行!
result = graph.invoke({"query":"psychological thriller"})
print(result['response'])
で、これ結局何に使えるの?
今日の話、まとめるとMMRは「関連性」と「多様性」のバランスを取る技術。じゃあ具体的にどんな場面で輝くのかっていうと、応用範囲はめっちゃ広い。
- 推薦システム
ECサイトで「Tシャツ」って検索したら、色違いだけじゃなくて、VネックやUネック、素材違い、ブランド違いなんかもバランス良く見せてくれるとか。YouTubeで一つの動画を見終わった後、関連動画が同じチャンネルのものばかりじゃなくて、他のクリエイターの似たテーマの動画もサジェストしてくれるとか。 - RAGパイプライン
LLMに何かを質問したとき、その根拠となる情報を複数のドキュメントから引っ張ってくるのがRAGだよね。このとき、MMRを使えば、同じような内容のドキュメントばかり参照するんじゃなくて、異なる側面から解説しているドキュメントを幅広く集めてこれる。結果として、LLMの回答がより多角的で深みのあるものになる。これはマジで重要。 - ドキュメント要約
長い文章や複数の記事を要約するときも使える。文章の中からトピックの異なる重要な文を抜き出すことで、冗長じゃない、ポイントが押さえられた要約が作れる。
要するに、ユーザーに「もうこれ知ってるよ」って思わせる瞬間を減らして、「お、こんなのもあるんだ!」っていうセレンディピティ(偶然の素敵な出会い)を増やしてくれる技術なんだよね。これからの検索や推薦の体験をデザインする上で、めちゃくちゃ強力な武器になると思う。
今まで検索結果が似たようなものばっかりで、イラっとした経験ってある?もしあったら、どんな時だったかコメントで教えてくれると嬉しいな!
