포스트

LLM 시대(2026년 5월)의 OCR Document AI: “레이아웃 + 스키마 + 검증”으로 표·PDF를 구조화 추출하는 법

LLM 시대(2026년 5월)의 OCR Document AI: “레이아웃 + 스키마 + 검증”으로 표·PDF를 구조화 추출하는 법

들어가며

문서 OCR/이해 파이프라인에서 진짜 어려운 문제는 “텍스트를 읽는 것”이 아니라, 표/레이아웃을 깨뜨리지 않고 업무 스키마(JSON)일관되게 뽑아내는 겁니다. 특히 PDF(스캔/디지털 혼합), 표(merged cell, multi-line header), 라인아이템(동적 행 수), 그리고 페이지를 넘나드는 합계/세금 규칙에서 터집니다.

2026년 5월 시점의 실무 결론은 대체로 이렇습니다.

  • 언제 쓰면 좋은가
    • 문서 종류가 다양하고 레이아웃 변형이 잦은 경우(거래명세서/인보이스/운송장/BOL/재무제표 PDF 등)
    • “정확한 JSON 스키마”가 필요하고, 후처리/검증까지 포함해 ETL로 붙여야 하는 경우
    • 표·PDF에서 의미 단위 추출(라인아이템, 섹션별 조항, 표의 헤더-바디 매핑)이 핵심인 경우
    • LLM을 검증/정규화/보정 레이어로 쓰고, OCR은 좌표/레이아웃을 안정적으로 확보하는 용도일 때 (현업에서 이 조합이 가장 잘 굴러갑니다)
  • 언제 쓰면 안 되는가
    • 초고정형 서식(레이아웃이 99% 동일) + 초대량(일 수십만 건)이라면, LLM은 비용이 과합니다. 전통 Document AI(템플릿/모델) + 규칙 기반이 더 싸고 안정적일 수 있어요.
    • “표 전체를 그대로 DB로” 같은 요구는 LLM 단독으로는 재현성이 낮습니다. (예: Textract Queries는 “표 전체/행·열 전체를 질의로 뽑기”가 제한적이라는 점도 명시되어 있습니다. (docs.aws.amazon.com))
    • 규제/감사 대응으로 완전한 결정 경로 설명이 필요한 경우: LLM은 설명 가능성 확보를 위해 추가 설계(근거 스팬, 좌표 링크, 규칙 검증)가 필요합니다.

🔧 핵심 개념

1) “OCR → Layout → (LLM) Schema Extraction”이 표준이 된 이유

전통적인 흐름은 보통 이랬습니다.

  1. OCR(문자 인식)
  2. Layout 분석(문단/표/셀)
  3. 키-값/테이블 추출 모델(문서 타입별)
  4. 후처리(정규화/검증/합계 체크)

2026년에는 여기에 LLM(특히 멀티모달) 이 들어오면서 두 가지 전략이 공존합니다.

  • A. OCR 없이 이미지/PDF를 LLM에 바로 넣기
    최근 연구들은 “강한 MLLM이면 OCR이 꼭 필요하지 않을 수도 있다”는 결과를 내기도 합니다. (arxiv.org)
    다만 실무에서는 좌표/근거/셀 단위 정합성 때문에 OCR/Layout를 완전히 빼기 어렵습니다.

  • B. OCR/Layout로 ‘구조’를 잡고, LLM은 ‘의미를 스키마로’ 정렬
    이 접근이 현업에서 가장 재현성이 좋습니다. Azure Document Intelligence v4.0이 문서 구조/표/그림을 Markdown 등으로 내보내 RAG/후처리에 쓰는 방향을 강조한 것도 같은 맥락입니다. (techcommunity.microsoft.com)
    Google Document AI도 Layout parser로 텍스트·표·리스트를 추출하고 “context-aware chunk”를 만들어 검색/생성형 워크플로우에 붙이는 흐름을 문서로 제공합니다. (docs.cloud.google.com)

핵심은: Layout(좌표/구조)은 deterministic하게, 의미 매핑/정규화는 LLM으로.

