Work/OCR-VLM

[OCR/VLM 기반 비정형 문서 자동 구조화] 1. 프로젝트 개요 & 아키텍처 설계

raeul0304 2026. 4. 20. 17:09

📌 왜 만들었나

 

장비에서 추출되는 PDF 파일에는 여러 실험 결과 테이블이 존재하는데,

장비마다 레이아웃이 다르고 이를 수기로 입력을 해야할 때 많은 인력/시간 비용이 들어감과 동시에 human error로 인한 문제가 발생하고 있어 이를 해결하기 위한 시스템이 필요하다고 하셨다.

 

장비마다 추출되는 레이아웃은 비교적 고정적이지만,

이걸 어느정도 솔루션화 해서 범용적으로 사용될 수 있는 것을 원하시는 듯 했다.

 

초반에 우리 부서는 로컬 OCR 모델들은 다양한 테이블의 형태들을 모두 커버하기에 무리가 있어 (그리드가 없거나, 값이 여러 행을 걸쳐 있을 경우, 혹은 주변에 메타데이터가 많을 경우) openai를 사용해 테이블 데이터 추출을 했다. (프롬프트는 아래 공유해두겠다)

하지만 장당 약 15원 정도의 비용이 발생을 하고, 외부 llm을 사용할 수 없는 기업에서는 보안 문제가 발생할 수 있어

로컬 모델들을 활용한 OCR 작업을 하는 것을 목표로 하게 되었다.

 

기존 단순 OCR 방식으로는 정확한 구조적 데이터 추출이 어려워, 본 파이프라인은 이러한 비정형 문서를 자동으로 처리하여 테이블 데이터를 정형화하고, 반복 출현하는 동일 양식 문서에 대해 템플릿을 등록, 재활용함으로써 추출 작업의 효율성과 일관성을 확보하기 위해 개발되었다.

 

 

 

🔻 처리해야하는 PDF 유형

 

[단일 페이지, 단일 테이블] - 주요 오류 발생 사항 : 빈 값이 근처 값들로 채워지거나, 컬럼을 잘못 분리하는 오류

 

 

[단일 페이지, 복수 테이블] - 주요 오류 발생 사항 : 스키마가 다른 테이블을 분리하지 못하고 하나의 테이블로 인식하여 추출, 컬럼 누락 및 분리의 오류(하나의 컬럼을 두 개로 분리)

 

 

 

 

[복수 페이지, 복수 테이블] - 주요 오류 발생 사항 : 명확한 그리드가 없어 컬럼 밀림/누락 현상, 메타데이터 포함 텍스트 추출

 

 

 

[테이블 레이아웃이 다양한 경우] - 주요 오류 발생 사항 : 행이 길어질 경우 텍스트 추출 정확도가 저하됨, 전체 페이지에서 테이블이 차지하는 영역이 작아 테이블로 인식을 못하거나 값이 누락되는 경우 발생

 

 

 

위와 같은 유형과 오류를 모두 핸들링 할 수 있는 시스템 구축을 목표로 했다.

 

 

 

 

🚀 전체 시스템 아키텍처

 

🔹 서비스 구성

 

이어지는 포스트에서 왜 이러한 설계를 하게 되었는지에 대해 더 자세히 설명을 하겠지만,

회사 서버 설치된 GPU는 A6000 두 장이지만, 이걸 나 혼자 사용할 수 있는 것이 아니기 때문에 GPU가 넉넉하지 못했다.

최종적으로 선택하게 된 PaddleOCR-VL과 Table Recognition V2는 dependency 충돌로 서로 다른 환경에서 구동을 해야했기도 했고, Qwen 모델들까지 올리니 종종 GPU가 모자라는 문제가 발생했다.. 이에 대한 많은 경험이 없었는지라 많이 아쉬움이 남지만, 처음부터 MLOps에 태웠으면 어땠을까 생각이 들기도 한다. 나중에 좀 여유가 생기면 한 번 따로 시도를 해보고 싶다.

 

아무튼, PaddleOCR-VL과 Table Recognition V2 모델이 같은 데이터를 공유하면서 다른 환경에서 작동이 되도록 개별 서버에 띄워 작동을 시켰고, DB 및 공유 폴더에 중간 과정을 적재하면서 작업을 이어나갔다.

 

