請求書の自動取り込みを動かし始めて数週間、例外ログはきれいなのに経理の数字が合わない、という相談を受けたことがあります。原因はすぐに分かりました。スキャンの粗い一枚で、Claude が合計金額の桁を一つ読み違え、しかもそれを confidence: 0.96 と申告して、何の例外も投げずに通り抜けていたのです。
個人開発で複数のサービスを並行して回している私自身、当初はこの種の誤りを例外ハンドリングだけで防げると思い込んでいました。構造化抽出でいちばん怖いのは、APIが落ちることでも JSON が壊れることでもありません。例外を一切出さずに、もっともらしく間違った値が下流へ流れていくことです。JSON.parse は成功し、Zod も通り、ログは緑のまま、台帳の数字だけが静かにずれていく。この記事は、その「静かな誤り」を本番に届く前に捕まえるための検証層をどう組むか、という実装メモです。抽出パイプラインの組み立て方そのものではなく、抽出した後の「信用していいかどうか」の判定に焦点を当てます。
confidence を信号として扱わない
最初に手放すべき思い込みは、モデルが返す confidence を品質の指標として使えるという期待です。LLM の自己申告する確信度は、出力の正しさとはほとんど相関しません。読み違えた請求書ほど、迷いなく高い数字を返してくることすらあります。確信度はあくまで「モデルが自分の出力をどう自己評価したか」であって、外部の真実と照合した結果ではないからです。
ですから私は、confidence をゲートには使わず、優先度付けにだけ使う方針にしています。低い値が出たら人手レビューの列の前の方に並べる、それくらいの扱いです。合否そのものは、モデルの外側にある検証で決めます。検証の材料は三つあります。スキーマ(型として正しいか)、算術整合(数字同士のつじつまが合うか)、二重抽出の一致(別経路で抽出した結果と揃うか)。順に見ていきます。
第一段:スキーマは「形」しか守らない
Zod による検証は最初の砦ですが、守れる範囲を正しく見積もっておく必要があります。スキーマが弾けるのは「型が違う」「必須項目が欠けている」「列挙にない値が来た」といった構造の異常だけです。total が数値であることは保証できても、その数値が正しいかは何も言えません。
それでも、抽出固有の制約をスキーマに織り込むと、初段でかなりの誤りが落ちます。日付は ISO 形式に強制する、金額は非負にする、通貨は ISO 4217 の列挙に限る、といった具合です。「ありえない形」をここで全部落としておくと、後段の算術検証が本質的な誤りだけに集中できます。
// src/schema.ts
import { z } from "zod";
const isoDate = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "YYYY-MM-DD 形式で抽出してください")
.refine((s) => !Number.isNaN(Date.parse(s)), "実在する日付であること");
const money = z.number().finite().nonnegative();
export const InvoiceSchema = z.object({
invoiceNumber: z.string().min(1).optional(),
issueDate: isoDate.optional(),
dueDate: isoDate.optional(),
vendor: z.object({ name: z.string().min(1), taxId: z.string().optional() }),
lineItems: z
.array(
z.object({
description: z.string().min(1),
quantity: z.number().positive().optional(),
unitPrice: money.optional(),
amount: money,
})
)
.min(1, "明細が0件の請求書は抽出失敗とみなす"),
subtotal: money.optional(),
tax: money.optional(),
total: money,
currency: z.string().length(3), // ISO 4217
});
export type Invoice = z.infer<typeof InvoiceSchema>;
lineItems に .min(1) を付けているのが地味に効きます。明細が空の請求書はまず存在しないので、0件で返ってきたら抽出が失敗していると断じてよい。スキーマを「データの正しさ」ではなく「業務上ありえない形の検出器」として設計すると、初段の網が引き締まります。
第二段:数値ドキュメントは算術で殴る
請求書・領収書・見積書のように数字が支配的なドキュメントには、ドキュメント自身の中に冗長性があります。明細の金額を足せば小計になり、小計に税を足せば合計になる。この内部の算術関係は、外部の正解データを持っていなくても誤りを検出できる、極めて強力な検証手段です。
// src/arithmetic.ts
import type { Invoice } from "./schema";
export type Discrepancy = { field: string; expected: number; got: number; diff: number };
// 通貨ごとの許容誤差(最小単位の丸め用)。JPY は端数なし、USD は 1 セント。
const TOLERANCE: Record<string, number> = { JPY: 0, USD: 0.01, EUR: 0.01 };
export function auditInvoiceMath(inv: Invoice): Discrepancy[] {
const tol = TOLERANCE[inv.currency] ?? 0.01;
const issues: Discrepancy[] = [];
const lineSum = inv.lineItems.reduce((s, li) => s + li.amount, 0);
// 明細合計と小計の整合
if (inv.subtotal !== undefined && Math.abs(lineSum - inv.subtotal) > tol) {
issues.push({ field: "subtotal", expected: lineSum, got: inv.subtotal, diff: inv.subtotal - lineSum });
}
// 小計+税=合計の整合(小計が無ければ明細合計で代用)
const base = inv.subtotal ?? lineSum;
const expectedTotal = base + (inv.tax ?? 0);
if (Math.abs(expectedTotal - inv.total) > tol) {
issues.push({ field: "total", expected: expectedTotal, got: inv.total, diff: inv.total - expectedTotal });
}
// 各明細の 数量×単価=金額(両方そろっている行のみ)
for (const [i, li] of inv.lineItems.entries()) {
if (li.quantity !== undefined && li.unitPrice !== undefined) {
const expected = li.quantity * li.unitPrice;
if (Math.abs(expected - li.amount) > tol) {
issues.push({ field: `lineItems[${i}].amount`, expected, got: li.amount, diff: li.amount - expected });
}
}
}
return issues;
}
冒頭の桁違いの事故は、まさにこの検証で止まります。合計だけが他とつじつまの合わない値になっていれば、diff が許容誤差を超えて検出される。重要なのは、正解を一切持っていなくても、ドキュメントの内部矛盾だけで誤りを名指しできる点です。実運用では、ここで検出した diff の符号と桁を見ると、桁の読み違いなのか、税率の取り違えなのか、明細の取りこぼしなのかまで当たりが付きます。
通貨ごとの許容誤差を入れているのは、丸め由来の微小なずれで人手レビューを溢れさせないためです。JPY は端数が出ないので誤差ゼロ、小数を持つ通貨だけ最小単位を許す。ここをゼロ固定にすると、正しい請求書まで延々と差し戻されます。
第三段:二重抽出の一致率でゲートする
算術で守れるのは数字の整合だけです。取引先名・契約期間・住所のような自由記述のフィールドには、内部の冗長性がありません。ここで効くのが、同じドキュメントを別の条件でもう一度抽出し、結果が揃うかを見るという冗長化です。アンサンブルの考え方を抽出に持ち込むものだと考えてください。
二回の呼び出しは、温度や視点を変えると独立性が上がります。私は一次抽出を claude-sonnet-4-6、検証用の二次抽出を視点を変えたプロンプトで同じく Sonnet、それでも食い違うフィールドだけを claude-opus-4-8 で裁定させる三段構えにしています。全件 Opus で二度引くのは贅沢すぎますし、Sonnet 同士の不一致は「そもそも読みにくい箇所」を正確に教えてくれるので、コストを集中投下する場所の地図になります。
// src/agreement.ts
import Anthropic from "@anthropic-ai/sdk";
import { InvoiceSchema, type Invoice } from "./schema";
const client = new Anthropic();
// 正規化してから比較する(全角空白・前後空白・大文字小文字を吸収)
const norm = (v: unknown) =>
typeof v === "string" ? v.normalize("NFKC").trim().toLowerCase() : v;
export function fieldAgreement(a: Invoice, b: Invoice): string[] {
const mismatches: string[] = [];
const keys: (keyof Invoice)[] = ["invoiceNumber", "issueDate", "dueDate", "total", "currency"];
for (const k of keys) {
if (norm(a[k]) !== norm(b[k])) mismatches.push(String(k));
}
if (norm(a.vendor.name) !== norm(b.vendor.name)) mismatches.push("vendor.name");
return mismatches;
}
// 視点を変えた二次抽出。一次と独立性を持たせるため指示の枠組みを変える
export async function secondPass(content: string): Promise<Invoice> {
const res = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 2048,
system:
"あなたは監査担当です。請求書を一字一句確認し、記載されている値だけを JSON で写し取ってください。" +
"推測・補完・四捨五入は禁止。読み取れない項目は null。有効な JSON のみを返すこと。",
messages: [{ role: "user", content: `次の請求書を転記してください:\n\n${content}` }],
});
const text = res.content[0].type === "text" ? res.content[0].text : "";
const json = text.match(/\{[\s\S]*\}/)?.[0] ?? text;
return InvoiceSchema.parse(JSON.parse(json));
}
文字列比較の前に NFKC 正規化を挟んでいるのは現場で必須でした。全角・半角の違いや前後の空白だけで「不一致」と判定すると、実質同じ値で延々と再抽出が走ります。この無駄な往復は前処理ひとつで回避できます。正規化なしの一致率は、本当の誤りではなく表記ゆれを測ってしまうのです。一致率を運用指標として見るなら、比較の前処理こそ丁寧に設計する価値があります。
検証層をひとつのゲートにまとめる
三段の検証を、合否と「なぜ落ちたか」を返す単一の判定にまとめます。下流が扱うのは accept / review / reject の三値だけにして、判断の理由は必ず添える設計にします。
// src/verify.ts
import { InvoiceSchema, type Invoice } from "./schema";
import { auditInvoiceMath } from "./arithmetic";
import { fieldAgreement, secondPass } from "./agreement";
export type Verdict =
| { status: "accept"; data: Invoice }
| { status: "review"; data: Invoice; reasons: string[] }
| { status: "reject"; reasons: string[] };
export async function verifyInvoice(raw: unknown, sourceText: string): Promise<Verdict> {
// 1. スキーマ:形が壊れていれば即 reject
const parsed = InvoiceSchema.safeParse(raw);
if (!parsed.success) {
return { status: "reject", reasons: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) };
}
const inv = parsed.data;
const reasons: string[] = [];
// 2. 算術:内部矛盾は review(自動 accept させない)
const mathIssues = auditInvoiceMath(inv);
for (const d of mathIssues) {
reasons.push(`算術不整合 ${d.field}: 期待 ${d.expected} / 抽出 ${d.got}(差 ${d.diff})`);
}
// 3. 二重抽出:重要フィールドの不一致は review
try {
const second = await secondPass(sourceText);
const diff = fieldAgreement(inv, second);
if (diff.length > 0) reasons.push(`二次抽出と不一致: ${diff.join(", ")}`);
} catch {
reasons.push("二次抽出に失敗(独立検証なしのため要確認)");
}
if (reasons.length === 0) return { status: "accept", data: inv };
return { status: "review", data: inv, reasons };
}
ここで意識しているのは、自動的に通すか / 人に渡すか / 捨てるかの三分割を曖昧にしないことです。算術や二重抽出で引っかかったものを「だいたい合ってるから accept」に倒すと、検証層を作った意味がなくなります。疑わしいものは必ず review に落とす。捨てるのはスキーマすら通らない構造破綻だけにとどめます。
人手レビューに回す境界をどう引くか
検証層を入れると、必ず一定割合が review に積まれます。ここを全部人手で見ていたら自動化の意味が薄れるので、レビュー量そのものを設計対象として扱います。私が目安にしている考え方は次の三つです。
第一に、金額の大きい伝票ほど厳しくします。同じ diff でも、数千円の領収書と数百万円の請求書ではレビューに回す価値が違います。閾値を金額でスケールさせ、小口は多少のずれを飲み、高額は些細な不一致でも人を呼ぶ。第二に、取引先ごとに学習させます。あるベンダーのレイアウトで二重抽出の不一致が続くなら、そのテンプレートに固有の読みにくさがあるということなので、レビュー必須に固定してしまう方が安全です。第三に、レビュー結果を抽出プロンプトに還元します。人が直した箇所はそのまま few-shot の材料になり、同型の誤りを次から減らせます。
検証層は「人手を消す装置」ではなく「人手を価値の高いところへ集中させる装置」だと考えると、設計がぶれません。全件を信用するのでも全件を疑うのでもなく、疑うべき場所を機械が名指しして、人はそこだけを見る。この分業が回り始めると、処理量を増やしても破綻しなくなります。
コストと精度のつり合い
検証層は API 呼び出しを増やすので、コスト設計と一体で考える必要があります。素朴に全件を二度・三度引くと費用が膨らむので、段階的にコストを投下することを推奨します。一次抽出と二次抽出は Sonnet で揃え、不一致フィールドの裁定だけ Opus を呼ぶ。長く変わらないシステムプロンプトにはプロンプトキャッシュを効かせ(定型部分で入力コストを最大 90% 程度削減できます)、即時性が不要なバッチはまとめて Message Batches に流す(バッチ経路はおおむね 50% 安くなります)。
| 工程 | モデル / 手段 | 狙い |
| 一次抽出 | claude-sonnet-4-6 | 大半のドキュメントはこれで十分。速度とコストの主力 |
| 二次抽出(独立検証) | claude-sonnet-4-6(視点変更) | 独立性を上げるのは温度・指示の枠組みであって、モデルの格上げではない |
| 不一致フィールドの裁定 | claude-opus-4-8 | 食い違った箇所だけに高い推論を集中。全件 Opus を避ける |
| 定型システムプロンプト | プロンプトキャッシュ | 抽出指示・スキーマ説明は変わらないのでキャッシュ対象に |
| 即時性不要の大量処理 | Message Batches | 夜間の一括取り込みなど、レイテンシを許せる経路に寄せる |
費用の主因は「全件を最上位モデルで複数回引くこと」です。検証を入れたいがコストが怖い、という場合に効くのは、検証の有無を切り替えることではなく、検証を安いモデルで広く、高いモデルで狭く配分し直すことです。一致した大多数は安価に通し、食い違ったごく一部にだけ高い推論を投じる。この勾配を付けられるかどうかで、検証層が現実的な運用に乗るかが決まります。
運用で見ているシグナル
最後に、入れて終わりにせず継続して観察している指標を挙げておきます。日次で accept / review / reject の比率、算術不整合の検出件数、二重抽出の不一致率、API エラーによる二次抽出の失敗率、そして人手レビューで実際に修正が入った割合です。とくに**「review に積んだのに人が直さなかった割合」**は重要で、これが高いなら閾値が厳しすぎてレビュー担当の時間を浪費していますし、逆に accept の中から事後的に誤りが見つかるなら検証が緩い。この二つの誤りのバランスを、扱う伝票の金額感に合わせて少しずつ寄せていきます。
抽出器そのものの精度を追いかけるより、「間違っているのに通ってしまう確率」を運用可能な水準に保つ仕組みを作る方が、本番では効きます。モデルは確信を持って間違えることがある、という前提を最初から設計に織り込んでおく。それが、自動取り込みを安心して回し続けるための、いちばん地味で確実な土台になります。
同じように静かな誤りと向き合っている方の参考になれば幸いです。