2) 스키마 기반 추출: “프롬프트”가 아니라 “계약(Contract)”

LLM을 문서 추출에 붙이면 늘 터지는 문제가 “JSON이 깨짐 / 누락 / 타입 틀림”입니다. 이걸 막는 장치가 Structured Outputs(= JSON Schema 기반 제약 디코딩) 입니다. OpenAI는 API 레벨에서 JSON Schema 기반 Structured Outputs를 제공하고, 특정 모델 스냅샷에서 지원 범위를 명시합니다. (platform.openai.com)

이게 실무에서 바꾸는 게임의 룰:

  • “모델이 JSON을 잘 찍어주길 바란다”가 아니라
  • 스키마를 계약으로 고정하고,
  • 모델은 채우기(fill) 만 하게 만들 수 있습니다.
  • 그리고 마지막에 validator-guided repair(검증기 기반 수선)까지 붙이면, 파이프라인이 견고해집니다(유사 접근이 2026년 논문/아키텍처에서도 반복됩니다). (arxiv.org)

3) 표/페이지 처리에서 중요한 내부 흐름(구조/흐름)

표·PDF에서 안정적으로 구조화하려면, 보통 아래 순서를 권합니다.

  1. Ingestion
    • PDF가 “텍스트 PDF”인지 “스캔 이미지”인지 판별
    • 페이지 단위 렌더링(DPI 200~300) + deskew/denoise(필요 시)
  2. Layout/OCR
    • 페이지별: blocks/lines/words + bounding boxes
    • tables: cell grid + row/col span + header candidates
  3. Chunking(문서 분할)
    • “페이지 n” 단위가 아니라 섹션/표 단위로 자르는 게 포인트
    • Google Layout parser가 “chunk”를 강조하는 것도 같은 이유입니다. (docs.cloud.google.com)
  4. LLM Schema Extraction
    • 입력: (a) 표의 셀 텍스트 + (b) 주변 캡션/제목 + (c) 좌표 요약
    • 출력: JSON schema(라인아이템 배열, totals, vendor, dates…)
  5. Validation & Reconciliation
    • 합계 검증(∑ line_items == total), 통화/날짜 표준화, vendor canonicalization
    • 실패 시 재시도 전략: 다른 chunk 재구성 / 상위 모델로 escalation / OCR 재수행
  6. Human-in-the-loop(옵션)
    • confidence 낮은 필드만 태스크로 올리기

여기서 “다른 접근과의 차이점”은:

  • 전통 Document AI: 문서 타입별 학습/튜닝의 비중이 큼
  • 2026 LLM-하이브리드: 스키마/검증/오케스트레이션이 경쟁력
    (텍스트 인식 자체는 commodity가 되고, 파이프라인 설계가 실력 차이를 만듭니다.)

💻 실전 코드

아래 예시는 “PDF 인보이스/거래명세서에서 라인아이템 + 합계 + 세금”을 뽑아 DB에 넣기 좋은 JSON을 만드는 현실형 파이프라인입니다.

  • OCR/Layout: (예시로) Azure Document Intelligence Layout을 호출해 텍스트/테이블 구조 확보
  • LLM: OpenAI gpt-4o-2024-08-06 + Structured Outputs(JSON Schema) 로 스키마 강제 (developers.openai.com)
  • 검증: Pydantic + 합계 일치 검사

주의: Azure DI v4.0이 GA이며, 최신 API 버전 업데이트가 권장됩니다. (learn.microsoft.com)

0) 의존성/환경

1
2
3
4
python -m venv .venv
source .venv/bin/activate

pip install "openai>=1.0.0" "pydantic>=2.7.0" requests python-dotenv

.env

1
2
3
AZURE_DI_ENDPOINT="https://<your-resource-name>.cognitiveservices.azure.com/"
AZURE_DI_KEY="<key>"
OPENAI_API_KEY="<key>"