서비스명 포트 환경(env) 주요 모듈 / 역할
ocr_service 8001 paddleocrvl server.py (진입점 · 인증 · job 관리) paddleocr_vl.py (PaddleOCR-VL 레이아웃·OCR) paddleocr_page_worker.py (subprocess 격리 추론)
table_service 8002 ocr_llm server.py (Table Rec v2 + Qwen-VL
오케스트레이터) table_rec_v2.py (테이블 Grid 추출) process_with_vlm.py (VLM 정제 파이프라인) vlm.py (Qwen-VL 모델 래퍼)
main (gateway) 8000 공용 main.py (작업 흐름 조율, 서비스 간 호출
순서 관리) common/config.py · common/database.py
Streamlit UI 8501 공용 app.py (결과 열람, 자연어 질의, 정제 요청, 템플릿 지정)

 

 

 

🔹 GPU 할당 구조

 

GPU 장치 적재 모델 비고
GPU 0 RTX A6000 #0 Table Rec v2,
Qwen-VL
CUDA_VISIBLE_DEVICES=0 / ocr_llm env
GPU 1 RTX A6000 #1 PaddleOCR-VL CUDA_VISIBLE_DEVICES=1 / paddleocrvl env

 

 

 

🔹 데이터베이스 스키마

 

테이블 저장 내용
tenants SaaS 멀티테넌트 정보 (tenant_id, api_key)
jobs 작업 단위 메타데이터 (job_id, tenant_id, status, file_name, page_count, error_msg)
ocr_results PaddleOCR-VL 출력 (페이지별 OCR JSON, 크롭 이미지 경로, identification_metadata)
table_results Table Rec v2 + VLM 정제 결과 (테이블별 DataFrame JSON, layout_json)
templates 사용자 지정 템플릿 (테이블 식별용 컬럼 구성, tenant별 관리)
saved_tables 사용자가 최종 확정한 테이블 데이터 (save_tables 시점의 DataFrame JSON 영구 저장)

 

 

 

 

 

🚀 처리 프로세스 및 단계별 산출물

전체 파이프라인은 STEP 1 ~ STEP 7로 구성되며,

각 단계는 독립적으로 재시도 가능하도록 중간 결과를 PostgreSQL에 저장한다.\

 

STEP 1 PDF → 페이지 이미지 변환
서비스/모듈: ocr_service |  paddleocr_vl.py
입력: 사용자 업로드 PDF (file_bytes)
산출물: 페이지별 PNG 이미지 (page_001.png … page_NNN.png) 저장 경로: {CROP_BASE_DIR}/{job_id}/paddleocr/pages/
상세: PDF DPI 300 기준으로 래스터라이징하여 페이지 단위 이미지를 생성한다. 이후 처리 단계는 모두 이 이미지를 입력으로 사용한다.
STEP 2 레이아웃 분석 & 텍스트/영역 크롭
서비스/모듈: ocr_service | paddleocr_page_worker.py (subprocess 격리)
입력: 페이지 PNG 이미지
산출물: layout_json (영역 유형·좌표·OCR 텍스트), 크롭 이미지: text/, table/, chart/ 서브디렉토리별 저장, DB: ocr_results 테이블 INSERT
상세: PaddleOCR-VL pipeline.predict() subprocess로 격리하여 호출한다. GPU 데드락(CPU 100% / GPU 0% )을 방지하기 위해 페이지당 1개의 독립 프로세스로 실행하며, 완료 후 GPU 메모리를 즉시 해제한다. 각 페이지에서 text / table / chart 영역을 탐지하고 좌표 기반으로 이미지를 크롭한다.
STEP 3 테이블 Grid 구조 추출
서비스/모듈: table_service |  table_rec_v2.py
입력: table/ 크롭 이미지
산출물: HTML 테이블 문자열, grid 이미지 (table_grid_NNN.png) ,
저장 경로: {CROP_BASE_DIR}/{job_id}/table_rec/

상세: PaddleX Table Recognition v2 파이프라인을 통해 크롭된 테이블 이미지에서 셀 Grid 구조를 추출하고 HTML로 변환한다. HTML은 이후 VLM 정제
단계의 보조 참조 자료로 활용된다.
STEP 4 VLM 기반 테이블 구조 정제
서비스/모듈: table_service |  process_with_vlm.py
입력: table 크롭 이미지 + table_rec_v2 HTML
산출물: sub_tables JSON (block_id, headers, data_rows, anchor 정보),
DB: table_results
테이블 INSERT

