取り組みの背景 — 「上位3件は当たり前に合うのに、4件目に正解が埋もれている」問題
RAGを本番投入してしばらく経つと、どこかのタイミングで奇妙な現象に気づきます。社内ドキュメント検索を作って、ある質問に対して関連ドキュメントを正しく取得できているはずなのに、ユーザーが「答えてほしかったドキュメント」が応答に出てこないという状況です。ログを掘り返すと、Top 5の取得結果のうち、本当に答えるべきドキュメントは4位や5位に入っていて、Geminiは1位と2位の似たような別ドキュメントの方を引用していた、という構図です。
私が運用しているナレッジ検索でも、Embeddings だけで構築した初期版は「答えに近そうな文書」は取れていても、「答えそのものの文書」を1位に押し上げる力が足りませんでした。これは Gemini の応答品質ではなく、検索の順位付け が甘いことが原因です。今回はこの問題に決着をつけるためのリランカー設計を、Vertex AI Ranking API と Gemini を使った LLM リランカーの2方式で実装していきます。
ベクトル検索の基本については Gemini Embedding × Pinecone のRAG実装 と Qdrant ハイブリッドRAG に書きましたので、未読の方はそちらを先に読むと前提が揃います。本記事はそれらの続き として、検索した後の「順位を直す」レイヤーに焦点を当てます。
なぜリランカーが必要なのか — リコールと精度を分けて考える
Embeddings ベースの検索は、コサイン類似度で並べ替えるだけのシンプルな仕組みです。これは大量のドキュメントから「関連していそうな候補」を素早く絞り込むのには優れていますが、上位N件の中での順位 は意外と粗いものになります。
具体的には、以下の3つのケースで Embeddings だけだと取りこぼしが起こります。
同義表現が多いドメイン : 「払戻」「返金」「キャンセル料の返却」が混在する規約集では、ベクトル類似度だけでは「どれが質問の答えに最も近いか」を区別しきれない
長文ドキュメント : 5,000文字のドキュメントを1つのベクトルに圧縮すると、その中の特定の段落が答えだったとしても、ドキュメント全体の意味でしか比較できない
キーワード一致が決定的な場合 : 商品コード「SKU-A1234」を含むドキュメントを探すような場面では、意味の近さよりも「その文字列を含むか」が重要
これらに共通するのは、「リコール(候補の中に正解が入っているか)」は Embeddings で十分なのに、「精度(その正解を1位に押し上げられるか)」が足りないという状況です。リランカーは、候補集合は変えずに順位だけを直す ためのコンポーネントとして機能します。
設計の基本形はこうです。
Stage 1: Embeddings で Top 50〜100件の候補を高速に取得する(リコール最大化)
Stage 2: リランカーで上位5〜10件に絞り直す(精度最大化)
Stage 3: その上位をプロンプトに詰めて Gemini に渡す
Stage 1 と Stage 2 は「速度と精度のトレードオフ」を別々のレイヤーに切り分けるための分離です。Embeddings 単体で精度を上げようと頑張るより、ずっと素直に効きます。
実装1: Vertex AI Ranking API でリランキングする
Google Cloud には Discovery Engine の中に Ranking API という独立したサービスがあり、リランカーとしてそのまま使えます。50件のドキュメントを semantic-ranker-default@latest モデルに渡すと、関連度スコア付きで再順位付けされた結果が返ってきます。
最初にコードを示します。google-cloud-discoveryengine クライアントの RankServiceClient を使う最小実装です。
# rerank_with_vertex.py
# 役割: Embeddings で取得した候補を Vertex AI Ranking API でリランクする
# 期待出力: スコア付き上位N件のドキュメント
import os
from google.cloud import discoveryengine_v1
PROJECT_ID = os.environ[ "GOOGLE_CLOUD_PROJECT" ]
LOCATION = "global"
RANKING_CONFIG = "default_ranking_config"
def rerank (query: str , candidates: list[ dict ], top_k: int = 10 ) -> list[ dict ]:
"""
candidates: [{"id": "doc-1", "title": "...", "content": "..."}, ...]
"""
client = discoveryengine_v1.RankServiceClient()
parent = (
f "projects/ { PROJECT_ID } /locations/ { LOCATION } /"
f "rankingConfigs/ { RANKING_CONFIG } "
)
records = [
discoveryengine_v1.RankingRecord(
id = c[ "id" ],
title = c.get( "title" , "" ),
content = c[ "content" ][: 4000 ], # 1レコード上限に注意
)
for c in candidates
]
request = discoveryengine_v1.RankRequest(
ranking_config = parent,
model = "semantic-ranker-default@latest" ,
top_n = top_k,
query = query,
records = records,
)
try :
response = client.rank( request = request)
except Exception as e:
# リランカーが落ちても元の候補で動かし続ける
print ( f "[rerank] fallback to embeddings order: { e } " )
return candidates[:top_k]
# スコア降順で並んでいる
ranked = []
by_id = {c[ "id" ]: c for c in candidates}
for r in response.records:
doc = by_id[r.id]
doc[ "rerank_score" ] = r.score
ranked.append(doc)
return ranked
ここで重要なのは、try/except でリランカーが落ちても元の候補順で続行する 部分です。Stage 2 はあくまで順位を整える役割で、Stage 1 が機能していれば、最悪リランカーが死んでも検索結果は返せます。可用性をリランカーに依存させない設計が、本番では効きます。
呼び出し側はこうなります。
# pipeline.py
from rerank_with_vertex import rerank
from your_embeddings_search import search_with_gemini_embeddings
def retrieve (query: str ) -> list[ dict ]:
# Stage 1: 50件をリコール
candidates = search_with_gemini_embeddings(query, top_k = 50 )
# Stage 2: 上位10件にリランク
top10 = rerank(query, candidates, top_k = 10 )
return top10
Vertex AI Ranking API の利点は、自前で評価モデルを用意しなくていい ことです。料金は1,000レコードあたり数セント程度で、Gemini を judge にする LLM リランカーよりずっと安く、レイテンシも100ms前後です。日本語にも対応しています。一方で、ドメイン固有の言い回し(医療・法律・社内用語)には弱い場合があり、その場合は次の LLM リランカー方式を検討する必要があります。
実装2: Gemini を judge にした LLM リランカー
Vertex AI Ranking API では精度が足りない場合、Gemini を judge として使うリランカーを自作できます。考え方は、**「クエリと候補ドキュメントを並べて、Gemini に関連度スコアを出させる」**というシンプルなものです。
LLM リランカーには大きく2つの方式があります。
Pointwise(点ごと採点) : 各候補に対して「このドキュメントはクエリにどれだけ答えているか」を1〜10でスコアリングする
Pairwise(対戦比較) : 候補2つを並べて「どちらがクエリに対してより答えているか」を比較します。これをトーナメント方式で繰り返して順位を作る
Pairwise の方が精度は高いですが、N件をリランクするのに O(N log N) 回の Gemini 呼び出しが必要でコストとレイテンシが膨らみます。本番では Pointwise を推奨します。10件を並列で評価するなら 1リクエストで済ませるバッチ採点プロンプト が現実解です。
# rerank_with_gemini.py
# 役割: Gemini 2.5 Flash をリランカーとして使い、候補を一括採点する
# 期待出力: スコア付き上位N件
import os
import json
import google.generativeai as genai
from pydantic import BaseModel
genai.configure( api_key = os.environ[ "GEMINI_API_KEY" ])
RERANK_PROMPT = """あなたは検索エンジンの関連度評価者です。
以下のクエリに対して、各ドキュメントが「直接的な答え」になっているかを 0.0〜1.0 で採点してください。
採点基準:
- 1.0: クエリの問いに直接かつ明確に答えている
- 0.7: 答えに近いが部分的、または周辺情報を含む
- 0.4: 関連はあるが答えではない
- 0.0: 関連性が低い
【クエリ】
{query}
【ドキュメント一覧】
{documents}
JSON形式で ` {{ "scores": [ {{ "id": "doc-1", "score": 0.92, "reason": "簡潔な理由" }} ] }} ` のように返してください。
"""
class RankItem ( BaseModel ):
id : str
score: float
reason: str
class RankResult ( BaseModel ):
scores: list[RankItem]
def rerank_with_gemini (
query: str , candidates: list[ dict ], top_k: int = 10
) -> list[ dict ]:
docs_text = " \n\n " .join(
f "[ { c[ 'id' ] } ] \n{ c[ 'content' ][: 1500 ] } " # 1500字に切り詰めて Token 暴発を防ぐ
for c in candidates
)
prompt = RERANK_PROMPT .format( query = query, documents = docs_text)
model = genai.GenerativeModel(
"gemini-2.5-flash" ,
generation_config = {
"response_mime_type" : "application/json" ,
"response_schema" : RankResult,
"temperature" : 0.0 , # 採点は決定論にする
},
)
try :
response = model.generate_content(prompt)
result = RankResult.model_validate_json(response.text)
except Exception as e:
print ( f "[rerank_gemini] fallback: { e } " )
return candidates[:top_k]
# スコア降順で並べ替え
score_by_id = {item.id: item.score for item in result.scores}
ranked = sorted (
candidates,
key =lambda c: score_by_id.get(c[ "id" ], 0.0 ),
reverse = True ,
)[:top_k]
for c in ranked:
c[ "rerank_score" ] = score_by_id.get(c[ "id" ], 0.0 )
return ranked
このコードのポイントは3つあります。
第一に、response_schema で Pydantic モデルを渡すことで、Gemini の出力を JSON として確実に受け取れるようにしています。temperature=0.0 も合わせて、リランカーとしての再現性 を確保します。同じクエリで毎回違う順位が返ると、効果測定ができなくなります。
第二に、各ドキュメントは [1500] で切り詰めています。50件を入れると100,000トークンを軽く超えてしまうので、Stage 1 の候補数 N と1ドキュメントあたりの長さの掛け算でトークン上限内に収まるよう設計します。リランカーの入力は「ドキュメント全文」ではなく、「答えが入っていそうな抜粋」で十分です。
第三に、Gemini 2.5 Flash を使っています。リランカーはレイテンシ予算が厳しい(100〜500ms 内に収めたい)ので、Pro ではなく Flash で十分です。私の手元のベンチでは、20件を1リクエストでリランクするのに Flash で平均400ms、Pro で1.2秒程度でした。
実装3: ハイブリッドリトリーバ + リランカーの三段構え
実用的な本番構成では、Stage 1 自体もハイブリッド化します。BM25 のキーワード検索と Embeddings のベクトル検索を別々に走らせて、それぞれの上位を集約してから、Stage 2 のリランカーに渡す構成です。
# hybrid_pipeline.py
# 役割: BM25 + Embeddings の候補をマージし、Vertex Ranking API でリランク
from rank_bm25 import BM25Okapi
from your_embeddings_search import search_with_gemini_embeddings
from rerank_with_vertex import rerank
def hybrid_retrieve (
query: str , corpus: list[ dict ], bm25: BM25Okapi, top_k: int = 10
) -> list[ dict ]:
# ① BM25 で 30件
bm25_scores = bm25.get_scores(query.split())
bm25_top = sorted (
zip (corpus, bm25_scores), key =lambda x: x[ 1 ], reverse = True
)[: 30 ]
bm25_ids = {doc[ "id" ] for doc, _ in bm25_top}
# ② Embeddings で 30件
emb_top = search_with_gemini_embeddings(query, top_k = 30 )
emb_ids = {doc[ "id" ] for doc in emb_top}
# ③ 両者の和集合(重複は除去・最大60件)
candidates_map = {doc[ "id" ]: doc for doc, _ in bm25_top}
for doc in emb_top:
candidates_map.setdefault(doc[ "id" ], doc)
candidates = list (candidates_map.values())
# ④ 共通している候補は信頼度を上げる(簡易RRF)
overlap = bm25_ids & emb_ids
for c in candidates:
c[ "recall_signal" ] = 2 if c[ "id" ] in overlap else 1
# ⑤ Vertex AI Ranking で精度を出す
return rerank(query, candidates, top_k = top_k)
ここで使っている RRF(Reciprocal Rank Fusion)の簡易版 は、両方の検索器が共通して上位に挙げた候補に重みを乗せるテクニックです。本格的に効果を出すなら score = 1/(k + rank_bm25) + 1/(k + rank_emb) の形で実装しますが、リランカーが後段にいるので、ここでは「両方に出てきたかどうか」のシグナルを残せば十分です。リランカーが最終判断を下します。
このパイプラインを私のドメインで試したところ、Embeddings 単体での Top1 一致率(評価セット100件)は52%でしたが、ハイブリッドリトリーバ + Vertex Ranking で71%、+ Gemini Flash リランカーで78%まで上がりました。Embeddings だけのときに「答えが10件中4位」だったケースのほとんどが、リランカーで1〜3位に押し上がっています。
リランカーの効果測定 — NDCG と MRR を本番監視に組み込む
リランカーを入れた瞬間に体感は変わりますが、数字で見ないと運用できません 。リランカー導入後は、以下の2つを定期的に見るようにしています。
MRR(Mean Reciprocal Rank) : 評価セットの各クエリで、正解ドキュメントが何位にあるかの逆数を平均します。1位なら 1.0、2位なら 0.5、見つからなければ 0
NDCG@5 : 上位5件の品質を、正解の位置と関連度を加味して採点します。リランカーの調整で最も動く指標
評価セットは100〜300件程度のクエリと正解ペアを社内で作って、CI に組み込みます。
# eval_rerank.py
# 役割: 評価セットでMRRとNDCG@5を計算する
import math
from typing import Callable
def mrr (ranked_lists: list[list[ str ]], golds: list[ str ]) -> float :
total = 0.0
for ranked, gold in zip (ranked_lists, golds):
try :
rank = ranked.index(gold) + 1
total += 1.0 / rank
except ValueError :
total += 0.0
return total / len (ranked_lists)
def ndcg_at_k (ranked_lists: list[list[ str ]], golds: list[ str ], k: int = 5 ) -> float :
total = 0.0
for ranked, gold in zip (ranked_lists, golds):
ideal = 1.0 # 単一正解前提
dcg = 0.0
for i, doc_id in enumerate (ranked[:k]):
if doc_id == gold:
dcg = 1.0 / math.log2(i + 2 )
break
total += dcg / ideal
return total / len (ranked_lists)
def evaluate (retrieve_fn: Callable, eval_set: list[ dict ]) -> dict :
queries = [item[ "query" ] for item in eval_set]
golds = [item[ "gold_id" ] for item in eval_set]
ranked_lists = [
[doc[ "id" ] for doc in retrieve_fn(q)] for q in queries
]
return {
"mrr" : mrr(ranked_lists, golds),
"ndcg@5" : ndcg_at_k(ranked_lists, golds, k = 5 ),
"n_queries" : len (queries),
}
このスクリプトを GitHub Actions などで毎日走らせて、MRR が前日比で 5% 以上下がったら Slack に通知する、という運用にしています。Gemini Flash のモデル更新やリランカー API の挙動変化を回帰検出 できます。
評価指標について体系的に
よくある間違い・落とし穴
リランカー導入で詰まりやすいポイントを、私が踏んだ順に並べておきます。
落とし穴1: Stage 1 の候補数を絞りすぎる
Embeddings の Top 5 だけリランカーに渡しても、5件の中に正解が入っていなかったらどうしようもありません。Stage 1 はリコール最大化 が目的なので、Top 50〜100まで広げるべきです。「リランカーが遅くなるから絞ろう」と考えがちですが、それでは導入意義が消えます。
落とし穴2: クエリとドキュメントの言語ミスマッチ
日本語クエリで英語ドキュメントを検索する場合、Vertex AI Ranking API は基本的にうまく動きますが、semantic-ranker-default よりも semantic-ranker-512 のようなクロスリンガルモデルが用意されている場合があります。判定が怪しい時はモデルを切り替えてみてください。Gemini をリランカーにすると、この多言語問題は自動的に解決します。
落とし穴3: スコアの絶対値を信用してフィルタする
「rerank_score が 0.5 未満は捨てる」という閾値設計をしたくなりますが、絶対値はモデルやクエリで分布が変わります。相対順位 だけを信用して、フィルタは別ロジック(例: クエリと最上位スコアの差で判断)に切り出してください。
落とし穴4: 評価セットを作らずに導入する
リランカーを入れた直後は体感的に「良くなった気がする」だけで、数字では判別不能です。100件の評価セットを作る労力を惜しまないでください。3〜4時間で作れます。後から効果測定できないシステムは、いずれ「なんとなく悪化」したときに原因が追えなくなります 。
落とし穴5: リランカー単独で完璧を目指す
リランカーは Stage 1 の候補が良くないと魔法は起こせません。Embeddings の品質、チャンク分割、メタデータフィルタ — Stage 1 側の改善も並行して進めるべきです。リランカーは「最後の押し上げ」であって、検索全体の万能薬ではありません。
チャンク分割がリランカー精度に与える影響
リランカーを入れたのに精度が思ったほど伸びない、という相談を受けることがあります。掘り下げると、チャンク分割の粒度がリランカー前提と噛み合っていないケースが大半でした。リランカーは「クエリと候補を照合する」ことしかしないので、候補のチャンクが「答えを含む適切な単位」になっていないと、いくら順位を整えても1位に押し上げられない わけです。
私が現場で使っているチャンク設計の原則は次の3つです。
第一に、1チャンクは1つの主張・1つの手順を含む単位 にします。Markdownの見出し(H2やH3)で素直に分けるのが基本で、長すぎる場合は段落単位で再分割します。1チャンク300〜800文字を目安にしていて、これより大きいと「答えの段落」と「答えと無関係な段落」が同じベクトルに混ざって、Embeddings の比較精度が落ちます。
第二に、前後のオーバーラップを50〜100文字確保 します。質問文の主語がチャンクの前段落にあるパターン(「この機能は〜のときに使います」の「この機能」が前のチャンクで定義されている、など)を吸収できます。オーバーラップを取らないと、リランカーが「文脈から外れた断片」を見せられて誤判定します。
第三に、チャンクには見出しと親見出しをメタデータとして付与 します。リランカーに渡す content は本文だけでなく「[サポート規約 > 払戻について] 商品到着から30日以内に〜」のように、文書構造を明示すると採点が安定します。Vertex AI Ranking API の RankingRecord.title は、まさにこの目的のためのフィールドです。
# chunker.py
# 役割: 見出し階層を保持しながら適切な粒度でチャンク分割する
import re
def split_with_headings (markdown: str , max_chars: int = 600 ) -> list[ dict ]:
chunks: list[ dict ] = []
current_h1, current_h2, current_h3 = "" , "" , ""
buffer = []
buffer_len = 0
def flush ():
nonlocal buffer, buffer_len
if buffer_len > 0 :
chunks.append({
"title" : " > " .join( filter ( None , [current_h1, current_h2, current_h3])),
"content" : "
".join(buffer).strip(),
})
buffer = []
buffer_len = 0
for line in markdown.split( "
"):
if line.startswith( "# " ):
flush(); current_h1 = line[ 2 :].strip(); current_h2 = current_h3 = ""
continue
if line.startswith( "## " ):
flush(); current_h2 = line[ 3 :].strip(); current_h3 = ""
continue
if line.startswith( "### " ):
flush(); current_h3 = line[ 4 :].strip()
continue
buffer.append(line)
buffer_len += len (line)
if buffer_len >= max_chars:
flush()
flush()
return chunks
このチャンカーで分割したドキュメントを Embeddings に入れ、title フィールドを Vertex AI Ranking API に渡すと、リランカーの精度が体感で5〜10%動きます。チャンク戦略はリランカー導入と同時にレビュー するのが効率的で、片方だけ最適化しても効果が出にくい領域です。
リランカー結果のキャッシュとコスト最適化
リランカーは候補生成より計算コストが高く、毎リクエストでフルに走らせると無駄が多くなります。本番では2階層のキャッシュで節約できます。
階層1は正規化クエリ単位の完全一致キャッシュ です。クエリ文字列をトリム・小文字化・全角半角正規化した上でハッシュ化し、リランク結果の上位ID列を Redis に TTL 30〜60分で保存します。同じユーザーが同じ質問を繰り返すケース、複数ユーザーが「営業時間」のような頻出クエリを投げるケースをここで吸収できます。
階層2はペア単位(クエリ × 候補ID)のスコアキャッシュ です。Stage 1 の候補が変わってもリランクスコアは流用できる場面が多いので、md5(query + doc_id) → score の形で TTL 24時間程度保存します。これにより、上位50件中の40件が前回と同じならリランカーは差分10件だけ採点する設計が可能になります。
# rerank_cache.py
# 役割: ペア単位スコアキャッシュで Gemini リランカー呼び出しを削減
import hashlib
import json
from typing import Optional
import redis
r = redis.Redis( host = "localhost" , port = 6379 , decode_responses = True )
SCORE_TTL = 24 * 60 * 60 # 24時間
def _key (query: str , doc_id: str ) -> str :
h = hashlib.md5( f " { query } :: { doc_id } " .encode()).hexdigest()
return f "rerank:score: { h } "
def get_cached_scores (query: str , doc_ids: list[ str ]) -> dict[ str , float ]:
keys = [_key(query, d) for d in doc_ids]
values = r.mget(keys)
return {
d: float (v) for d, v in zip (doc_ids, values) if v is not None
}
def save_scores (query: str , scored: list[ dict ]) -> None :
pipe = r.pipeline()
for item in scored:
pipe.set(_key(query, item[ "id" ]), str (item[ "rerank_score" ]), ex = SCORE_TTL )
pipe.execute()
def rerank_with_cache (query: str , candidates: list[ dict ], rerank_fn) -> list[ dict ]:
cached = get_cached_scores(query, [c[ "id" ] for c in candidates])
uncached = [c for c in candidates if c[ "id" ] not in cached]
# 未キャッシュ分だけ採点
if uncached:
new_scored = rerank_fn(query, uncached, top_k = len (uncached))
save_scores(query, new_scored)
for c in new_scored:
cached[c[ "id" ]] = c[ "rerank_score" ]
# キャッシュとマージしてスコア降順
for c in candidates:
c[ "rerank_score" ] = cached.get(c[ "id" ], 0.0 )
return sorted (candidates, key =lambda x: x[ "rerank_score" ], reverse = True )
このキャッシュ層を入れると、私の運用ではリランカー呼び出しが平均60%削減されました。Gemini Flash でリランカーを動かしている場合、これは月間API費用に直接効きます。注意点としては、ドキュメント本文を更新したらそのドキュメントIDのキャッシュを明示的に無効化する仕組みが必要なことで、save_scores 時にバージョン番号を組み込んだキー設計(rerank:score:v{n}:{hash})にしておくと、ドキュメント更新時の一括無効化がシンプルになります。
まとめ — 次にやるべき1つのこと
リランカーは「検索が惜しい」と感じている本番RAGに、最も投資対効果の高い改善です。Embeddings の段階で Top 1 一致率が30〜50%程度のシステムなら、Vertex AI Ranking を入れるだけで70%台まで届くケースが多いです。
明日からの一歩としては、評価セットを100件作って、現在の MRR と NDCG@5 を測る ところから始めてください。数字が見えれば、Vertex AI Ranking と Gemini リランカーのどちらが自分のドメインに合うかは、1日試せば結論が出ます。リランカーの議論はそこから本当に役立つ意思決定に変わります。
私自身、最初は「Embeddings をもっと頑張れば順位は良くなるはず」と思って Embedding モデルを差し替えたり次元数を増やしたり試行錯誤しましたが、振り返るとリランカーを最初に入れていればずっと早く目標精度に到達できていました。同じ回り道を踏まないで済むよう、この記事が役立てば嬉しいです。