포스트

LLM 시대의 OCR Document AI: 2026년 7월 기준 “문서 이해→구조화 추출”을 프로덕션에 넣는 설계 가이드

LLM 시대의 OCR Document AI: 2026년 7월 기준 “문서 이해→구조화 추출”을 프로덕션에 넣는 설계 가이드

들어가며

문서 OCR/이해 파이프라인이 해결하는 문제는 단순히 “PDF에서 text 뽑기”가 아닙니다. 실무에서 진짜 골칫거리는:

  • 레이아웃이 시각적으로 풍부(Visually Rich) 해서 텍스트만 뽑으면 의미가 깨짐(헤더/푸터, 컬럼, footnote, 표, 페이지 번호, 섹션 구조)
  • 표(Table) 구조가 핵심인데 cell 병합/다중 헤더/페이지 넘김(table spanning pages) 때문에 결과가 흔들림
  • 최종 목표는 대개 DB에 넣을 수 있는 schema(JSON/SQL) 로 “정규화된 구조화 추출”인데, OCR 결과가 제각각이라 후처리가 폭발

2026년 흐름은 “OCR → Layout/Structure → (필요 시) LLM로 schema 매핑/검증/수정”의 하이브리드가 가장 현실적인 선택지로 굳어지고 있습니다. Google은 Gemini 기반 layout parsing을 Document AI에 붙여 레이아웃/구조 추출을 강화했고(docs.cloud.google.com), Microsoft는 Document Intelligence에서 layout 모델을 중심으로 문서 구조/표/reading order를 안정적으로 뽑아 RAG/semantic chunking에 연결하는 방향을 강조합니다(learn.microsoft.com). AWS Textract는 여전히 “텍스트/표/폼 구조 추출”을 강점으로 유지하며 API가 꾸준히 업데이트되고 있습니다(문서 업데이트가 2026-06-26로 표기)(docs.aws.amazon.com).

언제 쓰면 좋나

  • 문서 유형이 다양하고(공급사/기관별 양식 상이) 규칙 기반 파서가 유지보수 지옥인 경우
  • 표가 핵심이고, 표를 “그대로 복원”보다 “업무 스키마로 정규화”해야 하는 경우(예: 거래내역/청구서 라인아이템)
  • Human-in-the-loop(검수 UI)까지 고려한 확률적 파이프라인을 설계할 수 있을 때

언제 쓰면 안 되나

  • 문서가 1~2종으로 고정이고 레이아웃도 안정적이라면: LLM을 넣기보다 deterministic 규칙+검증이 더 싸고 안전함
  • 보안/규제상 원문을 외부로 못 보내거나, latency/비용이 극단적으로 민감한데 온프렘 대안이 없는 경우
  • “정확도 99.9%”를 무검수로 요구하는 고위험 업무(세무/법무 자동 제출 등): LLM은 “그럴듯한 오답” 리스크가 있어 설계 난도가 급상승

🔧 핵심 개념

1) Document Understanding의 레이어 분리: OCR vs Layout vs Schema

실무적으로는 아래 3단을 분리해 생각하는 게 안정적입니다.

  1. OCR (Text recognition)
    이미지/PDF에서 글자를 인식. 하지만 OCR만으로는 “이 텍스트가 어디에 속하는지”가 약합니다.

  2. Layout/Structure (Document layout analysis)
    페이지 내 블록(단락/제목/헤더/푸터/표/그림), reading order, table grid를 추출합니다. Microsoft가 말하는 “hierarchical document structure analysis”가 여기고, RAG에서도 chunk를 잘 자르려면 이 계층 구조가 중요합니다(learn.microsoft.com).

  3. Schema mapping (Structured extraction)
    “InvoiceNumber”, “LineItems[]”, “Tax”, “Total” 같은 업무 스키마로 매핑.
    2026년 트렌드는 여기서 LLM을 “추출기”로 쓰기보다 (a) 후보 생성 + (b) validator로 강제 + (c) repair 루프로 쓰는 패턴이 강합니다(스키마 우선/검증 우선)(arxiv.org).

2) 왜 LLM을 OCR 자리에 두면 실패하는가 (그리고 어디에 두면 이득인가)

