RAG

[RAG] PDF 테이블 추출 시 선 인식 문제 해결 방법(커스터마이징 feat. Pdfplumber)

moonzoo 2025. 4. 21. 16:15

 

0. 개요

이전 PDF Loader 관련해서 글을 작성한 적이 있는데, 따로 댓글이나 메일로 질문을 남겨주시는 분들이 있으시더라구요. 그래서 아래 PDF Loader 비교 글에서 사용한 예시 PDF에서 발생한 문제를 어떤 식으로 해결할 수 있는지 예시를 작성해서 전달드리고자 이 글을 작성하게 됐습니다.

https://mz-moonzoo.tistory.com/86

https://mz-moonzoo.tistory.com/73

 

[RAG] Document Loader 비교 (feat. PDF, Markdown 변환)

0. Document Loader (PDF)RAG를 하려면 수 많은 Raw Data들을 파싱해야 합니다.Raw Data들 중에서도 가장 범용적으로 사용되는 파일은 PDF이기 때문에, 이번 글에서는 PDF 파일에서 한글을 추출해내는 것이 중

mz-moonzoo.tistory.com

 

 

[RAG] Upstage Document Parse 리뷰 및 테스트

제가 예전에 작성한 글에 이번 글에서 언급되는 것들이 있어서 링크 함께 전달드리겠습니다. Document Layout Analysis 글https://mz-moonzoo.tistory.com/55 [Computer Vision] Document Layout Analysis (feat. OCR)1. Document La

mz-moonzoo.tistory.com

 

표 추출 문제

 

시간이 지나면서 여러 PDF Loader 라이브러리들이 고도화되고 있으나, 여전히 표에서 텍스트를 추출하는 것에 어려움을 겪고 있습니다. 그 이유는 PDF마다 표의 구조나 형식이 다르기 때문입니다. 

 


 

1. Pdfplumber 기반 문제 파악

Python 라이브러리 중 pdfplumber가 많은 연구가 이뤄지고 있으며, 강력한 기능과 편리성 덕분에 널리 사용되고 있습니다. 하지만 위의 답변에서 언급했듯이 만능 pdf loader는 현재 존재하지 않습니다. 어떤 라이브러리를 사용해 커스터마이징을 하여 자신이 직면한 문제를 해결할 솔루션을 만드느냐가 중요한 것 같습니다.

 

그럼 역시 처음에 언급한 것처럼 PDF Loader 글에서 추출에 실패한 PDF 문서를 이번에는 pdfplumber를 사용해 pdfplumber가 특정 PDF 테이블을 인식하지 못했던 원인을 분석하고,  페이지 크롭(Cropping)과 명시적 수직선 지정(Explicit Vertical Lines) 해결한 예시를 공유드리도록 하겠습니다.

 

그럼 예시부터 보여드리도록 하겠습니다.

아래의 예시 표를 보시면 가로 세로선 구분이 명확하지 않음을 확인하실 수 있습니다.

 

 

문제 1. pdfplumber의 extract_text() : 표의 구조를 반영하지 못함.

 

이를 pdfplumber의 extract_text() 함수를 사용해 텍스트를 추출해보면...

|구분|지역|제휴 호텔/공항|
|---|---|---|

|호텔|서울/경기|콘레드호텔,그랜드힐튼호텔, 르메르디앙(구 리츠칼튼)...|

과 같이 구조를 유지한 상태로 추출됨을 기대했으나, 서울/콘래드 호텔, 경기  츠칼튼)과 같이 텍스트가 추출 되어 전혀 다른 의미를 가진 텍스트로 추출됐습니다. 이렇게 추출한 텍스트를 RAG에 활용하면 할루시네이션이 발생하게 되는거죠.

 

문제 2. pdfplumber의 extract_table() : 가로 세로 선의 부재로 테이블 누락

 

위의 예시에는 사람이 봤을 땐 명확한 표가 존재하지만, pdfplumber의 extract_table() 또는 extract_tables() 함수 실행 시, 일부 행이 누락되거나 아예 빈 리스트([])가 반환되었습니다.

 

