LLMアプリで社内規程や技術ドキュメントを検索し、回答を生成する場合、「その回答はどこに書いてあるのか」を利用者が確認できることが重要です。
回答文だけを表示すると、内容が正しくても検証できません。逆に、検索結果をそのまま並べるだけでは、利用者が必要な箇所を読みにくくなります。
この記事では、RAG(Retrieval-Augmented Generation)型のLLMアプリに引用元表示を実装する際の設計パターンを紹介します。実装のポイントは、検索段階で出典情報を失わず、生成段階では引用IDを明示させ、表示前に引用の妥当性を検証することです。
1. 引用元情報を検索結果に持たせる
まず、ドキュメントを分割して検索インデックスに登録する際、本文だけでなく出典情報も一緒に保存します。
最低限、次のようなメタデータを持たせます。
-
source_id: 文書内で一意なID -
title: 文書タイトル -
url: 元ページへのリンク -
pageまたはsection: ページ番号や見出し -
chunk_id: 検索対象チャンクのID
実際に試した構成では、LLMに渡すコンテキストと、画面表示用のメタデータを別々に管理しました。本文にURLを直接埋め込むより、IDで紐付けたほうがプロンプトを簡潔に保てます。
from dataclasses import dataclass
@dataclass
class SearchResult:
chunk_id: str
text: str
title: str
url: str
section: str
score: float
results = [
SearchResult(
chunk_id="chunk-001",
text="パスワードは90日ごとに変更する必要があります。",
title="社内セキュリティ規程",
url="https://example.com/security-policy",
section="第3章 パスワード管理",
score=0.91,
)
]
この時点で、検索結果を単なる文字列に変換してしまわないことが重要です。後続処理で引用リンクを作るため、メタデータは回答生成が終わるまで保持します。
2. コンテキストに引用IDを付ける
検索結果をLLMへ渡す際は、各チャンクに [S1] のような引用IDを付けます。
def build_context(results: list[SearchResult]) -> str:
blocks = []
for index, result in enumerate(results, start=1):
blocks.append(
f"[S{index}]\n"
f"タイトル: {result.title}\n"
f"セクション: {result.section}\n"
f"本文: {result.text}"
)
return "\n\n".join(blocks)
生成用プロンプトには、回答ルールも明記します。
あなたは検索結果に基づいて回答するアシスタントです。
ルール:
- 検索結果に根拠がない内容は断定しない
- 事実を述べる文の末尾に、根拠となる引用IDを付ける
- 引用IDは [S1] の形式で記載する
- 根拠が複数ある場合は [S1][S2] のように記載する
- 回答できない場合は「確認できませんでした」と答える
検索結果:
{context}
質問:
{question}
回答例は次のようになります。
パスワードは90日ごとに変更する必要があります。[S1]
ここで大切なのは、LLMにURLそのものを書かせないことです。URLを生成させると、存在しないURLや誤ったURLが出力される可能性があります。LLMには引用IDだけを生成させ、実際のリンクはアプリケーション側で解決します。
3. 引用IDから表示用リンクを生成する
LLMの回答に含まれる引用IDを抽出し、対応する検索結果から出典一覧を組み立てます。
import re
def extract_source_ids(answer: str) -> list[str]:
ids = re.findall(r"\[S(\d+)\]", answer)
return sorted(set(f"S{source_id}" for source_id in ids))
def build_citations(
answer: str,
results: list[SearchResult],
) -> list[dict]:
source_ids = extract_source_ids(answer)
citations = []
for source_id in source_ids:
index = int(source_id[1:]) - 1
if 0 <= index < len(results):
result = results[index]
citations.append({
"id": source_id,
"title": result.title,
"section": result.section,
"url": result.url,
"chunk_id": result.chunk_id,
})
return citations
APIレスポンスは、本文と引用を分けて返す設計にすると、Web UIやモバイルアプリで扱いやすくなります。
{
"answer": "パスワードは90日ごとに変更する必要があります。[S1]",
"citations": [
{
"id": "S1",
"title": "社内セキュリティ規程",
"section": "第3章 パスワード管理",
"url": "https://example.com/security-policy",
"chunk_id": "chunk-001"
}
]
}
画面上では、本文中の [S1] をクリック可能なバッジに置き換え、回答の下に「出典」一覧を表示する形が分かりやすいです。長いURLを本文に表示せず、タイトルとセクションを見せると、可読性も保てます。
4. 引用の妥当性を検証する
引用IDが存在するだけでは、十分な品質とはいえません。LLMが検索結果にないIDを出力したり、回答の主張と関係ない出典を付けたりする可能性があるためです。
まずは、存在しない引用IDを除外します。
def validate_citations(
answer: str,
results: list[SearchResult],
) -> tuple[str, list[str]]:
valid_ids = {
f"S{index}"
for index in range(1, len(results) + 1)
}
referenced_ids = extract_source_ids(answer)
invalid_ids = [
source_id
for source_id in referenced_ids
if source_id not in valid_ids
]
return answer, invalid_ids
本番運用では、次のようなチェックも有効です。
- 引用IDが1つもない回答を検出する
- 検索結果にない引用IDをエラーとして記録する
- 引用元チャンクのスコアが低い場合に注意表示する
- 回答の各段落に引用があるか確認する
- 出典ページが削除されていないか定期的に確認する
引用が不正な場合は、無理にリンクを表示せず、「出典を特定できませんでした」と扱うほうが安全です。引用機能は回答をもっともらしく見せるための装飾ではなく、利用者が検証するための機能だからです。
5. 実装時に注意したこと
チャンクを細かくしすぎない
チャンクが短すぎると、主語や条件が別チャンクに分かれてしまいます。たとえば「管理者は申請できる」という文と、「緊急時に限る」という条件が分離すると、LLMが条件を落とす可能性があります。
見出し、段落、箇条書きの単位を意識し、前後の文脈が残るように分割します。
引用は主張単位で付ける
回答全体の末尾に引用を1つだけ付けるより、主張や段落ごとに引用を付けたほうが検証しやすくなります。
通常の申請は平日の営業時間内に受け付けます。[S1]
緊急申請は管理者の承認が必要です。[S2]
出典の版を保存する
同じURLの内容が更新される場合、後から見たページと回答生成時の根拠が異なることがあります。文書の更新日時、版番号、取得日時もメタデータとして保存しておくと、監査や問い合わせ対応に役立ちます。
「出典付き」と「正しい」は別
引用元が表示されていても、LLMが出典内容を誤って要約する可能性は残ります。そのため、重要な業務では、回答生成後に検索チャンクとの一致を確認する処理や、人による確認フローも必要です。
まとめ
引用元表示の処理全体の流れを図にすると次の通りです。
LLMアプリの引用元表示は、次の流れで設計すると実装しやすくなります。
- 検索結果にURLや文書位置などのメタデータを持たせる
- コンテキストに
[S1]のような引用IDを付ける - LLMにはURLではなく引用IDを生成させる
- アプリ側で引用IDを実際の出典リンクに変換する
- 不正な引用や根拠のない回答を検証する
私が検証した範囲では、引用をLLMの自由生成に任せず、検索結果のメタデータとアプリケーションロジックで管理する構成が最も扱いやすいと感じました。利用者が回答の根拠をすぐ確認できるようになると、LLMアプリを業務で使う際の安心感と運用性が大きく向上します。