PDF, 이미지, 웹페이지 등 비정형 데이터에서 LLM을 활용하여 구조화된 정보를 추출하는 실전 기법을 학습합니다.
실무에서 다루는 데이터의 대부분은 비정형입니다. 스캔된 송장 PDF, 영수증 이미지, 계약서 문서, 뉴스 웹페이지 등에서 구조화된 데이터를 추출하는 것은 오랫동안 어려운 과제였습니다.
전통적인 접근 방식은 규칙 기반 파서, 정규표현식, 템플릿 매칭을 사용했지만, 문서 형식이 조금만 달라져도 실패하는 취약성이 있었습니다. LLM의 등장으로 이 패러다임이 근본적으로 바뀌고 있습니다.
텍스트가 포함된 PDF는 텍스트를 직접 추출한 후 LLM에 전달합니다.
import fitz # PyMuPDF
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field
class InvoiceData(BaseModel):
"""송장 데이터"""
invoice_number: str = Field(description="송장 번호")
date: str = Field(description="발행일 (YYYY-MM-DD)")
vendor_name: str = Field(description="공급자 상호")
vendor_registration_number: str = Field(description="사업자등록번호")
total_amount: float = Field(description="합계 금액")
tax_amount: float = Field(description="세액")
items: list["InvoiceItem"] = Field(description="품목 목록")
class InvoiceItem(BaseModel):
"""송장 품목"""
description: str = Field(description="품목명")
quantity: int = Field(ge=1, description="수량")
unit_price: float = Field(description="단가")
amount: float = Field(description="금액")
def extract_text_from_pdf(pdf_path: str) -> str:
"""PDF에서 텍스트를 추출합니다."""
doc = fitz.open(pdf_path)
text_parts = []
for page in doc:
text_parts.append(page.get_text())
return "\n".join(text_parts)
def extract_invoice_data(pdf_path: str) -> InvoiceData:
"""PDF 송장에서 구조화된 데이터를 추출합니다."""
text = extract_text_from_pdf(pdf_path)
client = instructor.from_openai(OpenAI())
result = client.chat.completions.create(
model="gpt-4o-2026-02",
response_model=InvoiceData,
max_retries=2,
messages=[
{
"role": "system",
"content": (
"다음 텍스트는 송장(Invoice) PDF에서 추출한 내용입니다. "
"송장의 모든 정보를 정확히 추출하세요. "
"금액은 숫자만 포함하고 통화 기호는 제외하세요."
)
},
{"role": "user", "content": text}
]
)
return result스캔된 문서는 **OCR(Optical Character Recognition)**광학 문자 인식이 필요합니다. 최근의 멀티모달 LLM은 이미지를 직접 인식할 수 있어, 별도의 OCR 단계 없이도 데이터를 추출할 수 있습니다.
import fitz
import base64
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field
def pdf_pages_to_images(pdf_path: str) -> list[str]:
"""PDF 각 페이지를 base64 이미지로 변환합니다."""
doc = fitz.open(pdf_path)
images = []
for page in doc:
pix = page.get_pixmap(dpi=200)
img_bytes = pix.tobytes("png")
b64 = base64.b64encode(img_bytes).decode("utf-8")
images.append(b64)
return images
def extract_from_scanned_pdf(pdf_path: str) -> InvoiceData:
"""스캔 PDF에서 멀티모달 LLM으로 데이터를 추출합니다."""
images = pdf_pages_to_images(pdf_path)
client = instructor.from_openai(OpenAI())
# 이미지를 Vision API에 전달
content = [
{
"type": "text",
"text": "이 송장 이미지에서 모든 정보를 추출하세요."
}
]
for img_b64 in images:
content.append({
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{img_b64}",
"detail": "high"
}
})
result = client.chat.completions.create(
model="gpt-4o-2026-02",
response_model=InvoiceData,
max_retries=2,
messages=[
{
"role": "system",
"content": "송장 이미지에서 정확한 데이터를 추출하세요."
},
{"role": "user", "content": content}
]
)
return result멀티모달 LLM의 이미지 인식 능력은 전통적인 OCR 도구(Tesseract 등)보다 레이아웃 이해력이 뛰어납니다. 특히 한국어 문서, 복잡한 테이블, 손글씨가 포함된 문서에서 큰 차이를 보입니다.
영수증, 명함, 제품 라벨 등 다양한 이미지에서 구조화된 데이터를 추출할 수 있습니다.
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field
import base64
class ReceiptItem(BaseModel):
"""영수증 품목"""
name: str = Field(description="상품명")
quantity: int = Field(ge=1, description="수량")
price: float = Field(description="가격")
class ReceiptData(BaseModel):
"""영수증 데이터"""
store_name: str = Field(description="매장명")
date: str = Field(description="구매일 (YYYY-MM-DD)")
items: list[ReceiptItem] = Field(description="구매 품목 목록")
subtotal: float = Field(description="소계")
tax: float = Field(description="부가세")
total: float = Field(description="합계")
payment_method: str = Field(description="결제 수단 (카드/현금/기타)")
def extract_receipt(image_path: str) -> ReceiptData:
"""영수증 이미지에서 데이터를 추출합니다."""
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode("utf-8")
client = instructor.from_openai(OpenAI())
return client.chat.completions.create(
model="gpt-4o-2026-02",
response_model=ReceiptData,
max_retries=2,
messages=[
{
"role": "system",
"content": (
"영수증 이미지에서 모든 정보를 정확히 추출하세요. "
"금액은 숫자만 포함합니다. "
"날짜 형식이 다른 경우 YYYY-MM-DD로 변환하세요."
)
},
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{img_b64}",
"detail": "high"
}
}
]
}
]
)웹 스크레이핑과 LLM을 결합하면 복잡한 HTML 구조에서도 원하는 데이터를 정확히 추출할 수 있습니다.
import httpx
from bs4 import BeautifulSoup
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field
class ProductInfo(BaseModel):
"""상품 정보"""
name: str = Field(description="상품명")
price: float = Field(description="가격 (원)")
original_price: float | None = Field(
default=None,
description="할인 전 원래 가격 (할인이 없으면 null)"
)
rating: float | None = Field(
default=None,
description="평점 (5점 만점)"
)
review_count: int = Field(default=0, description="리뷰 수")
description: str = Field(description="상품 설명 요약")
specifications: dict[str, str] = Field(
default_factory=dict,
description="주요 사양 (키-값 쌍)"
)
def extract_product_from_url(url: str) -> ProductInfo:
"""웹페이지에서 상품 정보를 추출합니다."""
# HTML 가져오기
response = httpx.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, "html.parser")
# 불필요한 요소 제거
for tag in soup.find_all(["script", "style", "nav", "footer"]):
tag.decompose()
# 메인 콘텐츠 텍스트 추출
text = soup.get_text(separator="\n", strip=True)
# 텍스트 길이 제한 (토큰 절약)
text = text[:5000]
client = instructor.from_openai(OpenAI())
return client.chat.completions.create(
model="gpt-4o-2026-02",
response_model=ProductInfo,
messages=[
{
"role": "system",
"content": (
"다음은 상품 페이지에서 추출한 텍스트입니다. "
"상품 정보를 정확히 추출하세요. "
"정보를 찾을 수 없는 필드는 null로 표시하세요."
)
},
{"role": "user", "content": text}
]
)테이블 형태의 데이터는 비정형 문서에서 가장 추출하기 어려운 구조 중 하나입니다. LLM을 활용하면 다양한 형태의 테이블을 일관된 구조로 변환할 수 있습니다.
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field
class TableCell(BaseModel):
"""테이블 셀"""
value: str = Field(description="셀 값")
is_header: bool = Field(default=False, description="헤더 셀 여부")
class ExtractedTable(BaseModel):
"""추출된 테이블"""
title: str = Field(description="테이블 제목 (유추 가능한 경우)")
headers: list[str] = Field(description="열 헤더 목록")
rows: list[list[str]] = Field(description="데이터 행 (각 행은 문자열 목록)")
row_count: int = Field(description="데이터 행 수 (헤더 제외)")
class DocumentTables(BaseModel):
"""문서에서 추출한 모든 테이블"""
tables: list[ExtractedTable] = Field(description="추출된 테이블 목록")
total_tables: int = Field(description="총 테이블 수")
def extract_tables(document_text: str) -> DocumentTables:
"""문서 텍스트에서 테이블을 추출합니다."""
client = instructor.from_openai(OpenAI())
return client.chat.completions.create(
model="gpt-4o-2026-02",
response_model=DocumentTables,
messages=[
{
"role": "system",
"content": (
"다음 문서에서 모든 테이블을 추출하세요. "
"테이블이 텍스트 형태로 표현되어 있더라도 구조를 파악하여 추출합니다. "
"각 테이블의 헤더와 데이터 행을 분리하세요."
)
},
{"role": "user", "content": document_text}
]
)문서에서 개체(Entity)를 식별하고, 개체 간 관계(Relationship)를 추출하는 것은 지식 그래프 구축의 기초입니다.
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal
class Entity(BaseModel):
"""추출된 엔티티"""
name: str = Field(description="엔티티 이름")
entity_type: Literal[
"person", "organization", "location",
"product", "event", "date", "amount"
] = Field(description="엔티티 유형")
description: str = Field(description="엔티티에 대한 간단한 설명")
class Relation(BaseModel):
"""엔티티 간 관계"""
source: str = Field(description="주체 엔티티 이름")
relation_type: str = Field(description="관계 유형 (예: CEO_OF, LOCATED_IN)")
target: str = Field(description="대상 엔티티 이름")
confidence: float = Field(
ge=0.0, le=1.0,
description="관계 추출 신뢰도"
)
class KnowledgeGraph(BaseModel):
"""추출된 지식 그래프"""
entities: list[Entity] = Field(description="추출된 엔티티 목록")
relations: list[Relation] = Field(description="엔티티 간 관계 목록")
def extract_knowledge_graph(text: str) -> KnowledgeGraph:
"""텍스트에서 엔티티와 관계를 추출하여 지식 그래프를 구성합니다."""
client = instructor.from_openai(OpenAI())
return client.chat.completions.create(
model="gpt-4o-2026-02",
response_model=KnowledgeGraph,
messages=[
{
"role": "system",
"content": (
"텍스트에서 모든 주요 엔티티를 식별하고, "
"엔티티 간의 관계를 추출하세요. "
"관계 유형은 UPPER_SNAKE_CASE로 작성하세요. "
"확실하지 않은 관계는 confidence를 낮게 설정하세요."
)
},
{"role": "user", "content": text}
]
)
# 사용 예시
text = """
삼성전자는 2024년 서울 강남구에 새로운 AI 연구소를 설립했습니다.
이재용 회장이 직접 개소식에 참석했으며, 초대 소장으로 김박사가 임명되었습니다.
이 연구소는 Google DeepMind와 공동 연구 협약을 체결할 예정입니다.
"""
kg = extract_knowledge_graph(text)
for entity in kg.entities:
print(f"[{entity.entity_type}] {entity.name}: {entity.description}")
for rel in kg.relations:
print(f" {rel.source} --{rel.relation_type}--> {rel.target} ({rel.confidence:.0%})")여러 문서에서 동일한 스키마로 데이터를 추출해야 하는 경우, 일관된 추출 품질을 유지하는 것이 중요합니다.
import asyncio
import instructor
from openai import AsyncOpenAI
from pydantic import BaseModel, Field
class ContractKeyTerms(BaseModel):
"""계약서 핵심 조건"""
contract_type: str = Field(description="계약 유형")
parties: list[str] = Field(description="계약 당사자")
effective_date: str = Field(description="계약 시작일")
expiration_date: str | None = Field(description="계약 종료일")
total_value: float | None = Field(description="계약 총액")
key_obligations: list[str] = Field(description="주요 의무사항")
termination_conditions: list[str] = Field(description="해지 조건")
async def extract_single_contract(
client: instructor.AsyncInstructor,
document_text: str,
doc_id: str
) -> tuple[str, ContractKeyTerms]:
"""단일 계약서에서 핵심 조건을 추출합니다."""
result = await client.chat.completions.create(
model="gpt-4o-2026-02",
response_model=ContractKeyTerms,
max_retries=2,
messages=[
{
"role": "system",
"content": "계약서에서 핵심 조건을 추출하세요."
},
{"role": "user", "content": document_text}
]
)
return doc_id, result
async def extract_batch_contracts(
documents: dict[str, str]
) -> dict[str, ContractKeyTerms]:
"""여러 계약서를 비동기로 동시 추출합니다."""
client = instructor.from_openai(AsyncOpenAI())
tasks = [
extract_single_contract(client, text, doc_id)
for doc_id, text in documents.items()
]
results = await asyncio.gather(*tasks, return_exceptions=True)
extracted = {}
for result in results:
if isinstance(result, Exception):
print(f"추출 실패: {result}")
else:
doc_id, data = result
extracted[doc_id] = data
return extracted대량의 문서를 동시에 처리할 때는 API Rate Limit에 주의해야 합니다. asyncio.Semaphore를 사용하여 동시 요청 수를 제한하는 것이 좋습니다. 이 부분은 6장에서 자세히 다룹니다.
Unstract는 비정형 데이터를 위한 LLM 기반 ETL 도구입니다. 코드 없이 PDF, 이미지, 이메일 등에서 데이터를 추출하는 워크플로우를 구성할 수 있습니다.
Unstract의 핵심 개념은 Prompt Studio로, 시각적 인터페이스에서 추출 프롬프트를 설계하고 테스트할 수 있습니다. 추출 결과는 JSON, CSV, 데이터베이스 등 다양한 형태로 내보낼 수 있습니다.
Unstract 같은 도구를 직접 구축하려면 이 시리즈에서 다루는 모든 기술 요소가 필요합니다. 스키마 정의, LLM 호출, 검증, 배치 처리, 폴백 전략 등이 통합되어야 합니다.
이번 장에서는 비정형 데이터에서 구조화된 정보를 추출하는 다양한 기법을 학습했습니다.
핵심 내용을 정리하면 다음과 같습니다.
6장에서는 대량 문서 처리를 위한 자동화 파이프라인을 구축합니다. 배치 처리, 비동기 추출, 품질 검증 루프, 셀렉터-LLM 하이브리드 접근, 비용 최적화 전략을 다룹니다.
이 글이 도움이 되셨나요?
대량 문서 처리 파이프라인을 구축하고, 배치 처리, 비동기 추출, 품질 검증 루프, 비용 최적화 전략을 학습합니다.
Pydantic v2로 LLM 출력 스키마를 정의하고, Instructor 라이브러리로 자동 재시도와 스트리밍 구조화 출력을 구현합니다.
전통 ETL과 LLM-enhanced ETL을 비교하고, Transform 단계에 LLM을 적용하여 분류, 요약, 정규화, 감성분석을 수행하는 방법을 학습합니다.