이유는 아래 표의 빨간선이 보이시나요? 일부 수평선들이 중간에 끊어져 있거나, 미세한 좌표 차이로 인해 하나의 긴 선이 아닌 여러 개의 짧은 선으로 인식되는 것을 발견했습니다. 그래서 pdfplumber의 table_settings에서 horizontal_strategy: "lines"와 vertical_strategy: "lines"으로는 추출이 불가능한 것입니다.

 

원인 분석: 불완전한 선(Line) 정보의 함정

pdfplumber의 "lines" 전략은 감지된 연속적인 수평선과 수직선의 교차점을 기반으로 셀(Cell)을 구성합니다. 하지만 PDF 생성 방식이나 원본 문서의 상태에 따라 다음과 같은 문제가 발생하여 "lines" 전략이 실패할 수 있습니다.

  1. 끊어진 선: 선이 시각적으로는 이어져 보여도, PDF 내부 데이터에서는 여러 조각으로 나뉘어 있을 수 있습니다.
  2. 미세한 좌표 차이: 여러 선분의 시작점(x0)이나 끝점(x1), 높이(top 또는 bottom)가 미세하게 달라 pdfplumber가 이를 동일한 선으로 간주하지 못할 수 있습니다.
  3. 부분적인 선: 테이블 외곽선 등 일부 선만 존재하고 내부 구분선이 부족한 경우에도 테이블 구조 파악에 실패할 수 있습니다.

이러한 이유로, 단순히 "lines" 전략만으로는 테이블 전체 구조를 정확히 파악하기 어렵게 됩니다.


2. Cropping과 Explicit Vertical Lines 지정을 통한 문제 해결.

문제 해결을 위해 다음 두 가지 접근 방식을 조합했습니다.

  1. 테이블 영역 집중 (Page Cropping): 페이지 전체가 아닌, 실제 테이블이 포함된 영역만 잘라내어 분석 대상 범위를 좁힙니다. 이는 불필요한 노이즈를 제거하고 pdfplumber가 테이블 관련 선들에 더 집중하도록 돕습니다.
  2. 수직선 구조 명시 (Explicit Vertical Lines): 수평선 정보가 불완전하더라도, 컬럼(열)을 구분하는 수직선의 위치를 직접 지정하여 테이블의 기본 구조를 강제합니다.

 

1단계: 테이블 영역 식별 및 페이지 크롭 (Cropping)

 

가장 먼저, 테이블이 포함된 영역을 정확히 파악하여 해당 부분만 잘라내는 작업을 수행합니다. 이를 위해 페이지 내의 모든 수평선(page.horizontal_edges) 정보를 활용하여 테이블의 전체적인 경계(bounding box)를 찾습니다.

먼저, 선들의 좌표 목록을 받아 가장 왼쪽(min_x0), 가장 오른쪽(max_x1), 가장 위쪽(min_y0), 가장 아래쪽(max_y0) 좌표를 찾는 함수를 정의합니다.

 

# x, y의 최소/최대값을 찾는 함수
def find_min_max_x(data):
    if not data:  # 데이터가 비어있는 경우 처리
        return None, None, None, None

    min_x0 = float('inf')  # x0의 최솟값 (가장 왼쪽)
    max_x1 = float('-inf') # x1의 최댓값 (가장 오른쪽)
    min_y0 = float('inf')  # y0(top)의 최솟값 (가장 위쪽)
    max_y0 = float('-inf') # y0(bottom)의 최댓값 (가장 아래쪽)

    for item in data:
        min_x0 = min(min_x0, item['x0'])
        max_x1 = max(max_x1, item['x1'])
        min_y0 = min(min_y0, item['top'])   # pdfplumber에서 y 좌표는 위쪽이 작음
        max_y0 = max(max_y0, item['bottom']) # pdfplumber에서 y 좌표는 아래쪽이 큼

    return min_x0, max_x1, min_y0, max_y0

그 다음, 특정 PDF 파일(2018년 BC글로벌 브랜드서비스.pdf)의 5번째 페이지(인덱스 4)를 대상으로 이 함수를 실행하여 테이블의 경계를 찾습니다.

