環境情報
- Python 3.11.8
- FastAPI 0.111.0
- PostgreSQL 16 + pgvector 0.7.0
- LLM: 構造化出力(JSON Schema)に対応したチャットAPI
- 判定対象: 社内規程・利用規約 約1,200条 × 申請データ 300件
- 評価指標: Accuracy / Precision@1 / Precision@3
何が起きるか
規程や利用規約の適合判定を自動化すると、まず全体のAccuracyが目に入ります。ところが実運用で痛いのは平均ではなく上位の誤りです。人間は一覧の上から見るので、1位が間違っていれば、その後の正解率がどれだけ高くてもシステムは信用されません。
そして上位に誤りが固まりやすい典型パターンが、条件の例外節です。
第12条 以下の物品の持ち込みを禁止する。(1) 発火性物品 (2) 刃物類。ただし、業務上必要な工具であって、所属長の承認を得たものについては、この限りでない。
「禁止」という主文だけを拾えばNG、「ただし」以降だけを拾えばOK。判定がどちらに倒れるかは、モデルの気分やプロンプトの書き方で簡単に反転します。平均精度が高く見えても、こうした条文に当たった瞬間に上位が壊れます。
平均精度が嘘をつく
筆者の検証では、全体Accuracy 0.96に対してPrecision@1は0.71でした。つまり最初の1件は3割外れている。この差は指標の作り方の問題ではなく、見ている場所の違いです。
| 指標 | 値 | 現場での意味 |
|---|---|---|
| Accuracy(全体) | 0.96 | ほぼ正しい、ように見える |
| Precision@1 | 0.71 | 最初に見る1件が3割外れ |
| Precision@3 | 0.83 | 上位3件で1件弱が怪しい |
| Recall(例外節のみ) | 0.62 | ただし書きを4割取りこぼす |
例外節だけを抽出してRecallを測ると、全体Accuracyの陰で0.62まで落ちていました。平均は、少数の厄介な条文を埋めてしまうのです。
実装1: 判定根拠を必ず出力に残す
対策の第一歩は、判定結果に根拠を残すことです。後から人が照合できない出力は、検証不能なブラックボックスになります。
from typing import Literal
from pydantic import BaseModel, Field
class Judgement(BaseModel):
decision: Literal["allow", "deny", "needs_review"]
rationale: str = Field(description="判定理由を1〜2文で")
cited_clause_ids: list[str] = Field(description="根拠にした条文ID")
cited_quotes: list[str] = Field(description="原文からの抜粋。改変・要約は禁止")
exception_checked: bool = Field(description="ただし書きを確認したか")
confidence: float = Field(ge=0.0, le=1.0)
cited_quotes に原文抜粋を改変せず出させるのがポイントです。要約を許すと、存在しない条文をそれらしく書けてしまいます。抜粋なら原文と機械的に突合できます。
実装2: 上位N件+境界帯を抽出する
全件を人が見るのは現実的ではありません。見るべきは「上位」と「境目」です。
def sample_for_review(rows, top_k: int = 3, band: float = 0.05):
"""rows: [{"id":..., "score":..., "decision":...}, ...]"""
ordered = sorted(rows, key=lambda r: (-r["score"], r["id"]))
picked = {r["id"] for r in ordered[:top_k]}
# スコアが閾値近傍にある「境目」も拾う
for r in ordered:
if abs(r["score"] - 0.5) <= band:
picked.add(r["id"])
return [r for r in ordered if r["id"] in picked]
ソートキーに id を足しているのは、同点で順位が入れ替わると検証結果が再現しないためです。地味ですが、回帰テストを回すなら必須です。
実装3: 引用の突合を自動化する
人が照合する前に、明らかな不一致は機械で落とします。
import re
import unicodedata
def normalize(s: str) -> str:
s = unicodedata.normalize("NFKC", s)
s = re.sub(r"[\s\u3000]", "", s)
s = s.replace("・", "").replace(",", "").replace(".", "")
return s
def is_verbatim(quote: str, clause_text: str, min_len: int = 15) -> bool:
q = normalize(quote)
if len(q) < min_len:
return False
return q in normalize(clause_text)
これで「引用が原文に存在しない」ケースを弾けます。残ったものだけを人が読みます。
照合の手順
- 判定を全件実行し、スコア付きで保存する
- 上位3件+閾値±0.05の境界帯を抽出する
- 人が元の条文と1件ずつ突き合わせる(1件あたり2〜3分)
- 不一致を「例外の見落とし」「引用のずれ」「同点による順位入れ替え」に分類する
- 分類に応じてプロンプト・ルールを修正し、回帰テストに追加する
この流れなら、300件のうち照合対象は30〜50件程度に収まります。
ハマりポイント
- 表記ゆれ: 全角/半角、中黒、括弧の種類で突合が落ちる。NFKC正規化を先に挟む
- 条文番号の自動採番: 改行やルビの扱いでIDがずれ、引用が別条文に当たる
- 「ただし」の検出漏れ: キーワード検索だけでなく、条文を木構造で持ち、ただし書きを子ノードとして管理する
-
同点問題:
sortedのキーを固定しないと、同じ入力でも上位Kが変わる - 入れ子の例外: 「ただし書きのただし書き」は2段階判定に分けた方が安定する
FAQ
Q. 全件、人が見ればいいのでは?
A. 1,200条×300件を毎回照合するのは回りません。上位と境界に絞るだけで、体感としてリスクの大半を拾えます。
Q. temperatureは0にすべき?
A. 0を推奨します。ただし同点問題は残るので、順位の再現性はソートキーで担保してください。
Q. 例外判定だけ別モデルに分ける?
A. 有効です。主文判定と例外判定を分けると、Recallの計測もやりやすくなります。
まとめ
- 例外節は判定を反転させる。平均精度では見えない
- 判定には根拠条文の原文抜粋を必ず残す
- 照合対象は「上位K件+境界帯」に絞る
- 引用の突合は機械で前処理し、人は判断に集中する
- 一覧の上位は最初に見られる場所。平均より、上位の正しさを先に確認する
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中