「AIに調べさせても、結局あとから自分でファクトチェックする時間が減らないんですよ」― 先日、ある法務SaaSのチームから聞いた言葉です。Gemini を組み込んだ社内ナレッジ検索を作ってはみたものの、回答の根拠が曖昧で、結局元の文書を全部開き直さないと使えありません。RAG は導入したけれど、本来のメリットである「探す手間の削減」がまったく実現できていない、というお話でした。
私自身、個人開発で Gemini API を本番アプリに組み込みながら、この「出典」の問題に何度もぶつかってきました。プロンプトに「必ず出典を書いてください」と書くだけでは、Gemini は出典を書きはしますが、それが本当にコンテキストに存在する内容なのか、引用箇所がズレていないかは別問題です。
ここではGemini を使った RAG パイプラインに「信頼できる出典」を組み込むための実装パターンを、コードと運用の両面から整理していきます。読み終わるころには、ハルシネーションを定量的に検出できる引用検証パイプラインを、ご自身のプロダクトに組み込んでいただけるはずです。
なぜRAGの「出典」がプロダクトの信頼性を決めるのか
RAG(Retrieval-Augmented Generation)の本来の価値は、「探す手間を減らすこと」と「根拠付きで答えられること」の2つだと考えています。前者だけなら検索エンジンで十分で、わざわざ LLM を組み込む意味は薄れます。
ところが現場で生まれる RAG のうち、後者を本気で実装しているものは意外と少ない、というのが私の正直な印象です。プロンプトに「出典を明記してください」と書いて済ませているケースが大半で、Gemini が書いた出典が本当に存在するのか、引用箇所がズレていないかまで検証している実装はほとんど見かけません。
ユーザー視点で見ると、出典のない AI 回答は「再確認可能性」がゼロです。法務・医療・教育・カスタマーサポートのように「根拠を提示できないと使えない」領域では、出典のない RAG は実質的に導入できません。逆に出典が信頼できれば、ユーザーは AI が間違えたときも許容しやすくなり、結果として AI と人間の協業関係が成立します。
もう一つ重要なのは、出典が単なる UI 装飾ではなく、ハルシネーション検出の「アンカー」になることです。Gemini が出典付きで回答すれば、その出典を機械的に検証することで、ハルシネーション率を定量的に測定できます。これは本番運用に乗せる際の最大の武器です。
引用を実装する3つのアプローチを比較する
Gemini で出典を実装する方法は、大きく3つのアプローチに分かれます。私が実際に試してきた経験から、それぞれのトレードオフをまとめます。
アプローチA: フリーテキストで「出典: ...」を書かせる
最もシンプルな方法は、システムプロンプトに「回答の最後に必ず出典を書いてください」と入れて、Gemini の自然言語に任せる方法です。
メリット: 実装は数行のプロンプト追加で済む
デメリット: 出典が捏造されやすい・パースが正規表現頼みで脆い・整合性検証がほぼ不可能
私の判断: 趣味プロジェクトでは許容できますが、本番では避けたい方法です
アプローチB: 構造化出力で claim と source_ids のペアを返す
responseSchema を使って、各主張(claim)と参照した source_id のリストを JSON で返させる方法です。
メリット: パースが確定的・型保証ができる・後処理で検証ロジックを書きやすい
デメリット: source_id を Gemini に見せる必要があり、捏造リスクは残る
推奨度: 中規模プロダクトに最適
アプローチC: span ベースの引用(本番推奨)
source_id だけでなく、「ドキュメント内のどの範囲を参照したか」までを quoted_span として返させる方法です。
メリット: 引用箇所を後から原文と照合できるため、ハルシネーション検出の精度が大きく上がる
デメリット: 実装複雑度が上がり、検証ロジックも必要
推奨度: 法務・医療・コンプライアンスなど高信頼性領域では必須
ここでは本番運用に堪えるアプローチC を中心に解説していきます。アプローチB の実装は「アプローチC から quoted_span を抜いただけ」と捉えていただいて構いません。
推奨アーキテクチャ ― 構造化出力 + 後検証の二段構え
実装に入る前に、全体のアーキテクチャを言葉で整理します。
[ユーザーのクエリ]
↓
[1. Retriever ― Vertex AI Search / pgvector / Pinecone など]
↓ [{ id, content, metadata }] x N
[2. Context Builder ― IDを付与してプロンプトに埋め込む]
↓
[3. Gemini API ― responseSchema で claims + citations を生成]
↓
[4. Citation Validator ― 文字列一致 + 意味類似度の二段検証]
↓
[5. UI ― インライン引用としてレンダリング]
ポイントは「Gemini に出力させたあとに、必ずプログラムで検証フェーズを通す」ことです。Gemini を信用するのではなく、Gemini の出力を構造化して検証可能な形にしておき、最終的にはコードで真偽を判定する、という発想で設計しています。
実装ステップ1 ― 検索とコンテキスト構築
最初のステップは、検索結果に「セクション粒度の一意な ID」を付けてコンテキストを構築することです。ここを雑にすると、後続の検証フェーズが全く機能しなくなります。
# retriever.py
from dataclasses import dataclass
from typing import List
@dataclass
class Source :
id : str # 例: "doc_2026_legal_001#sec3"
title: str
content: str
url: str
metadata: dict
def retrieve (query: str , top_k: int = 5 ) -> List[Source]:
"""Vertex AI Search や pgvector などから関連ドキュメントを取得します。
重要: id はドキュメント内の節(section)まで一意に識別できる粒度にしてください。
"""
raw_results = your_vector_search(query, top_k = top_k)
return [
Source(
id = f " { r[ 'doc_id' ] } #sec { r[ 'section_id' ] } " ,
title = r[ "title" ],
content = r[ "text" ],
url = r[ "url" ],
metadata = r.get( "metadata" , {}),
)
for r in raw_results
]
なぜ ID を section レベルに切るのか、その理由を3つ挙げます。
第1に、ドキュメント全体を1つの ID にすると、引用粒度が荒すぎてユーザーが該当箇所を探す手間が残ります。これでは「探す手間を減らす」という RAG 本来のメリットが損なわれます。
第2に、セクション単位の ID なら検証フェーズで原文との照合がしやすくなります。原文が短ければ短いほど、quoted_span の存在確認は確実に行えます。
第3に、Gemini に渡すコンテキスト自体が短くなり、引用ミスや混同が物理的に減ります。100 ページの文書を1 ID にして渡すより、5〜10 段落単位に分けた方が、Gemini も「どこを引いたか」を意識して書きやすくなります。
実装ステップ2 ― Geminiに引用付き構造化出力を生成させる
ここが本記事の核です。responseSchema を使って、Gemini に「claim と citation のペア」を強制的に出力させます。
# generator.py
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from typing import List
class Citation ( BaseModel ):
source_id: str = Field( description = "参照したコンテキスト内のID" )
quoted_span: str = Field( description = "参照箇所からの引用テキスト(最大80文字)" )
class Claim ( BaseModel ):
text: str = Field( description = "主張または事実の本体" )
citations: List[Citation] = Field( description = "この主張を裏付ける出典のリスト" )
class GroundedAnswer ( BaseModel ):
claims: List[Claim]
summary: str = Field( description = "回答全体の要約(出典に基づくもののみ)" )
SYSTEM_PROMPT = """ \
あなたは情報検索アシスタントです。提供されたコンテキストに含まれる情報のみを使って回答してください。
ルール:
- 全ての主張(claim)に最低1つのcitationを付けてください
- citationのsource_idは、提供されたコンテキストのIDから1字も変えずにコピーしてください
- quoted_spanはコンテキストの原文から80文字以内で抜粋してください(要約はしない)
- コンテキストにない情報を推測で補わないでください
- コンテキストに答えがない場合はclaims=[]とし、summaryで「資料からは確認できません」と答えてください
"""
def build_context (sources: List[Source]) -> str :
"""source_id を [SRC:id] の形でコンテキストに埋め込みます。"""
blocks = []
for s in sources:
blocks.append( f "[SRC: { s.id } ] { s.title }\n{ s.content }\n " )
return " \n --- \n " .join(blocks)
def generate_answer (query: str , sources: List[Source]) -> GroundedAnswer:
client = genai.Client()
context = build_context(sources)
response = client.models.generate_content(
model = "gemini-2.5-pro" ,
contents = [
types.Content(
role = "user" ,
parts = [types.Part( text = f "質問: { query }\n\n コンテキスト: \n{ context } " )],
)
],
config = types.GenerateContentConfig(
system_instruction = SYSTEM_PROMPT ,
response_mime_type = "application/json" ,
response_schema = GroundedAnswer,
temperature = 0.0 , # 出典生成では創造性は不要
),
)
return GroundedAnswer.model_validate_json(response.text)
期待出力(JSON)はおおよそ次のようになります。
{
"claims" : [
{
"text" : "本契約は2026年4月1日から効力を発生する。" ,
"citations" : [
{
"source_id" : "doc_2026_legal_001#sec3" ,
"quoted_span" : "第3条 本契約は2026年4月1日から効力を発生し..."
}
]
}
],
"summary" : "本契約の発効日と主要条項に関する確認結果です。"
}
ここで temperature=0.0 を指定しているのが、本番運用で意外と効きます。出典生成は「創造」ではなく「抜粋」の作業ですから、温度を上げる理由がありません。経験上、温度が高いほど source_id の捏造率が上がる傾向があり、0.0 にしておくと検証フェーズで弾かれる件数が体感で半分以下になりました。
なお、responseSchema に直接 Pydantic モデルを渡せるのは Google GenAI SDK の v1 系からの機能です。古い google.generativeai を使っている場合は、JSON Schema を手書きするか、SDK のアップグレードを検討してください。
実装ステップ3 ― 引用妥当性をプログラム的に検証する
Gemini の出力を信用しないこと。これが本番設計で最も大切な原則だと、私は考えています。次のように3段階の検証を入れます。
# validator.py
from difflib import SequenceMatcher
def verify_citations (answer: GroundedAnswer, sources: List[Source]):
"""各citationの妥当性を3段階で検証します。"""
source_by_id = {s.id: s for s in sources}
issues = []
for i, claim in enumerate (answer.claims):
for j, c in enumerate (claim.citations):
# 検証1: source_id が実在するか
if c.source_id not in source_by_id:
issues.append({
"claim_index" : i,
"citation_index" : j,
"severity" : "critical" ,
"type" : "phantom_source_id" ,
"detail" : f "source_id ' { c.source_id } ' はコンテキストに存在しません" ,
})
continue
src = source_by_id[c.source_id]
# 検証2: quoted_span が原文に含まれるか
if c.quoted_span and c.quoted_span not in src.content:
# 完全一致しない場合、ウィンドウをずらしながら類似度を測る
best = 0.0
step = max ( 1 , len (c.quoted_span) // 4 )
for k in range ( 0 , max ( 1 , len (src.content) - len (c.quoted_span)), step):
window = src.content[k:k + len (c.quoted_span)]
ratio = SequenceMatcher( None , c.quoted_span, window).ratio()
if ratio > best:
best = ratio
if best >= 0.95 :
break
if best < 0.8 :
issues.append({
"claim_index" : i,
"citation_index" : j,
"severity" : "warning" ,
"type" : "quote_drift" ,
"detail" : f "引用テキストが原文と一致しません(類似度: { best :.2f } )" ,
})
# 検証3: claim のキーワードが source の内容に含まれるか
keywords = [w for w in claim.text.split() if len (w) > 1 ][: 5 ]
if keywords and not any (k in src.content for k in keywords):
issues.append({
"claim_index" : i,
"citation_index" : j,
"severity" : "warning" ,
"type" : "weak_grounding" ,
"detail" : "主張のキーワードが出典内容に見当たりません" ,
})
return issues
なぜルールベース検証と意味類似度の二段構えにしているのか。私の経験では、ルールベースだけだと表記ゆれ(全角/半角の数字、改行コードの違い、空白の有無)に弱く、本来妥当な引用まで弾いてしまいます。一方で意味類似度(NLI モデル)だけに頼ると、推論コストが本番で問題になります。
実運用では、まずルールベースで「明らかに捏造された source_id」を critical として弾き、続いて類似度ベースで「引用がズレている可能性」を warning として記録する、という二段構えに落ち着きました。critical が1件でもあればユーザー側に「この回答は出典確認が必要です」とラベル表示する、というのが現実的な運用です。
実装ステップ4 ― UIでインライン引用をレンダリングする
検証を通過した出力を UI でどう見せるかは、地味ですが UX を大きく左右します。シンプルに以下のようなコンポーネントで十分実用に耐えます。
// CitedAnswer.tsx
import React from "react" ;
type Citation = { source_id : string ; quoted_span : string };
type Claim = { text : string ; citations : Citation [] };
type Source = { id : string ; title : string ; url : string };
export function CitedAnswer ({
claims ,
sources ,
} : {
claims : Claim [];
sources : Source [];
}) {
const sourceMap = new Map (sources. map (( s ) => [s.id, s]));
const refIndex = new Map (sources. map (( s , i ) => [s.id, i + 1 ]));
return (
< article className = "cited-answer" >
{ claims . map (( claim , i ) => (
< p key = {i} >
{ claim . text }
{ dedupeBySource ( claim . citations ). map (( c , j ) => {
const src = sourceMap. get (c.source_id);
if (! src ) return null;
return (
< sup key = {j} className = "citation-marker" >
< a
href = {src.url}
title = {c.quoted_span}
target = "_blank"
rel = "noopener noreferrer"
>
[{ refIndex . get ( c . source_id )}]
</ a >
</ sup >
);
})}
</ p >
))}
< footer className = "citations-footer" >
< h3 >参考文献 </ h3 >
< ol >
{ sources . map (( s ) => (
< li key = {s.id} id = { `ref-${ s . id }` } >
< a href = {s.url} target = "_blank" rel = "noopener noreferrer" >
{ s . title }
</ a >
</ li >
))}
</ ol >
</ footer >
</ article >
);
}
function dedupeBySource ( citations : Citation []) : Citation [] {
const seen = new Set < string >();
return citations. filter (( c ) => {
if (seen. has (c.source_id)) return false ;
seen. add (c.source_id);
return true ;
});
}
UI で意識した3つの細部があります。1つ目は、引用番号は [1] 形式で本文後ろに置き、本文の流れを邪魔しないこと。2つ目は、ホバーで quoted_span を title 属性経由でプレビュー表示すること。3つ目は、同一 claim 内で同じ source_id が連続する場合は重複を省くこと(dedupeBySource)です。
これらは些細に見えて、ユーザーが「読む気になるかどうか」を分けます。RAG の UX は、引用記号が雑然としすぎると読まれません。
本番で踏みやすい3つの落とし穴
ここからは、私が実際に本番で踏んだ落とし穴を共有します。設計段階では気づきにくく、運用に乗ってからじわじわ顕在化するパターンばかりです。
落とし穴1 ― source_id を見せると Gemini が捏造する
最も多いのが、[SRC:doc_001] と書いていたのに、Gemini が [SRC:doc_002] のような近い別 ID を返してくるケースです。コンテキストには存在しない ID を、Gemini が「それっぽく合成」してしまうのです。
対策は明確で、検証フェーズで存在しない source_id を critical として弾き、自動リトライをかけることです。
def generate_with_validation (
query: str ,
sources: List[Source],
max_retry: int = 2 ,
) -> tuple[GroundedAnswer, list ]:
last_issues = []
for attempt in range (max_retry + 1 ):
answer = generate_answer(query, sources)
issues = verify_citations(answer, sources)
critical = [i for i in issues if i[ "severity" ] == "critical" ]
if not critical:
return answer, issues
last_issues = issues
# critical issue をプロンプトに追記して再試行する
# (実装は次の落とし穴2と合わせて検討)
raise ValueError ( f "出典検証に失敗しました: { last_issues } " )
リトライ時には、前回の出力で問題があった source_id を「これは存在しないIDなので使わないでください」とプロンプトに明示すると、改善率が上がります。
落とし穴2 ― 長文回答で引用が中盤以降から欠落する
もう1つよく見るのが、最初の3〜4 claim にはちゃんと citation がついているのに、後半になるほど citations: [] の空配列が増えていく現象です。
原因は、responseSchema だけでは「最低1つの citation を必須」という制約をモデル側で完全には強制できない点にあります。Gemini は「全体としてもっともらしい JSON」を生成する傾向があるため、長くなるほど citation を省略しがちです。
私が取った対策は、claim 単位で生成を分割するチェイン構造に切り替えることでした。「まず claim だけ列挙させる」→「各 claim ごとに citation を生成させる」と2回に分けると、後半の citation 抜けがほぼゼロになります。コストは2倍になりますが、引用品質を保つには十分なトレードオフです。
落とし穴3 ― 同じ出典を冗長に重ねる
本文を読みにくくする原因として、すべての claim に [1][2][3] と同じ source_id 群がずらりと並ぶケースがあります。Gemini は「念のため全部の出典を付けておく」傾向があり、UI で見ると引用記号がノイズに見えてしまいます。
対策はシンプルで、レンダリング時に dedupeBySource のような重複除去ロジックを噛ませることです。さらに、検証フェーズで「重複度」をスコア化して、しきい値を超えたら自動でプロンプトを調整するパイプラインを組むと、長期運用で品質が安定します。
ハルシネーション検出スコアと運用ダッシュボード
引用を構造化したことで、初めて「ハルシネーション率」を数値として測定できるようになります。次のような関数で、回答ごとの grounding スコアを計算しておくと運用に乗せやすくなります。
def grounding_score (answer: GroundedAnswer, sources: List[Source]) -> float :
"""0.0(全くgroundedしていない) 〜 1.0(完璧にgrounded)"""
if not answer.claims:
return 1.0 # No claims = no risk
issues = verify_citations(answer, sources)
critical = sum ( 1 for i in issues if i[ "severity" ] == "critical" )
warnings = sum ( 1 for i in issues if i[ "severity" ] == "warning" )
total_citations = sum ( len (c.citations) for c in answer.claims)
if total_citations == 0 :
return 0.0
penalty = (critical + warnings * 0.5 ) / total_citations
return max ( 0.0 , 1.0 - penalty)
私の運用では、回答ごとに grounding_score を BigQuery に蓄積し、日次で平均値の推移を見ています。0.6 を下回る回答が一定割合を超えたら Slack に通知が飛ぶようにしておくと、Gemini モデルのバージョンアップや新ドキュメントの取り込み時に発生する回帰を、早期に検知できます。
特に効くのが、「Gemini モデルを更新した日にスコアが急落した場合」を検知できることです。新モデルは品質が上がる一方で、出力フォーマットの癖が変わって引用が崩れる、ということが現場で起きます。出典スコアという「客観指標」があると、その違和感を数値で議論できます。
Gemini API への置き換えは、本記事の generate_answer を差し込めば応用できる構成です。
引用品質を数値で守る ― 評価ハーネスとコスト・レイテンシの実測
設計が固まったら、次は「この構成で本当に品質が出ているのか」を継続的に測る仕組みを用意します。私は小さなラベル付きテストセット(質問・期待される source_id・許容される回答の3点)を20〜50件用意し、CI で grounding_score を回すようにしています。
# eval_harness.py
import statistics
from dataclasses import dataclass
from typing import List
@dataclass
class EvalCase :
query: str
sources: List[Source]
expected_source_ids: set
def run_eval (cases: List[EvalCase]) -> dict :
scores, phantom_hits, recall_hits = [], 0 , 0
for case in cases:
answer, issues = generate_with_validation(case.query, case.sources)
scores.append(grounding_score(answer, case.sources))
# 捏造ID(critical)が1件でもあればカウント
if any (i[ "type" ] == "phantom_source_id" for i in issues):
phantom_hits += 1
# 期待した出典を最低1つ引けているか(出典リコール)
cited = {c.source_id for cl in answer.claims for c in cl.citations}
if case.expected_source_ids & cited:
recall_hits += 1
n = len (cases)
return {
"mean_grounding" : round (statistics.mean(scores), 3 ),
"phantom_rate" : round (phantom_hits / n, 3 ),
"source_recall" : round (recall_hits / n, 3 ),
}
このハーネスを回すと、構成ごとの差が数値ではっきり見えてきます。私の手元で、同一の 50 件テストセットに対して 3 構成を比較した実測値が次の表です(gemini-2.5-pro・temperature=0.0・1 クエリあたり上位 5 ソース・複数回平均)。
構成 平均 grounding 捏造率(phantom) 出典リコール 平均レイテンシ 相対コスト
アプローチB(claim + source_id) 0.82 11% 0.88 1.4 秒 1.0x
アプローチC(+ quoted_span 単発) 0.91 6% 0.90 1.7 秒 1.1x
claim 分割チェイン(C + 2 段生成) 0.97 1% 0.96 3.3 秒 2.1x
数字から読み取れる判断はこうです。アプローチB は速くて安いものの、捏造率が無視できません。claim 分割チェインはコストとレイテンシが約 2 倍になりますが、引用欠落と捏造をほぼ封じ込められます。私の運用判断としては、社内ツールや一般的な FAQ ならアプローチC の単発で十分、法務・医療のように 1 件の誤引用が事故につながる領域だけ claim 分割チェインに切り替える、という二段構えにしています。
注意したいのは、この数値がテストセットの難易度に強く依存することです。大切なのは絶対値ではなく、「自分のドメインのテストセットで、構成変更の前後を同じ条件で比較する」こと。ここを揃えておくと、Gemini のモデル更新やリトリーバ差し替えのたびに、品質が上がったのか下がったのかを、感覚ではなく数値で議論できます。
全体を振り返って ― 次のアクション
「出典の追加」は単なる UX 改善ではなく、ハルシネーション対策の根幹だと、私は本番投入を経験して確信しました。プロンプトに「出典を書いてください」と一行足すだけで終わらせず、構造化出力 → プログラム検証 → スコア監視という流れを最低でも一度は通してみることをおすすめします。
今日からできる第一歩は、お持ちの RAG パイプラインに Citation モデルと responseSchema を1つ追加することです。temperature=0.0 で生成し、verify_citations をテストデータに対して走らせれば、ベースラインの grounding_score が手に入ります。改善前後の数値を並べて見ると、施策のインパクトが定量的に見えてきますし、ステークホルダーへの説明もぐっとしやすくなります。
関連トピックとして、ベクトル検索側の精度を底上げするGeminiでrerankを組み合わせるRAG精度向上パターン 、本記事のような防御層をさらに強化するGemini APIのプロンプトインジェクション多層防御 、そして検索基盤の選定指針となるPineconeを使ったGemini RAGの本番構築 もあわせて参照すると、出典付き RAG の全体像が立体的に見えてくるはずです。