📚

RAGの“出典表示”をプロンプト依存から脱却する:出典検証 v2.3.0 実装メモ

に公開

前回の記事では、「RAGの検索精度を上げるチャンク分割を実装してみた!」を作りました。
まだ読んでない人はぜひ読んでみてください 🙇‍♂️

👉 前回の記事

はじめに

社内ドキュメント検索を想定したRAGを作っています!
その中で、特に重要だと感じているのが以下です。

  • ハルシネーションで誤情報を出したくない

そこで当初は、プロンプトで

「出典を書け」

と指示していました。

ただ、実運用に近づけるほど
「プロンプトだけでは守られないパターン」 が出てきました。

RAG全体の処理フロー

まず、このRAGシステムの全体像です。

┌─────────────────────────────────────────────────────────────────┐
│                        ユーザー質問                              │
│                  「有給休暇は何日もらえますか?」                  │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  1. クエリ拡張 (main.py)                                        │
│     QueryExpander で検索キーワードを追加                         │
│     → 「有給休暇は何日もらえますか? 有給 勤怠 休み」             │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  2. ベクトル検索 (main.py → ChromaDB)                           │
│     拡張クエリで類似ドキュメントを検索(上位3件)                 │
│     → attendance.md, expense.md, ... を取得                     │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  3. LLM回答生成 (main.py → Gemini API)                          │
│     検索結果をコンテキストとしてプロンプトに含め、回答を生成       │
│     → 「入社6ヶ月後に10日付与されます。(attendance.md)」         │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  4. 出典検証 ★v2.3.0で追加 (main.py)                            │
│     verify_citation_presence() で回答に出典が含まれるかチェック   │
│     adjust_confidence_by_citation() で信頼度を調整               │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  5. レスポンス返却 → フロントエンド表示 (frontend/index.html)    │
│     回答 + 出典 + 検証結果(警告があれば表示)                    │
└─────────────────────────────────────────────────────────────────┘

今までの実装

LLMに渡す入力

ChromaDBで検索した結果(sources)と、その内容(context)をLLMに渡して回答を生成しています。
プロンプトで「出典を必ず含めてください」と指示していました。

# 検索でヒットしたファイル名のリスト
# → この後の「出典検証」で、LLMの回答がこのリストに含まれるかチェックする
sources = ["attendance.md"]

# 検索結果の内容(LLMに渡すコンテキスト)
context = """
## attendance.md - 有給休暇
有給休暇は入社6ヶ月後に10日付与されます。
"""

# LLMへのプロンプト
prompt = f"""
以下の資料のみに基づいて回答してください。
回答には必ず参照元ファイル名を含めてください。  ← ★ここでお願いしている

{context}

質問: 有給休暇はいつから何日もらえますか?
"""

プロンプト依存のリスク

この設計だと プロンプト依存 になってしまい、次のような問題が起きます。

❌ パターン1:出典を書かない

LLMの出力

有給休暇は入社6ヶ月後に10日付与されます。

問題点

  • 回答は正しいが、どの資料を見ればいいか分からない
  • 人間が確認できない
  • 指示しても省略されることがある

❌ パターン2:存在しないファイル名を持ち出す(捏造)

company_policy.md によると、有給休暇は入社6ヶ月後に10日付与されます。

問題点

  • 実際に検索したのは attendance.md
  • LLMが存在しないファイル名を生成
  • 最も危険なパターン

❌ パターン3:曖昧な言及

勤怠関連の資料によると、有給休暇は入社6ヶ月後に10日付与されます。

問題点

  • どの資料か特定できない
  • 出典のようで出典になっていない

解決策:出典検証をコードで強制する

ここからが本題です。

LLMの回答文を解析して、出典の状態をコードで判定 します。

出典品質の定義

citation_quality 条件
strong attendance.md のような完全一致
weak attendance のようなベース名一致
none 出典が検出できない

さらに 捏造検出 も行います。

  • 回答に *.md がある
  • しかし sources に存在しない

実装コード

1. 出典検証 verify_citation_presence()

import re
from typing import List, Optional