Visually Rich Document에서 MLLM/VLM이 유망하다는 조사도 있지만(aclanthology.org), 프로덕션에서는 “OCR-free로 한 방에 끝”이 생각보다 어렵습니다.

  • 문서가 길어질수록(수십 페이지 PDF) 컨텍스트/비용/지연이 커지고, 중간 누락/왜곡이 검증하기 어려움
  • 표는 특히 “grid”가 중요해서, LLM이 자연어로 요약해버리면 셀 단위 정합성이 깨지기 쉽습니다
  • 반대로 LLM을 schema mapping + validation/repair에 두면:
    • 레이아웃 엔진이 뽑은 근거(텍스트/좌표/셀)를 기반으로 정규화를 잘함
    • “규칙으로 커버하기 어려운 예외(약어/단위/표 헤더 변형)”를 흡수 가능

Google의 “Gemini layout parser”는 이 간극을 줄이려고, OCR/레이아웃 파싱 자체에 generative LLM을 결합한 형태로 보입니다(레이아웃 요소/테이블 구조 등 파싱)(docs.cloud.google.com). 즉, LLM을 완전 대체재가 아니라 구조 추출의 품질 부스터로 쓰는 접근입니다.

3) Table/PDF 처리에서 핵심 난제 3가지

  1. Merged cells / multi-row headers: 헤더가 2~3줄로 쌓이거나 병합되면 “컬럼 의미”가 흔들림
  2. Cross-page tables: 페이지 넘어가면서 헤더 반복/생략, subtotal/continued 등장
  3. PDF 텍스트 레이어 함정: “스캔 PDF + hidden text layer”처럼 혼종이면 extractor가 잘못된 레이어를 우선해 오염될 수 있음

최근 연구에서도 “전통적(heuristic) 파이프라인이 비용/설명가능성 측면에서 다시 경쟁력”이 있다는 주장까지 나옵니다(예: SPARTAN은 GPU/학습 없이도 고정밀 table extraction을 목표)(nature.com). 결론은: LLM만이 답이 아니라, 파이프라인 전체를 비용/성능/검증성으로 최적화해야 합니다.


💻 실전 코드

아래 예제는 “대량 PDF(은행명세서/청구서)에서 표 포함 내용을 추출 → LLM으로 스키마(JSON)로 정규화 → Pydantic으로 검증 → 실패 시 재시도”까지를 현실적으로 엮은 형태입니다.

  • OCR/Layout: AWS Textract AnalyzeDocument (TABLES + FORMS)
  • Schema mapping: LLM(Chat Completions 계열 가정)
    • 실제로는 OpenAI/Azure OpenAI/Bedrock 등 어떤 LLM이든 대체 가능
  • 검증: pydantic
  • 포인트: LLM 입력에는 “원문 전체”가 아니라 Textract가 준 table cell + key-value를 압축해 넣습니다(비용/안정성).

0) 의존성/환경

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

pip install boto3 pydantic==2.* tenacity python-dotenv
# LLM SDK는 예시로 openai를 가정(대체 가능)
pip install openai

.env

1
2
3
AWS_REGION=us-east-1
OPENAI_API_KEY=YOUR_KEY
LLM_MODEL=gpt-4.1-mini

1) Textract로 표/폼 구조 추출 → “근거 기반” 중간 표현 만들기

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
import os, json
import boto3
from dotenv import load_dotenv

load_dotenv()
REGION = os.environ["AWS_REGION"]

textract = boto3.client("textract", region_name=REGION)

def analyze_pdf_bytes(pdf_bytes: bytes) -> dict:
    # TABLES + FORMS를 함께 켜야 key-value(폼)와 표를 같이 얻기 좋습니다.
    resp = textract.analyze_document(
        Document={"Bytes": pdf_bytes},
        FeatureTypes=["TABLES", "FORMS"],
    )
    return resp

def build_intermediate(textract_json: dict) -> dict:
    """
    LLM에 바로 던질 '압축된 근거'를 구성합니다.
    - full raw blocks를 던지지 말고, 테이블은 (row,col,text)로 평탄화
    - key-value는 (key,text,value,text)로 정리
    """
    blocks = {b["Id"]: b for b in textract_json["Blocks"]}
    tables = []
    kvs = []

    # 1) Key-Value 추출(간단화: KEY_VALUE_SET만)
    for b in textract_json["Blocks"]:
        if b["BlockType"] == "KEY_VALUE_SET" and "KEY" in b.get("EntityTypes", []):
            key_text = _collect_text_from_relationships(blocks, b.get("Relationships", []))
            value_text = ""
            for rel in b.get("Relationships", []):
                if rel["Type"] == "VALUE":
                    for vid in rel["Ids"]:
                        vb = blocks[vid]
                        value_text = _collect_text_from_relationships(blocks, vb.get("Relationships", []))
            if key_text.strip():
                kvs.append({"key": key_text.strip(), "value": value_text.strip()})

    # 2) Table 추출: TABLE -> CELL -> WORD
    for b in textract_json["Blocks"]:
        if b["BlockType"] == "TABLE":
            cell_rows = []
            for rel in b.get("Relationships", []):
                if rel["Type"] == "CHILD":
                    for cid in rel["Ids"]:
                        cb = blocks[cid]
                        if cb["BlockType"] != "CELL":
                            continue
                        text = _collect_text_from_relationships(blocks, cb.get("Relationships", []))
                        cell_rows.append({
                            "row": cb.get("RowIndex"),
                            "col": cb.get("ColumnIndex"),
                            "rowSpan": cb.get("RowSpan", 1),
                            "colSpan": cb.get("ColumnSpan", 1),
                            "text": text.strip(),
                        })
            # row/col로 정렬
            cell_rows.sort(key=lambda x: (x["row"], x["col"]))
            tables.append({"cells": cell_rows})

    return {"key_values": kvs, "tables": tables}

