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”이 표준이 된 이유
전통적인 흐름은 보통 이랬습니다.
- OCR(문자 인식)
- Layout 분석(문단/표/셀)
- 키-값/테이블 추출 모델(문서 타입별)
- 후처리(정규화/검증/합계 체크)
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에서 안정적으로 구조화하려면, 보통 아래 순서를 권합니다.
- Ingestion
- PDF가 “텍스트 PDF”인지 “스캔 이미지”인지 판별
- 페이지 단위 렌더링(DPI 200~300) + deskew/denoise(필요 시)
- Layout/OCR
- 페이지별: blocks/lines/words + bounding boxes
- tables: cell grid + row/col span + header candidates
- Chunking(문서 분할)
- “페이지 n” 단위가 아니라 섹션/표 단위로 자르는 게 포인트
- Google Layout parser가 “chunk”를 강조하는 것도 같은 이유입니다. (docs.cloud.google.com)
- LLM Schema Extraction
- 입력: (a) 표의 셀 텍스트 + (b) 주변 캡션/제목 + (c) 좌표 요약
- 출력: JSON schema(라인아이템 배열, totals, vendor, dates…)
- Validation & Reconciliation
- 합계 검증(∑ line_items == total), 통화/날짜 표준화, vendor canonicalization
- 실패 시 재시도 전략: 다른 chunk 재구성 / 상위 모델로 escalation / OCR 재수행
- 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) 의존성/환경
python -m venv .venv
source .venv/bin/activate
pip install "openai>=1.0.0" "pydantic>=2.7.0" requests python-dotenv
.env
AZURE_DI_ENDPOINT="https://<your-resource-name>.cognitiveservices.azure.com/"
AZURE_DI_KEY="<key>"
OPENAI_API_KEY="<key>"
1) 스키마 정의 + LLM 추출(Structured Outputs)
# 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")
예상 출력(예시)
{
"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가지만 알려주시면 위 코드/아키텍처를 그 조건에 맞춰 더 구체적으로 튜닝해드릴게요.