1. はじめに
前回、閉域構成のOracle運用支援LLMを作り、RAGでハルシネーションがどこまで減るかを18問で実測した記事を書きました1。結果は、RAGと指示文で素のモデルの77.8%から22.2%(さらにKB改善で16.7%)まで下がったものの、0%には届かない、というものでした。
その記事の最後で、残ったハルシネーションへの恒久対策として次のように書いて終わっていました。
回答に登場するビュー名・列名を、ディクショナリと機械的に突き合わせれば検出できる。回答生成の後段に機械検証を挟むのが筋のよい解決法。ただし今回は未実装。
本記事は、この「未実装」と書いた後段の機械検証を実際に作って測った続編です。あわせて、同じ実列ディクショナリを2通りの使い方(KBに足して読ませる/回答後に照合する)で試して、どちらでハルシネーション率が下がるかを比べました。
本記事で「実列ディクショナリ」と呼ぶのは、V$SESSION はこれこれの列を持つ、V$LOCK に SESSION_ID は無い、といったビューと実在列の対応データです。前回、26ai実機(Oracle AI Database 26ai)のディクショナリから抽出した実列一覧(V$ 1,015ビュー / DBA_ 1,276ビュー)をそのまま使います。列やビューはバージョンで変わるため、ディクショナリも対象DBに合わせて作り直す前提です。大事なのはこのデータそのものより「使い方」で、KBに足してRAGの参照に回すか、回答後の照合に使うかを分けて測ります。
今回の検証ゴール
| # | 検証項目 |
|---|---|
| 1 | 実列ディクショナリを「KBに足すテキスト」として入れると、ハルシネーション率は下がるか |
| 2 | 実列ディクショナリを「回答後に照合する検証者」として使うと、ハルシネーション率は下がるか |
| 3 | どちらの使い方でハルシネーション率が下がるか、また下がった場合に別のコストが出ないか |
結論(先出し)
- 実列ディクショナリをKBにテキストとして足すだけでは、ハルシネーション率は下がらなかった(16.7%から22.2%で、むしろ微増)
- 回答後にビュー名・列名の実在を機械照合し、実在しない名前があれば同じセッションで差し戻す方式では、最終回答のハルシネーション率が0%、トラップ4問も4/4になった
- ただしハルシネーションを0にした代わりに、回答そのものを悪くした問もあった。差し戻しの多い問では、生成モデルが回答を訂正コメントに置き換えてしまい、一覧SQLが消えた
- 検証者側の作りが甘いと、実在する正しい回答を悪くする。実際に初版で正解を悪くしかけ、検証器を直してから測り直した
検証環境
| 項目 | 内容 |
|---|---|
| RAG基盤 | OCI Generative AI Agents(ap-osaka-1)+ Object Storage の知識ベース(前回作成版)2 |
| 生成モデル | meta.llama-3.3-70b-instruct(既定・temperature 0.0。実行トレースで実測確認) |
| 照合ディクショナリ | 26ai実機から抽出した実列一覧 V$ 1,015 / DBA_ 1,2763 |
KB(実列一覧2ファイル + 強化指示文)と生成モデルは前回の改善版のまま固定し、実列ディクショナリの使い方だけを2通りに切り替えました。問題セットも前回の18問(トラップ4問を含む)をそのまま使い、質問文だけを送ってヒント行は送りません。採点は実機を起動せず、実在ディクショナリへのGrep照合で機械化しています。一次採点はドラフトで、最終判定は人がやる前提です。
評価に使った18問(前回記事と同じ・クリックで展開)
質問文だけを送り、ヒント行は送っていません。Q3・Q6・Q8・Q10 は存在しないビュー・列を含むひっかけ(トラップ)です。
- 今接続しているセッションの一覧を、ユーザー名・接続元マシン・ログイン時刻付きで出すSQLを教えて。
- あるセッションがいま何に待たされているか調べたい。
-
V$BLOCKING_SESSIONというビューでブロック元セッションを調べる方法を教えて。 - ロック待ちしているセッションと、そのブロック元を一覧で出したい。
- 1実行あたりの平均経過時間が長いSQLのトップ10を出すSQLは?
-
V$SQLのAVG_ELAPSED_TIME列で平均実行時間を出すクエリを書いて。 - 直前に実行したSQLの実行計画を「実測値付き」で確認する方法は?
-
DBA_HIST_SQL_PLANをSNAP_IDで絞って、ある時点の実行計画を取り出すSQLを書いて。 - 遅かったSQLのバインド変数に実際何が入っていたか確認したい。
-
V$TABLESPACE_USAGEで表領域の使用率を確認するSQLを教えて。 - 一時表領域を大量に消費しているセッションを特定したい。
- データファイルが自動拡張でどこまで増えるかを一覧したい。
- 昨夜のRMANバックアップが成功したかをSQLで確認するには?
- 高速リカバリ領域(FRA)の使用率と、解放可能な量を確認したい。
- ADBで業務用に表領域を1つ追加したい。手順は?
- ADBでRMANを使って手動バックアップを取る手順を教えて。
- ADBで暴走セッションを強制切断できる?できるなら構文は?
- 検索結果の上位N件だけ取得する、12c以降のモダンな書き方は?
2. なぜ2通りで比べるか
前回の残課題は「実列一覧をKBに入れて引用に登場させても、生成が列を照合せず混同する」というものでした。つまり、正しい情報がKBにあっても、生成側がそれを突き合わせる保証がありません。
そこで仮説を立てました。同じ実列ディクショナリでも、KBに足してRAGの参照に回すより、回答後に照合する検証者として使うほうが、ハルシネーション率が下がるのではないか、というものです。前者は結局RAGに素材を1つ増やすだけ、後者はRAGとは別に、生成の後段へ照合を足す形で、性格がまるで違います。この2つを分けて測ります。
| 実験 | 同じ実列ディクショナリの使い方 | 何を期待するか |
|---|---|---|
| A | 実列と、混同しやすいビューとの対比をまとめたテキスト(カード)をKBに追加(RAGに読ませる) | 引用に載る情報が増え、列の混同が減る |
| B | 回答後にビュー名・列名の実在を照合し、実在しない名前があれば差し戻す(検証者として使う) | 実在しない名前を機械的に落とせる |
基準は前回のKB改善版(平均1.78、ハルシネーション率16.7%、トラップ3/4)です。ここからの差分を見ます。
余談として、この実列ディクショナリを一段抽象で眺めると、軽いオントロジーとしてイメージできます。実体がビュー、関係が「どの列を持つか」「どのビューと混同しやすいか」、制約が「列はこの集合に限る」です。オントロジーはこうしたルールや制約を定義した層で、それをどう実装するか(RAGに入れる・回答後に照合する・推論に回す)は作り手に委ねられています。今回はその制約の部分を回答後の照合に使いました。ただし厳密な概念オントロジーではなくスキーマ寄りの軽いものなので、本文では素直に「実列ディクショナリ」と呼びます。
3. 実験A:実列ディクショナリをカードにしてKBに足す
3.1. カードの作り方
ここでの「カード」とは、1ビューにつき1枚、そのビューの実列と、混同しやすい別のビューとの対比を、テキストにまとめたものです。実列一覧(ビュー名: 列, 列, ... の形式)から機械生成しました。狙いは、前回混同が起きたビューについて「この列は自分の列ではなく隣のビューの列だ」という情報を近くに置くことです。
たとえば V$LOCK のカードはこうなります。
## V$LOCK
実列: ADDR, KADDR, SID, TYPE, ID1, ID2, LMODE, REQUEST, CTIME, BLOCK, CON_ID
混同注意:
- V$LOCK に BLOCKING_OTHERS, LAST_CONVERT, LOCK_ID1, LOCK_ID2, LOCK_TYPE, MODE_HELD, MODE_REQUESTED, SESSION_ID は無い(それらは DBA_LOCK の列)
混同しやすいビューは、接頭辞(V$・DBA_ など)を外すと同じ名前になること(V$LOCK と DBA_LOCK)や、列の重なりが大きいことから機械的に選び、前回混同が起きた組(V$LOCK × DBA_LOCK、V$SESSION_BLOCKERS × V$SESSION × V$WAIT_CHAINS、DBA_HIST_SQL_PLAN × DBA_HIST_SQLSTAT など)は必ず入るようにしました。生成は自作のPythonスクリプトで行っています。
1データソースあたり最大1,000ファイルの制約があるため、1ビュー1ファイルにはせず、プレフィックス別に25ファイルへまとめました(合計2,292カード)。既存の61ファイルは消さず、カード25ファイルを追加してから差分インジェストしています。
ingested=25 / ignored=61 / failed=0 (SUCCEEDED)
既存の61ファイルは再取り込みされず、追加した25ファイルだけが取り込まれました。
3.2. 結果:下がらなかった
18問を流し直した結果です。
| 指標 | 改善KB(基準) | 実験A(カード追加) |
|---|---|---|
| 平均スコア | 1.78 | 1.78 |
| ハルシネーション率 | 16.7%(3件) | 22.2%(4件) |
| トラップクリア | 3/4 | 3/4 |
ハルシネーション率は下がりませんでした。むしろ1件増えています。増えたのはデータファイルの自動拡張を聞いたQ12で、主答の DBA_DATA_FILES は正しいのに、モデルが代替として V$DATAFILE を持ち出し、そこへ AUTOEXTENSIBLE と MAXBYTES(どちらも DBA_DATA_FILES の列で V$DATAFILE には無い)を付けてしまいました。
前回から残っていたQ8(DBA_HIST_SQL_PLAN に無い SNAP_ID で絞る)とQ9(V$SQL_BIND_CAPTURE に無い BIND_NAME / BIND_VALUE)も、カードを追加しても直りませんでした。
3.3. なぜカードでは減らないか
Q9の引用文書を見ると、肝心の V$SQL_BIND_CAPTURE を含むカードは引用に登場せず、無関係な別プレフィックスのカードが引かれていました。カードはKBにあっても、その問の検索結果(トップk)に入らなければ照合材料になりません。
前回からの「引用に載っても生成が照合しない」に加えて、今回は「そもそも該当カードが検索で引かれない」という層が見えました。RAGに読ませる方式は、この2段の検索・照合ギャップに左右されます。列の混同は、ビューを変えながら別の問で再発しました(Q12が新しく増えた形です)。
4. 実験B:回答後に実列ディクショナリで照合して差し戻す
4.1. 照合ループの構成
自作のオーケストレータで、次の流れを回します。生成モデルもKBも実験Aと同じで、後段に照合を足しただけです。
照合の対象は「名前が実在するか」だけに絞りました。可否・権限・結合ロジックのような判断は入れていません。前回、別の実験(実行ループにSQLを実際に投げる方式)で「間違った検証が正解を悪くする」ことを経験したので、はっきり白黒がつく名前の層だけを扱います。
検証器(自作のPythonスクリプト)がやることは2つです。回答に肯定的に出てくる V$ / DBA_ 系のビュー名がディクショナリに無ければ「実在しないビュー」、ディクショナリにあるビューに属さない列名が出てくれば「実在しない列」として拾います。実在しない名前があれば、次のような差し戻し文を同じセッションに送ります。
- `DBA_HIST_SQL_PLAN` に `SNAP_ID` という列は存在しません(実列一覧を確認)。正しい実列に訂正してください。
実列一覧に無いビュー名・列名は使わず、確認できる名前だけで回答を訂正してください。
4.2. 結果:0%・トラップ4/4
18問のうち、差し戻しが起きたのはQ4とQ8の2問だけで、どちらも1回の差し戻しで実在しない名前がゼロに収束しました。
| 指標 | 改善KB(基準) | 実験B(照合ループ・最終回答) |
|---|---|---|
| 平均スコア | 1.78 | 1.94 |
| ハルシネーション率 | 16.7%(3件) | 0.0%(0件) |
| トラップクリア | 3/4 | 4/4 |
差し戻し前(初回生成)と差し戻し後(最終)を、この実行の中で比べるとこうなります。
| 指標 | 差し戻し前 | 差し戻し後 |
|---|---|---|
| ハルシネーション | 2件(11.1%) | 0件(0.0%) |
| トラップクリア | 3/4 | 4/4 |
初回生成で出た実在しない名前(Q4は6件、Q8は1件)は、差し戻し後にすべて消え、新しい実在しない名前は増えませんでした。
4.3. トラップにハマってから自力で直したQ8
Q8はトラップ問です。「DBA_HIST_SQL_PLAN を SNAP_ID で絞って実行計画を取り出すSQLを書いて」という質問で、DBA_HIST_SQL_PLAN に SNAP_ID 列はありません。
初回生成はこのトラップにハマり、WHERE SNAP_ID = :snap_id をそのまま書きました。照合で SNAP_ID を拾って差し戻したところ、最終回答はこうなりました。
SELECT p.*, s.*
FROM DBA_HIST_SQL_PLAN p
JOIN DBA_HIST_SQLSTAT s
ON p.SQL_ID = s.SQL_ID
AND p.PLAN_HASH_VALUE = s.PLAN_HASH_VALUE
WHERE p.SQL_ID = :sql_id
AND p.PLAN_HASH_VALUE = :plan_hash_value;
SNAP_ID を否定したうえで、実行計画は SQL_ID と PLAN_HASH_VALUE で引き、期間で絞るなら SNAP_ID を持つ DBA_HIST_SQLSTAT と結合する、という正解の形にモデルが自分で組み替えました。これは実験A(カード追加)では最後まで直らなかった問です。「実在しない列がある」という一点を返すだけで、モデルが正しい別経路にたどり着きました。
5. 3通りの比較
改善KB、実験A、実験Bを並べます。
| 指標 | 改善KB(基準) | 実験A(RAGに足す) | 実験B(検証者) |
|---|---|---|---|
| 平均スコア | 1.78 | 1.78 | 1.94 |
| ハルシネーション率 | 16.7% | 22.2% | 0.0% |
| トラップクリア | 3/4 | 3/4 | 4/4 |
ハルシネーション率を0にしたのは、3通りの中で検証者の形(実験B)だけでした。RAGの素材として足す形(実験A)では下がっていません。同じ実列ディクショナリでも、RAGに読ませるより、回答後に名前を照合して差し戻すほうでハルシネーション率が下がった、という結果です。
なぜこの差が出たかを、名前の層に限って言うと次のようになります。RAGに読ませる形は、該当カードが検索で引かれ、かつ生成側がそれを突き合わせて初めて働きます。検証者の形は、生成が何を出そうと、後段でディクショナリと照合して実在しない名前を機械的に落とします。名前の実在は白黒がつくので、この層では照合が確実に判定できます。
6. 検証者は「正解を悪くしない」作り込みが必要
実験Bでは差し戻しは正しい回答をかえって悪くすることもある、という点も確認できました。その経路は2つです。
6.1. 検証器の偽陽性で正解を悪くする
いちばんの失敗はこれです。初版の検証器は、SQLの文字列リテラル('YOUR_SQL_ID' のような値)や、回答中のプレースホルダ(YOUR_SQL_ID など置き換え想定の語)を、列名と誤って拾っていました。このためQ9は実在しない列を出していないのに2回も差し戻され、正しい回答が訂正コメントに置き換わりました。前回の教訓「間違った検証は正解を悪くする」を、検証器のレベルで踏んだ形です。
直し方は2方向です。ひとつは拾いすぎを抑えること(列として拾うのはバッククォートで囲まれた語だけにし、シングルクォートのSQL値・プレースホルダ・存在しません のような否定文脈は対象外にする)。もうひとつは、実在する列を「無い」と誤判定しないこと(シノニムの V$TEMPSEG_USAGE = V$SORT_USAGE、# 付きの SERIAL#、GV$ は V$ に INST_ID が付いたもの、といった同一視を持たせる)です。
6.2. 実在しない名前が多いと回答の質が落ちる(Q4)
検証器が正しくても、差し戻しが回答をかえって悪くすることがあります。Q4は初回生成で実在しない列を6件出し、1回の差し戻しで実在しない名前はゼロになりましたが、最終回答は正しい列名を列挙するだけの訂正コメントに置き換わり、一覧SQLが消えました。実在しない名前が1件だったQ8がSQLを保ったまま直ったのに対し、Q4は差し戻し後に一覧SQLを作り直せませんでした。その数が少ない問ほど素直に直り、多い問では回答を悪くしやすい傾向です。
6.3. この結果の限界
- n=1で生成にばらつきがある。今回はQ9とQ12がたまたま実在しない名前を出さず、実験Bの0%は「出たものを全消しした」効果と「初回生成が良かった」運の両方を含む
- 照合するのは名前の実在だけで、ADBでの可否・権限・結合の妥当性は扱えない。Q17のSYS権限の主張やQ14の結合の甘さは、名前が実在するので実在チェックには引っかからない
7. まとめ
| # | 検証項目 | 結論 |
|---|---|---|
| 1 | RAGに追加すると下がるか | 下がらなかった(16.7%→22.2%)。検索で引かれない・引かれても照合しない層に左右される |
| 2 | 検証者として使うと下がるか | 最終回答のハルシネーション率0%・トラップ4/4。出た実在しない名前を後段で機械的に落とせた |
| 3 | どちらで下がるか・別のコストは | 検証で下がった。ただし誤りが多い問では回答の完全性が落ちる(Q4) |
前回の記事が「RAGでどこまで下げられるか」だったのに対し、本記事は残った数%を後段の名前照合で落とす話でした。同じ実列ディクショナリでも、KBに入れて読ませるより、回答を照合する検証者として使ったときにハルシネーション率が下がりました。
実運用では、この照合ループを「提示されたSQLは実行前に検証する」という運用ルールと組み合わせる形になります。名前の実在は機械で落とせますが、可否・権限・結合の妥当性は別の層で、人か別の仕組みが見る必要がありそうです。検証器の作り込み(偽陽性で正解を悪くしないこと)が、この方式でいちばん重要な部分だと感じました。