1) 스키마 정의 + LLM 추출(Structured Outputs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# extract_invoice.py
import os
import time
import json
import requests
from typing import List, Optional, Literal
from dotenv import load_dotenv
from pydantic import BaseModel, Field, conlist, ValidationError
from openai import OpenAI

load_dotenv()

# --------- 1) Target Schema (DB-friendly contract) ----------
class Money(BaseModel):
    currency: Literal["USD", "KRW", "EUR", "JPY", "CNY", "GBP"] = "USD"
    amount: float = Field(..., description="numeric amount, dot decimal")

class LineItem(BaseModel):
    description: str
    quantity: Optional[float] = None
    unit_price: Optional[Money] = None
    line_total: Money
    sku: Optional[str] = None

class InvoiceExtraction(BaseModel):
    vendor_name: str
    invoice_number: Optional[str] = None
    invoice_date: Optional[str] = Field(None, description="ISO-8601 date string if possible")
    purchase_order: Optional[str] = None

    subtotal: Optional[Money] = None
    tax: Optional[Money] = None
    total: Money

    line_items: conlist(LineItem, min_length=1)
    warnings: List[str] = []

# --------- 2) Azure Document Intelligence Layout call ----------
def azure_di_layout_analyze(pdf_bytes: bytes) -> dict:
    endpoint = os.environ["AZURE_DI_ENDPOINT"].rstrip("/")
    key = os.environ["AZURE_DI_KEY"]

    # Use Layout model; API version should match your resource configuration
    url = f"{endpoint}/documentintelligence/documentModels/prebuilt-layout:analyze?api-version=2024-11-30"
    headers = {
        "Ocp-Apim-Subscription-Key": key,
        "Content-Type": "application/pdf",
    }
    r = requests.post(url, headers=headers, data=pdf_bytes)
    r.raise_for_status()

    op_loc = r.headers.get("operation-location")
    if not op_loc:
        raise RuntimeError("No operation-location returned from Azure DI")

    # Poll
    for _ in range(60):
        pr = requests.get(op_loc, headers={"Ocp-Apim-Subscription-Key": key})
        pr.raise_for_status()
        data = pr.json()
        if data.get("status") in ("succeeded", "failed"):
            if data["status"] == "failed":
                raise RuntimeError(f"Azure DI failed: {json.dumps(data)[:500]}")
            return data
        time.sleep(1)

    raise TimeoutError("Azure DI polling timeout")

def flatten_layout_to_llm_input(di_result: dict) -> str:
    """
    Turn DI result into a compact, LLM-friendly representation.
    Key idea: keep table cells + nearby context, not raw full JSON.
    """
    analyze = di_result["analyzeResult"]
    pages = analyze.get("pages", [])
    tables = analyze.get("tables", [])

    out = []
    out.append("DOCUMENT_PAGES:")
    for p in pages[:30]:
        out.append(f"- page {p.get('pageNumber')}: width={p.get('width')} height={p.get('height')}")

    out.append("\nTABLES:")
    for ti, t in enumerate(tables[:50]):
        rows = t.get("rowCount")
        cols = t.get("columnCount")
        out.append(f"\n[TABLE {ti}] rows={rows} cols={cols}")
        # collect cells sorted
        cells = sorted(t.get("cells", []), key=lambda c: (c.get("rowIndex", 0), c.get("columnIndex", 0)))
        for c in cells[:2000]:
            txt = (c.get("content") or "").replace("\n", " ").strip()
            ri = c.get("rowIndex")
            ci = c.get("columnIndex")
            rs = c.get("rowSpan", 1)
            cs = c.get("columnSpan", 1)
            if txt:
                out.append(f"  - r{ri}c{ci} span({rs},{cs}): {txt}")

    # include some top-level content lines if available
    # (some DI outputs include "content" at analyzeResult level)
    content = analyze.get("content", "")
    if content:
        # keep head/tail to avoid token blow-up
        out.append("\nCONTENT_HEAD:")
        out.append(content[:4000])
        out.append("\nCONTENT_TAIL:")
        out.append(content[-2000:])

    return "\n".join(out)

# --------- 3) LLM extraction with Structured Outputs ----------
def llm_extract(schema_input: str) -> InvoiceExtraction:
    client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

    # Use Structured Outputs (JSON Schema) to harden output
    # If your SDK supports Pydantic helpers, you can generate JSON schema from the model.
    json_schema = InvoiceExtraction.model_json_schema()

    resp = client.responses.create(
        model="gpt-4o-2024-08-06",
        input=[
            {
                "role": "system",
                "content": (
                    "You are a senior document extraction engine. "
                    "Extract invoice fields into the given JSON schema. "
                    "Prefer values grounded in TABLES. "
                    "If uncertain, fill warnings and leave nullable fields null."
                ),
            },
            {
                "role": "user",
                "content": (
                    "Extract structured invoice data.\n\n"
                    "=== LAYOUT/TABLE DUMP ===\n"
                    f"{schema_input}\n"
                ),
            },
        ],
        response_format={
            "type": "json_schema",
            "json_schema": {
                "name": "InvoiceExtraction",
                "schema": json_schema,
                "strict": True
            },
        },
    )

    data = json.loads(resp.output_text)
    return InvoiceExtraction.model_validate(data)

# --------- 4) Validation / reconciliation ----------
def reconcile(ex: InvoiceExtraction) -> InvoiceExtraction:
    warnings = list(ex.warnings)

    # Sum line items sanity check (tolerate small OCR rounding)
    sum_lines = sum(li.line_total.amount for li in ex.line_items)
    if abs(sum_lines - ex.total.amount) > max(0.02 * ex.total.amount, 1.0):
        warnings.append(f"Total mismatch: sum(line_items)={sum_lines} vs total={ex.total.amount}")

    return ex.model_copy(update={"warnings": warnings})

def run(pdf_path: str):
    with open(pdf_path, "rb") as f:
        pdf_bytes = f.read()

    di = azure_di_layout_analyze(pdf_bytes)
    llm_in = flatten_layout_to_llm_input(di)

    try:
        ex = llm_extract(llm_in)
    except ValidationError as ve:
        # In production: store raw output, fallback to repair prompt, or escalate model
        raise RuntimeError(f"Schema validation failed: {ve}")

    ex = reconcile(ex)
    print(ex.model_dump_json(indent=2, exclude_none=True))

if __name__ == "__main__":
    run("sample_invoice.pdf")

예상 출력(예시)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
  "vendor_name": "ACME SUPPLY CO.",
  "invoice_number": "INV-104892",
  "invoice_date": "2026-05-12",
  "subtotal": { "currency": "USD", "amount": 1840.0 },
  "tax": { "currency": "USD", "amount": 147.2 },
  "total": { "currency": "USD", "amount": 1987.2 },
  "line_items": [
    {
      "description": "Nitrile Gloves, Large (Case)",
      "quantity": 10,
      "unit_price": { "currency": "USD", "amount": 120.0 },
      "line_total": { "currency": "USD", "amount": 1200.0 }
    },
    {
      "description": "Safety Goggles",
      "quantity": 16,
      "unit_price": { "currency": "USD", "amount": 40.0 },
      "line_total": { "currency": "USD", "amount": 640.0 }
    }
  ],
  "warnings": []
}