import pdfplumber

# PDF 열기 (파일 경로는 실제 환경에 맞게 설정)
pdf_path = "2018년 BC글로벌 브랜드서비스.pdf"
try:
    pdf = pdfplumber.open(pdf_path)
    page = pdf.pages[4] # 5번째 페이지 선택

    # 페이지 내 수평선들의 좌표를 기반으로 테이블 전체 영역 찾기
    min_x0, max_x1, min_y0, max_y0 = find_min_max_x(page.horizontal_edges)

    if min_x0 is None:
        print("오류: 페이지에서 수평선을 찾을 수 없습니다.")
        # 오류 처리 또는 페이지 전체 사용 등의 로직 추가
    else:
        print(f"찾아낸 테이블 영역: x0={min_x0}, x1={max_x1}, y0={min_y0}, y1={max_y0}")

        # 찾은 좌표에 약간의 여백(padding)을 주어 페이지 크롭
        padding = 3
        crop_bbox = (min_x0 - padding, min_y0 - padding, max_x1 + padding, max_y0 + padding)

        # 페이지 경계를 벗어나지 않도록 조정 (선택 사항이지만 권장)
        crop_bbox = (
             max(0, crop_bbox[0]),
             max(0, crop_bbox[1]),
             min(page.width, crop_bbox[2]),
             min(page.height, crop_bbox[3])
        )

        cropped_page = page.crop(crop_bbox)
        print(f"페이지 크롭 완료. 크롭 영역: {crop_bbox}")

        # 디버깅을 위해 크롭된 페이지 이미지 생성
        im = cropped_page.to_image(resolution=150)

except FileNotFoundError:
    print(f"오류: PDF 파일을 찾을 수 없습니다 - {pdf_path}")
except IndexError:
    print(f"오류: 페이지 인덱스가 잘못되었습니다. 해당 PDF에 {4+1}번째 페이지가 없습니다.")
except Exception as e:
    print(f"처리 중 오류 발생: {e}")

 

crop된 테이블 페이지

 

핵심:

page.horizontal_edges를 사용하여 테이블의 대략적인 상하좌우 경계를 파악하고, 약간의 여백(padding=3)을 주어 page.crop()으로 해당 영역만 잘라냅니다. 이렇게 하면 불필요한 주변 요소가 제거되어 이후 테이블 분석의 정확도를 높일 수 있습니다.

 

2단계: 수직선 위치 명시 (Explicit Vertical Lines)

테이블의 열(column)을 구분하는 수직선의 위치를 명시적으로 지정하는 단계입니다. 이 코드에서는 한 가지 가정을 사용합니다.

수평선들의 시작점(x0) 좌표가 테이블의 세로 구분선 위치와 일치한다는 것입니다.

테이블의 격자 구조 가정:

  • 일반적인 테이블은 가로선(행 구분선)과 세로선(열 구분선)이 교차하는 격자(Grid) 형태로 이루어져 있습니다.
  • 이런 구조에서, 특정 셀 내부를 가로지르는 수평선(horizontal_edges)은 보통 해당 열(column)의 왼쪽 경계를 이루는 수직선에서 시작하여 오른쪽 경계를 이루는 수직선에서 끝납니다.
  • 따라서, 수평선의 시작점(x0)의 x 좌표는 그 수평선이 시작되는 위치의 세로 구분선(Vertical Line)의 x 좌표와 일치할 가능성이 높습니다.

이 가정은 특정 형태의 테이블에서는 유효할 수 있지만, 모든 테이블에 적용되지는 않을 수 있다는 점에 유의해야 합니다.

먼저, 크롭하기 전 원본 페이지(page)의 수평선(page.horizontal_edges) 정보에서 모든 x0 값을 추출합니다.

lines = page.horizontal_edges

item_list = []
for item in lines :
    item_list.append(item['x0']) # 수평선의 시작 x좌표들을 리스트에 추가

