1
2

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じゃない ― 精度を上げる前に読む「検索側の事件簿」【RAG事件簿 #1】

1
Posted at

Noir Interrogation_ The Lamp Suspect.png

LLMは、たぶん冤罪です。

社内規程や製品マニュアルを読み込ませたRAGチャットボットが、自信満々に間違ったことを言う。するとチームの誰かが言います。「やっぱりLLMってハルシネーションするよね」「もっと賢いモデルに替えようか」。

その気持ち、すごく分かります。ただ、RAGの不具合を数えきれないほど追いかけてきた身として言わせてください。RAGが嘘をついたとき、LLMが主犯なのは体感で3割もありません。 残りの7割以上は、LLMに渡す前の段階、つまり「検索」と「データ」の側で事件が起きています。

この記事では、RAGの回答精度を下げる典型的な原因を「事件」として5つ取り上げ、それぞれの手口と再発防止策をPythonのコード付きで解説します。モデルを替える前に、課金額を増やす前に、ぜひ一度この事件簿をめくってみてください。

この記事で分かること

  • RAGの「ハルシネーション」の多くが検索側の問題である理由
  • 但し書きが消えるチャンキングの落とし穴と、構造を意識した分割方法
  • ベクトル検索が品番や条文番号に弱い理由と、BM25とのハイブリッド検索(RRF)
  • 古い版の文書と新しい版の文書が混ざる問題への対処
  • 権限フィルタを「検索の前」にかけるべき理由
  • 検索と生成を分けて評価する方法(Recall@k / MRR)
  • 「答えない」を設計に組み込む方法

対象読者は、RAGをひとまず動かしたものの「精度がいまいち」で手が止まっている方です。LangChainやLlamaIndexを使っていても、使っていなくても読めるように、コードは素のPython中心で書いています。


序章:モデルを替えても、直らなかった

2026年の9月は、新しいモデルの発表がとにかく続きました。毎週のように「前世代より賢い」「さらに安い」というニュースが流れてきて、正直、追いかけるだけで息切れしそうになります。

そんな時期に、VentureBeatに載ったある記事のタイトルが目に留まりました。

Companies can build RAG in days. Making it reliable enough to run the business is much harder
(RAGは数日で作れる。だが、業務を任せられるほど信頼できるものにするのは、はるかに難しい)
― VentureBeat, 2026年9月27日

記事の中で、筆者は企業データの質、チャンキング、ベクトル検索だけでは足りない理由、検索と生成を分けた評価、権限管理、インデックスの鮮度、コストとレイテンシのトレードオフを挙げています。そして、こう書いています。

The model is only one part of the system.
(モデルは、システムの一部にすぎない)

読みながら、何度もうなずきました。モデルが毎月のように賢くなっても、検索が渡す資料が間違っていれば、出てくる答えも間違ったままです。どれほどの名探偵でも、偽の証拠を渡されれば推理を誤ります。

シャーロック・ホームズは『ボヘミアの醜聞』でワトソンにこう言っています。

It is a capital mistake to theorize before one has data.
(データを手にする前に推理を組み立てるのは、重大な誤りだ)

「LLMのせいだ」という推理も、データを見ずに組み立てるとたいてい外れます。では、実際の現場にどんな事件が転がっているのか。順番に見ていきましょう。

今回の舞台:架空の会社「にゃー商事」

説明のために、架空の会社「にゃー商事」の社内規程Q&Aボットを例にします。社員が「有給っていつまでに申請すればいい?」と聞くと、就業規則を検索して答えてくれる、ごくありふれたRAGです。

構成もいたって普通です。

質問 → 埋め込み → ベクトルDBで類似検索(top-k) → 上位チャンクをプロンプトに詰める → LLMが回答

この「ごく普通」の構成に、事件の芽がいくつも埋まっています。


事件ファイル No.1:消えた但し書き

事件の概要

社員Aさんが質問しました。

Q. 子どもが急に熱を出しました。今日の有給を今から申請できますか?

ボットの回答はこうです。

A. 有給休暇は取得日の3営業日前までに申請する必要があります。当日の申請はできません。

