왜 정규 표현식(Regex)으로 내 인보이스를 파싱할 수 없었는가 (그리고 무엇이 해결했는가)
요약
인보이스 데이터 추출을 위해 정규 표현식과 OCR, 미세 조정 모델을 시도했으나 실패한 경험을 공유합니다. 결국 LLM의 함수 호출(Function calling) 기능을 활용해 구조화된 JSON 출력을 얻음으로써 문제를 해결했습니다.
핵심 포인트
- 정규 표현식과 규칙 기반 방식은 다양한 인보이스 레이아웃 대응에 한계가 있음
- 소규모 데이터셋을 활용한 모델 미세 조정은 변동성 대응에 어려움이 있음
- LLM의 함수 호출(Function calling)을 통한 구조화된 출력 추출이 가장 효과적임
나는 인보이스 데이터 추출을 위한 정규 표현식 (Regex) 파이프라인을 구축하는 데 3번의 주말을 보냈다. 결국 100개의 PDF 테스트 세트에서 63%의 정확도를 달성했다. 나의 공동 창업자는 나를 바라보며 말했다. "이게 프로덕션 (Production) 단계에서 쓸 수 있는 수준인가요?"
아니요. 전혀 아니었습니다.
이것은 내가 모든 예외 케이스 (Edge case)를 능가하려고 애쓰는 것을 멈추고, 이 문제를 본질적인 모습인 언어 이해 (Language understanding) 작업으로 다루기 시작한 이야기입니다. 그리고 아니요, 해결책은 내 100개의 인보이스로 모델을 미세 조정 (Fine-tuning)하는 것이 아니었습니다. 진짜 비결은 훨씬 더 간단했습니다.
문제: 모든 인보이스는 제각각이다
우리는 작은 비용 관리 도구를 만들고 있었습니다. 사용자가 PDF 인보이스를 업로드하면, 우리는 공급업체 이름, 날짜, 총액, 그리고 품목 (Line items)을 추출해야 했습니다. 간단해 보이죠, 그렇죠?
하지만 인보이스는 서로 다른 국가, 언어, 레이아웃 (Layout)에서 옵니다. 어떤 것은 스캔된 이미지 (OCR을 통해)이고, 어떤 것은 테이블처럼 보이지만 실제로는 테이블이 아닌 HTML 내보내기 파일입니다. 어떤 것에는 할인, 세금 열, 배송비가 포함되어 있습니다. 한 공급업체는 총액을 우측 상단에 배치합니다. 다른 업체는 법적 문구 단락 뒤의 좌측 하단에 배치합니다.
나는 PyTesseract와 정규 표현식 (Regex) 패턴의 숲으로 시작했습니다. 공급업체가 추가될 때마다 새로운 패턴을 추가했습니다. 그것은 빠르게 감당할 수 없게 되었습니다. 또 다른 공급업체가 실제로는 손으로 쓴 영수증이 포함된 이미지가 삽입된 PDF를 보낸 후, 나는 다른 접근 방식이 필요하다는 것을 알게 되었습니다.
시도했지만 실패했던 것들
첫째, OCR + 규칙 (Rules)을 시도했습니다. Tesseract는 괜찮지만, 레이아웃 보존 (Layout preservation) 능력이 형편없습니다. 병합된 셀이 있는 테이블은 텍스트의 덩어리가 되어버립니다. 그 상태에서 정규 표현식 (Regex)을 사용하는 것은 추측에 불과합니다.
다음으로, Camelot이나 Tabula 같은 레이아웃 파서 (Layout parsers)를 시도했습니다. 이것들은 깔끔한 테이블이 있는 디지털 PDF에는 잘 작동하지만, 스캔된 문서나 명시적인 테이블 테두리가 없는 인보이스에서는 실패합니다.
그 후, 개체명 인식 (Named Entity Recognition, NER)을 위해 작은 BERT 모델을 미세 조정 (Fine-tuning)하는 것을 고려했습니다. 나는 300개의 인보이스를 수동으로 라벨링했습니다. 일주일간의 학습 후, 검증 (Validation) 세트에서 74%의 F1 점수를 얻었습니다. 하지만 학습 데이터가 충분한 변동성 (Variance)을 커버하지 못했기 때문에 프로덕션 (Production) 정확도는 더 낮았습니다.
그리고 7B 파라미터 (7B parameter) LLM을 파인튜닝 (Fine-tuning) 한다고요? 그것은 새로운 인보이스 형식이 나타날 때마다 GPU 시간과 비용이 많이 드는 지속적인 재학습 (Retraining)을 요구합니다. 사이드 프로젝트 (Side project)로서는 실용적이지 않습니다.
결국 해결책이 된 것: 범용 LLM으로부터의 구조화된 출력 (Structured output)
LLM이 텍스트에서 정보를 추출할 수 있다는 것은 알고 있었지만, 어려운 점은 신뢰할 수 있는 구조화된 JSON을 다시 받아내는 것이었습니다. 돌파구는 스키마 (Schema)를 강제하기 위해 함수 호출 (Function calling) (도구 사용 (Tool use)이라고도 함)을 사용하는 것이었습니다. "인보이스 총액을 알려줘"라고 요청하는 대신, 타입이 지정된 파라미터 (Typed parameters)를 가진 함수를 정의합니다:
{
"name": "extract_invoice",
"parameters": {
...
그러면 LLM은 해당 파라미터들이 채워진 함수 호출을 출력합니다. 마크다운 (Markdown)도, 자유 형식의 텍스트 (Free text)도 없습니다. 매번 파싱 가능한 JSON 객체를 얻게 됩니다 (데이터를 찾을 수 없는 경우 거부 응답이 올 수도 있습니다).
처음에는 OpenAI의 API로 시작했지만, 현재 많은 제공업체가 동일한 형식을 지원한다는 것을 발견했습니다. 예를 들어, https://ai.interwestinfo.com/의 엔드포인트 (Endpoints) 또한 함수 스키마를 수용하며, 이는 제가 API 종속 (Lock-in)을 피하고 싶었을 때 도움이 되었습니다.
코드: 최소한의 추출 파이프라인 (Extraction pipeline)
제가 사용하는 핵심 함수입니다. OCR 텍스트 (또는 원본 PDF 텍스트)를 입력받아 구조화된 데이터를 반환합니다.
import json
from openai import OpenAI
...
이것이 전부입니다. 인보이스당 한 번의 API 호출만 수행합니다. 그런 다음 업로드 시 배치 작업 (Batch job)으로 이를 실행하고 JSON을 저장합니다. 만약 LLM이 중요한 필드에 대해 null을 반환하면, 수동 검토 (Manual review) 대상으로 표시합니다.
교훈과 트레이드오프 (Trade-offs)
- 비용 (Cost): 모델과 텍스트 길이에 따라 추출당 약 $0.002–0.01가 소요됩니다. 한 달에 1,000개의 인보이스를 처리한다면 $2–10가 듭니다. 이는 수동 데이터 입력 사무원보다 저렴합니다.
- 지연 시간 (Latency): 인보이스당 2~5초가 걸립니다. 백그라운드 처리 (Background processing)에는 적합하지만, 실시간 (Real-time) 처리에는 적합하지 않습니다.
- 정확도 (Accuracy): 현재 테스트 세트에서 약 92%의 정확도를 얻고 있습니다. 오류는 주로 인보이스가 극도로 지저분하거나 데이터가 누락되었을 때 발생합니다. 함수 호출 (Function calling) 모델은 무관한 숫자를 무시하는 능력이 놀라울 정도로 뛰어납니다.
- 모델의 중요성 (The model matters): GPT-4o-mini는 매우 잘 작동합니다. GPT-3.5-turbo와 같은 이전 모델들은 때때로 필드 이름을 환각 (Hallucinate)하거나 잘못된 형식의 JSON을 생성합니다. 최신 지시 이행 (Instruction-following) 모델을 사용하세요.
- 스키마 설계 (Schema design): 필드를 관대하게 선택 사항 (Optional)으로 만드세요. 모든 품목 (Line item)을 필수 사항으로 요구하면, 모델이 가짜 데이터를 만들어낼 수 있습니다. null 값을 허용하고 이를 후속 단계 (Downstream)에서 처리하는 것이 더 낫습니다.
이 접근 방식을 사용하지 말아야 할 때
- 인보이스가 매우 표준화되어 있다면 (예: 모두 동일한 공급업체로부터 오는 경우), 규칙 기반 파서 (Rule-based parser)가 더 저렴하고 빠를 것입니다.
- 실시간 추출이 필요하다면 (예: 결제 처리 중), 지연 시간이 너무 높을 수 있습니다.
- 민감한 데이터를 다루는 경우, 외부 API로 데이터를 전송하는 것이 컴플라이언스 (Compliance)를 위반할 수 있습니다 (단, 현재 일부 제공업체는 온프레미스 (On-premise) LLM을 제공합니다).
다음에 다시 한다면 다르게 할 점
정규 표현식 (Regex)과 싸우는 대신, 첫날부터 바로 LLM 함수 호출 (Function calling)로 시작했을 것입니다. 또한 검증 단계 (Validation step)를 추가했을 것입니다. 즉, 합계 금액을 숫자로 파싱한 뒤 품목 (Line item)들의 합계와 교차 검증하여, 불일치할 경우 검토 대상으로 표시하는 방식입니다. 이 방법만으로도 남은 오류의 약 절반을 잡아낼 수 있습니다.
그리고 작은 피드백 루프 (Feedback loop)를 구축하는 데 시간을 투자했을 것입니다. 사용자가 추출 내용을 수정하면, 그 수정 사항을 학습 신호 (Training signal)로 다시 보내는 방식입니다 (미세 조정 (Fine-tuning) 또는 퓨샷 프롬프팅 (Few-shot prompts) 용도). 하지만 이런 과정 없이도 기본 성능은 탄탄합니다.
결국 세 번의 주말 동안 정규 표현식 지옥을 겪은 끝에, 마침내 제대로 작동하는 것을 만들어냈습니다. 코드는 겨우 30줄 남짓입니다. 어려운 부분은 코드가 아니었습니다. 내 문제가 레이아웃 (Layout)이 아니라 언어 (Language)였다는 사실을 깨닫는 것이었습니다.
엉망진창인 문서를 파싱(Parsing)하면서 겪었던 여러분의 공포스러운 경험은 무엇인가요? 여러분은 그 문제를 어떻게 해결했는지, 혹은 여전히 어떤 부분에서 어려움을 겪고 있는지 듣고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기