# 중복 제거 및 정렬
# set()으로 중복을 제거하고 list()로 다시 변환한 뒤 오름차순 정렬
# [1:] 슬라이싱: 가장 작은 x0 값 (첫 번째 요소)은 제외합니다.
# 이는 가장 왼쪽 경계선(min_x0)은 별도로 추가할 것이기 때문입니다.
filter_item_list = sorted(list(set(item_list)))[1:]

print(f"추출된 고유 x0 값 (첫 값 제외): {filter_item_list}")

 

이제 추출된 x0 값들을 사용하여 table_settings를 구성합니다. vertical_strategy는 "explicit"로 설정하고, explicit_vertical_lines 리스트에 테이블의 왼쪽 경계(min_x0), 내부 세로 구분선들(filter_item_list), 그리고 오른쪽 경계(max_x1)를 순서대로 추가합니다.

# 테이블 찾기 설정 초기화
table_settings = {
    "vertical_strategy": "explicit",  # 수직선은 우리가 제공하는 리스트를 사용
    "horizontal_strategy": "lines",   # 수평선은 pdfplumber가 찾은 선 사용 (크롭된 페이지 기준)
    "explicit_vertical_lines": [min_x0]  # 리스트 시작은 테이블 가장 왼쪽 경계(min_x0)
}

# 중간의 세로 구분선들 추가 (수평선의 x0 값들 기반)
table_settings["explicit_vertical_lines"].extend(filter_item_list)

# 리스트 끝에 테이블 가장 오른쪽 경계(max_x1) 추가
table_settings["explicit_vertical_lines"].extend([max_x1])

print(f"최종 Table Settings: {table_settings}")

 

핵심:

  • 수평선의 x0 좌표들을 이용해 수직 구분선 위치를 추정했습니다. (주의: 이 방식의 정확성은 테이블 구조에 따라 다를 수 있습니다.)
  • explicit_vertical_lines 리스트에는 반드시 테이블의 가장 왼쪽 경계, 모든 내부 세로 구분선, 가장 오른쪽 경계의 x좌표가 오름차순으로 정렬되어 포함되어야 합니다.

3단계: 테이블 추출 및 검증 (시각 디버깅)

최종적으로 구성된 table_settings를 사용하여 크롭된 페이지(cropped_page)에서 테이블을 추출합니다. 그리고 이 설정이 어떻게 적용되었는지 확인하기 위해 im.debug_tablefinder()를 사용하여 시각적으로 디버깅합니다.

 

아래의 테이블을 보시면 셀이 원하는 대로 나눠졌고, 기존에 인식하지 못한 테이블을 인식한 것을 확인하실 수 있습니다.

text = cropped_page.extract_table(table_settings=table_settings)

# 디버그: 테이블 찾기 시각화
im.debug_tablefinder(table_settings=table_settings)

핵심:

extract_table() 함수는 최종 table_settings를 인수로 받아 테이블 데이터를 파싱합니다. im.debug_tablefinder()는 동일한 설정을 사용하여 어떤 선과 셀이 인식되었는지 시각적으로 보여주므로, 설정이 올바르게 작동하는지, 놓치거나 잘못 합쳐진 셀은 없는지 확인하는 데 매우 유용합니다.

이 과정을 통해, 초기 설정으로는 추출되지 않던 테이블 데이터를 성공적으로 얻을 수 있습니다. 특히 디버깅 이미지를 확인하면, 크롭과 명시적 수직선 지정을 통해 테이블 구조가 얼마나 명확하게 인식되는지 시각적으로 이해할 수 있습니다.

 

4단계: 추출 결과 확인

위처럼 셀이 잘 나눠졌으면 아래와 같이 표의 구조를 유지한 상태로 텍스트를 추출할 수 있습니다. 어떤가요? 잘된 것 같나요?

['호텔', '서울/\n경기', '콘래드호텔, 그랜드힐튼호텔, 르메르디앙(구 리\n츠칼튼), 플라자호텔, 쉐라톤 서울 디큐브시티,\n반얀트리 서울(호텔), MVL 호텔 킨텍스, 노보텔\n앰버서더 강남, 노보텔앰버서더 독산, 코트야드\n메리어트 타임스퀘어, 여의도 메리어트, 팔래스\n호텔, 메이필드 호텔, 라마다 서울, 서울 그랜드\n앰버서더, 베스트웨스턴가든호텔, 그랜드 하얏\n트 인천']
['', '강원', '하이원리조트강원랜드 호텔,\n하이원리조트컨벤션 호텔']
['', '전남', 'MVL 호텔 여수']
['', '대구', '대구 그랜드호텔, 대구 인터불고호텔(대구점)']
['', '부산', '해운대 그랜드']

 

