(3/3) 검토 로그를 되먹여 양식별 파서 붙이기 — 양식 클러스터링·앵커 추출·회귀 테스트
- PDF 에서 데이터를 뽑아 DB 에 넣기 — 구조체 파싱 vs OCR, 뭘 언제 쓰나
- 검증 레이어를 적재 파이프라인에 꿰기 — 배치 인입·멱등 적재·사람 확인 큐
- 검토 로그를 되먹여 양식별 파서 붙이기 — 양식 클러스터링·앵커 추출·회귀 테스트 ← 지금 글
Summary
1편에서 PDF 에서 값을 뽑는 두 갈래(구조체 파싱·OCR)를 정확도로 갈라 썼고, 2편에서 그 값을 검증 게이트·멱등 적재·사람 확인 큐 로 이어지는 파이프라인에 꿰었어요. 2편 끝에서 이런 얘기를 남겼죠 — 검토 큐에 쌓인 교정 로그를 되먹여서, 양식별 파서를 붙이는 얘기를 다음에 하겠다고.
이게 왜 중요하냐면, 2편의 파이프라인은 틀린 값을 막긴 하지만 정확도를 스스로 올리진 않아요. 거절된 문서는 계속 사람 확인 큐로 가고, 사람은 매번 같은 거래처의 같은 필드를 고쳐요. 그 교정 로그를 그냥 흘려보내면, 검토 부담은 영원히 안 줄어요. 그런데 이 로그를 잘 보면 어느 양식이 우리를 괴롭히는지 가 다 적혀 있어요. 거기에 전용 파서를 하나 붙이면, 그 양식은 다시는 큐로 안 와요.
이 글에서는 그 피드백 루프를 짭니다. 검토 로그로 어디에 노력을 쏟을지 를 정하고, 앵커(라벨) 기반 전용 파서 로 그 양식만 정확히 읽고, 사람이 고친 값을 골든셋 회귀 테스트 로 굳히고, 양식이 바뀌면 드리프트로 잡아내는 — 여기까지가 3부작의 마지막 조각이에요.
💡 이 글에서 다루는 것
- 검토 로그가 사실은 공짜 라벨 데이터 인 이유
- 문서를 양식(template)으로 묶기 — 무엇을 키로 삼나
- 거절이 몰린 양식 을 찾아 파서 붙일 곳 정하기 (파레토)
- 앵커 추출 — 라벨을 찾아 그 옆의 값을 읽는 전용 파서
- 양식별 파서 라우터 — 등록된 양식은 전용, 나머지는 1편의 범용으로
- 교정 로그를 골든셋 회귀 테스트 로, 그리고 양식 드리프트 감지
2편의 사람 확인 큐(enqueue_review 로 쌓은 로그)를 이어받아요. 안 보셨어도 따라올 수 있게 썼지만, 큐에 뭐가 쌓이는지는 2편에 있으니 같이 보시길 추천드려요.
1. 검토 로그는 공짜 라벨 데이터예요
2편에서 enqueue_review 로 큐에 넣을 때, 값만 넣지 않고 출처(어떤 양식·어떤 필드·원본 문자열·페이지·좌표) 를 같이 실었어요. 그리고 사람이 고치면 apply_correction 으로 확정값이 붙었죠. 그러니까 검토가 한 건 끝날 때마다, 이런 레코드가 하나씩 쌓여요.
log = {
"template": "acme_tax_invoice", # 어떤 양식에서
"field": "공급가액", # 어떤 필드가
"raw_text": "8O,OOO", # 이렇게 잘못 읽혔고
"corrected": "80000", # 사람이 이렇게 고쳤다
"page": 1,
"bbox": [412, 380, 468, 398], # 그 페이지의 이 위치
"reasons": ["낮은 confidence 0.61"],
}
이게 값비싼 데이터예요. “이 양식의 이 위치에 있는 값의 정답은 이거다” 라는 라벨을, 사람이 검토하면서 공짜로 달아준 거거든요. 머신러닝 하려고 일부러 라벨링 외주를 주는 그 데이터를, 우리는 운영하면서 저절로 모으고 있는 셈이에요.
핵심은 이걸 버리지 않는 것. 고치고 적재하고 끝내면 로그가 사라지지만, 쌓아두면 두 가지를 할 수 있어요 — 어디에 파서를 붙일지 정하고(3장), 그 파서가 맞는지 검증하는 정답지로 쓰고(6장).
✅ 2편에서 “검토 큐는 부담이 아니라 개선 신호의 출처” 라고 했는데, 이 장이 그 말의 실체예요. 큐가 곧 라벨셋입니다.
2. 문서를 양식으로 묶기
전용 파서는 양식(template) 단위 로 붙여요. 그러려면 문서가 어느 양식인지 알아내는 키가 필요해요. 두 가지 방법이 있어요.
- 문서에 박힌 안정적 식별자 — 발행처 사업자번호 + 문서종류 같은. 구조체 파싱으로 확실히 읽히는 네이티브 PDF 라면 이게 제일 튼튼해요.
- 레이아웃 지문 — 페이지 상단의 앵커 단어들과 그 위치로 지문을 만드는 방법. 스캔본이라 식별자를 못 믿을 때 써요.
레이아웃 지문은 상단 라벨 단어 몇 개를 좌표 순으로 이어 붙여서 만들어요. PyMuPDF 의 단어 단위 추출(get_text("words"))을 쓰면 각 단어의 좌표가 같이 나와요.
import fitz
def template_signature(pdf_path: str) -> str:
"""페이지 상단 앵커 단어들을 좌표 순으로 이어 양식 지문을 만든다."""
doc = fitz.open(pdf_path)
words = doc[0].get_text("words") # (x0, y0, x1, y1, 단어, ...)
doc.close()
top = sorted(words, key=lambda w: (round(w[1] / 20), w[0]))[:5]
return "|".join(w[4] for w in top)
print(template_signature("acme_invoice_1.pdf"))
print(template_signature("acme_invoice_2.pdf")) # 같은 거래처 다른 문서
세금계산서|공급자|등록번호|상호|성명
세금계산서|공급자|등록번호|상호|성명
같은 거래처의 같은 양식이면 지문이 같게 나와요. 이 지문(또는 사업자번호 키)을 문서마다 붙여두면, 검토 로그를 양식별로 모을 수 있어요.
💡 지문을 상단 라벨로만 만드는 건, 라벨은 양식마다 고정이고 값(금액·이름)은 문서마다 달라서 지문에 섞이면 안 되기 때문이에요. 그래서 상단 몇 줄의 “제목·항목명” 만 쓰는 거예요.
3. 거절이 몰린 양식부터 — 파레토
양식이 수십 개면 전부에 전용 파서를 붙일 순 없어요. 그럴 필요도 없고요. 검토 로그를 양식별로 세어 보면, 보통 소수의 양식이 거절의 대부분 을 차지해요. 거기부터 붙이는 게 가장 남는 장사예요.
from collections import Counter
def review_hotspots(logs: list[dict], top_n: int = 3) -> list[tuple[str, int]]:
"""검토 로그를 양식별로 세어, 거절이 많은 순으로."""
counts = Counter(log["template"] for log in logs)
return counts.most_common(top_n)
logs = ([{"template": "acme_tax_invoice"}] * 120
+ [{"template": "beta_receipt"}] * 18
+ [{"template": "gamma_statement"}] * 9
+ [{"template": "delta_form"}] * 3)
for template, n in review_hotspots(logs):
print(f"{n:>4}건 {template}")
120건 acme_tax_invoice
18건 beta_receipt
9건 gamma_statement
acme_tax_invoice 하나가 검토의 대부분을 먹고 있어요. 여기에 전용 파서 하나만 붙여도 검토 큐가 확 줄어요. 나머지 잔챙이 양식은 1편의 범용 경로로 그냥 두고, 큐에서 사람이 처리하게 둬도 돼요. 파서를 붙이는 건 비용 이니까, 로그가 “여기가 아프다” 고 가리키는 곳에만 붙이는 거예요.
4. 앵커 추출 — 라벨을 찾아 그 옆의 값을 읽기
전용 파서가 왜 정확하냐면, 그 양식에서 값이 어디 있는지 우리가 알기 때문 이에요. 공급가액 이라는 라벨은 늘 있고, 그 값은 라벨 오른쪽 같은 줄에 있죠. 범용 표 추출이 셀 경계로 헤매는 것과 달리, 라벨을 앵커 삼아 그 옆을 콕 집어 읽어요.
def extract_by_anchor(page, label: str, dx: float = 140, dy: float = 6) -> str | None:
"""label 단어를 찾아, 그 오른쪽 같은 줄에 있는 값을 읽는다."""
words = page.get_text("words")
anchor = next((w for w in words if w[4] == label), None)
if anchor is None:
return None
_, ay0, ax1, _, *_ = anchor
right = [w for w in words
if abs(w[1] - ay0) < dy and w[0] > ax1 and w[0] - ax1 < dx]
right.sort(key=lambda w: w[0])
return "".join(w[4] for w in right) or None
이걸 양식 전용 파서로 감싸요. 그 양식에 있는 라벨 이름만 알면 돼요.
import fitz
def parse_acme_tax_invoice(pdf_path: str) -> dict:
"""acme 세금계산서 전용 파서 — 라벨 앵커로 값을 집는다."""
doc = fitz.open(pdf_path)
page = doc[0]
rec = {
"supply_amount": extract_by_anchor(page, "공급가액"),
"total": extract_by_anchor(page, "합계금액"),
}
doc.close()
return rec
print(parse_acme_tax_invoice("acme_invoice_1.pdf"))
{'supply_amount': '80,000', 'total': '125,000'}
1편에서 범용 표 추출이 셀을 밀리게 읽을 수 있다고 했던 그 문제가, 여기선 원리적으로 안 생겨요. 값의 위치를 라벨로 고정했으니까요. 물론 이 파서는 acme 양식에서만 맞아요. 그래서 라우터가 필요합니다.
5. 라우터 — 등록된 양식은 전용, 나머지는 범용으로
양식별 파서를 레지스트리에 등록해두고, 문서가 들어오면 그 양식의 전용 파서가 있으면 쓰고, 없으면 1편의 범용 경로로 떨어뜨려요. 이게 “양식별 파서 붙이기” 의 실체예요 — 범용을 버리는 게 아니라, 아픈 양식에만 전용을 얹는 거죠.
PARSERS = {
"acme_tax_invoice": parse_acme_tax_invoice,
# 검토 로그가 가리키는 양식부터 하나씩 등록해 나감
}
def parse(pdf_path: str) -> dict:
tkey = resolve_template(pdf_path) # 2장의 지문/식별자
parser = PARSERS.get(tkey)
if parser is not None:
return parser(pdf_path) # 양식 전용 — 정확
return parse_generic(pdf_path) # 1편의 범용 경로 (fallback)
print(resolve_template("acme_invoice_1.pdf"), "→", "전용" if resolve_template("acme_invoice_1.pdf") in PARSERS else "범용")
print(resolve_template("unknown_9.pdf"), "→", "전용" if resolve_template("unknown_9.pdf") in PARSERS else "범용")
acme_tax_invoice → 전용
zeta_unknown → 범용
이 구조의 좋은 점은 점진적 이라는 거예요. 처음엔 전부 범용으로 돌리다가, 검토 로그가 아픈 양식을 가리키면 그 양식 파서를 하나 더 등록해요. 라우터는 그대로 두고 PARSERS 에 한 줄만 늘리면 되죠. 그리고 전용 파서로 넘어간 양식은 검토 큐에서 빠져나가요.
6. 교정 로그를 골든셋 회귀 테스트로
전용 파서를 붙였으면, 정말 맞는지 를 계속 확인해야 해요. 여기서 검토 로그가 두 번째 역할을 해요 — 사람이 확정한 교정값이 그대로 정답지(골든셋) 가 되거든요.
def build_golden(logs: list[dict]) -> list[dict]:
"""사람이 확정한 교정을 (양식·필드·정답·문서) 골든셋으로."""
return [{"template": log["template"], "field": log["field"],
"expected": log["corrected"], "pdf": log["pdf_path"]}
for log in logs if log["corrected"] is not None]
이 골든셋에 파서를 돌려서, 사람이 매긴 정답과 일치하는지 세요.
def check_against_golden(golden: list[dict]) -> dict:
passed = sum(parse(c["pdf"]).get(c["field"]) == c["expected"] for c in golden)
return {"cases": len(golden), "passed": passed,
"rate": round(passed / len(golden), 3)}
golden = build_golden(review_logs)
print(check_against_golden(golden))
{'cases': 84, 'passed': 82, 'rate': 0.976}
이걸 배포 파이프라인에 회귀 테스트로 걸어두면, 파서를 고치다가 예전에 맞던 문서를 망가뜨리는 사고를 막아요. 통과율이 떨어지면 최근 바꾼 파서가 뭔가 깼다는 신호예요. 검토로 모은 실제 문서로 검증하니까, 합성 예제보다 훨씬 믿음직해요.
💡 통과 못 한 케이스(위 예에선 2건)는 다시 검토 큐로 돌려서 원인을 봐요. 파서 버그면 고치고, 진짜 애매한 문서면 골든셋에서 빼거나 예외로 표시하고요.
7. 양식 드리프트 감지 — 거래처가 양식을 바꾸면
전용 파서는 그 양식이 안 바뀐다는 가정 위에 서 있어요. 그런데 거래처가 어느 날 계산서 양식을 바꾸면, 라벨 위치가 틀어져서 앵커 추출이 헛다리를 짚어요. 무서운 건, 파서가 조용히 틀린 값을 뱉을 수 있다는 거예요.
이걸 잡는 방법은 2편의 관측을 양식별로 쪼개는 거예요. 등록된 양식의 거절률이 갑자기 튀면, 그 양식이 바뀐 거예요.
def drift_alert(template: str, base_rate: float, today_rate: float,
jump: float = 0.15) -> str | None:
"""등록된 양식의 거절률이 평소보다 크게 튀면 경보."""
if today_rate - base_rate >= jump:
return f"⚠️ {template}: 거절률 {base_rate:.0%} → {today_rate:.0%} — 양식 변경 의심"
return None
print(drift_alert("acme_tax_invoice", base_rate=0.03, today_rate=0.04))
print(drift_alert("acme_tax_invoice", base_rate=0.03, today_rate=0.42))
None
⚠️ acme_tax_invoice: 거절률 3% → 42% — 양식 변경 의심
전용 파서를 붙인 양식은 거절률이 3% 근처로 낮게 유지되는 게 정상이에요. 그게 갑자기 42% 로 튀면, 파서가 못 읽기 시작한 거고 — 즉 양식이 바뀐 거예요. 이때 그 양식을 잠깐 범용 경로로 되돌리거나 검토 큐로 몰아두고, 바뀐 라벨 위치에 맞춰 앵커를 손보면 돼요. 드리프트를 사람이 큐에 파묻히기 전에 숫자로 먼저 아는 것 — 이게 관측을 양식별로 쪼갠 이유예요.
8. 정리 — 세 편을 한 바퀴로
3부작을 한 그림으로 이으면 이런 순환이에요.
| 단계 | 하는 일 | 어느 편 |
|---|---|---|
| 판별·추출 | 텍스트 레이어 유무로 구조체 파싱 or OCR | 1편 |
| 검증·적재 | 검증 게이트 → 멱등 UPSERT → 사람 확인 큐 | 2편 |
| 로그 수집 | 교정값+출처를 라벨로 쌓기 | 3편 1장 |
| 양식 묶기 | 지문/식별자로 template 키 | 3편 2장 |
| 파서 붙이기 | 거절 몰린 양식에 앵커 전용 파서 + 라우터 | 3편 3~5장 |
| 회귀·드리프트 | 골든셋 테스트, 양식 변경 감지 | 3편 6~7장 |
마지막으로 세 가지만 다시 짚을게요.
- 검토 로그를 버리지 마세요. 그건 사람이 공짜로 달아준 라벨이에요. 어디에 파서를 붙일지도, 그 파서가 맞는지도 전부 이 로그가 알려줘요.
- 전용 파서는 아픈 양식에만. 전부에 붙이지 말고, 거절이 몰린 소수 양식부터. 라우터가 나머지는 범용으로 받아주니 점진적으로 늘리면 돼요.
- 정확도는 한 번 올리고 끝이 아니에요. 골든셋 회귀로 지키고, 드리프트로 양식 변화를 잡아야 계속 유지돼요.
1편이 “맞는 도구로 뽑기”, 2편이 “틀린 값을 문 앞에서 돌려보내기” 였다면, 3편은 그 문 앞에서 돌아간 값들을 되먹여 다음 문서의 정확도로 바꾸는 순환이었어요. 이 세 편이 한 바퀴로 맞물리면, PDF 뭉치는 시간이 갈수록 손이 덜 가고 더 믿을 수 있는 테이블이 돼요.
일단 오늘은 여기까지…..
PDF 에서 DB 까지, 판별·검증·되먹임 세 편으로 한 바퀴를 다 돌았네요. 다음엔 다른 주제로 또 정리해서 돌아올게요.