Aさんは仕方なく出社しました。ところが、就業規則の原文にはこう書かれていたのです。

第18条(年次有給休暇の申請)
1. 年次有給休暇を取得する場合は、取得日の3営業日前までに所定の様式により申請しなければならない。
2. 前項の規定にかかわらず、傷病、家族の看護その他やむを得ない事由がある場合は、
   当日の始業時刻までに所属長へ連絡することにより、事後の申請を認める。

当日でも申請できます。しかも、まさにAさんのケースです。

手口:固定長チャンキングによる分断

ログを確認すると、検索で取れていたのは第1項を含むチャンクだけでした。原因は、文字数で機械的に区切る固定長チャンキングです。たとえば500文字ごとに切ると、第1項の末尾でちょうど区切られ、第2項が次のチャンクに回ってしまうことがあります。

しかも厄介なことに、第2項だけのチャンクには「有給」という単語が一度も出てきません。「前項の規定にかかわらず」と書いてあるだけなので、質問との類似度が上がらず、上位に入らないのです。

LLMは、渡された資料を忠実に読んだだけです。資料に但し書きが含まれていなかった以上、「当日は申請できない」と答えるのは、むしろ誠実な振る舞いとすら言えます。冤罪その1です。

再発防止策:文書の構造で切る

日本語の規程やマニュアルには、たいてい「章・条・項」や見出しといった構造があります。これを無視して文字数で切るのは、本を読むときに目をつぶってページを破るようなものです。

構造を意識した分割の最小実装はこんな感じです。

structural_chunker.py
import re
from dataclasses import dataclass, field

ARTICLE_PATTERN = re.compile(r"^(第[0-90-9一二三四五六七八九十百]+条)((.+?))", re.MULTILINE)


@dataclass
class Chunk:
    text: str
    metadata: dict = field(default_factory=dict)


def split_by_article(doc_text: str, doc_title: str, max_chars: int = 1200) -> list[Chunk]:
    """条単位で分割する。1条が長すぎる場合だけ、項の境界でさらに分ける。"""
    matches = list(ARTICLE_PATTERN.finditer(doc_text))
    chunks: list[Chunk] = []

    for i, m in enumerate(matches):
        start = m.start()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(doc_text)
        body = doc_text[start:end].strip()
        article_no, article_title = m.group(1), m.group(2)

        # 見出しを毎チャンクの先頭に付けて、文脈を失わないようにする
        header = f"【{doc_title}】{article_no}({article_title})"

        if len(body) <= max_chars:
            chunks.append(Chunk(
                text=f"{header}\n{body}",
                metadata={"doc": doc_title, "article": article_no, "title": article_title},
            ))
            continue

        # 長い条は「項」(行頭の数字+ピリオド)の境界で切り、同じ見出しを付け直す
        paragraphs = re.split(r"\n(?=[0-90-9]+[..])", body)
        buf = ""
        for p in paragraphs:
            if buf and len(buf) + len(p) > max_chars:
                chunks.append(Chunk(f"{header}\n{buf.strip()}",
                                    {"doc": doc_title, "article": article_no, "title": article_title}))
                buf = ""
            buf += "\n" + p
        if buf.strip():
            chunks.append(Chunk(f"{header}\n{buf.strip()}",
                                {"doc": doc_title, "article": article_no, "title": article_title}))

    return chunks

ポイントは2つあります。

  1. 意味のまとまり(条)を単位に切る。 「原則」と「例外」は同じ条の中に書かれていることが多いので、条ごとに切れば両方が一緒に検索結果に入ってきます。
  2. すべてのチャンクの先頭に見出しを付ける。 長い条をやむを得ず分割した場合でも、後半のチャンクに「第18条(年次有給休暇の申請)」という見出しが付くので、「前項の規定にかかわらず」しか書かれていないチャンクでも「有給」という質問に引っかかるようになります。

2つ目は地味ですが、効果は大きいです。私はこれを勝手に「チャンクに名札を付ける」と呼んでいます。名札のない人に「有給担当の方いますか?」と呼びかけても、誰も振り向いてくれませんから。

