8 분 소요

📄 PDF 에서 DB 로, 틀리지 않게 3부작 — PDF 에서 값을 뽑는 두 갈래(구조체 파싱 vs OCR)를 정확도로 갈라 쓰는 법(1편), 그 값을 검증 게이트·멱등 적재·사람 확인 큐로 이어지는 적재 파이프라인에 꿰는 법(2편), 그리고 검토 큐에 쌓인 로그를 되먹여 양식별 전용 파서를 붙이고 회귀·드리프트로 지키는 법(3편)까지, "틀린 값을 DB 에 넣지 않는다"는 한 목표로 이어집니다. 전체 3편.
  1. PDF 에서 데이터를 뽑아 DB 에 넣기 — 구조체 파싱 vs OCR, 뭘 언제 쓰나
  2. 검증 레이어를 적재 파이프라인에 꿰기 — 배치 인입·멱등 적재·사람 확인 큐
  3. 검토 로그를 되먹여 양식별 파서 붙이기 — 양식 클러스터링·앵커 추출·회귀 테스트지금 글

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 까지, 판별·검증·되먹임 세 편으로 한 바퀴를 다 돌았네요. 다음엔 다른 주제로 또 정리해서 돌아올게요.


← 이전 글: (2/3) 검증 레이어를 적재 파이프라인에 꿰기 — 배치 인입·멱등 적재·사람 확인 큐