⚡ 실전 팁 & 함정

Best Practice 1) “표 전체를 LLM에 맡기지 말고, 표를 ‘덤프’해서 스키마만 채우게”

표 추출에서 LLM이 강한 건 “이 셀들이 어떤 의미인지”를 매핑하는 능력이지, 픽셀에서 그리드를 재구성하는 능력이 아닙니다. 그래서 Layout/OCR 엔진으로 셀을 확보하고, LLM은 스키마 필드 채우기에 집중시키는 게 비용/성능/재현성 균형이 좋습니다. (Google Layout parser가 테이블/리스트를 뽑아 chunk를 만드는 방향도 이 철학과 맞습니다. (docs.cloud.google.com))

Best Practice 2) “스키마 강제 + validator-guided repair”를 기본값으로

  • Structured Outputs로 1차 방어(스키마 일탈 차단) (platform.openai.com)
  • 그 다음 비즈니스 검증(합계/세금/통화) 를 2차 방어로 둡니다.
  • 실패 시 재시도는 “같은 프롬프트 반복”이 아니라:
    • chunk를 다르게 구성(표 + 캡션만)
    • OCR 재시도(DPI 조정, deskew)
    • 상위 모델로 escalation
    • 필드별 재질의 (invoice_number만 다시 뽑기) 이런 식으로 “실패 원인”을 바꾸는 재시도가 효율적입니다.

