LLM으로부터 신뢰할 수 있는 구조화된 데이터를 마침내 얻어낸 방법
요약
LLM을 활용한 PDF 데이터 추출 과정에서 발생하는 JSON 형식 불일치와 환각 문제를 해결하는 방법을 다룹니다. 단순 프롬프트 엔지니어링의 한계를 지적하며, 함수 호출(Function Calling)과 Pydantic을 이용한 런타임 검증의 결합이 해결책임을 제시합니다.
핵심 포인트
- 프롬프트 엔지니어링만으로는 구조화된 데이터의 일관성 보장이 어려움
- 입력 텍스트 변화에 따라 JSON 키와 형식이 변하는 취약성 존재
- 함수 호출(Function Calling) 기능을 활용한 구조적 출력 유도
- Pydantic 등을 이용한 런타임 스키마 검증의 중요성
지난달 제가 저질렀던 난장판에 대해 이야기해 보겠습니다.
저는 고객의 PDF에서 송장 번호, 날짜, 품목(line items), 합계와 같은 주요 세부 정보를 추출하는 작은 내부 도구를 만들고 있었습니다. 전형적인 자동화 작업이었죠. 계획은 간단했습니다. PDF 텍스트를 LLM (Large Language Model)에 쏟아붓고, JSON 형식을 정중하게 요청하면, 짠 — 데이터베이스에 삽입할 준비가 된 구조화된 데이터가 완성되는 것이었습니다.
물론, 제대로 작동하지 않았습니다. 근처에도 못 갔죠.
사라지지 않는 문제
처음 몇 번의 테스트는 유망해 보였습니다. 저는 다음과 같은 프롬프트(prompt)와 함께 짧은 송장을 GPT-3.5-turbo에 보냈습니다:
다음 텍스트에서 송장 번호, 날짜, 총액, 품목을 추출하세요. JSON으로 반환하세요.
한 번은 완벽하게 형식이 맞춰진 JSON을 반환했습니다. 하지만 다른 송장을 시도하자 "Invoice Number"(대문자 시작), "inv_num"(언더스코어), 심지어 한 번은 "INVOICE_NUMBER"(모두 대문자)와 같은 키(key)들이 나왔습니다. JSON의 구조가 매번 바뀌었습니다. 때로는 품목을 배열(array)로 감싸기도 하고, 때로는 객체(object)로 만들기도 했습니다. 제 파서(parser)는 문서 세 개 만에 망가졌습니다.
예시를 사용하는 퓨샷 프롬프팅 (few-shot prompting)도 시도해 보았습니다. 그것이 약간의 도움이 되어 이제 키(key)는 일관되게 나왔지만, 값(value)에는 여전히 이상한 오타가 있었고 ("1,234.56" vs "1234.56"), 가끔은 존재하지 않는 품목을 환각 (hallucination)하기도 했습니다.
저는 이틀 동안 프롬프트를 수정하는 데 시간을 보냈습니다. 온도를 0으로 설정했나요? 확인했습니다. 명시적인 스키마 (schema)가 포함된 시스템 프롬프트 (system prompt)? 확인했습니다. 생각의 사슬 (Chain-of-thought)? 확인했습니다. 그 무엇도 프로덕션 (production) 환경에 필요한 신뢰성을 제공하지 못했습니다.
막다른 길: 프롬프트 엔지니어링만으로는 부족함
오해는 마세요. 프롬프트 엔지니어링 (prompt engineering)은 강력합니다. 하지만 구조화된 데이터 추출에 있어서는 취약합니다. 입력 텍스트의 미세한 변화(다른 문구, 추가 공백, 엉뚱한 표 등)가 모델의 출력 형식을 변화시킬 수 있기 때문입니다. 그리고 고객사의 PDF 형식을 제가 제어할 수 없었기 때문에, 저는 막다른 길에 다다랐습니다.
심지어 프롬프트에 JSON 스키마를 사용하고 모델에게 이를 채우도록 지시하는 방법도 시도했습니다. 다음과 같이 작성하곤 했습니다:
{
"invoice_number": "string",
"date": "date (YYYY-MM-DD)",
...
하지만 모델은 "date (YYYY-MM-DD)"를 리터럴 문자열 (literal string)로 해석하거나, 소스 텍스트가 모호할 경우 제약 조건을 무시하곤 했습니다. 검증은 사후에 이루어졌고, 그때는 이미 너무 늦었습니다. 데이터가 이미 손상된 상태였기 때문입니다.
마침내 성공한 방법: 함수 호출 (function calling) + 런타임 검증 (runtime validation)
돌파구는 단일 프롬프트 (single prompt)로부터 완벽한 JSON을 얻겠다는 생각을 버렸을 때 찾아왔습니다. 대신, GPT-4 및 이후 모델에서 사용할 수 있는 기능인 LLM의 함수 호출 (function calling) 능력을 활용하고, 이를 런타임 스키마 검증 (runtime schema validation)과 결합했습니다.
기본적인 아이디어는 다음과 같습니다:
- 엄격한 스키마를 가진 함수를 정의합니다 (Python의 Pydantic과 같은 도구 사용).
- 모델에게 구조화된 출력을 반환하기 위해 이 함수를 호출할 수 있다고 알려줍니다.
- 모델이 함수 호출 인자 (function call argument)를 (JSON 형식으로) 반환하면, 해당 JSON을 스키마에 따라 검증합니다.
- 검증에 실패하면, 오류를 모델에 다시 전달하고 재시도하도록 요청합니다.
이를 통해 단발성 생성 (one-shot generation)이 모델이 정확한 오류 메시지를 바탕으로 스스로를 수정할 수 있는 루프 (loop)로 바뀌었습니다.
코드 예시
저는 OpenAI의 함수 호출 기능이 포함된 채팅 완성 (chat completions)을 사용하여 Python으로 이를 구현했습니다. 다음은 단순화된 버전입니다:
import json
from pydantic import BaseModel, Field, ValidationError
from openai import OpenAI
...
핵심 라인은 ValidationError를 잡아내는 부분입니다. 단순히 오류를 기록하는 대신, 이를 가짜 함수 응답 (fake function response)으로 다시 보냅니다. 그러면 모델은 자신의 출력을 조정합니다. 제 테스트 결과, 이 루프는 한 번의 재시도 이상의 과정이 거의 필요하지 않았습니다.
배운 점
- 검증(Validation)은 사후 처리 단계가 아니라 루프의 일부여야 합니다. 오류가 모델로 다시 피드백되어 모델이 스스로 수정할 수 있게 만듭니다.
- 함수 호출(Function calling)은 프롬프트보다 출력 형식을 훨씬 더 잘 제한합니다. 모델은 함수 매개변수에 정의된 스키마(Schema)와 일치하는 유효한 JSON을 생성해야 함을 인지합니다.
- Pydantic의 스키마 생성은 구세주와 같습니다. 런타임 검증(Runtime validation)과 OpenAI 함수 정의 모두를 위한 단일 진실 공급원(Single source of truth)을 제공합니다.
- 하지만 마법은 아닙니다. 복잡한 스키마(중첩된 배열, 기본값이 있는 선택적 필드 등)는 때때로 모델을 혼란스럽게 할 수 있습니다. 스키마를 가능한 한 평탄하고 단순하게 유지하세요.
트레이드오프(Trade-offs) 및 사용하지 말아야 할 경우
이 방식은 지연 시간(Latency)을 추가합니다(한 번의 재시도 ≈ 비용과 시간의 두 배). 만약 실시간 채팅처럼 무엇보다 속도가 중요하다면, 가끔 오류가 발생하더라도 더 단순한 프롬프트 전용 방식이 더 나을 수 있습니다.
또한
LLM으로부터 신뢰할 수 있는 구조화된 데이터를 얻는 것은 가능하지만, LLM이 불완전하다는 사실을 인정해야 합니다. 저는 영리한 프롬프트로 그 불완전함과 싸우는 대신, 모델이 스스로를 수정할 수 있는 방법을 제공함으로써 그 불완전함과 함께 작업하는 법을 배웠습니다. 함수 호출 (Function calling) + 검증 (Validation) 루프는 화려하지는 않지만, 확실히 작동합니다.
이제 궁금합니다. 여러분은 LLM으로부터의 구조화된 출력 (Structured output)을 어떻게 처리하시나요? 비슷한 방식을 시도해 보셨나요, 아니면 완전히 다른 접근 방식을 가지고 계신가요? 댓글로 알려주세요. 저는 항상 이 루프를 개선할 방법을 찾고 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기