請求書を読ませるパイプラインを動かしていて、一番ヒヤリとした瞬間を先に書きます。バリデーションは通っていました。Zod のスキーマも通り、モデルが返した confidence は 0.95 でした。それでも total の桁が一つずれていたのです。クラッシュも例外も何も出ません。集計の段になって、ある取引先の月次合計だけが妙に小さいことに気づいて、ようやく遡って見つけました。
構造化抽出の本当の敵は、エラーで止まる失敗ではありません。止まる失敗はログに残り、リトライで拾えます。怖いのは、形式的にはすべて正しく、値だけが静かに間違っている抽出です。ここでは Claude API でドキュメント抽出を本番運用したときに効いた、「沈黙する誤り」を捕まえるための設計を、動くコードとともに共有します。
なぜ「スキーマ検証」では足りないのか
スキーマ検証が保証してくれるのは「形」だけです。total が数値であること、issueDate が文字列であることは確認できますが、その数値や文字列がドキュメントの内容と一致しているか は一切見ていません。
抽出が静かに間違う典型は、おおむね次の3つに収れんします。
ひとつめは桁とカンマの読み違いです。1,250,000 を 1250.00 と読む、¥ と小数点の混在で桁が動く、といったものです。ふたつめはフィールドの取り違えで、issueDate(発行日)の欄に dueDate(支払期限)を入れてしまう、subtotal と total を逆に拾うようなケースです。みっつめは「もっともらしい補完」で、ドキュメントに書かれていない税率を一般的な値で埋めてしまう類です。
どれも confidence は高く出ます。モデルが自己申告する信頼度は「自分の読み取りに対する自信」であって、「正解との一致」ではないからです。ここを混同すると、一番信頼度が高いフィールドが一番危ない、という事故が起きます。
不変条件 — ドキュメント自身に検算させる
外部の正解データがなくても、ドキュメントの中には検算に使える関係が必ず潜んでいます。請求書なら明細の合計が小計に一致し、小計と税の和が総額に一致します。日付なら発行日は支払期限以前です。これらを**不変条件(invariant)**として明文化し、抽出直後に機械的に検算します。
Zod の superRefine を使うと、型検証と不変条件の検証を一枚のスキーマにまとめられます。
// src/schema/invoice.ts
import { z } from "zod" ;
const Money = z. number (). finite (). nonnegative ();
const LineItem = z. object ({
description: z. string (). min ( 1 ),
quantity: z. number (). positive (). optional (),
unitPrice: Money. optional (),
amount: Money,
});
// 許容誤差: 端数処理のずれを 1 通貨単位まで許す
const EPS = 1 ;
export const InvoiceSchema = z
. object ({
invoiceNumber: z. string (). optional (),
issueDate: z. string (). optional (), // ISO 8601 を期待
dueDate: z. string (). optional (),
currency: z. string (). default ( "JPY" ),
lineItems: z. array (LineItem). min ( 1 ),
subtotal: Money. optional (),
tax: Money. optional (),
total: Money,
})
. superRefine (( inv , ctx ) => {
// 不変条件1: 明細の合計 ≒ 小計
if (inv.subtotal !== undefined ) {
const sum = inv.lineItems. reduce (( s , li ) => s + li.amount, 0 );
if (Math. abs (sum - inv.subtotal) > EPS ) {
ctx. addIssue ({
code: z.ZodIssueCode.custom,
path: [ "subtotal" ],
message: `lineItems合計(${ sum })と小計(${ inv . subtotal })が不一致` ,
});
}
}
// 不変条件2: 小計 + 税 ≒ 総額
if (inv.subtotal !== undefined && inv.tax !== undefined ) {
const expected = inv.subtotal + inv.tax;
if (Math. abs (expected - inv.total) > EPS ) {
ctx. addIssue ({
code: z.ZodIssueCode.custom,
path: [ "total" ],
message: `小計+税(${ expected })と総額(${ inv . total })が不一致` ,
});
}
}
// 不変条件3: 発行日 <= 支払期限
if (inv.issueDate && inv.dueDate) {
if ( new Date (inv.issueDate) > new Date (inv.dueDate)) {
ctx. addIssue ({
code: z.ZodIssueCode.custom,
path: [ "dueDate" ],
message: "発行日が支払期限より後になっている" ,
});
}
}
// 不変条件4: 各明細の quantity × unitPrice ≒ amount
inv.lineItems. forEach (( li , i ) => {
if (li.quantity !== undefined && li.unitPrice !== undefined ) {
const expected = li.quantity * li.unitPrice;
if (Math. abs (expected - li.amount) > EPS ) {
ctx. addIssue ({
code: z.ZodIssueCode.custom,
path: [ "lineItems" , i, "amount" ],
message: `数量×単価(${ expected })と金額(${ li . amount })が不一致` ,
});
}
}
});
});
export type Invoice = z . infer < typeof InvoiceSchema>;
ポイントは、不変条件違反を例外で握り潰さずに、どのフィールドが (path) どう矛盾したか を構造化して残すことです。path を後段のルーティングや再抽出にそのまま使えるようにしておきます。safeParse で受け、error.issues を持ち回します。
// src/validate.ts
import { InvoiceSchema, type Invoice } from "./schema/invoice" ;
export type FieldFault = { path : string ; message : string };
export function validateInvoice ( raw : unknown ) : {
ok : boolean ;
data ?: Invoice ;
faults : FieldFault [];
} {
const result = InvoiceSchema. safeParse (raw);
if (result.success) return { ok: true , data: result.data, faults: [] };
const faults : FieldFault [] = result.error.issues. map (( iss ) => ({
path: iss.path. join ( "." ),
message: iss.message,
}));
return { ok: false , faults };
}
ここまでで、桁の読み違いやフィールドの取り違えの多くは「形式は正しいのに検算が合わない」として表面化します。total が一桁ずれていれば、小計+税との突き合わせでほぼ確実に落ちます。
モデルの信頼度は、不変条件違反率で校正する
抽出時にモデルへ confidence を返させること自体は有用です。ただしその数値をそのまま信用してはいけません 。校正(calibration)の手順を一度だけ通します。
手元のサンプル(正解付き、数十件で十分です)に対して、モデルの自己申告 confidence をビン(たとえば 0.9〜1.0、0.8〜0.9…)に分け、各ビンで実際にどれだけ不変条件違反や正解不一致が出るかを数えます。私の手元では、自己申告 0.9 以上のビンでも数%は不変条件に落ちました。つまり「0.9 なら安全」ではなく、「0.9 でも 1 ドキュメントあたり数フィールドは検算で守る必要がある」という読み替えになります。
// src/calibration.ts
type Sample = { reported : number ; failed : boolean }; // failed = 不変条件違反 or 不一致
export function calibrate ( samples : Sample [], bins = 5 ) {
const table = Array. from ({ length: bins }, ( _ , b ) => ({
range: [b / bins, (b + 1 ) / bins] as [ number , number ],
n: 0 ,
failed: 0 ,
}));
for ( const s of samples) {
const idx = Math. min (bins - 1 , Math. floor (s.reported * bins));
table[idx].n ++ ;
if (s.failed) table[idx].failed ++ ;
}
// 実測の誤り率を「補正済み信頼度」として返す
return table. map (( t ) => ({
reportedRange: t.range,
empiricalErrorRate: t.n ? t.failed / t.n : null ,
}));
}
この補正済みの誤り率を閾値判定に使います。モデルの生の 0.95 ではなく、「そのビンの実測誤り率」で人手レビューへ送るかどうかを決めます。校正は四半期に一度、あるいはモデルを差し替えたときに引き直せば十分です。モデル更新でこの曲線は普通に変わるので、claude-opus-4-8 へ上げたときも一度引き直しました。
二段抽出 — 落ちたフィールドだけ上位モデルで取り直す
全ドキュメントを最初から上位モデルで抽出するのはコストが見合いません。実用的だったのは、安いモデルで全体を抽出し、不変条件に落ちたフィールドだけを上位モデルで取り直す 二段構成です。
一段目は claude-haiku-4-5 か claude-sonnet-4-6。二段目は、faults に挙がったフィールドだけを名指しで claude-opus-4-8 に再抽出させます。ドキュメント全体ではなく、矛盾したフィールドに焦点を当てたプロンプトにすることで、トークンも判断のぶれも抑えられます。
// src/extract.ts
import Anthropic from "@anthropic-ai/sdk" ;
import { validateInvoice, type FieldFault } from "./validate" ;
const client = new Anthropic ();
// 一段目: 全体抽出(安いモデル)
async function extractAll ( docText : string ) : Promise < unknown > {
const res = await client.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 2048 ,
system:
"請求書から指定スキーマで抽出し、JSONのみ返す。書かれていない値はnull。推測で埋めない。" ,
messages: [{ role: "user" , content: docText }],
});
return JSON . parse ( textOf (res));
}
// 二段目: 落ちたフィールドだけ名指しで取り直す(上位モデル)
async function reextractFields (
docText : string ,
faults : FieldFault []
) : Promise < Record < string , unknown >> {
const fields = [ ...new Set (faults. map (( f ) => f.path. split ( "." )[ 0 ]))];
const res = await client.messages. create ({
model: "claude-opus-4-8" ,
max_tokens: 1024 ,
system:
"次のフィールドだけを請求書から厳密に読み直す。各値の根拠となる原文の断片もquoteとして返す。書かれていなければnull。" ,
messages: [
{
role: "user" ,
content:
`対象フィールド: ${ fields . join ( ", " ) } \n ` +
`検算で矛盾した箇所: \n ` +
faults. map (( f ) => `- ${ f . path }: ${ f . message }` ). join ( " \n " ) +
` \n\n --- 原文 --- \n ${ docText }` ,
},
],
});
return JSON . parse ( textOf (res)) as Record < string , unknown >;
}
export async function extractInvoice ( docText : string ) {
const first = await extractAll (docText);
let v = validateInvoice (first);
if (v.ok) return { data: v.data, passes: 1 , faults: [] };
// 落ちたフィールドだけ上位モデルで補修してマージ
const patch = await reextractFields (docText, v.faults);
const merged = { ... (first as object ), ... patch };
v = validateInvoice (merged);
return {
data: v.ok ? v.data : undefined ,
passes: 2 ,
faults: v.faults, // 二段目でも残れば人手へ
};
}
function textOf ( res : Anthropic . Message ) : string {
const block = res.content[ 0 ];
const t = block.type === "text" ? block.text : "" ;
const m = t. match ( /```json \s * ( [\s\S] *? ) \s * ```/ ) || t. match ( / \{ [\s\S] * \} / );
return m ? (m[ 1 ] ?? m[ 0 ]) : t;
}
二段目で原文の quote(根拠となる断片)を返させているのが効きます。値だけでなく出どころを持たせると、人手レビューに回ったときの確認が一気に速くなりますし、quote がドキュメントに実在しない場合は補完の疑いとして弾けます。
人手レビューへ送る基準
二段目を経ても faults が残るドキュメント、そして金額が一定額を超えるドキュメントは、機械の自信に関わらず人手に送ります。基準は欲張らず、運用しながら数字で詰めます。私の手元では、(1) 二段抽出後に不変条件が残る、(2) 補正済み誤り率が閾値を超えるビンに入る、(3) total が社内基準額を超える、のいずれかでレビュー行きにしています。
レビューに送ること自体は失敗ではありません。間違いを自動で気づける状態にしておくこと が目的で、全件を自動で通すことではないからです。実際、レビュー率が数%に収まる限り、運用は十分に回ります。
計測 — 追うべきは field 単位の誤り率
ここが一番の方針転換でした。最初は「ドキュメント単位の成功率」を見ていましたが、これは粗すぎます。10 フィールド中 1 つだけ間違っているドキュメントも「失敗」に丸められ、どのフィールドが弱いのかが見えません。
切り替えたのはフィールド単位の誤り率 です。total、issueDate、各 lineItem.amount… とフィールドごとに、不変条件違反率と(サンプルがある範囲での)正解不一致率を出します。すると「dueDate の取り違えが突出して多い」「スキャン PDF だけ lineItems の取りこぼしが増える」といった、手の打ちどころが見える粒度になります。
// src/metrics.ts
type Run = { faults : { path : string }[]; fields : string [] };
export function fieldErrorRates ( runs : Run []) {
const total : Record < string , number > = {};
const bad : Record < string , number > = {};
for ( const r of runs) {
for ( const f of r.fields) total[f] = (total[f] ?? 0 ) + 1 ;
for ( const fault of r.faults) {
const head = fault.path. split ( "." )[ 0 ];
bad[head] = (bad[head] ?? 0 ) + 1 ;
}
}
return Object. keys (total)
. map (( f ) => ({ field: f, rate: (bad[f] ?? 0 ) / total[f] }))
. sort (( a , b ) => b.rate - a.rate);
}
この表を週次で眺めると、改善の打ち手がプロンプト調整なのか、不変条件の追加なのか、入力正規化(スキャン PDF の画像化品質)なのかが切り分けられます。document 単位の成功率では、この切り分けは永遠にできません。
私自身、個人開発でアプリ事業と複数の技術ブログの自動投稿を半ば放置気味に並行で回している立場で、似た教訓を何度も踏みました。たとえば AdMob の収益レポートと請求側の数字を月末に突き合わせるときも、最初は「全体の合計さえ合っていればよし」で済ませていたのですが、ある月だけ特定の通貨の行が静かにずれていて、合計の粒度では最後まで気づけませんでした。全体が「だいたい動いている」ように見えるときほど、粗い成功率だけを見ていると、一点が静かに壊れ続けていることに気づけません。抽出パイプラインでも事情は同じで、フィールド単位まで割って初めて、どこを直せば一番効くのかが手元の数字で語れるようになりました。個人開発で何本も並行運用していると、この「割って見る」習慣が結局いちばんの時間の節約になります。
まとめ
次の一歩として、いま動かしているパイプラインに不変条件をひとつ足してみてください。請求書なら「小計+税=総額」の検算ひとつでも、桁の読み違いの大半は表面化します。そこから、落ちたフィールドだけの再抽出、フィールド単位の計測、と広げていけば、「自信満々に間違える」抽出を、静かに通さない運用へ寄せていけます。