def _collect_text_from_relationships(blocks: dict, rels: list) -> str:
    out = []
    for rel in rels or []:
        if rel["Type"] == "CHILD":
            for cid in rel["Ids"]:
                cb = blocks[cid]
                if cb["BlockType"] == "WORD":
                    out.append(cb["Text"])
                elif cb["BlockType"] == "SELECTION_ELEMENT":
                    if cb.get("SelectionStatus") == "SELECTED":
                        out.append("[X]")
    return " ".join(out)

예상 중간 출력(요약):

1
2
3
4
5
6
{
  "key_values": [{"key":"Account Number","value":"123-45-6789"}, ...],
  "tables": [
    {"cells":[{"row":1,"col":1,"text":"Date"}, {"row":1,"col":2,"text":"Description"}, ...]}
  ]
}

2) LLM으로 “업무 스키마” JSON 추출 + Pydantic 검증 + repair 루프

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
from typing import List, Optional
from pydantic import BaseModel, Field, ValidationError
from tenacity import retry, stop_after_attempt, wait_exponential
from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
MODEL = os.environ.get("LLM_MODEL", "gpt-4.1-mini")

class LineItem(BaseModel):
    date: str
    description: str
    amount: float
    currency: str = "USD"

class Statement(BaseModel):
    account_number: Optional[str] = None
    period_start: Optional[str] = None
    period_end: Optional[str] = None
    line_items: List[LineItem] = Field(default_factory=list)
    total_amount: Optional[float] = None

SYSTEM = """You are a senior document extraction engine.
You MUST output valid JSON only, matching the provided schema.
Do not hallucinate: if a field is not present, use null/empty.
Numbers: parse as float. Currency: infer from symbols if present else USD.
Table: prefer table cells for line_items. Preserve sign for debits/credits.
"""

def llm_extract_statement(intermediate: dict, last_error: str | None = None) -> dict:
    schema_hint = Statement.model_json_schema()
    user = {
        "schema": schema_hint,
        "intermediate": intermediate,
        "notes": "If table header is multi-row, treat the last header row as column names.",
        "last_error": last_error,
    }

    resp = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": json.dumps(user, ensure_ascii=False)},
        ],
        temperature=0,
    )
    content = resp.choices[0].message.content
    return json.loads(content)

@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
def extract_with_validation(intermediate: dict) -> Statement:
    last_error = None
    for _ in range(2):  # 한 번 더 repair 기회
        raw = llm_extract_statement(intermediate, last_error=last_error)
        try:
            return Statement.model_validate(raw)
        except ValidationError as e:
            last_error = str(e)
    # 마지막 시도에서 실패하면 예외로 올려서 retry(tenacity)로 재시도
    raise ValueError(f"Extraction failed: {last_error}")

3) 엔드투엔드 실행(파일 입력 → 결과 저장)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def run(pdf_path: str, out_path: str):
    with open(pdf_path, "rb") as f:
        pdf_bytes = f.read()

    tex = analyze_pdf_bytes(pdf_bytes)
    intermediate = build_intermediate(tex)

    statement = extract_with_validation(intermediate)

    with open(out_path, "w", encoding="utf-8") as f:
        f.write(statement.model_dump_json(indent=2, ensure_ascii=False))

if __name__ == "__main__":
    run("samples/bank_statement.pdf", "out/statement.json")

⚡ 실전 팁 & 함정

