0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

引用付きRAGの作り方:ハルシネーションを抑える実装

0
Posted at

はじめに

RAG を触ったことがある人なら、一度はこう思ったはずだ。「回答は流暢なのに、どこまで信じていいか分からない」。デモでは「答えられること」ばかりが強調され、「その答えがどこから来たのか」は置き去りにされがちだ。

私はブックマーク管理ツールに引用付きの Ask 機能を実装した。その経験から言えるのは、引用は UI の飾りではないということだ。引用はハルシネーションに対抗するためのエンジニアリングである。一文ごとに遡れる出处があれば、間違っていてもユーザーは自分で検証できる。この記事では、引用を「表示する」ためではなく「信頼できる」ために必要な設計判断を書く。

引用粒度:どこまで細かく遡れるべきか

最初に悩むのが引用の単位だ。選択肢は大きく 3 つある:

  • 文書単位:実装は楽だが、ユーザーがリンクを開いても該当箇所を探せない。「引用があるのに検証できない」は引用がないのと同じだ。
  • 文単位:精度は高いが、引用番号が文中に溢れて読み物として破綻する。
  • チャンク単位:両者の中間。私の結論はここがスイートスポットだ。

ただし条件がある。チャンクは必ずメタデータを持つこと。ソース URL、タイトル、保存日時、そして原文中の位置(offset)。チャンクが「どの文書のどこ」なのかを失った瞬間、引用はただの飾り番号になる。前回書いたチャンキングの記事でも触れたが、メタデータの断裂は引用システム全体を壊す。

検索側:引用が信頼できる前提は検索が信頼できること

引用の話をすると生成側に目が行きがちだが、実は検索側の設計が先だ。鉄則はひとつ:生成は、検索で返ってきたチャンクの範囲内でのみ行う。順序は「検索 → コンテキストの限定 → 生成」で、この順序を逆にしてはいけない。

ここでハイブリッド検索が効いてくる。固有名詞(人名・製品名)の取りこぼしは BM25 が拾い、言い換えや意味的な近さはベクトル検索が拾う。引用の信頼性は、結局のところ「正しいチャンクがコンテキストに入っているか」に依存する。検索が間違っていれば、その後の工程がどれだけ丁寧でも引用は嘘になる。

やってはいけないアンチパターンがひとつある。生成時にモデルへ「常識で補ってよい」余地を与えることだ。コンテキストの外に出た瞬間、引用番号は捏造され始める。プロンプトでどれだけ縛っても、コンテキスト外の生成を許す設計では引用は守れない。

生成側:モデルにきちんと出典を付けさせる

プロンプト設計

指示は具体的にする。「適切に引用して」では足りない。私が使っているテンプレートの骨子はこうだ:

以下のコンテキストのみを用いて質問に答えてください。
- 各主張の直後に [n] の形式で引用番号を付けてください
- 引用番号は、今回渡したコンテキスト内のチャンクのみを指すものとします
- コンテキストに含まれない情報を補ってはいけません

構造化出力

段落を書かせてから番号を振らせるのではなく、「主張 → 引用 ID」のペアを出力させる。順序を変えるだけで、後処理の検証が格段に楽になる。

生成後の検証

モデルを信じない。プログラムで検証する:

def verify_citations(answer_claims, chunks):
    chunk_ids = {c.id for c in chunks}
    chunk_text = {c.id: c.text for c in chunks}
    for claim in answer_claims:
        for cid in claim.citation_ids:
            if cid not in chunk_ids:
                return False, f"存在しないチャンクを参照: {cid}"
            if not supports(chunk_text[cid], claim.text):
                return False, f"チャンクが主張を裏付けていない: {cid}"
    return True, "OK"

supports の中身は、最初は単純な語彙的重なりでもよい。重要なのは「検証の仕組みがあること」で、完璧な entailment 判定ではない。

失敗談

2 つほど痛い目を見た。

  1. モデルが引用番号を付けたが、その番号が別クエリ用のチャンクを指していた。番号の形式だけ見れば正しく見える。生成後の検証で「ID は存在するが主張を裏付けていない」として弾かれた。形式の正しさと内容の正しさは別物だ。
  2. チャンクを細かく切りすぎて、引用先が半文だけのチャンクになった。読者がリンクを開いても前後が分からず「で、何の話?」となる。これは前回の記事で書いたチャンクサイズのトレードオフと直結している。

拒否する勇気:検索で見つからないときは黙る

引用システムで一番効いたのは、実は「答えない」機能だった。top-k の類似度がすべて閾値を下回ったら、「知識庫に関連する内容が見つかりませんでした」とだけ返す。

一度でも自信満々にでっち上げた回答を出すと、ユーザーはそのツール全体を信じなくなる。沈黙は一時の不便だが、捏造は信頼の破産だ。閾値はオフラインの評価セットと人手の抜き打ちチェックで決め、「もう少し広く探す」再試行ボタンを付けておくと体験が丸くなる。

実装例

なお、この仕組みは私が開発しているブックマーク管理ツール「LinkWise」の引用付き Ask として実際に動いている。

効果と教訓

引用を付けて変わったこと:回答の横に「原文を開く」が付いただけで、ユーザーの振る舞いが変わった。正しそうな回答は開かれず、怪しい回答ほど開かれる。つまり引用は「信頼の表明」ではなく「検証の導線」として機能している。

一番の教訓は、引用システムで最も難しいのは生成側ではなく、チャンクのメタデータが収集→分割→インデックスの全工程で途切れないことだ。どこか一箇所でもメタデータが欠落すると、そのチャンクからの引用は死ぬ。地味だが、ここが全ての土台である。

最小構成で再現するなら

週末で試すなら:LangChain / LlamaIndex の citation 対応 + 上のような生成後検証スクリプトで十分動く。最初は検証を甘めにし、誤検出のログを眺めながら締めていくのが近道だ。

最後に、読者のみなさんに質問です。引用の粒度はどのレベルを選んでいますか?その理由もぜひコメントで教えてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?