PDFから抽出したテキストには要注意
PDFからテキストを抜き出すと、改行位置が崩れたり、ヘッダー・フッター(「社外秘」「3/42ページ」など)が本文に混ざったりします。正規表現で条を拾う前に、抽出結果を必ず人の目で数ページ分確認してください。チャンキングの品質は、それより前の抽出の品質を超えられません。Garbage in, garbage out です。


事件ファイル No.2:品番を覚えられない名探偵

事件の概要

次は製品マニュアルのボットです。サポート担当者がこう聞きました。

Q. KX-2041のフィルター交換周期は?

ボットの回答はこうでした。

A. フィルターは6か月ごとの交換を推奨しています。

実はこれ、KX-2014(数字が入れ替わった別機種)の情報でした。KX-2041の正しい交換周期は3か月です。

手口:ベクトル検索は「意味」は得意でも「綴り」に弱い

埋め込みベクトルによる検索は、「有給」と「年休」と「休暇を取る」が近い意味だと分かってくれる、とても賢い仕組みです。一方で、KX-2041とKX-2014のような一字違いの識別子を区別するのは苦手です。埋め込みモデルから見れば、どちらも「KXという型番の何か」で、ベクトル空間ではほぼ同じ位置に来てしまいます。

条文番号(第12条と第21条)、エラーコード(E-503とE-305)、社内システム名、人名なども同じです。業務で使う質問ほど、こうした「綴りが一致していないと困る」語が多く含まれます。

再発防止策:BM25とのハイブリッド検索 + RRF

対策の定番は、キーワード検索(BM25)とベクトル検索を両方実行し、結果を統合するハイブリッド検索です。

統合のやり方はいろいろありますが、私がまず試すのは RRF(Reciprocal Rank Fusion) です。理由はシンプルで、スコアを正規化しなくていいからです。BM25のスコアとコサイン類似度はスケールがまったく違うので、足し合わせようとすると重み調整の沼にはまります。RRFは順位だけを使うので、その沼を最初から避けられます。

$$
\mathrm{RRF}(d) = \sum_{r \in R} \frac{1}{k + \mathrm{rank}_r(d)}
$$

$k$ は慣例的に60がよく使われます。式の通り、「どの検索でもそこそこ上位に来る文書」が最終的に上に浮かびます。

日本語でBM25を使う場合、本来は形態素解析器でトークン化したいところですが、まずは依存を増やさずに文字bigramで始めるのがおすすめです。品番のような未知語にも強く、辞書のメンテナンスも要りません。

hybrid_search.py
import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer


def char_bigrams(text: str) -> list[str]:
    t = text.replace(" ", "").replace("\n", "")
    return [t[i:i + 2] for i in range(len(t) - 1)] or [t]


class HybridRetriever:
    def __init__(self, chunks: list[str], model_name: str = "intfloat/multilingual-e5-small"):
        self.chunks = chunks
        self.bm25 = BM25Okapi([char_bigrams(c) for c in chunks])
        self.model = SentenceTransformer(model_name)
        # e5系モデルは文書側に "passage: "、質問側に "query: " を付ける約束がある
        self.doc_emb = self.model.encode(
            [f"passage: {c}" for c in chunks], normalize_embeddings=True
        )

    def _bm25_ranking(self, query: str, n: int) -> list[int]:
        scores = self.bm25.get_scores(char_bigrams(query))
        return list(np.argsort(scores)[::-1][:n])

    def _vector_ranking(self, query: str, n: int) -> list[int]:
        q = self.model.encode([f"query: {query}"], normalize_embeddings=True)[0]
        scores = self.doc_emb @ q
        return list(np.argsort(scores)[::-1][:n])

    def search(self, query: str, top_k: int = 5, pool: int = 50, rrf_k: int = 60) -> list[tuple[int, float]]:
        fused: dict[int, float] = {}
        for ranking in (self._bm25_ranking(query, pool), self._vector_ranking(query, pool)):
            for rank, idx in enumerate(ranking, start=1):
                fused[idx] = fused.get(idx, 0.0) + 1.0 / (rrf_k + rank)
        return sorted(fused.items(), key=lambda x: x[1], reverse=True)[:top_k]