Best Practice 1) “구조 추출”과 “의미 추출”을 분리하고, LLM에는 근거를 압축해서 넣기

  • 원문 PDF를 통째로 LLM에 넣는 방식은 비용/지연/누락 리스크가 큽니다.
  • 대신 Textract/Document Intelligence/Document AI 같은 엔진에서 reading order + tables + key-values를 얻고, LLM은 schema mapping + 정규화 + 예외 처리에 집중시키세요. (Microsoft도 문서 구조 분석의 중요성을 RAG 맥락에서 반복 강조)(learn.microsoft.com)

Best Practice 2) Schema-first + validator-guided repair

  • “LLM 출력은 항상 틀릴 수 있다”를 전제로, Pydantic/JSON Schema/비즈니스 룰로 강제하세요.
  • 위 예제처럼 ValidationError를 LLM에 다시 주면, 단순 추출보다 안정적으로 수렴합니다(최근 파이프라인 연구도 유사한 패턴을 언급)(arxiv.org).

Best Practice 3) Table은 “복원”이 아니라 “정규화” 기준으로 설계

  • 표를 DataFrame으로 예쁘게 재구성하는 것과, 업무에 필요한 컬럼만 정확히 뽑는 것은 다릅니다.
  • 헤더가 흔들리는 문서라면, LLM에게 “헤더 후보 목록을 만들고 컬럼 매핑”을 시키는 편이 유지보수에 유리합니다(표 추출 파이프라인에서 LLM을 보조로 쓰는 아이디어가 실제로 등장)(nature.com).

흔한 함정/안티패턴

  • confidence score 맹신: OCR/추출 confidence가 높아도 “문맥상 틀린 값”이 나옵니다. 특히 손글씨 수정/취소선 같은 케이스에서 silent failure가 발생하기 쉽습니다(현업 경험담에서도 반복)(reddit.com).
  • multi-page 문서에서 page boundary 무시: “표가 페이지를 넘는 순간” 품질이 급락합니다. 페이지별로 처리 후 이어 붙이려면, “header 반복/continued” 규칙과 합치기 전략이 필요합니다.
  • 비용 폭주: LLM을 “원문+이미지”로 호출하면 토큰/이미지 비용이 폭발합니다. 먼저 레이아웃 엔진으로 압축하고, LLM 호출을 “문서당 1~2회”로 제한하는 설계가 현실적입니다.

비용/성능/안정성 트레이드오프(현실적인 판단 기준)

  • 최저 비용/최고 처리량이 목표면: 전통 OCR+heuristic/table pipeline도 다시 검토 가치가 있습니다(학습/GPU 없이도 경쟁력을 주장하는 연구가 나옴)(nature.com).
  • 문서 다양성/예외 처리가 핵심이면: Layout 엔진 + LLM 정규화가 총비용(TCO) 관점에서 유리해지는 경우가 많습니다.
  • 감사/설명가능성이 필요하면: “LLM이 읽고 요약”이 아니라, “레이아웃 근거 기반으로 schema 매핑” + “검증 로그/근거 좌표 저장”을 남기세요.

🚀 마무리

정리하면, 2026년 7월 기준 “OCR Document AI + LLM”의 정답은 LLM 올인이 아니라:

  • (1) Textract/Document Intelligence/Document AI 같은 엔진으로 텍스트+레이아웃+표 구조를 안정적으로 추출하고(learn.microsoft.com)
  • (2) LLM은 schema-first 정규화 + validation/repair에 배치해서
  • (3) 표/멀티페이지/예외 케이스를 “검증 가능한 형태”로 수렴시키는 설계입니다.

도입 판단 기준은 간단합니다.

  • 문서 유형/레이아웃이 계속 늘고, 규칙 기반 유지보수가 한계를 보이면: 하이브리드(Structure → LLM schema) 로 가는 게 맞습니다.
  • 문서가 고정이고 정확도가 절대적으로 중요하면: LLM은 최소화하고, deterministic + 검수/감사를 강화하세요.

다음 학습 추천:

  • “Visually Rich Document + MLLM” 동향을 잡고 싶다면 관련 survey를 먼저 훑고(aclanthology.org),
  • 실제 프로덕션 운영 관점(마이크로서비스/스케일링/재처리/관측성)은 운영 아키텍처 논문류를 참고해 파이프라인을 설계하는 게 도움이 됩니다(arxiv.org).

원하시면, (A) Google Document AI(Gemini layout parser) 기반으로 동일한 파이프라인을 재작성하거나(docs.cloud.google.com), (B) “표가 페이지를 넘는 bank statement”를 기준으로 cross-page table stitching 전략(헤더 정렬/continued 감지/라인아이템 정규화)을 더 깊게 확장해 드릴 수 있습니다.

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