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の回答に幻覚引用が混ざる事故を、LLMなしの検証コード13行で止めた話 — 検索順位が壊れた本当の原因3つ

0
Posted at

はじめに

社内FAQのRAGを公開した初日に、問い合わせが来ました。

「返品は60日以内って本当ですか?」

実際の規約は30日です。ログを見ると、AIは [doc9] という存在しない資料IDを引用していました。資料は doc1〜doc4 しかありません。要するに AIが引用元を捏造し、その捏造に私が気づかず公開した——これが今回踏んだ失敗です。

しかも厄介なのは、プロンプトには「根拠IDを必ず付けること」と書いてあったことです。指示を守るかどうかは別問題で、出力を検証しない限り事故は防げません。 この記事では、LLMを呼ばずにテストできる引用検証コードと、そこで見つかった検索順位の設計ミス3つを書きます。

何が起きていたか

構成はよくある最小RAGです。

要素 使ったもの
検索 自前のTF-IDF風スコア(ライブラリ無し)
生成 汎用LLM API(プロンプトで引用を指示)
検証 無し ← ここが今回の敗因

検証が無いというより、**「検証の書き方が分からない」**状態でした。LLMの出力は毎回変わるので、テストが書けないと思い込んでいたのです。しかし、検証したいのは文章ではなく引用IDです。それなら決定論的にテストできます。

検証コード:引用IDだけを機械的にチェックする

追加ライブラリは不要です。標準ライブラリだけで動きます。

# rag_cite.py — 追加ライブラリなしで動く最小RAG(引用IDの検証つき)
import math
import re

DOCS = [
    {"id": "doc1", "text": "返品は商品到着後30日以内であれば可能です。送料は当社負担です。"},
    {"id": "doc2", "text": "返品は未開封に限ります。開封済みの場合は交換対応となります。"},
    {"id": "doc3", "text": "保証期間は購入日から1年間です。修理には受付番号が必要です。"},
    {"id": "doc4", "text": "領収書はマイページから再発行できます。再発行には注文番号が必要です。"},
]

# 助詞・記号は文字単位では情報を持たないので落とす(これが無いと順位が壊れる)
STOP = set("はがをにのともやかへねよなを、。?!「」・  \r\n\t")


def tokenize(text):
    """英数字はまとめて1語、日本語はストップワードを除いた1文字単位で分割する"""
    tokens = []
    for chunk in re.findall(r"[a-z0-9]+|[ぁ-んァ-ヶ一-龠]+", text.lower()):
        if chunk.isascii():
            tokens.append(chunk)
        else:
            tokens.extend(c for c in chunk if c not in STOP)
    return tokens


def score(query_tokens, doc_text):
    """クエリ語の出現数 ÷ 文書長の平方根(簡易TF-IDFの代用)"""
    doc_tokens = tokenize(doc_text)
    if not doc_tokens:
        return 0.0
    hit = sum(doc_tokens.count(t) for t in set(query_tokens))
    return hit / math.sqrt(len(doc_tokens))


def retrieve(query, k=3):
    qt = tokenize(query)
    ranked = sorted(DOCS, key=lambda d: score(qt, d["text"]), reverse=True)
    return [(d["id"], d["text"]) for d in ranked[:k]]


def build_prompt(query, hits):
    """根拠を必ず [docN] で示させる。守られない前提で後段でも検証する。"""
    context = "\n".join(f"[{i}] {t}" for i, t in hits)
    return (
        "以下の資料だけを根拠に回答してください。\n"
        "資料に無いことは『資料にありません』と答えてください。\n"
        "各文の末尾に根拠IDを [docN] の形式で必ず付けてください。\n\n"
        f"# 資料\n{context}\n\n# 質問\n{query}\n"
    )


def extract_citations(answer):
    """回答中の [docN] を集合で返す"""
    return set(re.findall(r"\[([A-Za-z]+\d+)\]", answer))


def check(answer, valid_ids):
    """回答を公開してよいか判定する。問題のリストを返す(空なら合格)"""
    cited = extract_citations(answer)
    problems = []
    if not cited:
        problems.append("引用が1つも無い")
    bogus = sorted(cited - set(valid_ids))
    if bogus:
        problems.append(f"存在しない引用ID: {bogus}")
    return problems


if __name__ == "__main__":
    query = "返品は何日以内ですか?"
    hits = retrieve(query, k=3)
    valid = [i for i, _ in hits]

    print("--- 検索結果 ---")
    for i, t in hits:
        print(f"[{i}] {t}")
    print("\n--- プロンプト ---")
    print(build_prompt(query, hits))

    # ここでLLMを呼ぶ。llm() はあなたの使っているAPIクライアントに置き換える
    # answer = llm(build_prompt(query, hits))
    good = "返品は商品到着後30日以内です [doc1]。ただし未開封に限ります [doc2]。"
    bad = "返品は60日以内です [doc9]。"

    for label, answer in [("正常な回答", good), ("幻覚を含む回答", bad)]:
        print(f"{label}: {answer}")
        print(f"  検出した引用: {sorted(extract_citations(answer))}")
        print(f"  判定: {check(answer, valid) or ['PASS']}")

実行結果はこうなります。

正常な回答: 返品は商品到着後30日以内です [doc1]。ただし未開封に限ります [doc2]。
  検出した引用: ['doc1', 'doc2']
  判定: ['PASS']
幻覚を含む回答: 返品は60日以内です [doc9]。
  検出した引用: ['doc9']
  判定: ["存在しない引用ID: ['doc9']"]

LLMを呼ばずに、幻覚だけを狙って再現できます。 これがテストを書ける形にする、という意味です。

テストコード(これが本体)