e5系モデルの「接頭辞」を忘れずに
intfloat/multilingual-e5 系のモデルは、学習時に質問側へ query: 、文書側へ passage: を付けています。これを付け忘れても動きはしますが、精度が目に見えて落ちます。エラーが出ないぶん気づきにくい、いちばん静かなバグの一つです。使うモデルのモデルカードは、最後まで読みましょう。

このハイブリッド化だけで、品番や条文番号を含む質問の取りこぼしはかなり減ります。さらに精度が欲しければ、統合後の上位20〜50件をクロスエンコーダ(リランカー)で並べ直す二段構えにします。ただ、リランカーはレイテンシとコストが増えるので、まずはRRFまでで評価を取り、それでも足りなければ足す、という順番をおすすめします。

急がば回れ、です。いきなり全部入りにすると、どの部品が効いたのか分からなくなります。


事件ファイル No.3:食い違う二人の証人

事件の概要

Q. 在宅勤務手当はいくらですか?

A. 在宅勤務手当は月額3,000円です。なお、月額5,000円とする規定もあり、条件により異なる可能性があります。

一見、丁寧な回答です。でも実際は「2024年版では3,000円、2026年4月の改定で5,000円」という話で、条件によって異なるわけではありません。 古い版の規程と新しい版の規程が、両方インデックスに残っていたのです。

手口:インデックスに「時間」の概念がない

ベクトルDBにとって、2024年版と2026年版の規程はただの「よく似た2つの文書」です。どちらが新しいかを知る手がかりがなければ、両方を同じ重みで返してきます。

食い違う証言を2つ渡されたLLMは、なんとか両方を矛盾なく説明しようとして「条件により異なる可能性があります」とまとめました。言ってみれば、気の利く探偵がありもしない第三の説を作ってしまったわけです。これをLLMの嘘と呼ぶのは、少しかわいそうな気がします。

再発防止策:版の管理と差分インデックス

やることは、図書館の司書さんの仕事に近いです。

  1. すべてのチャンクに「文書ID・版・施行日・廃止日」をメタデータとして持たせる
  2. 検索時に「現在有効な版」だけに絞る(過去の版を聞かれたときだけ外す)
  3. 文書が更新されたら、変わった部分だけをインデックスし直す

3つ目は、文書の内容からハッシュを取っておくと簡単に実装できます。

incremental_index.py
import hashlib
from datetime import date


