正規表現 vs LLM 構造化テキスト解析 は何をしますか?
構造化テキスト(クイズ、フォーム、請求書、文書)を解析するための実践的な判断フレームワーク。主要な洞察:正規表現は95〜98%のケースを安価かつ決定論的に処理します。残りのエッジケースにのみLLMの高価な呼び出しを予約します。
アクティベートするタイミング
- 繰り返しパターンを持つ構造化テキスト(質問、フォーム、テーブル)の解析
- テキスト抽出における正規表現とLLMの選択
- 両方のアプローチを組み合わせたハイブリッドパイプラインの構築
- テキスト処理におけるコスト/精度のトレードオフの最適化
判断フレームワーク
テキスト形式は一貫性があり繰り返しパターンを持つか?
├── はい(90%以上がパターンに従う)→ 正規表現から開始
│ ├── 正規表現で95%以上処理可能 → 完了、LLM不要
│ └── 正規表現で95%未満 → エッジケースのみLLMを追加
└── いいえ(自由形式でばらつきが大きい)→ LLMを直接使用
アーキテクチャパターン
ソーステキスト
│
▼
[正規表現パーサー] ─── 構造を抽出(95〜98%の精度)
│
▼
[テキストクリーナー] ─── ノイズを除去(マーカー、ページ番号、アーティファクト)
│
▼
[信頼度スコアラー] ─── 低信頼度の抽出をフラグ付け
│
├── 高信頼度(≥0.95)→ 直接出力
│
└── 低信頼度(<0.95)→ [LLMバリデーター] → 出力
実装
1. 正規表現パーサー(大部分を処理)
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class ParsedItem:
id: str
text: str
choices: tuple[str, ...]
answer: str
confidence: float = 1.0
def parse_structured_text(content: str) -> list[ParsedItem]:
"""正規表現パターンを使って構造化テキストを解析する。"""
pattern = re.compile(
r"(?P<id>\d+)\.\s*(?P<text>.+?)\n"
r"(?P<choices>(?:[A-D]\..+?\n)+)"
r"Answer:\s*(?P<answer>[A-D])",
re.MULTILINE | re.DOTALL,
)
items = []
for match in pattern.finditer(content):
choices = tuple(
c.strip() for c in re.findall(r"[A-D]\.\s*(.+)", match.group("choices"))
)
items.append(ParsedItem(
id=match.group("id"),
text=match.group("text").strip(),
choices=choices,
answer=match.group("answer"),
))
return items
2. 信頼度スコアリング
LLMによるレビューが必要な可能性があるアイテムをフラグ付けします。
@dataclass(frozen=True)
class ConfidenceFlag:
item_id: str
score: float
reasons: tuple[str, ...]
def score_confidence(item: ParsedItem) -> ConfidenceFlag:
"""抽出の信頼度をスコアリングし、問題をフラグ付けする。"""
reasons = []
score = 1.0
if len(item.choices) < 3:
reasons.append("few_choices")
score -= 0.3
if not item.answer:
reasons.append("missing_answer")
score -= 0.5
if len(item.text) < 10:
reasons.append("short_text")
score -= 0.2
return ConfidenceFlag(
item_id=item.id,
score=max(0.0, score),
reasons=tuple(reasons),
)
def identify_low_confidence(
items: list[ParsedItem],
threshold: float = 0.95,
) -> list[ConfidenceFlag]:
"""信頼度しきい値を下回るアイテムを返す。"""
flags = [score_confidence(item) for item in items]
return [f for f in flags if f.score < threshold]
3. LLMバリデーター(エッジケースのみ)
def validate_with_llm(
item: ParsedItem,
original_text: str,
client,
) -> ParsedItem:
"""LLMを使用して低信頼度の抽出を修正する。"""
response = client.messages.create(
model="claude-haiku-4-5-20251001", # バリデーションには最も安価なモデル
max_tokens=500,
messages=[{
"role": "user",
"content": (
f"このテキストから質問、選択肢、回答を抽出してください。\n\n"
f"テキスト: {original_text}\n\n"
f"現在の抽出結果: {item}\n\n"
f"必要に応じて修正したJSONを返すか、正確であれば'CORRECT'を返してください。"
),
}],
)
# LLMのレスポンスを解析し、修正されたアイテムを返す...
return corrected_item
4. ハイブリッドパイプライン
def process_document(
content: str,
*,
llm_client=None,
confidence_threshold: float = 0.95,
) -> list[ParsedItem]:
"""完全なパイプライン: 正規表現 → 信頼度チェック → エッジケースにLLM。"""
# ステップ1: 正規表現による抽出(95〜98%を処理)
items = parse_structured_text(content)
# ステップ2: 信頼度スコアリング
low_confidence = identify_low_confidence(items, confidence_threshold)
if not low_confidence or llm_client is None:
return items
# ステップ3: LLMによるバリデーション(フラグが立ったアイテムのみ)
low_conf_ids = {f.item_id for f in low_confidence}
result = []
for item in items:
if item.id in low_conf_ids:
result.append(validate_with_llm(item, content, llm_client))
else:
result.append(item)
return result
実績メトリクス
プロダクションのクイズ解析パイプライン(410アイテム)より。
| メトリクス | 値 |
|---|---|
| 正規表現成功率 | 98.0% |
| 低信頼度アイテム | 8(2.0%) |
| 必要なLLM呼び出し | 約5回 |
| 全LLM方式と比較したコスト削減 | 約95% |
| テストカバレッジ | 93% |
ベストプラクティス
- 正規表現から開始する — 不完全な正規表現でも改善のベースラインとなる
- 信頼度スコアリングを使用する — LLMの支援が必要な箇所をプログラム的に特定する
- 最も安価なLLMを使用する — バリデーションにはHaikuクラスのモデルで十分
- 解析済みアイテムをミューテーションしない — クリーニング/バリデーションの各ステップでは新しいインスタンスを返す
- パーサーにはTDDが有効 — まず既知のパターンのテストを書き、その後エッジケースのテストを追加する
- メトリクスをログに記録する — 正規表現成功率、LLM呼び出し回数などでパイプラインの健全性を追跡する
避けるべきアンチパターン
- 正規表現で95%以上処理できる場合にすべてのテキストをLLMに送る(高コストで低速)
- 自由形式でばらつきの大きいテキストに正規表現を使用する(ここではLLMの方が適切)
- 信頼度スコアリングをスキップし、正規表現が「うまくいく」と期待する
- クリーニング/バリデーションのステップで解析済みオブジェクトをミューテーションする
- エッジケース(不正な入力、フィールド欠落、エンコーディング問題)をテストしない
使用するタイミング
- クイズ/試験問題の解析
- フォームデータの抽出
- 請求書/領収書の処理
- 文書構造の解析(ヘッダー、セクション、テーブル)
- コストが重要な、繰り返しパターンを持つ構造化テキスト全般