구조화된 데이터를 추출하기 위해 한 달 동안 LLM과 싸운 기록
요약
PDF 송장에서 구조화된 데이터를 추출하는 과정에서 겪은 비용, 일관성, 지연 시간 문제를 다룹니다. 단순 LLM 호출이나 정규 표현식의 한계를 넘어, 입력 구조화와 출력 검증을 결합한 스키마 가이드 추출 방식의 필요성을 설명합니다.
핵심 포인트
- 단순 LLM 호출은 비용, 일관성, 지연 시간 문제를 야기함
- 정규 표현식 기반 방식은 레이아웃 변형에 대응하기 어려움
- 전체 문서 입력 시 환각 및 스키마 드리프트 발생 가능성 높음
- 성공적인 추출을 위해 입력 구조화와 출력 검증 단계 분리 필요
수백 개의 PDF 문서에서 송장 품목(invoice line items)을 추출해야 했습니다. 날짜, 금액, 업체명 같은 것들이죠. AI를 사용하면 사소해 보였습니다. 하지만 모든 순진한 접근 방식은 비용을 낭비하거나, 값을 환각(hallucinate)하거나, 다양한 형식에 막혀버렸습니다.
제가 시도했던 것들, 실패한 것들, 그리고 마침내 효과를 본 기술을 소개합니다. 과장 없이, 솔직한 트레이드오프(trade-offs)만을 담았습니다.
진짜 문제
과거 인수 합병을 통해 얻은 PDF 송장 더미가 있었습니다. 레이아웃도 다르고, 폰트도 다르고, 일부는 스캔된 것이었습니다. 제 상사는 구조화된 CSV를 원했습니다. "그냥 LLM을 써"라고 말하더군요.
저는 가장 단순한 것부터 시작했습니다. PDF 텍스트를 GPT-4에 쏟아붓고 JSON 배열을 요청하는 프롬프트를 작성했습니다. 약 10개의 문서까지는 잘 작동했습니다. 하지만 200개에 도달하자 세 가지 벽에 부딪혔습니다.
- 비용 (Cost) – 송장 하나당 약 4K 토큰이 필요했고, 개당 $0.03로 계산하면 200개의 송장에 추출 비용만 $6가 들었습니다. 나쁜 수준은 아니지만, 이를 1만 개의 문서로 확장하면 돈이 줄줄 새게 됩니다.
- 일관성 부족 (Inconsistency) – 때때로 GPT는 리스트를 출력하고, 때로는 딕셔너리를 출력하며, 때로는 "데이터를 찾을 수 없습니다"라고 답했습니다.
- 지연 시간 (Latency) – 문서당 동기식 API 호출(synchronous API calls)이 각각 2~3초씩 걸렸습니다. 200개의 문서는 약 10분이 소요되었습니다. 배치(batch) 작업으로는 괜찮지만, 실시간(real-time)으로는 받아들일 수 없는 수준이었습니다.
제가 시도했던 것들 (실패한 방식)
순수 정규 표현식(Regex) 및 규칙 기반 방식
처음에는 이렇게 생각했습니다. "그냥 표일 뿐이야. 한 줄씩 파싱하면 돼." 저는 "Total: $X.XX"와 "Item: ..."를 잡아내기 위해 거대한 정규 표현식(regex)을 작성했습니다. 송장의 30%에서 작동했습니다. 나머지는 "Total Amount: $X" 또는 "TOTAL … $X"와 같은 변형이 있었습니다. 막다른 길이었습니다. 규칙을 패치(patching)하는 데 2주를 썼지만, 여전히 정확도는 50%에 머물렀습니다.
전체 문서를 LLM에 넣어 모두 추출하기
그다음에는 모든 곳에 LLM을 사용하는 방식으로 전환했습니다. (PyMuPDF를 통해) 전체 PDF 텍스트를 GPT-4에 입력하고 시스템 프롬프트로 "모든 품목을 JSON 배열로 추출하라"고 명령했습니다. 이 방식이 더 나았지만 여전히 문제가 있었습니다.
- 환각 (Hallucinations): 텍스트가 모호할 때 가짜 품목을 만들어냈습니다.
- 스키마 드리프트 (Schema drift): 어떤 때는
{"items": [...]}를 반환하고, 어떤 때는[{"item": ...}]를 반환했습니다. - 비용 (Cost): 긴 컨텍스트(long context) 때문에 송장당 $0.06가 들었습니다.
작은 모델을 파인튜닝(fine-tuning)하는 것도 시도해 보았습니다. 비용은 약간 저렴해졌지만 라벨링된 데이터(labeled data)가 필요했습니다. 200개의 문서를 위해 수 시간 동안 수동으로 라벨링을 해야 했습니다. 확장성(scalable)이 없었습니다.
결국 성공한 방법: 검증을 포함한 스키마 가이드 추출 (Schema-Guided Extraction with Validation)
돌파구는 문제를 두 단계로 분리했을 때 찾아왔습니다:
- 입력 구조화 (Structure the input) – PDF를 논리적 블록(예: 각 품목(line item)을 하나의 블록)으로 분할(chunking)합니다.
- 출력 검증 (Validate the output) – LLM이 엄격한 스키마(schema)를 출력하도록 강제한 다음, 이를 파싱(parse)하고 검증합니다.
1단계: 휴리스틱을 이용한 청킹 (Chunking by Heuristics)
PDF 전체를 한꺼번에 던지는 대신, 원문 텍스트를 추출하고 간단한 휴리스틱(heuristics)을 사용하여 각 품목을 분리했습니다. 송장(invoice)은 종종 설명, 수량, 단가, 합계를 구분하기 위해 탭(tab)이나 여러 개의 공백을 사용합니다. \n(?=\d+\.) 또는 \t+에 대한 빠른 정규 표현식(regex) 분할을 통해 후보 블록들을 얻을 수 있었습니다. 완벽하지는 않았지만 충분히 괜찮았습니다.
2단계: 스키마 가이드 추출 (Schema-Guided Extraction)
제가 원하는 결과물을 위해 Pydantic 모델을 정의했습니다:
from pydantic import BaseModel
from typing import Optional
...
그런 다음 각 청크(chunk)에 대해, 스키마를 JSON 형태로 포함하고 올바른 추출 사례를 몇 가지 포함한(few-shot) 프롬프트와 함께 LLM을 호출했습니다. 핵심은 함수 호출(function calling) / 도구 사용(tool use) API를 사용하여 구조화된 출력을 강제하는 것이었습니다. OpenAI 용어로는 다음과 같습니다:
import openai
response = openai.ChatCompletion.create(
...
이를 통해 LLM이 제가 정의한 정확한 필드만을 항상 반환하도록 강제할 수 있었습니다. 더 이상 스키마 드리프트(schema drift)는 발생하지 않았습니다.
3단계: 검증 및 재시도 (Validation & Retry)
재시도 루프(retry loop)를 추가했습니다. 만약 LineItem(**data)에서 검증 오류(예: quantity가 문자열인 경우)가 발생하면, 다음과 같은 메모와 함께 오류를 LLM에 다시 보냈습니다: “'quantity' 필드는 정수(integer)여야 합니다. 수정해 주세요.” 이 방법은 환각(hallucinations) 현상을 극적으로 줄여주었습니다.
for _ in range(2):
try:
line_item = LineItem(**data)
...
결과
- 비용 (Cost): gpt-4 대신 gpt-3.5-turbo를 사용하고 작은 청크 (chunks) 단위를 활용함으로써, 항목(line item)당 비용이 약 $0.003로 감소했습니다. 항목이 약 5개인 송장(invoice) 200개를 처리할 경우 총 $3가 소요됩니다.
- 정확도 (Accuracy): 형식이 잘 갖춰진 송장의 경우 100%에 육박하며, 스캔된 송장의 경우 약 85%의 정확도를 보였습니다 (여전히 정규 표현식 (regex)보다는 뛰어난 성능입니다).
- 지연 시간 (Latency): 병렬 처리 (비동기 HTTP 호출 (async HTTP calls))와 청킹 (chunking)을 통해 송장 200개를 약 2분 만에 처리했습니다.
트레이드오프 및 한계 (Trade-offs & Limitations)
- 청킹 휴리스틱 (Chunking heuristic): 매우 취약합니다. 송장에 명확한 구분자가 없는 경우 제가 만든 휴리스틱은 실패합니다. 이후에는 슬라이딩 윈도우 (sliding window) 방식 (텍스트 섹션이 겹치도록 하는 방식)으로 교체했습니다.
- 수백만 건의 데이터 처리 시 여전히 비싼 LLM: 대규모 확장이 필요한 경우, 귀하의 데이터로 학습된 전용 추출 모델 (예: LayoutLM)을 사용하는 것이 좋습니다.
- 스키마의 경직성 (Schema rigidity): 송장에 예상치 못한 필드(예: 스키마에 없는 "운송비(freight)")가 있는 경우, 해당 데이터를 놓치게 됩니다. 저는 모든 것을 포괄할 수 있도록 선택적 필드인
extra_info: str를 추가했습니다. - OpenAI 함수 호출 (function calling)에 대한 의존성: 특정 API에 종속되게 만듭니다. JSON 모드 (JSON mode)를 지원하는 로컬 모델 (Llama 3.2, Ollama 등)을 사용할 수도 있지만, 이 경우 속도가 느려질 수 있습니다.
다음에 다시 한다면 다르게 할 점 (What I’d Do Differently Next Time)
- 라벨링된 작은 테스트 세트로 시작하기: LLM을 건드리기 전에 청킹 휴리스틱을 조정하기 위해 단 20개의 송장이라도 먼저 준비하겠습니다.
- 검증 우선 설계 (validation-first design) 사용하기: Pydantic 스키마와 재시도 루프 (retry loop)를 먼저 작성한 다음, 나중에 LLM을 선택하겠습니다. 이 기술은 모델에 구애받지 않습니다 (model-agnostic).
- Interwest AI와 같은 도구 고려하기: (재시도 로직을 수동으로 코딩하지 않고도 이러한 추출 파이프라인을 구축할 수 있는 도구) – 나중에 이 도구를 발견했는데, 이를 사용했다면 일주일은 아낄 수 있었을 것입니다. 하지만 함정을 이해하기 위해 직접 구축한 것도 의미 있는 일이었습니다.
배운 점 (Lessons Learned)
황금률: 검증(validation)과 스키마 강제(schema enforcement) 없이는 LLM이 자유 형식의 JSON을 출력할 것이라고 절대 믿지 마세요. "이해(understanding)"와 "형식화(formatting)"의 역할을 분리하십시오. 입력을 청킹하고, 출력을 강제하며, 검증하고, 재시도하십시오.
이 접근 방식은 계약서, 영수증, 의료 기록 등 모든 문서 추출 작업에 적용 가능합니다. 핵심은 모델이 아니라 기술입니다.
저는 여전히 청킹 (chunking) 부분에 대해 반복적인 실험을 진행 중입니다. 완전히 비구조화된 테이블 (unstructured tables)은 어떻게 처리하시나요? 여러분만의 비결을 듣고 싶습니다. 비슷한 추출 악몽을 해결해 본 경험이 있다면 댓글을 남겨주세요.
지저분한 문서에서 구조화된 데이터 (structured data)를 추출하기 위한 여러분만의 기본 접근 방식은 무엇인가요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기