정규표현식 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("선택지_부족")
score -= 0.3
if not item.answer:
reasons.append("정답_누락")
score -= 0.5
if len(item.text) < 10:
reasons.append("짧은_텍스트")
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"올바르면 'CORRECT'를, 아니라면 수정된 JSON을 반환하세요."
),
}],
)
# 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이 더 적합)
- 신뢰도 점수를 건너뛰고 정규표현식이 "그냥 잘 작동할 것"이라고 기대하는 경우
- 정리/검증 단계에서 파싱된 객체를 변경하는 경우
- 예외 사례(잘못된 입력, 누락된 필드, 인코딩 문제)를 테스트하지 않는 경우
사용 시기
- 퀴즈/시험 문제 파싱
- 양식 데이터 추출
- 송장/영수증 처리
- 문서 구조 파싱 (헤더, 섹션, 테이블)
- 비용이 중요한 반복 패턴이 있는 구조화된 텍스트