def content_hash(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def sync_document(store, doc_id: str, version: str, effective_from: date, chunks: list[str]) -> None:
    """文書の新しい版を取り込み、旧版を失効させる。内容が変わっていなければ何もしない。"""
    new_hash = content_hash("\n".join(chunks))
    current = store.get_active_version(doc_id)

    if current and current["hash"] == new_hash:
        return  # 変更なし。埋め込みの再計算(=お金)を節約

    if current:
        # 物理削除せず「失効」させる。過去の版について聞かれる可能性と、監査のため
        store.mark_inactive(doc_id, version=current["version"], effective_to=effective_from)

    store.upsert_chunks(
        doc_id=doc_id,
        version=version,
        chunks=chunks,
        metadata={"effective_from": effective_from.isoformat(), "active": True, "hash": new_hash},
    )

store の部分は、お使いのベクトルDBに合わせて読み替えてください。多くのベクトルDBはメタデータによるフィルタに対応しているので、検索時に active == True の条件を付けるだけで、二人の証人問題はほぼ解決します。

もう一つ、地味に効く工夫があります。プロンプトに渡すチャンクに施行日を書き添えておくことです。

[資料1] 就業規則 第32条(在宅勤務手当) / 2026年4月1日施行
在宅勤務を行う従業員には、月額5,000円の在宅勤務手当を支給する。

万一フィルタをすり抜けて古い版が紛れ込んでも、LLMが日付を見て「新しいほうを優先する」判断をしやすくなります。正しい判断に必要な情報は、惜しまず渡しましょう。


事件ファイル No.4:見てはいけない資料

事件の概要

これは精度というよりセキュリティの事件ですが、RAGを業務で使うなら避けて通れないので取り上げます。

Q. 今期の昇給率ってどれくらい?

A. 申し訳ありませんが、その情報にはお答えできません。

一見、ちゃんと守れています。ところが、ログを見ると検索結果には役員会資料の「昇給原資の配分案」がしっかり含まれていました。 LLMがシステムプロンプトの「機密情報は答えないこと」に従ってくれたので、たまたま漏れなかっただけです。

手口:権限チェックを「生成の後」に任せている

「LLMに『答えるな』と指示しておけば大丈夫」という設計は、金庫の扉を開けっぱなしにして「中を見ないでくださいね」と貼り紙をするようなものです。言い回しを工夫した質問や、プロンプトインジェクションで突破される可能性は、ゼロにはなりません。

VentureBeatの記事でも、アクセス制御はモデルに届く前にかけるべきだと強調されていました。私もまったく同じ考えです。

再発防止策:検索クエリ自体に権限を含める

acl_filter.py
def retrieve_for_user(retriever, query: str, user) -> list[dict]:
    # ユーザーが閲覧できるグループを、検索条件そのものに含める
    allowed = set(user.groups) | {"all_employees"}
    return retriever.search(
        query,
        top_k=5,
        filter={"acl_groups": {"$in": sorted(allowed)}},  # フィルタ記法はDBに合わせる
    )

ここで大事なのは、**検索してから権限で絞る(後段フィルタ)のではなく、検索の時点で絞る(事前フィルタ)**ことです。後段で絞ると、上位5件がすべて閲覧不可の文書だった場合に結果が0件になり、「何かを隠している」こと自体が伝わってしまいます。さらに、ログや中間処理に機密文書が残るリスクもあります。

それから、権限情報を元のシステム(ファイルサーバーやSharePointなど)から定期的に同期する仕組みも忘れずに。異動や退職でグループが変わったのにRAGの権限情報だけ古いまま、というのは本当によくある話です。


事件ファイル No.5:正解したのに、不正解

事件の概要

最後は、いちばん気づきにくい事件です。

評価用に作った質問セットで回答をチェックしたところ、「通勤手当の上限は?」という質問に、ボットは正しく「月額5万円です」と答えました。評価は「正解」。

ところが、検索結果を見ると、取ってきたのは**出張旅費規程の「交通費の精算上限」**のチャンクでした。たまたま同じ5万円だっただけで、根拠はまったく別の文書だったのです。

手口:最終回答だけで評価している

最終回答の正誤だけを見ていると、こういう「まぐれ当たり」が正解として数えられてしまいます。逆に、検索は完璧なのに生成で失敗しているケースも、検索の問題と区別できません。

エドガー・ダイクストラの有名な言葉があります。

Program testing can be used to show the presence of bugs, but never to show their absence!
(テストはバグがあることを示せても、バグがないことは決して示せない)

最終回答だけの評価は、まさに「バグがないこと」を示したつもりになりやすい評価です。

再発防止策:検索と生成を分けて評価する

評価データに「質問」と「正解」だけでなく、**「正解の根拠になるチャンクのID」**も入れておきます。これがあると、検索の性能を単独で測れます。

eval_retrieval.py
from statistics import mean


def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
    """上位k件に、正解チャンクがどれだけ含まれているか"""
    if not relevant:
        return 0.0
    return len(set(retrieved[:k]) & relevant) / len(relevant)


def reciprocal_rank(retrieved: list[str], relevant: set[str]) -> float:
    """最初の正解チャンクが何位に出てきたか(1位なら1.0、2位なら0.5…)"""
    for rank, cid in enumerate(retrieved, start=1):
        if cid in relevant:
            return 1.0 / rank
    return 0.0


def evaluate(retrieve_fn, eval_set: list[dict], k: int = 5) -> dict:
    recalls, rrs, misses = [], [], []
    for item in eval_set:
        retrieved = retrieve_fn(item["question"])  # チャンクIDのリストを返す想定
        relevant = set(item["relevant_chunk_ids"])
        r = recall_at_k(retrieved, relevant, k)
        recalls.append(r)
        rrs.append(reciprocal_rank(retrieved, relevant))
        if r == 0.0:
            misses.append(item["question"])  # 1件も拾えなかった質問は、目で見る価値が高い
    return {
        f"recall@{k}": round(mean(recalls), 3),
        "mrr": round(mean(rrs), 3),
        "complete_misses": misses,
    }

評価データの1行はこんな形です。

{
  "question": "通勤手当の上限は?",
  "answer": "月額5万円",
  "relevant_chunk_ids": ["shugyo-kisoku_v2026_art41"]
}

この評価を回すと、改善の打ち手がはっきり分かれます。

検索 (Recall@k) 生成 (回答の正誤) 疑うべき場所
低い 低い チャンキング・検索方式・データの質
高い 低い プロンプト・渡すチャンクの数や順序・モデル
低い 高い まぐれ当たり。いちばん危険
高い 高い 次は応答速度とコストを見直す

3行目の「検索は外しているのに、回答は合っている」が、今回の事件の正体です。この状態の評価スコアは、本番で何かのきっかけで一気に崩れます。

そして、いちばん大事なことを書いておきます。complete_misses、つまり1件も正解を拾えなかった質問のリストは、必ず人の目で1件ずつ読んでください。 数字は「どれくらい悪いか」を教えてくれますが、「なぜ悪いか」は教えてくれません。なぜ悪いのかは、たいてい質問とチャンクを並べて眺めているうちに見えてきます。

Rob Pikeの「プログラミングの5つのルール」の2番目にも、こうあります。

Measure. Don't tune for speed until you've measured, and even then don't unless one part of the code overwhelms the rest.
(計測せよ。計測するまでは速度のチューニングをするな。計測した後でも、ある部分が全体を圧倒していない限りはするな)

これは速度についての言葉ですが、RAGの精度にもそのまま当てはまると思っています。計測もせずにチャンクサイズを変えたりモデルを替えたりするのは、霧の中でハンドルを切るようなものです。


最終章:「答えない」という正解

5つの事件を見てきましたが、最後にもう一つ、全部の事件に共通する話をさせてください。

RAGの評価でよく見落とされるのが、**「答えるべきでない質問に、答えてしまう」**という失敗です。資料に書かれていないことを聞かれたとき、それっぽい一般論で埋めてしまう。社内規程ボットでこれをやられると、かなり困ります。「一般的には〜です」と答えられた社員は、それが自社のルールだと思い込んでしまうからです。

論語に、こんな一節があります。

知るを知ると為し、知らざるを知らずと為す。是れ知るなり。
(知っていることは知っているとし、知らないことは知らないとする。それが本当に知るということだ)

2500年前の孔子が、RAGの設計思想をすでに語っていたと思うと、ちょっと愉快です。

実装としては、次の2つを組み合わせます。

1. 検索スコアが低すぎる場合は、LLMを呼ばずに打ち切る

abstain.py
NO_ANSWER = (
    "ご質問に該当する規程が見つかりませんでした。"
    "お手数ですが、人事部(内線1234)までお問い合わせください。"
)


def answer(question: str, retriever, llm, min_score: float) -> str:
    hits = retriever.search_with_scores(question, top_k=5)
    if not hits or hits[0].score < min_score:
        return NO_ANSWER  # LLMを呼ばないので、コストもゼロ
    return llm.generate(build_prompt(question, hits))

min_score の値は、勘で決めずに評価データから決めます。「資料に答えがない質問」も評価セットに混ぜておき、正解がある質問とない質問のスコア分布を並べて、境界を引きます。ここも結局、計測です。

2. プロンプトで「答えない」ことをはっきり許可する

あなたはにゃー商事の社内規程について回答するアシスタントです。
以下の[資料]に書かれている内容だけを根拠に回答してください。

- 資料に答えが書かれていない場合は、推測や一般論で補わず、
  「規程に記載が見つかりませんでした」と回答してください。
- 回答には、根拠とした資料の番号(例:[資料2])を必ず付けてください。
- 複数の資料の内容が食い違う場合は、施行日が新しいものを優先し、
  食い違いがあることも併記してください。

「答えなくていい」と明示的に書いておかないと、LLMはなんとかして役に立とうとします。その健気さが、ハルシネーションの正体の一つでもあります。「分からない」と言うことを、失敗ではなく正しい振る舞いとして定義してあげる。これだけで、回答の信頼性はかなり変わります。


「正答率98%」という数字の、正直な話

私はふだん、RAGチャットボットの構築をお手伝いするときに「正答率98%」という数字を目安としてお伝えしています。ただ、この数字にはきちんと但し書きを付けたいと思っています。消えた但し書きの事件を書いた直後ですし。

この98%は、その案件のために作った評価セットの上での数字です。評価セットの作り方次第で、正答率はいくらでも高くも低くも見せられます。簡単な質問ばかり集めれば99%は難しくありませんし、意地悪な質問を集めれば50%にもなります。

だから私が大事にしているのは、数字そのものよりも、

  • 実際の利用者が聞きそうな質問を、利用者本人から集めること
  • 「資料に答えがない質問」を必ず混ぜること
  • 検索と生成を分けて測ること
  • 外した質問を、1件ずつ自分の目で読むこと

の4つです。98%は、この地道な作業を続けた結果としてついてくる数字にすぎません。数字だけが一人歩きすると、それこそ「正解したのに、不正解」になってしまいます。


まとめ:事件簿の索引

最後に、今回の事件を一覧にしておきます。RAGの精度に悩んだときのチェックリストとして使ってください。

No. 事件 真犯人 再発防止策
1 消えた但し書き 固定長チャンキング 構造(条・見出し)で分割し、チャンクに見出しの名札を付ける
2 品番を覚えられない名探偵 ベクトル検索単体 BM25とのハイブリッド検索 + RRF
3 食い違う二人の証人 版管理のないインデックス 版・施行日のメタデータと有効版フィルタ、差分インデックス
4 見てはいけない資料 生成後の権限チェック 検索時点での事前フィルタと権限の定期同期
5 正解したのに、不正解 最終回答だけの評価 根拠チャンクIDを含む評価セットで、検索と生成を分けて測る
番外 知ったかぶり 「答えない」を許さない設計 スコアの閾値で打ち切り、プロンプトで「分からない」を許可する

こうして並べると、LLMそのものが真犯人だった事件は一つもありません。灯台下暗し。犯人は、いつも自分たちが用意したデータとパイプラインの中に潜んでいました。

もちろん、LLMが本当にハルシネーションを起こすこともあります。ただ、それを疑うのは、検索側の容疑をすべて晴らしてからでも遅くありません。そのほうがずっと安上がりで、ずっと確実です。

ブライアン・カーニハンは『The Elements of Programming Style』の中でこう書いています。

Everyone knows that debugging is twice as hard as writing a program in the first place. So if you're as clever as you can be when you write it, how will you ever debug it?
(デバッグはプログラムを書くことの2倍難しいことは誰もが知っている。だとすれば、書くときに全力で賢く書いてしまったら、いったいどうやってデバッグするのか)

RAGも同じだと思います。最初から賢い部品を全部載せするより、まずは素朴な構成で計測できる状態を作る。賢さは、犯人を突き止めてから少しずつ足していけばいいのです。


次回予告:評価セットという「証拠品」をどう集めるか

今回、何度も「評価セット」という言葉を使いました。けれど、実はRAGの改善でいちばん時間がかかり、いちばん差がつくのは、この評価セットづくりです。

  • 質問は何問あれば足りるのか。30問か、100問か、1,000問か
  • 現場の人に「質問を考えてください」とお願いすると、なぜ役に立たない質問ばかり集まるのか
  • LLMに評価用の質問を作らせると、どんな偏りが生まれるのか
  • 正解の根拠チャンクIDを、楽に、でも正確に付ける方法

次回の【RAG事件簿 #2】では、この「証拠品の集め方」を掘り下げます。正直に言うと、ここはかなり泥くさい話になります。でも、泥くさいところにこそ、他の記事には書かれていない知見が落ちているものです。

それでは、次の事件現場でお会いしましょう。


この記事が参考になったら、いいねやストックをしていただけると、次回を書く励みになります。「うちではこんな事件があった」というコメントも大歓迎です。

1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?