# test_cite.py — 幻覚の引用IDを弾けるかを確認する(pytestでもpython3でも動く)
from rag_cite import check, extract_citations, retrieve

VALID = ["doc1", "doc2"]

CASES = [
    ("返品は30日以内です [doc1]。", []),
    ("返品は30日以内です [doc1]。開封済みは交換です [doc2]。", []),
    ("返品は60日以内です [doc9]。", ["存在しない引用ID: ['doc9']"]),
    ("返品は30日以内です。", ["引用が1つも無い"]),
    ("", ["引用が1つも無い"]),
]


def test_check():
    for answer, expected in CASES:
        got = check(answer, VALID)
        assert got == expected, f"{answer!r}: {got} != {expected}"


def test_extract_ignores_plain_brackets():
    assert extract_citations("【重要】返品は30日以内です [doc1]") == {"doc1"}


def test_retrieve_ranks_the_right_document_first():
    hits = retrieve("返品は何日以内ですか?", k=3)
    assert hits[0][0] == "doc1", f"1位が doc1 ではない: {[h[0] for h in hits]}"


if __name__ == "__main__":
    test_check()
    test_extract_ignores_plain_brackets()
    test_retrieve_ranks_the_right_document_first()
    print(f"✅ 全{len(CASES)}ケース+検索順位テストが期待どおり")
✅ 全5ケース+検索順位テストが期待どおり

空文字列のケースを入れたのが効きました。 LLMが引用を一切付けずに回答するパターンは実際に起きます。if not cited を先に置いていないと、ここで素通りします。

失敗した設計3つ(検索順位が壊れた本当の原因)

検証を入れずに公開した、というのが最大の失敗ですが、その裏で検索そのものが壊れていました。 検証コードを書いて初めて見えました。

失敗1:クエリの文字数で文書が勝ってしまう

修正前のスコアは、助詞を落としていませんでした。実際に内部を覗くとこうです。

query tokens: ['返', '品', '何', '日', '以', '内', 'で', 'す']
doc1 len= 27 hit= 11 score= 2.117 | matched= ['す', 'で', '以', '内', '品', '日', '返']
doc2 len= 22 hit= 4  score= 0.853 | matched= ['す', '品', '返']
doc3 len= 23 hit= 5  score= 1.043 | matched= ['す', 'で', '日']
doc4 len= 26 hit= 4  score= 0.784 | matched= ['す', 'で']

doc2(返品は未開封に限ります)より doc3(保証期間の話)が上位に来ています。理由は doc3 が す・で・日 の3文字にヒットしたからです。「です」の「す」が、内容語の「返」「品」と同じ重みでした。

つまり 「です」で一致している文書が、返品について書かれた文書に勝つ。 検索が壊れているのに、出力は自然な日本語で返ってくるので気づけません。

→ 対策は STOP の除外です。上記コードは修正後で、doc1 → doc3 → doc2 の順位になり、doc1 が1位になります。とはいえ doc3 が2位に残っているのは、文字単位分割の限界です(「日」が「1年間」と部分一致)。本番なら形態素解析か埋め込みに置き換えてください。自前スコアは順位の当たり外れをテストで固定するまで信用しないのが正解です。

失敗2:存在しない引用IDを許す設計にしていた

最初は「引用が付いていればOK」という検証だけにしていました。これでは [doc9] を見逃します。 正しい判定は「検索結果に含まれるIDかどうか」です。

bogus = sorted(cited - set(valid_ids))  # valid_ids は retrieve() が返したID

「全記事のID」ではなく「今回の検索ヒットのID」と突き合わせるのがポイントです。全記事と突き合わせると、検索に載っていない資料を引用した回答を「正しい」と誤判定します。

失敗3:引用検証をログに出しっぱなしで、公開を止めていなかった

いちばん痛い失敗です。検証結果はログに書いていたのに、公開処理は検証と無関係に走っていました。 「問題があれば人間が気づく」は運用ではありません。

判定を関数1つに閉じ込めて、公開の前に必ず挟むように変えました。

problems = check(answer, valid)
if problems:
    return {"status": "REVIEW", "reason": problems, "answer": answer}
publish(answer)

「レビュー行き」という逃げ道を作るのがコツです。例外で落とすと、幻覚が1件出ただけで運用が止まります。止めずに隔離するほうが現実的です。

対策の比較表

検証のやり方 幻覚引用を検出 LLM呼び出し 実装コスト 向いている場面
目視で確認 △(気づけない) 必要 0 単発の確認
引用IDの一致検証(本記事) ○ 不要 小 全回答・常時
引用ID+内容の一致検証 ◎ 必要 中 規約・料金の回答
埋め込み類似度で再確認 ◎ 不要 大 大量ドキュメント
LLMに自己採点させる △(自己申告) 必要 中 補助的な用途

最初にやるべきは上から2番目です。 LLMを呼ばずに動くので、テストにもCIにもそのまま置けます。

まとめ

  • プロンプトで「引用して」と指示しても、守られる保証はない。出力を検証する
  • 検証対象は文章ではなく引用ID。だからLLMなしでテストできる
  • 許可リストは「全資料」ではなく「今回の検索ヒット」と突き合わせる
  • 検索順位は静かに壊れる。「です」の一致が内容語に勝つ。順位をテストで固定する
  • 検証NGは例外で止めず、レビュー行きに隔離する

「AIの出力はテストできない」は誤解でした。テストすべき部分を、文章からIDに落とすだけです。この一手間で、私が踏んだ事故は確実に防げます。


📗 RAG構築入門 — LLMに「知識」を与える検索拡張生成の実践(Kindle・読み放題対象)

🎁 読者特典を受け取る(無料・メール登録)

著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

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?