TL;DR
- 「元テキストから一言一句変えずに引用せよ」とプロンプトで命じても、LLM は引用を「抽出」せず「生成」するため、語尾改変・文の接合・捏造は防げない。
- 引用が本文に実在するかは、AI ではなく決定論的なコード(正規化 → 完全一致 → スライディングウィンドウ)で判定する。波ダッシュ・文中改行・全角半角など和文特有の表記ゆれも吸収する。
- 照合に落ちた根拠は LLM にリトライさせず、その場で破棄する。破棄率はメトリクスとして監視する。
この記事のコードは外部依存ゼロ・コメント込み約160行で、Vitest のテスト付きです。そのままコピーして使えます。
はじめに:その quote、本当に本文にありますか?
RAG や自律調査エージェントで「根拠付きの要約」を作るとき、こんな出力スキーマを書いたことがあると思います。
{
"summary": "分かったことの要約",
"quote": "その根拠となる本文の一節(必ず元テキストから一言一句変えずに抜き出すこと)"
}
そして返ってきた quote を、画面に「原文からの引用」として表示したり、後段の検証プロンプトに渡したりしていないでしょうか。
LLM が出力した quote を照合なしで信じるのは危険です。 プロンプトでどれだけ強く指示しても、モデルは次のような「それっぽい引用」を平然と返してきます。
| パターン | 例 |
|---|---|
| 語尾・ニュアンスの改変 | 「〜の可能性がある」→「〜である」 |
| 都合のいい接合 | 離れた2文をつなげて1文にする |
| 完全な捏造 | 文脈には沿っているが、本文のどこにも存在しない文 |
しかも根拠として採用された引用は、後段で「事実」として扱われるため、誤りがパイプライン全体に伝播します。
なぜプロンプトでは防げないのか
LLM にとって quote を埋める行為は、「本文の部分文字列をスライスすること」ではありません。**「この文脈で引用として最も自然なトークン列を生成すること」**です。実際のモデルはかなり正確に書き写しますが、それは確率的に高い精度が出ているだけで、一字一句の正確さが保証されているわけではありません。数件に1件のズレでも、根拠データとしては致命的です。
「なら本文中の位置(offset)を返させればいい」と考えたくなりますが、offset も同じく生成された数字です。結局、照合は必要になります。
ここで重要なのは、LLM が生む「ズレ」には性質の異なる2種類があるという点です。
| 種類 | 例 | 扱い |
|---|---|---|
| 表記ゆれ(意味は同じ) | 全角/半角、〜と~、“”と""、実体参照、HTML抽出時の文中改行 |
正規化で吸収し、合格させる |
| 内容の改変(意味が変わる) | 数値・語尾の書き換え、文の接合、捏造 | 不合格にして破棄する |
設計の要点は、正規化の範囲を「意味が変わらない差」に限定することです。正規化を緩めすぎると改変まで通してしまい、厳しすぎると正しい引用まで落とします。
なお、送り仮名の揺れ(「行なう」→「行う」)のように正規化で吸収しきれない差は、不合格側に倒します。正しい引用を少し取りこぼすコストは、捏造を通すコストより圧倒的に小さいからです(理由は後述の「なぜリトライしないのか」で)。
解決策:決定論的照合の流れ
[LLMが出力した quote]
│
▼
0. 前提チェック ── 空 → EMPTY / 短すぎる → TOO_SHORT
│
▼
1. 正規化(実体参照・NFKC・引用符/ダッシュ/波ダッシュ・空白)
│
▼
2. 存在判定:正規化した本文全体に含まれるか?
└── NO ──> NOT_FOUND(破棄)
│ YES
▼
3. 局所性判定:連続する 1〜3 文の窓に収まるか?(スライディングウィンドウ)
├── YES ──> 合格(窓の「原文」をスニペットとして返す)
└── NO ──> SPAN_TOO_LONG(破棄)
ステップ2と3の役割分担がポイントです。
- ステップ2(存在判定):「本文に実在するか」を判定します。本文全体に無い文字列は、どの窓にも無いので、ここで即座に落とします。
-
ステップ3(局所性判定):「1つの根拠として妥当な範囲か」を判定します。本文の半分をまるごと貼り付けたような
quoteは、実在はしても「根拠」としては不適切です。さらに、マッチした窓の原文が手に入るので、UI でのハイライトやログ出力にそのまま使えます。
正規化後の文字列はオフセットが原文とずれるため、「本文のどこにマッチしたか」を原文で示すには、原文を文単位に区切った窓で照合するのが素直な方法です。
実装(TypeScript・ゼロ依存)
// ---------- 型定義 ----------
export type RejectReason =
| 'EMPTY' // 正規化後に空文字
| 'TOO_SHORT' // 短すぎて「偶然の一致」と区別できない
| 'NOT_FOUND' // 本文のどこにも存在しない(捏造・改変)
| 'SPAN_TOO_LONG'; // 本文には存在するが、許容する文数を超えて跨いでいる
/** 判別可能なユニオン型: isValid で分岐すると matchedSnippet / reason が型で保証される */
export type QuoteVerificationResult =
| { isValid: true; matchedSnippet: string }
| { isValid: false; reason: RejectReason };
export interface VerifyOptions {
/** これ未満の文字数(正規化後・コードポイント単位)の引用は TOO_SHORT で弾く */
minLength?: number;
/** 引用が跨いでよい連続文の最大数(=スライディングウィンドウの最大幅) */
maxSentences?: number;
}
// ---------- 正規化 ----------
const NAMED_ENTITIES: Record<string, string> = {
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
};
/**
* HTML実体参照を「1パスで」デコードする。
* replace を連鎖させると `&lt;` → `<` → `<` のように二重デコードされるため。
*/
function decodeHtmlEntities(text: string): string {
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, body: string) => {
if (body[0] === '#') {
const isHex = body[1] === 'x' || body[1] === 'X';
const cp = parseInt(body.slice(isHex ? 2 : 1), isHex ? 16 : 10);
return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : match;
}
return NAMED_ENTITIES[body.toLowerCase()] ?? match;
});
}
/** 日本語(CJK)の文字・記号。これに隣接する空白は「意味のない空白」とみなして除去する */
const CJK = '[\\u3000-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff\\uff00-\\uffef]';
const SPACE_AROUND_CJK = new RegExp(` (?=${CJK})|(?<=${CJK}) `, 'g');
/**
* 照合用の正規化。「表記ゆれ」だけを吸収し、「内容の違い」は残す。
* 本文と引用の両方に同じ関数を適用するのがポイント。
*/
export function normalizeForVerification(text: string): string {
return decodeHtmlEntities(text)
.normalize('NFKC') // 全角英数→半角、半角カナ→全角、~(FF5E)→~ など
.replace(/[“”„‟″‶]/g, '"') // 二重引用符の統一
.replace(/[‘’‚‛′‵]/g, "'") // 一重引用符の統一
.replace(/[‐‑‒–—―−]/g, '-') // ダッシュ・マイナスの統一(長音「ー」は含めない)
.replace(/〜/g, '~') // 波ダッシュ(NFKC で変換されない)
.replace(/\s+/g, ' ') // 改行・タブ・全角空白を半角空白1つに圧縮
.replace(SPACE_AROUND_CJK, '') // 日本語の途中に挟まった改行・空白を除去
.trim()
.toLowerCase();
}
// ---------- 文分割 ----------
/** 欧文は「終端記号+空白」、和文は「。!?」の直後を文境界とする */
const SENTENCE_BOUNDARY = /(?<=[.!?。!?])\s+|(?<=[。!?])/g;
/** 原文上の [開始, 終了) オフセットで文を返す(スニペットを原文のまま切り出すため) */
function splitSentenceSpans(text: string): Array<[number, number]> {
const spans: Array<[number, number]> = [];
let start = 0;
for (const m of text.matchAll(SENTENCE_BOUNDARY)) {
const end = m.index!;
if (text.slice(start, end).trim()) spans.push([start, end]);
start = end + m[0].length;
}
if (text.slice(start).trim()) spans.push([start, text.length]);
return spans;
}
// ---------- 照合本体 ----------
/**
* 本文1件に対する検証関数を作る。
* 本文の正規化とウィンドウ生成は最初の1回だけ行い、引用ごとの照合は軽量に保つ。
*/
export function createQuoteVerifier(rawBody: string, options: VerifyOptions = {}) {
const { minLength = 8, maxSentences = 3 } = options;
const normBody = normalizeForVerification(rawBody);
const spans = splitSentenceSpans(rawBody);
// 幅 1 → maxSentences の順に並べる(=最小の窓が先にヒットする)
const windows: Array<{ raw: string; norm: string }> = [];
for (let size = 1; size <= maxSentences; size++) {
for (let i = 0; i + size <= spans.length; i++) {
const raw = rawBody.slice(spans[i][0], spans[i + size - 1][1]).trim();
windows.push({ raw, norm: normalizeForVerification(raw) });
}
}
return (quote: string): QuoteVerificationResult => {
const normQuote = normalizeForVerification(quote);
if (!normQuote) return { isValid: false, reason: 'EMPTY' };
if ([...normQuote].length < minLength) return { isValid: false, reason: 'TOO_SHORT' };
// 1. 存在判定: 本文全体に無ければ、どの窓にも無い。ここで即不合格。
if (!normBody.includes(normQuote)) return { isValid: false, reason: 'NOT_FOUND' };
// 2. 局所性判定: 連続 1〜maxSentences 文の窓に収まるか。
// 収まった窓の「原文」を返すので、UI のハイライトやログにそのまま使える。
const hit = windows.find((w) => w.norm.includes(normQuote));
return hit
? { isValid: true, matchedSnippet: hit.raw }
: { isValid: false, reason: 'SPAN_TOO_LONG' };
};
}
/** 単発で使う場合のショートハンド */
export function verifyQuoteExistence(
rawBody: string,
quote: string,
options?: VerifyOptions,
): QuoteVerificationResult {
return createQuoteVerifier(rawBody, options)(quote);
}
// ---------- パイプライン用フィルター ----------
export interface EvidenceItem {
sourceUrl: string;
summary: string;
quote: string;
}
export interface DiscardedEvidence {
item: EvidenceItem;
reason: RejectReason;
}
/**
* 同一ページ(rawBody)から抽出された根拠を検証し、合格/破棄に振り分ける。
* 破棄側も返すのは、破棄率をメトリクスとして監視するため。
*/
export function filterVerifiedEvidence(
rawBody: string,
items: EvidenceItem[],
options?: VerifyOptions,
): { verified: EvidenceItem[]; discarded: DiscardedEvidence[] } {
const verify = createQuoteVerifier(rawBody, options);
const verified: EvidenceItem[] = [];
const discarded: DiscardedEvidence[] = [];
for (const item of items) {
const result = verify(item.quote);
if (result.isValid) verified.push(item);
else discarded.push({ item, reason: result.reason });
}
return { verified, discarded };
}
実装のポイント
判別可能なユニオン型
isValid で分岐すれば、true 側では matchedSnippet が、false 側では reason が型レベルで保証されます。if (r.isValid) r.matchedSnippet と書いても undefined チェックが不要です。
実体参照は1パスでデコードする
.replace(/&/g, '&').replace(/</g, '<') のように連鎖させると、&lt; が < まで二重にデコードされます。本文に「< という文字列そのもの」が書かれた技術記事などで誤判定の原因になるため、正規表現1本でまとめて処理しています。
長音「ー」はダッシュとして扱わない
ダッシュ類の統一に ー(U+30FC)を含めると、「プラットフォーム」が「プラットフォ-ム」になります。本文と引用の両方に適用されるので照合自体は通りますが、意図しない同一視の原因になるため除外しています。
波ダッシュは NFKC では揃わない
~(U+FF5E 全角チルダ)は NFKC で ~ になりますが、〜(U+301C 波ダッシュ)は変換されません。Windows 由来の文書とそれ以外で混在しやすい文字なので、明示的に統一しています。
日本語に挟まった空白・改行を除去する
HTML からテキストを抽出すると、<br> やソースの折り返しで文の途中に改行が入ることがあります。単純に「空白を1つに圧縮」するだけでは、本文が 手数料は 従来の、引用が 手数料は従来の となって正しい引用が落ちます。そこで、日本語文字に隣接する空白だけを除去しています(英単語間の空白は保持します)。
大文字小文字は区別しない
最後の toLowerCase() で、PayNext と paynext を同一視しています。英語の略語(US と us)のように意味が変わる場合もありますが、引用の照合では「LLM が大文字小文字だけを変えた」ケースのほうが多いため吸収側に倒しています。厳密にしたい場合は外してください。
文字数はコードポイント単位で数える
str.length は UTF-16 のコード単位数なので、絵文字やサロゲートペアを含むと実際の文字数とずれます。[...str].length で数えています。
本文の前処理は1回だけ
同じページから10件の根拠を抽出した場合、引用ごとに本文を正規化し直すのは無駄です。createQuoteVerifier は本文の正規化と窓の生成を最初に1回だけ行い、照合関数を返します。
パイプラインへの組み込み:落ちたら即破棄
照合関数を、LLM パイプラインの途中に決定論的なフィルターとして置きます。
const { verified, discarded } = filterVerifiedEvidence(rawBody, extractedItems);
for (const { item, reason } of discarded) {
logger.warn('evidence_discarded', { reason, url: item.sourceUrl, quote: item.quote });
}
// 後段(要約・検証プロンプト)には verified だけを渡す
await synthesize(verified);
filterVerifiedEvidence が破棄側も返すのは、破棄率を監視するためです。モデルやプロンプトを変更した直後に NOT_FOUND の割合が跳ね上がったら、それは捏造が増えたシグナルです。照合は防御であると同時に、LLM の挙動の回帰を検知するセンサーにもなります。
なぜ LLM に「修正リトライ」をさせないのか
「照合に落ちたら『引用が本文にありません。抜き出し直してください』と再試行させればいいのでは?」と考えるかもしれません。本番運用では、リトライせず即時破棄を推奨します。
-
リトライしても直らないケースが多い
経験上、照合に落ちる引用の多くは、そもそも本文に該当する記述が無い(モデルの推論が飛躍している)ケースです。再試行しても、別の言い回しで同じ捏造を繰り返しがちで、レイテンシと API コストだけが増えます。 -
数件落としても全体の品質は保てる
調査パイプラインでは、通常は複数ドメイン・複数ページから多数の根拠を集めます。照合に落ちた1〜2件を機械的に切り捨てても、残りの検証済み根拠で十分に正確なサマリを組み立てられます。 -
「誤って捨てる」より「誤って通す」ほうが高くつく
正しい引用を1件取りこぼしても、出力の網羅性が少し下がるだけです。一方、捏造を1件通すと、それが「根拠付きの事実」として最終出力に混入します。非対称なコストに合わせて、判定は厳しい側に倒します。
このパターンが保証しないこと
照合で確かめられるのは**「引用が本文に実在するか」だけ**です。次の2つは保証しません。
-
要約が引用に支えられているか
引用は本物でも、summary側で引用に書かれていないことを主張している(=要約のハルシネーション)ケースは素通りします。要約と引用の整合性は、別の層(含意判定モデルや、LLM による「この引用からこの要約が言えるか」の判定など)で扱う必要があります。照合は、その判定の前提となる引用を本物に限定するための土台です。 -
引用が根拠として意味を持つか
本文に実在する一般的な言い回しは通ります。たとえば「そう考えられています」(10文字)は、本文にあれば合格します。minLengthだけでは防ぎきれないので、プロンプト側で「固有名詞・数値・日付のいずれかを含む一節を引用すること」と指定しておくと効果的です。
つまりこの照合は、ハルシネーション対策の「全部」ではなく、捏造された引用が後段に流れ込む経路を決定論的に塞ぐ1層です。
API の引用機能を使えば不要では?
主要な LLM API には、渡したドキュメント内の引用箇所を返す機能も登場しています。使える場面では有力な選択肢ですが、それでも自前の照合層を持つ価値はあります。
- モデル・プロバイダに依存しない:モデルを変更しても、検証ロジックは変わりません。
- 自分で取得した任意の本文に使える:スクレイピングや前処理を経たテキストなど、パイプラインの途中で生まれた本文にも適用できます。
- 独立した検証層になる:生成した側とは別のコードで確かめるので、「生成側が正しいと言っている」ことに依存しません。
テスト(Vitest)
改変・接合・捏造を確実に落とし、表記ゆれは確実に通すことを確認するテストです。Jest でも import 行を変えるだけで動きます。
import { describe, it, expect } from 'vitest';
import { verifyQuoteExistence, filterVerifiedEvidence } from './verifyQuote';
// HTML から抽出した本文を想定(インデント・文中改行・実体参照・全角記号が混在)
const mockBody = `
当社は2026年4月1日をもって、新決済プラットフォーム「PayNext」の提供を開始いたします。
本サービスにより、加盟店の決済手数料は従来の3.2%から
一律1.5%へと引き下げられます。
なお、既存の加盟店様におかれましては、特別な移行手続きは一切不要です。
受付時間は平日9時〜17時です。詳細は "FAQ" ページをご覧ください。
`;
describe('verifyQuoteExistence', () => {
describe('合格すべきケース', () => {
it('完全一致の引用は合格し、原文のスニペットを返す', () => {
const r = verifyQuoteExistence(mockBody, '新決済プラットフォーム「PayNext」の提供を開始いたします。');
expect(r.isValid).toBe(true);
// 正規化後の文字列ではなく原文(大文字・長音「ー」も保持)が返る
if (r.isValid) expect(r.matchedSnippet).toContain('プラットフォーム「PayNext」');
});
it('本文側の「文中改行」は無視して照合できる', () => {
const r = verifyQuoteExistence(mockBody, '加盟店の決済手数料は従来の3.2%から一律1.5%へと引き下げられます。');
expect(r.isValid).toBe(true);
});
it('隣り合う2文を跨ぐ引用は合格する', () => {
const r = verifyQuoteExistence(mockBody, '一律1.5%へと引き下げられます。なお、既存の加盟店様におかれましては');
expect(r.isValid).toBe(true);
});
it('全角/半角・波ダッシュ・引用符・実体参照のゆれを吸収する', () => {
const r = verifyQuoteExistence(mockBody, '受付時間は平日9時~17時です。詳細は“FAQ”ページをご覧ください');
expect(r.isValid).toBe(true);
});
});
describe('破棄すべきケース', () => {
it('数値を改変した引用は NOT_FOUND', () => {
const r = verifyQuoteExistence(mockBody, '加盟店の決済手数料は従来の3.2%から一律無料へと引き下げられます。');
expect(r).toEqual({ isValid: false, reason: 'NOT_FOUND' });
});
it('語尾を断定調に改変した引用は NOT_FOUND', () => {
const r = verifyQuoteExistence(mockBody, '既存の加盟店様は特別な移行手続きが一切不要である。');
expect(r).toEqual({ isValid: false, reason: 'NOT_FOUND' });
});
it('離れた2文を勝手に接合した引用は NOT_FOUND', () => {
const r = verifyQuoteExistence(mockBody, '「PayNext」の提供を開始いたします。なお、既存の加盟店様におかれましては');
expect(r).toEqual({ isValid: false, reason: 'NOT_FOUND' });
});
it('連続していても許容文数を超えて跨ぐ引用は SPAN_TOO_LONG', () => {
const body = '一つ目の文です。二つ目の文です。三つ目の文です。四つ目の文です。';
expect(verifyQuoteExistence(body, body)).toEqual({ isValid: false, reason: 'SPAN_TOO_LONG' });
expect(verifyQuoteExistence(body, body, { maxSentences: 4 }).isValid).toBe(true);
});
it('短すぎる引用は TOO_SHORT、空白のみは EMPTY', () => {
expect(verifyQuoteExistence(mockBody, '当社は')).toEqual({ isValid: false, reason: 'TOO_SHORT' });
expect(verifyQuoteExistence(mockBody, ' \n ')).toEqual({ isValid: false, reason: 'EMPTY' });
});
});
describe('空白の扱い', () => {
it('日本語に隣接する空白だけを除去し、英単語間の空白は保持する', () => {
const body = 'The fee will be reduced to 1.5% for all merchants.';
expect(verifyQuoteExistence(body, 'fee will be reduced to 1.5%').isValid).toBe(true);
expect(verifyQuoteExistence(body, 'feewill be reduced to 1.5%')).toEqual({ isValid: false, reason: 'NOT_FOUND' });
expect(verifyQuoteExistence('PayNextの提供を開始します。', 'PayNext の提供を開始します').isValid).toBe(true);
});
});
});
describe('filterVerifiedEvidence', () => {
it('合格と破棄を振り分け、破棄理由を返す', () => {
const base = { sourceUrl: 'https://example.com/news', summary: '' };
const { verified, discarded } = filterVerifiedEvidence(mockBody, [
{ ...base, quote: '特別な移行手続きは一切不要です。' },
{ ...base, quote: '手数料は来年さらに0.5%まで下がる予定です。' },
]);
expect(verified).toHaveLength(1);
expect(discarded.map((d) => d.reason)).toEqual(['NOT_FOUND']);
});
});
✓ verifyQuote.test.ts (11 tests)
Tests 11 passed (11)
導入時の調整ポイント
| パラメータ | 既定値 | 調整の目安 |
|---|---|---|
minLength |
8 |
短い引用は本文のどこかに偶然含まれやすく、根拠として意味を持ちません。英語中心なら 20 前後に上げてください。 |
maxSentences |
3 |
プロンプトで「1〜3文で引用せよ」と指示している数と揃えます。引用の前後が文の途中で切れると窓が1つ多く必要になるので、余裕を持たせたい場合は 4 に。 |
文分割と窓の扱いについては、次の3点に注意してください。
-
英語の略語が多い文書:
Dr.U.S.Dept.などのピリオドで文が細かく分割されるため、1文の正しい引用がSPAN_TOO_LONGになることがあります。英語中心ならmaxSentencesを大きめにするか、欧文側の境界に「略語+ピリオド」を除外する否定後読みを足してください(なおIntl.Segmenterの文分割でも、この例は略語の位置で分割されました)。// 「Dr.」「U.S.」「J.」(イニシャル)などの直後では文を区切らない const SENTENCE_BOUNDARY = /(?<!\b(?:Dr|Mr|Ms|Dept|U\.S|[A-Z])\.)(?<=[.!?])\s+|(?<=[。!?])/g; -
句点のない箇条書き:全体が1つの「文」として扱われるため、局所性判定が効きません(存在判定は正常に機能します)。箇条書きが多いソースでは、文分割に改行も加える選択肢があります。ただし和文では文中改行と区別できなくなるので、取得元の性質に合わせて判断してください。
-
巨大な本文:窓をすべて事前生成するため、メモリは本文の約
maxSentences倍を使い、引用1件ごとに全窓を走査します。Webページ程度なら問題になりませんが、MB級の文書を扱う場合は、窓を事前生成せず、存在判定を通った引用についてだけ窓を逐次生成し、ヒットした時点で打ち切る形に変えるとメモリを抑えられます。
また、本文の取得元に合わせて正規化を拡張するのも有効です(例:PDF 由来ならハイフネーションの除去、和文の鉤括弧 「」 と "" の統一など)。その際も**「意味が変わらない差だけを吸収する」**という原則は崩さないようにしてください。
まとめ
- LLM の「引用」はテキスト抽出ではなく、確率的な作文である。
- プロンプトの制約(「一言一句変えるな」)は防壁にならない。
- 正規化で吸収するのは表記ゆれだけ。内容の改変は落とす。
- 照合は「存在判定(本文全体)」と「局所性判定(スライディングウィンドウ)」の2段で行う。
- 照合が保証するのは「引用の実在」まで。要約との整合は別の層で扱う。
- 落ちた根拠はリトライせず即時破棄し、破棄率を監視する。
意味の解釈や要約は AI に任せ、事実の確認と合否の決定はコードが握る。
この境界線を引くことが、本番で信頼できる LLM アプリケーションの土台になります。