상세: Qwen-VL 모델에 크롭 이미지와 HTML을 함께 입력하여 단일 패스로 테이블 구조를 추출한다. 처리 내용은 다음과 같다.
-
헤더 재구성: 다중 행 헤더 병합 (top-down), 메타데이터 행 제거
- sub-table
분리: 하나의 크롭에 복수 테이블이 포함된 경우 anchor 기반으로 분리 - 컬럼 정렬: exact match → approximate match 순으로 열 매핑

- 누락값 복원: 이미지 재참조 방식으로 VLM에 재질의하여 빈 셀 값을 보완
프롬프트 설계 원칙으로 ROW LABEL RULE을 적용하여 도메인 특화 하드코딩 없이 범용적으로 동작한다.
STEP 5 후처리 (Postprocess)
서비스/모듈: table_service |  process_with_vlm.py
입력: sub_tables JSON (STEP 4 출력)
산출물: 최종 DataFrame JSON, DB: postprocess_results 테이블 INSERT
상세: STEP 4 raw sub_tables 결과에 대해 추가 정제 작업을 수행한다.
- 테이블 분리 요청: 사용자 지시 또는 자동 감지 시 단일 테이블을 복수로 분리
- 자연어 질의 기반 수정: 사용자의 자연어 입력을 text-to-pandas 방식으로
해석하여 layout_json 업데이트

- 이 단계의 결과는 Streamlit UI에서 실시간으로 확인·수정 가능하다.
STEP 6 식별 메타데이터 추출 (identification_metadata)
서비스/모듈: table_service |  process_with_vlm.py
입력: 최종 정제 완료된 테이블 DataFrame + 원본 레이아웃 JSON
산출물: identification_metadata JSON - 항목: 문서 제목, 작성 일자, 페이지 번호 등 동적 추출,  DB: ocr_results 컬럼 UPDATE
상세: 정제 작업이 완전히 완료된 이후, 각 테이블을 고유하게 식별하기 위한
메타데이터를 추출하는 단계이다. VLM 또는 규칙 기반 방식으로 문서 제목·작성일·테이블 제목·페이지 위치 등을 추출하여 ocr_results 테이블의 identification_metadata 필드에 저장한다.
STEP 7 Streamlit UI 결과 열람
서비스/모듈: Streamlit →  FastAPI 백엔드 연동  |  app.py
입력: DB 저장된 job 결과 (ocr_results, table_results, postprocess_results)
산출물: 화면 렌더링: PDF 원문 뷰어() + OCR 결과 테이블() 병렬 표시
상세: Streamlit 화면은 PDF 원문 뷰어() OCR 결과 테이블()를 나란히
표시하여 사용자가 추출 결과를 원문과 대조할 수 있도록 한다.
Streamlit rerun
으로 인한 LLM 중복 호출을 방지하기 위해 Streamlit 계층과 FastAPI 백엔드를 분리하는 순수 오케스트레이터 패턴을 채택하였다. 이후 STEP 8~10의 사용자 액션은 모두 이 화면에서 진행된다.
STEP 8 자연어 질의 처리 (Text-to-SQL)
서비스/모듈: Streamlit →  FastAPI 백엔드 (POST /table/query) 
입력: 사용자 자연어 입력 + 현재 테이블 DataFrame (선택된 table_id 기준)
산출물: 수정된 DataFrame JSON, DB: table_results.layout_json UPDATE
상세: Streamlit 하단 채팅 입력창에서 사용자가 자연어로 테이블 수정을 요청하면, Qwen2.5-Coder 모델이 해당 요청을 pandas 코드로 변환하여 실행한다.
실행 결과로 변경된 DataFrame table_results 테이블의 layout_json 필드에
업데이트된다. 질의 이력은 세션 내 채팅 히스토리로 관리하며,
동일 대화 맥락에서 연속 수정이 가능하다.
STEP 9 템플릿 설정 (지정 · 저장 · 추천 · 적용)
서비스/모듈: Streamlit →  FastAPI 백엔드 (POST /table/template)
입력: 사용자가 선택한 컬럼 구성 + 현재 테이블 메타데이터
산출물: DB: templates 테이블 INSERT/UPDATE
         
템플릿 매칭 결과: 추천 템플릿 목록 반환 및 자동 적용