def verify_citation_presence(answer: str, sources: List[str]) -> Optional[dict]:
    """
    回答に出典(ファイル名)が含まれるかチェック

    段階判定:
    1. *.md の完全一致 → citation_quality="strong"
    2. ベース名の一致 → citation_quality="weak"
    3. どちらもない → citation_quality="none"

    追加で「出典捏造検出」も実施:
    - 回答中に *.md があるのに sources にない → 警告
    """
    # sources が空なら検証不可(聞き返しフローなど)
    if not sources:
        return None

    # 回答から *.md パターンを抽出
    # 例: "attendance.md", "it_support.md" などをキャプチャ
    mentioned_md_files = set(re.findall(r'\b[\w_-]+\.md\b', answer))

    # 期待される出典(sourcesに含まれるもの)
    expected_sources = set(sources)

    # 捏造された出典(回答にあるがsourcesにない)
    unexpected_sources = mentioned_md_files - expected_sources

    # 正しく言及された出典(*.md形式で完全一致)
    mentioned_correctly = mentioned_md_files & expected_sources

    # ベース名での弱い一致チェック(*.mdが見つからなかった場合のみ)
    weak_matches = []
    if not mentioned_correctly:
        for source in sources:
            base_name = source.replace(".md", "")
            # 単語境界で囲まれている場合のみマッチ(誤検知軽減)
            if re.search(rf'\b{re.escape(base_name)}\b', answer, re.IGNORECASE):
                weak_matches.append(source)

    # 判定結果
    has_strong_citation = len(mentioned_correctly) > 0
    has_weak_citation = len(weak_matches) > 0
    has_unexpected = len(unexpected_sources) > 0

    # 警告メッセージの決定(優先度順)
    warning = None
    if has_unexpected:
        warning = f"回答に検索結果にない出典が含まれています: {', '.join(sorted(unexpected_sources))}"
    elif not has_strong_citation and not has_weak_citation:
        warning = "回答に出典が含まれていません"
    elif not has_strong_citation and has_weak_citation:
        warning = "出典の表記が不完全です(*.md形式での明示を推奨)"

    return {
        "has_citation": has_strong_citation or has_weak_citation,
        "citation_quality": "strong" if has_strong_citation else ("weak" if has_weak_citation else "none"),
        "mentioned_sources": list(mentioned_correctly) + weak_matches,
        "expected_sources": list(expected_sources),      # デバッグ用(検索結果の出典一覧)
        "mentioned_md_files": list(mentioned_md_files),  # デバッグ用(回答から抽出した*.md)
        "unexpected_sources": list(unexpected_sources),
        "warning": warning
    }

2. 信頼度調整 adjust_confidence_by_citation()

def adjust_confidence_by_citation(confidence: str, citation_result: Optional[dict]) -> str:
    """
    出典検証結果に基づいてconfidenceを調整

    ルール:
    1. citation_result が None → 調整なし(検証不可)
    2. unexpected_sources あり(捏造検出) → "low" に固定
    3. has_citation=false → 1段階ダウン(lowは下げ止まり)
    4. citation_quality="weak" → 調整なし(弱い警告のみ)
    """
    if citation_result is None:
        return confidence

    # 捏造検出: 最も深刻なので "low" に固定
    if citation_result.get("unexpected_sources"):
        return "low"

    # 出典なし: 1段階ダウン(下げ止まり)
    if not citation_result.get("has_citation"):
        if confidence == "high":
            return "medium"
        elif confidence == "medium":
            return "low"
        return confidence  # "low" は下げ止まり

    # citation_quality="weak" は警告のみ(confidence維持)
    return confidence

3. APIエンドポイントでの使用例

@app.post("/ask")
def ask_question(question: Question):
    # ... 検索・回答生成 ...

    # 出典検証(v2.3.0で追加)
    citation_result = verify_citation_presence(answer, sources)
    adjusted_confidence = adjust_confidence_by_citation(confidence, citation_result)

    response_data = {
        "answer": answer,
        "sources": sources,
        "confidence": adjusted_confidence,
        "version": "2.3.0"
    }

    # verification フィールドを追加
    if citation_result is not None:
        response_data["verification"] = {
            "citation_presence": citation_result
        }

    return response_data

判定結果の具体例

strong(正常)
{
  "answer": "有給休暇は入社6ヶ月後に10日付与されます。(attendance.md)",
  "confidence": "high"
}
weak(曖昧な一致)
{
  "answer": "attendanceの資料によると、有給休暇は10日付与されます。",
  "confidence": "high"
}
none(出典なし)
{
  "answer": "有給休暇は入社6ヶ月後に10日付与されます。",
  "confidence": "medium"
}
捏造検出
{
  "answer": "company_policy.md によると、有給休暇は10日付与されます。",
  "confidence": "low"
}

confidence 調整ルール

状況 confidence
strong 変更なし
weak 変更なし(警告のみ)
none 1段階ダウン
捏造検出 low に固定

フロントエンドUI

警告表示(XSS対策)

// 安全な実装
warningText.textContent = verification.citation_presence.warning;

LLMの出力は 信頼しない前提 で扱います。

この実装で得られた学び

1. プロンプトは「お願い」

方法 強制力
プロンプト
コード

2. 「禁止」より「検証」

  • ❌ 嘘をつくな
  • ✅ 嘘かどうか検証する

3. 社内RAGは「検証可能性」が重要

まとめ

v2.3.0 の出典検証は
思想ではなく挙動として「資料にないことは答えない」 を実現しました。

  • 出典品質の判定
  • confidence の自動調整
  • UIでの警告表示

実運用向けRAGでは、この考え方が不可欠だと実感しました。

GitHub: rag-portfolio

最後まで読んでいただきありがとうございました!

Discussion