하지만 중요한 것은 이 방법의 한계점입니다:

  • 항상 정확하지 않음: 이 방법은 위에서 설명한 가정에 기반하므로, 테이블 구조가 복잡하거나 (예: 셀 병합), PDF 생성 방식이 특이하여 수평선이 세로 구분선과 정확히 정렬되지 않는 경우에는 실패할 수 있습니다.
  • 세로선 정보 누락 가능성: 만약 특정 세로 구분선 위치에서 시작하는 수평선이 하나도 없다면, 해당 세로 구분선은 이 방식으로 찾아낼 수 없습니다.

결론적으로, horizontal_edges의 x0 값을 사용하는 것은 pdfplumber가 세로선을 제대로 감지하지 못할 때 시도해볼 수 있는 차선책 또는 휴리스틱입니다. 테이블의 수평선들이 비교적 잘 감지되고, 그 시작점들이 세로 구분선과 잘 정렬되어 있다는 "암묵적인 가정" 하에 작동하는 방식입니다.

 


3. 결론 : 정답은 없습니다.

이 글에서 소개한 페이지 크롭과 명시적 선 지정을 조합하는 방법은 pdfplumber에서 선 인식 문제로 테이블 추출에 어려움을 겪을 때 시도해볼 수 있는 하나의 해결 사례입니다. 디버깅 도구를 통해 문제를 진단하고, 페이지 구조를 분석하여 적절한 설정을 찾아가는 과정을 보여드렸습니다.

 

하지만 명심해야 할 점은, 모든 PDF 문서와 추출 목표가 다르기 때문에 이는 특정 케이스에 대한 예시일 뿐이며, 만능 해결책은 아니라는 것입니다. 실제로 PDF에서 데이터를 효과적으로 추출하기 위해서는 당면한 문제, 즉 작업하는 도메인 문서의 특성에 맞춰 접근 방식을 커스터마이징하거나 후처리 로직을 개발하는 과정이 필수적입니다.

 

어떤 라이브러리를 사용할지 결정하는 것 또한 상황에 따라 달라집니다. 예를 들어, 비교적 구조가 명확하고 깔끔한 표에서 빠르게 마크다운 형식의 결과를 얻고 싶다면 PyMuPDF4LLM, Upastage Document Parser, Azure 모두 괜찮은 선택지가 될 수 있습니다. 반면, 세밀한 조정과 커스터마이징이 필요할 때는 pdfplumber가 빛을 발합니다. pdfplumber는 다양한 추출 파라미터와 페이지 내 객체(선, 문자, 사각형 등)에 대한 상세한 접근을 제공하여 유연성이 높기 때문에 저는 커스터마이징이 필요한 복잡한 문서 처리에 자주 활용합니다.

 

또한, 새로운 AI 모델 학습을 위해 일관된 형식의 텍스트 데이터가 필요하다면, 때로는 특정 요구사항(예: 무조건 가로 순서로만 텍스트 추출)에 특화된 다른 PDF 로더를 선택하는 것이 더 적합할 수도 있습니다.

 

결론적으로, 아직 모든 PDF를 완벽하게 처리하는 단일 PDF 라이브러리는 존재하지 않습니다. 가장 중요한 것은 문제의 본질을 정확히 파악하고, debug_tablefinder와 같은 도구를 활용해 라이브러리가 문서를 어떻게 해석하는지 이해하며, 여러 가지 방법을 실험하고 조합하여 자신만의 데이터 추출 파이프라인을 구축하는 능력이 필요할 듯 합니다.

 

그럼 이러한 방식으로 문제를 해결할 수도 있다는 것을 전달드리며 글을 마치도록 하겠습니다.