請求書、契約書、問い合わせのメール。非構造なテキストから項目を取り出す作業は、これまで人がやるか、正規表現で頑張るしかありませんでした。
はこれが得意です。ただし毎回同じ結果になるとは限りません。この回は、非決定的な処理を前処理の工程に組み込むための作法を扱います。
1. 何が変わるか
従来の抽出手法と比べると、得意なことと苦手なことが逆転しています。
| 対象 | 正規表現など | LLM |
|---|---|---|
| 書式が完全に固定 | 確実 | 使う必要がない |
| 書式に揺れがある | 規則が爆発する | 得意 |
| 言い回しが多様 | 対応困難 | 得意 |
| 同じ結果の再現 | 保証される | 保証されない |
| 処理速度 | 速い | 遅い |
| 費用 | ほぼゼロ | 件数に比例 |
4行目が、工程に組み込むときの本質的な違いです。前処理は「同じ入力から同じ結果」を前提に組まれていることが多く、そこが崩れます。
書式が固定されている部分は従来の手法、揺れる部分だけ LLM。全部を LLM に任せる必要はありません。1行目のように、確実な方法があるならそちらを使うほうが速く安く確実です。
2. 形式を必ず検証する
最初にやるべきは、出力の形式が正しいかの検証です。LLM時代のテスト戦略 EP.07 で扱ったとおり、ここは決定的に判定できます。
import jsonimport refrom dataclasses import dataclass
@dataclassclass Extracted: invoice_no: str issued_on: str # YYYY-MM-DD total: int tax_rate: float
class ExtractionError(Exception): pass
def parse_and_validate(raw: str) -> Extracted: """モデルの出力を検証する。おかしければ例外を投げる。""" try: data = json.loads(raw) except json.JSONDecodeError as e: raise ExtractionError(f"JSON として読めない: {e}") from e
required = {"invoice_no", "issued_on", "total", "tax_rate"} missing = required - set(data) if missing: raise ExtractionError(f"項目が足りない: {sorted(missing)}")
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(data["issued_on"])): raise ExtractionError(f"日付の形式が違う: {data['issued_on']!r}")
if not isinstance(data["total"], int) or data["total"] < 0: raise ExtractionError(f"金額が不正: {data['total']!r}")
if not 0 <= float(data["tax_rate"]) <= 1: raise ExtractionError(f"税率が範囲外: {data['tax_rate']!r}")
return Extracted( invoice_no=str(data["invoice_no"]), issued_on=str(data["issued_on"]), total=int(data["total"]), tax_rate=float(data["tax_rate"]), )この検証は完全に決定的です。非決定的なのは抽出そのものだけで、その結果が使える形かどうかは確実に判定できます。ここを通らないものは、再試行するか人に回します。
範囲の検査も入れてください。税率が 3.0(300%)になっているといった誤りは、形式としては正しいので JSON の検証だけでは通ってしまいます。
3. 一致率を測る
形式が正しくても、中身が合っているとは限りません。実際のデータで一致率を測る必要があります。
- 1正解を人が作った検証データを用意する — 数十件でよい
- 2項目ごとに一致率を出す — 全体ではなく項目別に
- 3外れ方を分類する — どういう文書で外れるか
- 4閾値を決める — この項目はこの精度が必要、という基準
from collections import defaultdict
def field_accuracy(cases): """項目ごとの一致率と、外れた例を出す。
cases: [{"expected": {...}, "actual": {...}, "id": "..."}, ...] """ hit = defaultdict(int) total = defaultdict(int) misses = defaultdict(list)
for c in cases: for field, expected in c["expected"].items(): total[field] += 1 actual = c["actual"].get(field) if actual == expected: hit[field] += 1 else: misses[field].append((c["id"], expected, actual))
print(f"{'項目':16s}{'一致率':>8}{'件数':>7}") for field in sorted(total, key=lambda f: hit[f] / total[f]): rate = hit[field] / total[field] print(f"{field:16s}{rate:8.1%}{total[field]:7d}")
print("\n外れた例(各項目3件まで)") for field, ms in misses.items(): for case_id, exp, act in ms[:3]: print(f" {field:14s} {case_id}: 期待 {exp!r} / 実際 {act!r}")
return {f: hit[f] / total[f] for f in total}項目ごとに出すのが要点です。全体の一致率が90%でも、特定の項目だけ50%ということがあります。EP.19 で扱った「平均が少数派を隠す」のと同じ構造です。この連載で繰り返し出てくる形なので、集計する前に、分けて見る軸がないかを問う習慣をつけてください。
そして外れた例を実際に見てください。数字だけでは、どういう文書で外れるかが分かりません。見ると、「手書きの部分がある」「表が複雑」といった共通点が見つかることがあります。
4. 項目ごとに要求水準を変える
すべての項目に同じ精度を求める必要はありません。誤ったときの影響で分けます。
| 項目 | 要求水準 | 外れたときの影響 |
|---|---|---|
| 金額 | 極めて高い | 計算が狂う。人の確認が要る |
| 日付 | 高い | 期限の判断に影響 |
| 識別番号 | 高い | 突き合わせができなくなる |
| 取引先名 | 中 | 表記揺れは後段で吸収できる |
| 備考・摘要 | 低い | 参考情報 |
金額と識別番号は、人の確認を前提にするのが現実的です。逆に備考のような項目は、多少外れても実害が小さい。一律に「精度が足りないから使えない」と判断しないでください。全項目を人が確認するなら自動化の意味が薄れますが、重要な項目だけに絞れば、確認の量は大幅に減ります。
形式としては正しい数字なので、検証を通過して下流へ流れます。そして集計してから「合計が合わない」と気づく。レガシー再生の現場 EP.06 で扱った突き合わせと同じで、元と照合する手順が必要です。
5. 工程への組み込み方
検証と一致率の測定ができたら、工程に組み込みます。要点は、非決定的な部分を隔離することです。
| 段階 | 決定的か | 扱い |
|---|---|---|
| 文書の取り込み・整形 | 決定的 | 通常のテスト |
| 抽出(LLM) | 非決定的 | この一段だけ |
| 形式・範囲の検証 | 決定的 | 通常のテスト |
| 元との突き合わせ | 決定的 | 重要項目のみ |
| 保存・後処理 | 決定的 | 通常のテスト |
非決定的なのは中央の一段だけです。この形にしておけば、大半の工程は通常のテストで守れます。全体が非決定的な塊になっていると、検証のしようがありません。
def extract_with_fallback(client, text, max_retries=2): """抽出して検証。通らなければ再試行し、それでも駄目なら人へ。""" last_error = None
for attempt in range(max_retries + 1): raw = client.extract(text) try: return {"status": "ok", "data": parse_and_validate(raw), "attempts": attempt + 1} except ExtractionError as e: last_error = e
# 検証を通らなかったものは、捨てずに人の確認へ回す return { "status": "needs_review", "reason": str(last_error), "raw_text": text, "attempts": max_retries + 1, }通らなかったものを捨てないのが要点です。EP.17 でも書いたとおり、黙って落とすと件数が合わなくなり、後から気づけません。人の確認に回す枠を作っておきます。 の観点でも、処理できなかった件数は必ず記録に残すべき対象です(EP.22)。
6. 運用してからの確認
組み込んだ後も、継続して確認が要ります。モデルが更新されると、挙動が変わることがあります。
- 検証を通らなかった割合 — 上がっていれば何かが変わった
- 人の確認に回った件数 — 増えていれば調査
- 再試行の回数 — 増えていれば不安定になっている
- 定期的な一致率の測定 — 同じ検証データで測り直す
- モデル更新の記録 — 変化があったとき、原因の候補になる
4番目が重要です。同じ検証データを取っておいて、定期的に測り直す。数字が下がっていれば、モデルか入力の傾向が変わっています。これは LLM時代のテスト戦略 EP.08 で扱った評価セットと同じ役割です。
書式が固定なら従来の手法、揺れる部分だけ LLM。出力の形式と範囲の検証は決定的にできるので必ず入れる。一致率は項目ごとに測り、外れた例を実際に見る。項目ごとに要求水準を変える(金額は人の確認前提)。工程では非決定的な一段だけを隔離し、検証を通らなかったものは捨てずに人へ回す。そして同じ検証データで定期的に測り直してください。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。