RAG 上級講座 #4 クエリ変換とリランキング
第3回までで、チャンクと検索器を磨きました。今回は検索パイプラインの両端を見ます。前段ではユーザーの質問を検索に適した形に変え(クエリ変換)、後段では広く取ってきた候補を精密に絞り込みます(リランキング)。どちらも「検索器そのものはそのままにして」入力と出力に手を入れる技法なので、既存のパイプラインに組み込みやすいです。
ユーザーの質問は検索クエリではありません #
検索失敗の一つの系統は、質問の側にあります。実際のユーザーの質問はこんな形をしています。
- 「じゃあ、それっていつまでできるの?」 — 会話の文脈なしには、何を聞いているのかすら分かりません。
- 「返金したいんだけどカード払いで、一部キャンセルだとどうなるの?」 — 複数の質問が一文に重なっています。
こうした質問をそのまま埋め込むと、検索がぶれます。そこで検索の前に、質問を変換する段階を設けます。
クエリリライティング — 文脈を埋めて独立した質問へ #
チャットボット型の RAG で最も効果が大きい変換は、会話の文脈を反映して質問を書き直すことです。速くて安いモデルで十分なので、本回答とは別のモデルを使っても構いません。
def rewrite_query(history: list, question: str) -> str:
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=200,
system=(
"会話の文脈を反映して、最後の質問を検索用の独立した質問一文に書き直せ。"
"代名詞を実際の対象に置き換え、答えずに質問だけを出力せよ。"
),
messages=history + [{"role": "user", "content": question}],
)
return next(b.text for b in response.content if b.type == "text")
# 「じゃあ、それっていつまでできるの?」 → 「注文のキャンセルは決済後何日以内まで可能か?」リライティングした質問は検索だけに使い、答えの生成には元の質問と会話をそのまま使います。検索のための補助段階であって、会話を変える段階ではありません。
マルチクエリ — 一つの質問を複数の角度から #
文書と質問の語彙が異なる場合(第1回の診断で記述型の質問の失敗が多かった場合)には、同じ質問を別の表現で複数作り、すべてで検索するマルチクエリが効果的です。
def expand_queries(question: str, n: int = 3) -> list:
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=300,
system=f"質問を文書検索用に互いに異なる表現 {n}個に書き換えよ。1行に1つずつ。",
messages=[{"role": "user", "content": question}],
)
text = next(b.text for b in response.content if b.type == "text")
return [question] + [q.strip() for q in text.split("\n") if q.strip()]
def multi_query_search(question: str, top_k: int = 5) -> list:
rankings = [vector_search_ids(q, top_k=20) for q in expand_queries(question)]
return [chunks[i] for i in rrf(rankings, top_k=top_k)] # 第3回の RRF を再利用各クエリの結果を合わせるのに、第3回の RRF をそのまま再利用します。呼び出しが増えるぶん遅延とコストが付く技法なので、すべての質問で有効にするより、リライティングだけでは足りなかった質問群に適用するのがバランスの取れた選択です。
リランキング — 広く取ってきて精密に絞り込む #
今度は後段です。埋め込み検索には構造的な限界が一つあります。質問と文書をそれぞれ別々にベクトル化してから距離を測るため、二つを突き合わせて比較する精密さがありません。リランキング(reranking、検索した候補をより精密なモデルで並べ直すこと)は、この限界を補います。質問とチャンクをペアとして一緒に読んで関連度を採点するクロスエンコーダー(cross-encoder)モデルを使い、広く取ってきた候補を並べ直します。
from sentence_transformers import CrossEncoder
# 多言語リランカー。日本語の質問にも動作する。
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
def search_with_rerank(question: str, top_k: int = 5) -> list:
candidates = hybrid_search(question, top_k=30) # 第1段階: 広く30個
pairs = [(question, c["text"]) for c in candidates]
scores = reranker.predict(pairs) # 第2段階: ペアで精密に採点
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [c for c, _ in ranked[:top_k]] # 上位5個だけを生成に使う構造が核心です。第1段階の検索は速く広く、第2段階のリランキングは遅いが精密にという役割分担です。第1段階が再現率を、第2段階が精度を受け持ちます。クロスエンコーダーはペアごとにモデルを回すので遅く、チャンク全体には使えませんが、候補30個程度なら十分に使えます。
専用のリランカーの代わりに、Claude のような LLM に「この質問にこのチャンクは関連があるか」と問う LLM リランキングも可能です。より柔軟ですが、より遅く高くつきます。専用のリランカーから始めて、それでは足りない領域が確認されたときに検討する順番をおすすめします。
どこまで積み上げるか #
第2回から今回までの技法をすべて有効にすると、パイプラインはこうなります。リライティング →(マルチクエリ)→ ハイブリッド検索 → リランキング → 生成。段階ごとに遅延とコストが付くので、全部有効にすることが目標ではありません。第1回のゴールデンセット測定で数字が上がる段階だけを残すことが目標です。経験的に、効果対コストの良い出発点はリライティング(チャットボットなら)とリランキングの二つです。
よくつまずくところ #
- リライティングした質問で答えまで生成する — 変換した質問は検索用です。生成にまで使うと、ユーザーが聞いていない質問に答えることになります。
- 狭く取ってきてリランキングする — 候補5個をリランキングしても順番が変わるだけで、新しい正解は入ってきません。リランキングの前提は広い第1段階(20〜50個)です。
- 変換モデルに大きなモデルを使う — リライティングとクエリ拡張は単純な作業なので、小さなモデルで十分です。本回答より補助段階の方が高くつくのは本末転倒です。
まとめ #
今回は、検索の前段と後段を補強しました。
- 会話型の質問はリライティングで独立した質問にし、語彙の差が大きい質問はマルチクエリで複数の角度から検索します。
- リランキングは広く取ってきた候補をクロスエンコーダーで精密に絞り込みます。第1段階は再現率、第2段階は精度という分担です。
- 技法を全部有効にするのではなく、ゴールデンセットの数字が上がる段階だけを残します。
ここまでが検索品質、つまり「正解のチャンクを取ってくること」でした。次は生成の側です。正解のチャンクを渡したのに答えが間違う、チャンクにない内容を作り出す、といった問題を扱います。次回は「RAG 上級講座 #5 引用でハルシネーションを減らす」です。