2026年の春、個人開発している壁紙アプリ群のサポート返信ナレッジを Gemma 4 の RAG で引けるようにしていたときのことです。「Android 版でテーマを切り替えると画面が真っ白になる」という問い合わせに対して、検索が根拠として拾ってきたのは iOS 版の購入復元手順でした。生成された回答は文章としてはきれいにまとまっていて、ぱっと読むと正しそうに見えます。けれど中身は、その質問にまったく答えていませんでした。
この「それっぽいのに違う」回答が厄介なのは、目立つエラーを出さないことです。検索が外れても、言語モデルは手元に来たチャンクをなんとか繋いで流暢な文章を返してきます。結果として検索段階の失敗が生成の流暢さに覆い隠され、品質の劣化に気づくのが遅れます。私の場合、誤回答のログを後から精査したところ、原因のおよそ 70% は生成ではなく、検索の段階で正解文書を取り逃がしていたことにありました。
そこで大掛かりな評価基盤を導入する代わりに、30問のゴールデンデータセットと数十行の Python だけで回る小さな評価ハーネスを組みました。recall@5 は当初の 0.63 から、前処理とチャンク設計の見直しで 0.86 まで改善し、何より「変更を入れるたびに数値で確認する」という習慣が手に入りました。ここからは、その評価ハーネスの設計と実装を、Gemma 4 のローカル環境を例に順を追って共有します。なお、RAG パイプラインそのものの組み立ては Gemma 4 で RAG パイプラインを構築する — ローカルLLMで自分だけの検索AIを作る で扱っているため、本編は「すでに動いている RAG をどう測り、どう改善するか」に絞ります。
生成の流暢さが検索の失敗を隠す — 品質劣化の構造
RAG の品質問題は、検索と生成のどちらに原因があるかを切り分けないと、改善が空回りします。私が最初に犯した失敗がまさにこれでした。誤回答を見てプロンプトを念入りに直し続けたのですが、一向に良くなりません。検索ログを並べて初めて、そもそも正解チャンクが上位5件に入っていないケースが大半だと分かりました。プロンプトの改善は、検索が当たっていることを前提にした最後の仕上げです。順番を間違えると時間だけが溶けていきます。
それぞれの段階で起きる典型的な失敗は性質が異なります。
検索起因の失敗: 質問の言い換えに弱い、日本語特有の表記ゆれ(全角半角・カタカナ語・送り仮名)を吸収できない、ナレッジに存在しない質問に対して「一番近いだけ」のチャンクを返してしまう
生成起因の失敗: 正しいチャンクが渡っているのに要約の過程で条件や但し書きを落とす、複数チャンクの内容を混ぜて存在しない手順を作ってしまう
この2つは対処がまったく違うので、評価も二層に分けます。検索段階は「正解チャンクが上位に入ったか」という機械的な指標で、生成段階は「根拠に忠実な回答か」という審査で測ります。この分離が、ここから先の作業の骨格になります。
なお、切り分けの実務では「検索ログを人間が読める形で残す」だけでも一歩前進します。質問・上位5件のチャンク ID・スコア・採用された回答を1行の JSON で記録しておくと、誤回答の報告を受けてから原因の層を特定するまでが数分で済みます。評価ハーネスはこのログの延長線上にある仕組みで、特別なものではありません。
社内ナレッジ系の RAG 構成を扱った Antigravity で構築する RAG パイプライン — ベクトル検索 × LLM で社内ナレッジを即座に活用する でも触れられているとおり、ベクトル検索は導入自体は簡単です。難しさは導入後、「検索がどの程度当たっているのか誰も知らない」まま運用が続いてしまう点にあります。
ゴールデンデータセットは30問から始める
評価と聞くと数百問のベンチマークを想像しがちですが、個人開発の RAG なら、まず30問で十分に機能します。大切なのは問題数ではなく、質問タイプの内訳です。私は次の4タイプに分けて作っています。
事実参照型(12問): 「広告なし版の価格はいくらですか」のように、特定のチャンクに答えがそのまま書いてあるもの
手順型(8問): 「購入の復元はどう操作しますか」のように、複数ステップの説明を要するもの
否定・制約型(5問): 「ウィジェットには対応していますか」のように、対応していない事実を正しく答えてほしいもの
範囲外型(5問): ナレッジのどこにも答えがなく、「この情報ではお答えできません」と返すのが正解のもの
データ形式は JSONL にしています。1行1問で、後からタイプ別に集計しやすい構造です。
{ "id" : "q001" , "type" : "factual" , "question" : "広告なし版の価格はいくらですか" , "relevant_chunks" : [ "pricing-adfree-001" ], "answerable" : true }
{ "id" : "q013" , "type" : "procedural" , "question" : "機種変更後に購入を復元するにはどうすればいいですか" , "relevant_chunks" : [ "restore-purchase-001" , "restore-purchase-002" ], "answerable" : true }
{ "id" : "q021" , "type" : "negative" , "question" : "ロック画面ウィジェットには対応していますか" , "relevant_chunks" : [ "feature-scope-003" ], "answerable" : true }
{ "id" : "q026" , "type" : "out_of_scope" , "question" : "他社アプリの壁紙を取り込めますか" , "relevant_chunks" : [], "answerable" : false }
質問は実ユーザーの言い回しから拾う
質問文を自分で発明しないことが、このデータセットの価値を決めます。私の場合、App Store と Google Play のレビュー返信や問い合わせメールの文面が一次ソースでした。実ユーザーの言い回しには、作り手が想定しない言い換えが含まれます。「画面が真っ白になる」は、実際には「白くなる」「画面が消える」「固まって何も出ない」とも表現されていました。この多様性がそのまま、検索の弱点を突くテストケースになります。
正解チャンクには安定した ID を振る
relevant_chunks に入れる ID は、インデックスを再構築しても変わらない安定 ID にしてください。チャンクの連番をそのまま使うと、分割設定を変えた瞬間に全問の正解ラベルが無効になります。私は「文書スラッグ + 見出しスラッグ」をつなげた文字列(例: restore-purchase-001)を使い、チャンク分割を変えても文書と見出しの対応からラベルを引き直せるようにしています。
範囲外型を必ず入れる
RAG は構造的に「何かを返す」方向へ倒れやすく、答えないべき質問への失敗は平均スコアに表れにくい性質があります。範囲外型の5問は、件数としては少なくても、ハルシネーションの検知器として働く大切な枠です。後述する審査プロンプトとの組み合わせで効いてきます。
データセットは月に数問ずつ育てる
30問は出発点で、完成形ではありません。私は月に2〜3問のペースで、新しく届いた問い合わせのうち「検索が外しそうな言い回し」を選んで追加しています。追加の際は既存の質問と内容が重複していないかを確認し、重複する場合は言い換えバリエーションとして同じ relevant_chunks を共有させます。データセット自体は git で管理し、質問を追加・修正したコミットと評価スコアの変動を対応づけられるようにしておくと、「スコアが下がったのは RAG の劣化ではなく、データセットを難しくしたことが原因」という切り分けが後から一目でつきます。この運用を3ヶ月続けた現在は42問になっていて、検知器としての信頼度は当初より明らかに上がりました。
検索段階を単体で測る — recall@k・MRR・nDCG
検索段階の指標は3つで足ります。
recall@k: 上位 k 件に正解チャンクが1件でも入っていれば 1、いなければ 0。最優先で見る指標です
MRR: 最初に正解が出てきた順位の逆数。1位なら 1.0、3位なら 0.33。コンテキスト窓に限りがあるローカル LLM では、正解が「入っている」だけでなく「上位にある」ことが生成品質に直結します
nDCG@k: 順位の質を滑らかに測る指標。手順型のように正解チャンクが複数あるとき、その並び方まで評価できます
実装は標準ライブラリだけで書けます。検索部分は関数として注入する形にしておくと、ベクトル DB を入れ替えても評価コードはそのまま使えます。
import json
import math
from pathlib import Path
def load_golden (path):
"""JSONL のゴールデンデータセットを読み込みます。"""
items = []
for line in Path(path).read_text( encoding = "utf-8" ).splitlines():
if line.strip():
items.append(json.loads(line))
return items
def recall_at_k (retrieved, relevant, k):
"""上位 k 件に正解チャンクが 1 件でも含まれれば 1.0 を返します。"""
return 1.0 if set (retrieved[:k]) & set (relevant) else 0.0
def mrr (retrieved, relevant):
"""最初に正解が出た順位の逆数を返します。見つからなければ 0 です。"""
for rank, chunk_id in enumerate (retrieved, start = 1 ):
if chunk_id in relevant:
return 1.0 / rank
return 0.0
def ndcg_at_k (retrieved, relevant, k):
"""正解 1 / 不正解 0 の二値 nDCG を計算します。"""
dcg = 0.0
for rank, chunk_id in enumerate (retrieved[:k], start = 1 ):
if chunk_id in relevant:
dcg += 1.0 / math.log2(rank + 1 )
ideal_hits = min ( len (relevant), k)
idcg = sum ( 1.0 / math.log2(r + 1 ) for r in range ( 1 , ideal_hits + 1 ))
return dcg / idcg if idcg > 0 else 0.0
def evaluate (golden_path, search_fn, k = 5 ):
"""search_fn(question) が chunk_id のリストを返す前提で3指標を平均します。"""
items = [g for g in load_golden(golden_path) if g[ "answerable" ]]
scores = { "recall" : [], "mrr" : [], "ndcg" : []}
for g in items:
retrieved = search_fn(g[ "question" ])
scores[ "recall" ].append(recall_at_k(retrieved, g[ "relevant_chunks" ], k))
scores[ "mrr" ].append(mrr(retrieved, g[ "relevant_chunks" ]))
scores[ "ndcg" ].append(ndcg_at_k(retrieved, g[ "relevant_chunks" ], k))
return {name: round ( sum (v) / len (v), 3 ) for name, v in scores.items()}
自分の検索実装を search_fn に渡して実行すると、出力はこの形になります。
{'recall': 0.86, 'mrr': 0.79, 'ndcg': 0.81}
数値がつくと意思決定が一晩で終わる
私の環境(チャンク800字・オーバーラップ100字・多言語対応の埋め込みモデル)では、初期値が recall@5 = 0.63 でした。クエリの NFKC 正規化とカタカナ語の表記ゆれを吸収する同義語展開を挟んで 0.74 に、チャンクの分割境界を見出し単位に揃えて 0.86 まで上がりました。一方で、埋め込みモデルをより大きなものに入れ替える変更は +0.02 で、ロード時間の増加に見合いませんでした。「効くと思っていたものが効かない」という判断が実測値で一晩のうちにつくのは、想像していた以上に快適です。
k はコンテキストに載せる件数と揃える
recall@k の k を慣例の 5 や 10 のまま使う前に、自分の RAG が実際に何件のチャンクを生成へ渡しているかを確認してください。評価の k と本番でコンテキストに載せる件数がずれていると、評価上は「当たっている」のに本番では正解チャンクがプロンプトに入っていない、という食い違いが起きます。私は本番のコンテキストを5件にしているため、評価でも recall@5 を主指標とし、参考値として recall@10 を並記しています。recall@10 と recall@5 の差が大きいときは、正解が6〜10位に沈んでいる状態を意味するので、リランキングの導入で拾える余地がある、という読み方になります。ベクトル検索単体で recall が頭打ちになった場合は、BM25 のような語彙ベース検索を併用するハイブリッド構成を検討する価値がありますが、その判断も数値の頭打ちを確認できてからで遅くありません。
生成段階を測る — Gemma 4 を審査役にする
検索が当たっていても、生成が根拠を無視すれば誤回答になります。生成段階は LLM-as-a-Judge の形で、次の2軸を 0〜2 の3段階で採点させています。
忠実性: 回答の主張が、渡したチャンクに根拠を持っているか。根拠のない補完が混ざれば減点します
関連性: 回答が質問へ正面から答えているか。正しい内容でも質問とずれていれば減点します
審査は Ollama 経由のローカル Gemma 4 で動かしています。Ollama 側のモデル準備やキープアライブ設定は Antigravity × Ollama ローカルLLM 完全統合ガイド — Gemma 4 をオフラインで動かす実践パターン が前提になっているので、未構築の方はそちらを先に整えると進めやすいはずです。
import json
import requests
JUDGE_PROMPT = """あなたは RAG 回答の審査員です。
question / context / answer を受け取り、次の JSON だけを出力してください。
{"faithfulness": 0-2, "relevance": 0-2, "evidence": "根拠として引用した context 内の一文", "note": "30字以内の指摘"}
採点基準:
- faithfulness 2: 回答の全主張が context に根拠を持つ / 1: 一部に根拠なし / 0: 主要な主張に根拠なし
- relevance 2: 質問に正面から答えている / 1: 部分的 / 0: 質問とずれている
- context に答えがない質問へ「お答えできません」と返す回答は、両軸とも 2 とする
- 手順を答える回答では、手順の順序が context と一致しない場合 faithfulness を 1 減点する"""
def judge_answer (question, context, answer, model = "gemma-4-27b-it" ):
"""ローカル Gemma 4 に忠実性と関連性を採点させます。"""
payload = {
"model" : model,
"messages" : [
{ "role" : "system" , "content" : JUDGE_PROMPT },
{ "role" : "user" , "content" : json.dumps(
{ "question" : question, "context" : context, "answer" : answer},
ensure_ascii = False ,
)},
],
"format" : "json" ,
"stream" : False ,
"options" : { "temperature" : 0 },
}
res = requests.post( "http://localhost:11434/api/chat" , json = payload, timeout = 120 )
res.raise_for_status()
return json.loads(res.json()[ "message" ][ "content" ])
自己採点バイアスをどう避けるか
生成と審査を同じモデル・同じ設定にすると、自分の癖を「正しい」と判定しがちです。私は生成に 9B、審査に 27B の Gemma 4 を分け、審査側は temperature 0 に固定しています。さらにスコアだけでなく「根拠として引用した一文」を必ず出力させています。引用があると、審査自体が妥当かどうかを人間が数秒で確認でき、judge の出力を盲信せずに済みます。
30問という規模のもう一つの利点は、審査結果の全件を人間が突き合わせられることです。最初の一巡では、judge と私自身の判定の一致率は 87% でした。不一致だった4問を見ると、judge が「手順の順序違い」を見逃す傾向にあると分かったため、審査プロンプトに順序の観点を1行追加して一致率を 93% まで上げています。審査員もまた評価の対象になる、という感覚で運用しています。
審査にかかる時間も現実的な範囲です。メモリ64GBの M2 Max で 27B の量子化モデルを使った場合、30問の審査一巡はおよそ4分でした。クラウド API を使わないため何度回しても追加費用はゼロで、後述する夜間バッチにも気兼ねなく組み込めます。
チャンク設計と前処理を A/B で決める
評価ハーネスがあると、チャンク設計の議論が「好み」から「実験」に変わります。比較する軸はだいたい次の4つに集約されます。
チャンクサイズ: 400字 / 800字 / 1200字
分割の単位: 固定長か、見出し・段落単位か
オーバーラップ: 0字 / 100字 / 200字
前処理: NFKC 正規化、改行や装飾記号の整理、カタカナ表記ゆれの同義語辞書
組み合わせの総当たりは件数が膨らむため、私は「分割単位 → 前処理 → サイズ」の順に1軸ずつ固定して回しています。スイープ自体は短いスクリプトで足ります。
import itertools
import json
from build_index import build_index # チャンク設定を受けて再構築する自前関数
from eval_retrieval import evaluate
CHUNK_SIZES = [ 400 , 800 , 1200 ]
SPLIT_MODES = [ "fixed" , "heading" ] # 固定長か見出し単位か
NORMALIZE = [ False , True ] # NFKC 正規化の有無
results = []
for size, mode, norm in itertools.product( CHUNK_SIZES , SPLIT_MODES , NORMALIZE ):
search_fn = build_index( chunk_size = size, split = mode, normalize = norm)
metrics = evaluate( "golden/tuning.jsonl" , search_fn, k = 5 )
results.append({ "chunk" : size, "split" : mode, "nfkc" : norm, ** metrics})
print (results[ - 1 ])
with open ( "sweep_results.json" , "w" , encoding = "utf-8" ) as f:
json.dump(results, f, ensure_ascii = False , indent = 2 )
私の実測では、効果が大きかった順に「見出し単位の分割(recall +0.12)」「クエリの NFKC 正規化(+0.11)」「同義語展開(+0.07)」で、チャンクサイズ単体の 400 / 800 / 1200 の差は ±0.03 の範囲に収まりました。一般論ではチャンクサイズが主役として語られることが多いものの、日本語のサポート文書では表記ゆれ対策のほうが配当が大きい、というのが手元での結論です。サイズの議論に時間を使う前に、正規化と分割単位を先に片付けることをお勧めします。
ひとつ注意したいのは、recall を上げる変更が忠実性を下げる場合があることです。チャンクを細かくするほど検索は当たりやすくなる一方で、前後の文脈が欠けて生成側が誤読しやすくなります。検索指標と審査スコアを同じレポートに並べて見る運用にしておくと、この綱引きの存在に早く気づけます。
タイプ別スコアを1枚のレポートに集約する
検索3指標と審査2軸が揃うと、今度は見る場所が散らばります。私は最終的に「質問タイプ別 × 指標」のマトリクスを1枚のテキストレポートにまとめ、これだけを毎朝見る運用に落ち着きました。集計関数は短いものです。
from collections import defaultdict
def report_by_type (golden_items, results):
"""質問タイプごとに指標の平均をまとめて表示します。"""
by_type = defaultdict( list )
for item, res in zip (golden_items, results):
by_type[item[ "type" ]].append(res)
for qtype, rows in sorted (by_type.items()):
n = len (rows)
avg = {k: round ( sum (r[k] for r in rows) / n, 2 )
for k in rows[ 0 ]}
print ( f " { qtype :>12 } (n= { n } ): { avg } " )
レポートで最初に見るのは全体平均ではなく、タイプ別の最低値です。否定・制約型と範囲外型は問題数が少ないぶん、1問の失敗が割合として大きく出ます。ここが沈んでいるときはハルシネーション系の事故が近い、という早期警報として扱っています。逆に事実参照型だけが下がったときは、インデックスの再構築ミスやチャンク ID のずれといった機械的な原因を先に疑います。タイプ別の沈み方と原因の系統がおおむね対応してくれるおかげで、調査の初手が速くなりました。
回帰テストとして固定し、夜間に回す
数値は一度測って終わりでは意味が薄く、変更のたびに回って初めて効きます。私は pytest の閾値ゲートにして、インデックスの再構築・埋め込みモデルの更新・前処理の変更といったタイミングで必ず実行しています。
import pytest
from eval_retrieval import evaluate
from my_rag import search # 自分の検索実装に差し替えます
THRESHOLDS = { "recall" : 0.80 , "mrr" : 0.70 , "ndcg" : 0.75 }
@pytest.fixture ( scope = "session" )
def metrics ():
return evaluate( "golden/holdout.jsonl" , search, k = 5 )
@pytest.mark.parametrize ( "name" , sorted ( THRESHOLDS ))
def test_retrieval_quality (metrics, name):
assert metrics[name] >= THRESHOLDS [name], (
f " { name } が { metrics[name] :.3f } に低下しています(下限 { THRESHOLDS [name] } )"
)
閾値は「現状値マイナス余裕」で決める
閾値を理想値に置くと、最初から赤いダッシュボードができあがって誰も見なくなります。現状の実測値から 0.05 ほど引いた値を下限として置き、改善が定着したら閾値を引き上げる方式が、経験上は長続きします。テストの目的は理想の宣言ではなく、劣化の検知です。
Antigravity からの運用
このテストは Antigravity の tasks.json に登録して、エディタから1キーで回せるようにしています。加えて夜間は Background Agent に実行と記録だけを任せています。エージェントへ渡している指示は次のとおり、判断を含めない形に絞っています。
毎日 2:00 に golden/holdout.jsonl への評価を実行してください。
1. pytest tests/test_retrieval_quality.py -q を実行
2. eval_logs/YYYY-MM-DD.json に3指標を追記
3. 前回比で 0.05 以上下がった指標があれば、サマリに「要確認」と記載
判断や修正は行わず、実行と記録だけを担当してください。
閾値を割ったときに何を変えるかは人間の仕事です。エージェントに改善まで任せると、ゴールデンセットに過適合する方向の「改善」が混ざりやすいため、役割を実行と記録に限定するほうが安定して運用できています。
運用して見えた落とし穴
ハーネスを回し始めてからの3ヶ月で、自分で踏んだ罠と、危うく踏みかけた罠を残しておきます。
ゴールデンセットの使い回しによる過適合: 改善の試行錯誤に同じ30問を使い続けると、その30問専用の RAG ができあがります。私は「調整用20問・検証用10問」に分割し、検証用は月に一度しか実行しないルールにしました。調整用で +0.10 に見えた改善が、検証用では +0.04 だったこともあります
平均値だけを見る事故: 否定・制約型が全滅していても、事実参照型が好調なら平均は上がります。実際、運用初月に「平均は改善、否定型は5問中4問が失敗」という時期がありました。タイプ別の集計を必ず並記してください
審査プロンプトへの渡しすぎ: judge にナレッジ全文を渡すと、回答が根拠外の知識で補完していても「正しい」と採点されてしまいます。審査に渡すのは、その質問で検索がヒットしたチャンクだけに限定します
埋め込みモデルの更新事故: インデックス側とクエリ側で埋め込みモデルのバージョンがずれると、エラーは出ないまま recall だけが静かに崩れます。モデル名とバージョンをインデックスのメタデータへ書き込み、不一致を検出したら評価を即エラーで落とすようにしました
「答えない」の不正解化: 範囲外型への正答(情報がない旨を伝える回答)を、judge が「役に立たない」と減点する事故です。審査基準に「context に答えがない質問への、お答えできませんという回答は満点」と明記して解消しました
評価自体の再現性を固定し忘れる: 審査側の temperature を 0 にしていても、生成側の温度やシードが揺れると、同じ構成で回すたびにスコアが上下します。評価バッチでは生成側も temperature 0 に固定し、「構成が同じなら数値も同じ」という状態を先に作ってください。embedding キャッシュが古い設定の結果を返し、変更が反映されないまま評価していた事故も一度ありました。以来、キャッシュは構成内容のハッシュをキーに分離しています
どれも対処自体は1行か2行の話ですが、気づかないまま運用すると数値への信頼が根元から崩れます。評価基盤は「作って終わり」ではなく、基盤そのものも点検対象に含めておくと安心です。
次の一歩 — まず30問を書き出す
ここまでの実装はどれも数十行ですが、順番だけは強くお勧めしたい形があります。コードより先に、今日の問い合わせログや運用メモから30問を書き出してみてください。質問を書き出す過程そのものが、自分のナレッジのどこが薄いかの棚卸しになります。ハーネスの実装はその後で十分間に合います。
もし30問を書き出す時間が取れない週なら、誤回答に気づいたものだけをその場で1問ずつ追加していく方式でも構いません。私のデータセットにも、ユーザーからの指摘がきっかけで加わった質問が7問あります。失敗の記録がそのまま資産になる設計は、この仕組みの気に入っているところです。
評価基盤というと、個人開発には大げさに聞こえるかもしれません。私自身もそう思っていた側でした。それでも、30問の小さなセットを持っただけで、変更に対する判断の質は見違えるほど変わりました。2014年から個人開発を続けるなかでテストを書く習慣に何度も助けられてきましたが、RAG の評価はその延長線上にある、ささやかで確実な投資だったと感じています。同じようにローカル RAG を育てている方の参考になれば幸いです。