自分で書き溜めたメモを検索していて、手が止まったことがあります。個人開発で貯まったアプリのFAQ や実装ノートを埋め込み検索にかけ、「返金の締め切りは何日か」と尋ねたところ、最上位に出てきたのは返金ポリシーの理念を語った段落でした。言葉はよく似ています。けれど、日数はどこにも書いていません。
似ているのに、答えていない。埋め込み検索の取りこぼしは、たいていこの形をしています。そして私が最初に疑ったのは埋め込みモデルの精度でしたが、直したのはそこではありませんでした。二段目を足しただけです。粗く集めた候補を、Claude にもう一度並べ直してもらう。この記事は、その二段検索の設計と実装を、個人の小さなナレッジ検索を題材に記録したものです。
「意味が近い」と「答えになっている」は違う
埋め込み検索は、質問と文書を同じ意味空間のベクトルに変換し、近いものを返します。得意なのは「話題の近さ」です。返金について尋ねれば、返金について書かれた段落を残らず集めてきます。
問題は、そのランキングが「話題の近さ」で並んでいて、「質問への答えやすさ」で並んでいないことです。返金ポリシーの理念を語る段落は、返金という話題にはこの上なく近い。けれど「締め切りは何日か」という問いに対しては、具体的な日数を含んだ実務的な一文のほうが的確です。埋め込みだけの上位 top-3 をそのまま採用すると、意味は近いが答えではない候補が、答えを含んだ候補を押しのけて上に来ます。
これは埋め込みモデルを高価なものに替えても本質的には消えません。近さの尺度が違うものを、近さの精度で解こうとしているからです。必要なのは、集めた候補を「答えやすさ」で採点し直す二段目です。
二段検索の全体像
やることは単純です。一段目で広く浅く集め、二段目で狭く深く選びます。
一段目(recall)は埋め込み検索で、取りこぼしを減らすことだけを考えて top-3 ではなく top-20 ほど広めに拾います。ここでの目標は「答えを含む断片を候補の中に必ず入れておく」ことで、順位の正しさは問いません。二段目(rerank)で、その20件を Claude が「質問の答えとしてどれだけ的確か」で採点し、上位を数件だけ残します。
| 段 | 担当 | 目標 | 見る尺度 |
| 一段目 recall | 埋め込み検索 | 取りこぼさない(top-20 で広く) | 話題の近さ |
| 二段目 rerank | Claude | 的確な数件に絞る(top-3) | 質問への答えやすさ |
一段目は速く安く、二段目は賢く。役割を分けることで、埋め込みの弱点を Claude が補い、Claude に全文書を読ませる無駄を埋め込みが省きます。
実装①:埋め込みで候補を粗く集める
一段目は埋め込みモデルと保存先に依存しますが、二段目から見れば「候補のリストを返すもの」でしかありません。まず、そのインターフェースだけを決めておきます。Anthropic は埋め込みモデルを提供していないため、ここは各自の選択に委ねられます。ベクトルの保存と近傍検索を pgvector で組む具体は「Claude と pgvector で永続メモリを本番運用する」に譲り、本稿は候補を受け取った後に集中します。
from dataclasses import dataclass
@dataclass
class Candidate:
id: str
text: str
score: float # 一段目の類似度(大きいほど近い)
def recall_candidates(query: str, top_n: int = 20) -> list[Candidate]:
"""埋め込み検索で粗く top_n を集める。
埋め込みモデルや保存先(pgvector 等)は問わない前提のインターフェース。"""
vec = embed(query) # あなたの埋め込み関数
rows = vector_store.search(vec, k=top_n) # cosine 上位 top_n
return [Candidate(id=r.id, text=r.text, score=r.similarity) for r in rows]
top_n を 20 にしているのは、答えを含む断片を「候補の中には必ず入れておく」ための余白です。ここを 3 に絞るのが最初の落とし穴で、二段目がどれだけ賢くても、そもそも候補に無いものは選べません。取りこぼしは一段目でしか防げず、二段目では回避できないので、recall は広めに取ります。
実装②:Claude で候補を並べ直す
二段目の肝は二つあります。ひとつは、採点結果を構造化された形で確実に受け取ること。もうひとつは、候補を1リクエストにまとめて渡すことです。1件ずつ採点すると、呼び出しごとに基準が揺れて順位が安定せず、往復の回数だけコストも嵩みます。候補が20件なら往復は20倍に膨らみ、その分だけ本番運用のコストと遅延にそのまま効いてきます。全候補を一度に見せれば、Claude は相対的に比べながら一貫した尺度で採点できます。
構造化の受け取りには tool use を使い、tool_choice でツールを強制します。これで出力は必ず指定スキーマの JSON になります。スキーマ検証と修復まで含めた作法は「Claude API の構造化出力をスキーマ検証と修復ループで守る」が詳しいです。
import os
from anthropic import Anthropic
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
RERANK_TOOL = {
"name": "rank_candidates",
"description": "各候補が質問の答えとしてどれだけ的確かを 0.0〜1.0 で採点する",
"input_schema": {
"type": "object",
"properties": {
"rankings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"relevance": {"type": "number"}, # 0.0-1.0
"reason": {"type": "string"}, # なぜその点か(根拠)
},
"required": ["id", "relevance", "reason"],
},
},
},
"required": ["rankings"],
},
}
def rerank(query: str, cands: list[Candidate], model: str) -> list[dict]:
# 候補を1リクエストに束ねる。1件ずつ呼ぶと採点基準がぶれ、往復コストも嵩む。
passages = "\n\n".join(f"[{c.id}]\n{c.text}" for c in cands)
msg = client.messages.create(
model=model,
max_tokens=1024,
tools=[RERANK_TOOL],
tool_choice={"type": "tool", "name": "rank_candidates"},
messages=[{
"role": "user",
"content": (
f"質問: {query}\n\n"
"次の候補を、質問の答えとしての的確さで採点してください。"
"言い換えが似ているかではなく、質問に直接答えているかを見てください。\n\n"
f"{passages}"
),
}],
)
block = next(b for b in msg.content if b.type == "tool_use")
ranked = block.input["rankings"]
ranked.sort(key=lambda r: r["relevance"], reverse=True)
return ranked
プロンプトで「言い換えが似ているかではなく、質問に直接答えているか」を明示しているのが要点です。これが無いと、Claude も埋め込みと同じ「話題の近さ」に引きずられ、二段目を足した意味が薄れます。採点の尺度を言葉で固定することが、二段目の価値を決めます。
一段目だけの素朴な実装と並べると、違いは短いコードに凝縮されます。
# Before: 埋め込みの上位3件をそのまま返す(話題は近いが答えでないことがある)
def search_naive(query: str) -> list[Candidate]:
return recall_candidates(query, top_n=3)
# After: 広く集めてから Claude で答えやすさで並べ直す
def search_two_stage(query: str, model: str) -> list[dict]:
cands = recall_candidates(query, top_n=20)
return rerank(query, cands, model=model)
確信がないときは、答えない
二段目を入れて嬉しいのは、順位が良くなることだけではありません。採点が数値で返るので、「そもそも良い候補が無い」ことに気づけるようになります。最上位の relevance がしきい値に届かないなら、無理に上位を返さず「答えなし」を返す。この棄権が、確信のない検索に堂々と間違えるのを防ぎます。
ABSTAIN = 0.55 # 最上位がこの値未満なら「答えなし」を返す
def search(query: str, top_k: int = 3,
model: str = "claude-haiku-4-5-20251001") -> dict:
cands = recall_candidates(query, top_n=20)
if not cands:
return {"sources": [], "reason": "候補が見つかりませんでした"}
ranked = rerank(query, cands, model=model)
if ranked[0]["relevance"] < ABSTAIN:
# 意味は近くても答えになっていない。無理に上位を返さない。
return {"sources": [], "reason": "確信の持てる候補がありませんでした"}
by_id = {c.id: c for c in cands}
picked = [r for r in ranked[:top_k] if r["relevance"] >= ABSTAIN]
return {
"sources": [
{"id": r["id"], "text": by_id[r["id"]].text,
"relevance": r["relevance"], "reason": r["reason"]}
for r in picked
],
}
返しているのは回答そのものではなく、根拠となる断片と「なぜそれを選んだか」です。この後段で回答を生成するにせよ、まず的確な根拠を選び切っておくことが、後段の幻覚を減らします。検索側の取りこぼしが後段の品質をどう蝕むかは「RAG の想起低下を計測する」に踏み込んだ記録があります。
しきい値 0.55 は出発点で、正解が分かっている質問を数十件用意し、棄権が多すぎず取りこぼしも少ない値へ寄せていくのが実務的です。
コストと精度のトレードオフ
二段目は Claude 呼び出しを1クエリにつき1回増やします。候補を束ねているので回数は増えませんが、入力トークンは候補の総量だけ乗ります。おおよその見積りを持っておくと、採用判断が具体的になります。
候補20件が平均300トークンなら、本文だけで約6,000トークン。質問とツール枠を足して1クエリあたり概ね6千〜7千入力トークン、採点結果の出力は 20件×約40トークンで800トークン前後です。これは実測値ではなく設計上の試算ですが、桁を掴むには十分です。料金は改定されるため、最新の単価は Anthropic の料金ページで確認してください。
| 状況 | 採点に使うモデル | 考え方 |
| FAQ・短い断片が中心 | Haiku 4.5 | 答えやすさの判定は軽い。速く安く回す |
| 長文・専門的で紛らわしい候補 | Sonnet 5 | 微妙な差の見極めに賢さが要る場面だけ上げる |
| 候補が数件しか出ない検索 | 再ランク不要 | 並べ直す余地が無い。埋め込みの順位で足りる |
| 同じ質問が繰り返し来る | 結果をキャッシュ | 再ランク結果を質問キーで保存し呼び出しを省く |
小さく始めたい場合は、まず Haiku 4.5 を既定にすることを推奨します。私はこの二段構えを好んでいて、棄権が続く難しいカテゴリだけ Sonnet 5 に上げる形に落ち着きました。二段目は「常に賢く」より「必要なときだけ賢く」のほうが、個人の予算では長く続けられます。
まとめ
埋め込み検索が返すのは「話題の近さ」で、私たちが欲しいのは「質問への答えやすさ」です。この二つはよく似ていて、けれど確かに違います。粗く集める一段目と、答えやすさで選び直す二段目に役割を分けるだけで、検索の手触りははっきり変わります。
次の一歩として、いまお使いの検索の top_n を 20 に広げ、この rerank 関数を後ろに一つ挟んでみてください。棄権しきい値を持たせておけば、確信のない検索に堂々と間違える回数から先に減っていくはずです。私自身もまだ調整の途中ですが、二段目を足すという小さな一手が効く場面は、思っていたより多いと感じています。お読みいただきありがとうございました。