상세: 사용자는 Streamlit UI에서 현재 테이블의 컬럼을 선택하여 템플릿으로
지정하고 저장할 수 있다. 저장된 템플릿은 tenant 단위로 관리되며, 이후 동일
양식의 문서가 업로드될 때 컬럼 구성 유사도를 기반으로 적합한 템플릿을 추천한다. 추천된 템플릿을 적용하면 수작업 없이 컬럼 매핑이 자동으로 수행되어 반복 문서 처리 효율을 높인다.
STEP 10 결과 DB 저장 (save_tables)
서비스/모듈: Streamlit →  FastAPI 백엔드(POST /table/save)
입력: 최종 확정된 테이블 DataFrame (STEP 8~9 반영 완료본)
산출물: DB: saved_tables 테이블 INSERT
저장 항목: job_id, table_id, tenant_id, 최종 DataFrame JSON, 저장 시각
상세: 사용자가 자연어 질의(STEP 8) 및 템플릿 적용(STEP 9)을 통해 테이블을
최종 확정한 후 저장을 요청하면, 해당 시점의 DataFrame saved_tables 테이블에 저장한다. table_results layout_json과는 별도로 저장되어, 사용자가
명시적으로 확정한 최종본과 중간 작업본을 구분 관리한다.

 

 

 

 

 

 

🚀 전체 파이프라인 흐름

 

 

 

우선 paddleocr-vl 모델의 predict 결과를 보면, 레이아웃을 분류해주는 것을 확인할 수 있다.

block 단위로 분류가 되며, 각 block은 유형에 따라 bbox, content, coordinates 등 추가 정보들이 포함된다.

오류 사항에 따라 chart와 table로 분류된 블록의 bbox 값을 기반으로 이미지를 crop하여 각각 chart, table 폴더에 이미지를 임시 저장하였고, 이를 paddleocr-vl에 다시 넣어주어 최종 table 후보를 추려냈다. 일부 pdf에서 chart에 table이 포함되는 경우를 핸들링하기 위해 채택하였지만, 일반적인 경우에는 한 번만 모델을 돌려도 될 것 같다. 최종 업데이트된 json을 DB에 적재하였고, 한 번 request가 들어올 때마다 job을 생성하여 트래킹이 가능하도록 했다.

 

그 다음. 최종 table 후보 이미지에 그리드를 그려주기 위해 table recognition v2에 이미지들을 넣어주었다.

그리드를 넣어줬을 경우 VLM이 후보정을 할 때 보다 정확하게 테이블 구조를 파악함을 확인하여 선택한 layer다.

Table Recognition V2 모델 역시 html 형식으로 테이블 내 텍스트 및 구조를 추출하지만, paddleocr-vl 이하의 성능을 보여주어 따로 텍스트 값을 활용하지는 않았다. 그리고 이 모델 역시 테이블이 아닌 이미지에 대해서는 별도의 작업을 하지 않기 때문에 최종 테이블 이미지를 추려내는 데 활용이 되었다.

 

이렇게 추출된 테이블 이미지와 JSON을 VLM 모델(Qwen3-VL-8B)에 넣어 헤더를 재구성하고, 테이블 밖의 주변 메타데이터를 제거하고, 합쳐진 테이블을 분리하도록 후보정 작업을 이어나갔다.

 

VLM은 행이 길어졌을 때 텍스트 추출의 정확도가 떨어지기 때문에 컬럼 재배치에 주력하도록 했고, 텍스트 값은 paddleocr-vl이 산출한 텍스트 값을 사용하되, 누락된 컬럼이 있을 경우 VLM이 추출한 값을 활용하도록 했다.

 

text-to-sql을 연동하여 사용자가 요청한대로 테이블이 수정될 수 있도록 하였고,

테이블에 대한 템플릿을 만들어 저장함으로써, 유사한 테이블이 들어왔을 때 템플릿을 추천하여 해당 템플릿대로 테이블이 자동 가공될 수 있도록 최종 구현을 하였다.

 

레퍼런스가 많이 없어 삽질을 많이하게 된 프로젝트였지만,

여러 OCR 및 VLM을 테스트 해볼 수 있었고, 실제 서비스화를 할 때는 더 안정적인 파이프라인 설계가 필요할 것 같아

공부를 더 해보고 싶다는 마음이 들었다! MLOps를 더 공부해서 한 번 적용을 해보고 싶다.