Best Practice 3) Chunking은 “페이지”가 아니라 “의미 단위(표/섹션)”로

200페이지 PDF를 통으로 넣고 JSON 한 번에 뽑으려다 깨지는 사례는 커뮤니티에서도 반복됩니다(대개 invalid schema / truncation). (reddit.com)
문서→섹션→표로 분할하고, 마지막에 머지(merge)하세요.

흔한 함정/안티패턴

  • “Queries로 표 전체를 뽑자”: Textract Queries는 표 전체/행·열 전체 추출이 지원되지 않는 제한이 문서에 명시돼 있습니다. (docs.aws.amazon.com)
    → Queries는 특정 키-값이나 제한된 질의에 쓰고, 표는 TABLES 기능/레이아웃 기반으로 처리하는 식으로 역할 분담이 필요합니다.
  • 모델/엔진 업그레이드를 단순 버전업으로 취급: Document AI 계열은 버전업 시 레이아웃 가정이 바뀌어 추출 결과가 달라질 수 있습니다(실무 체감 이슈로 자주 나옵니다). (reddit.com)
    → “리그레션 테스트 세트(실제 문서 100~500개)”를 고정해 CI에서 비교하세요.
  • LLM 단독 OCR로 비용 폭발: “LLM OCR이 표에서 전통 OCR보다 낫다”는 주장도 있으나, 벤더/문서 특성/비용 구조에 따라 다릅니다. (parsli.co)
    → 고정형 대량은 전통 OCR이 유리, 변형 많은 표-heavy는 하이브리드/LLM이 유리인 경우가 많습니다.

비용/성능/안정성 트레이드오프(현실적 가이드)

  • 가장 싸고 빠름: 전통 OCR + 규칙/템플릿
    단, 문서 변형에 취약
  • 가장 균형 좋음(추천): Layout/OCR(좌표 안정) + LLM(스키마 매핑/정규화) + 검증
    운영 난이도는 있지만 “프로덕션”에 맞음
  • 가장 강하지만 비쌈: 멀티모달 LLM에 PDF를 직접 넣어 end-to-end
    PoC에는 좋지만, 대량·재현성·감사 대응에서 추가 설계가 필수

🚀 마무리

2026년 5월의 문서 이해/구조화 추출은 “OCR vs LLM” 싸움이 아니라, 파이프라인 설계(레이아웃 확보 → 스키마 강제 → 검증/재시도) 가 승패를 가릅니다.
도입 판단 기준은 아래 3가지로 정리할 수 있어요.

1) 문서 레이아웃 변형이 큰가? → 크면 LLM 기반 스키마 매핑 가치가 큼
2) 표/라인아이템이 핵심인가? → Layout 기반 셀 확보 + LLM 매핑이 가장 안전
3) 운영 요구(비용, SLA, 감사/재현성)가 빡센가? → Structured Outputs + validator + 리그레션 테스트가 필수

다음 학습 추천:

  • Google Document AI Layout parser 기반 chunking/테이블 추출 흐름(“layout → chunk”) (docs.cloud.google.com)
  • OpenAI Structured Outputs(JSON Schema)로 “추출 결과를 계약화”하는 패턴 (platform.openai.com)
  • Azure Document Intelligence v4.0의 구조/표 중심 출력과 RAG 결합 방향 (techcommunity.microsoft.com)

원하면, (1) “인보이스/거래명세서/재무제표” 중 어떤 문서인지, (2) 월 처리량과 허용 비용, (3) 반드시 맞아야 하는 필드(예: line_items vs total) 3가지만 알려주시면 위 코드/아키텍처를 그 조건에 맞춰 더 구체적으로 튜닝해드릴게요.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.