프로그래밍 PROGRAMMING/인공지능 AI

왜 PDF를 넣으면 답변 품질이 떨어질까 — 표, 이미지, 페이지 구조를 AI가 못 읽는 문제

매운할라피뇨 2026. 4. 23. 14:10
반응형

왜 PDF를 넣으면 답변 품질이 떨어질까 — 표, 이미지, 페이지 구조를 AI가 못 읽는 문제

 
"우리 사내 매뉴얼 200개를 RAG에 넣었어요. 잘 작동할 거예요." — 이렇게 시작했다가 일주일 후 답변 품질이 형편없는 RAG와 마주치는 것은 거의 모든 RAG 프로젝트의 통과 의례입니다.
원인을 추적해 보면 거의 매번 같은 곳에서 막힙니다 — PDF. 모든 사내 문서, 정책서, 계약서, 기술 매뉴얼은 PDF로 되어 있습니다. 그리고 PDF는 RAG가 다루기 가장 어려운 포맷입니다.
표는 텍스트로 변환하면 구조가 사라집니다. 이미지 안의 다이어그램은 OCR도 안 됩니다. 멀티 컬럼 레이아웃은 위에서 아래로 읽혀서 의미가 깨집니다. 페이지 헤더/푸터는 모든 청크에 노이즈로 들어갑니다. 한국어 PDF는 더 심합니다 — 글자가 깨지고, 띄어쓰기가 사라집니다.
이 글에서는 PDF가 왜 RAG에서 그렇게 어려운지, 그리고 실무에서 사용 가능한 라이브러리들 — PyPDF, pdfplumber, Unstructured, Docling, Marker, Vision LLM — 의 강점/약점을 코드와 함께 비교 분석합니다.
 

목차

  1. PDF가 RAG에서 어려운 본질적 이유
  2. 텍스트 추출의 함정 — 순서, 공백, 깨진 한글
  3. 표(Table) 처리 — 구조를 보존하는 게 핵심
  4. 이미지와 차트 — OCR과 비전 모델
  5. 페이지 헤더/푸터/페이지 번호 — 노이즈 제거
  6. 멀티 컬럼 레이아웃 — 읽기 순서의 함정
  7. 라이브러리 비교 — PyPDF, pdfplumber, Unstructured, Docling, Marker
  8. Vision LLM 활용 — GPT-4V/Claude로 직접 파싱
  9. 문서 구조 보존 청크 분할 — Element-aware Chunking
  10. 실전 PDF RAG 파이프라인 설계

 

1. PDF가 RAG에서 어려운 본질적 이유

PDF의 어려움을 이해하려면 PDF가 무엇인지부터 알아야 합니다.

PDF는 "텍스트 파일"이 아니다

많은 사람들이 PDF를 워드 문서 같은 텍스트 파일로 생각합니다. 그것은 큰 오해입니다.

워드 (.docx):
- 제목, 단락, 표, 리스트 등 "구조"가 명시됨
- "이건 H1, 저건 단락" 같은 의미 정보 포함
- HTML과 비슷한 구조

PDF (.pdf):
- "페이지 위 X,Y 좌표에 이 글자를 그려라"의 모음
- 시각적 표현만 존재
- 구조 정보 거의 없음 (또는 손상됨)

→ PDF는 "프린터에 보낼 명령의 묶음"에 가까움
→ 텍스트가 거기 있긴 하지만 "구조" 없이 좌표만 있음

이것이 PDF 파싱이 본질적으로 어려운 이유입니다. 추출은 가능하지만 의미는 잃어버립니다.

PDF의 3가지 유형

┌────────────────────────────────────────────────────────┐
│  PDF의 3가지 유형                                         │
│                                                        │
│  ❶ Text-based PDF (워드/한글에서 출력)                  │
│  - 텍스트 정보 있음                                      │
│  - 추출 가능, 다만 구조 정보 없음                        │
│  - 처리 난이도: 중                                       │
│                                                        │
│  ❷ Image-based PDF (스캔본)                             │
│  - 텍스트 없음, 이미지만 있음                           │
│  - OCR 필수                                             │
│  - 처리 난이도: 상                                       │
│                                                        │
│  ❸ Hybrid PDF (혼합)                                    │
│  - 일부는 텍스트, 일부는 이미지                          │
│  - 가장 까다로움 (어디까지 OCR해야 할지)                │
│  - 처리 난이도: 최상                                     │
└────────────────────────────────────────────────────────┘

실제 회사에서 만나는 PDF의 현실

┌────────────────────────────────────────────────────────┐
│  실무 PDF의 일반적 특징                                   │
│                                                        │
│  - 페이지 수: 50~500쪽                                  │
│  - 표가 많음 (전체의 30~50%)                            │
│  - 다이어그램/스크린샷 다수                              │
│  - 헤더/푸터: 회사 로고, 페이지 번호, 작성자 등          │
│  - 멀티 컬럼: 절반 이상이 2단 이상                      │
│  - 폰트 임베딩 문제: 한국어 깨짐 빈번                   │
│  - 버전 차이: 같은 문서라도 PDF 버전마다 다른 처리       │
│                                                        │
│  → 거의 모든 어려움이 한 문서에 다 들어 있음            │
└────────────────────────────────────────────────────────┘

 

2. 텍스트 추출의 함정 — 순서, 공백, 깨진 한글

가장 기본적인 텍스트 추출부터 함정이 있습니다.

함정 1: 읽기 순서

PDF에서 텍스트는 좌표 기반으로 저장됩니다. 라이브러리가 읽는 순서가 사람의 읽기 순서와 다를 수 있습니다.

원본 PDF:
┌──────────────────────────┐
│ [회사 로고]    [페이지 1] │  ← 헤더 (페이지 상단)
├──────────────────────────┤
│ 제목                     │
│                          │
│ 본문 단락 1...           │
│ 본문 단락 2...           │
│                          │
│   [표 데이터]             │
│                          │
│ 본문 단락 3...           │
└──────────────────────────┘

PyPDF의 추출 결과 (잘못된 순서):
"회사 로고 페이지 1 본문 단락 1 본문 단락 2 표데이터 제목 본문 단락 3"

pdfplumber의 추출 결과 (올바른 순서):
"제목 본문 단락 1 본문 단락 2 [표] 본문 단락 3 회사 로고 페이지 1"

라이브러리마다 다르게 처리됩니다. PyPDF는 객체 순서대로, pdfplumber는 좌표 기반으로 정렬합니다.

함정 2: 공백과 줄바꿈

PDF에는 공백 문자가 명시적으로 없는 경우가 많습니다. 단어 사이가 그저 X 좌표 차이일 뿐입니다.

PDF 내부 표현:
"안녕" at (10, 100)
"하세요" at (50, 100)

추출 시 라이브러리가 두 단어 사이에 공백을 넣어줘야 함
→ 안 넣으면 "안녕하세요"로 합쳐짐 (이 경우는 정상)
→ "Hello world"가 "Helloworld"로 합쳐지면 검색 불가

함정 3: 한국어 폰트 임베딩 문제

한국어 PDF에서 가장 흔한 문제입니다.

정상:
"환불 정책은 14일 이내 가능합니다"

깨진 추출:
"홖불 졙책은 14일 이내 가능합니다"  
또는
"\u0001\u0002\u0003 정책은 14일 이내..."
또는
"" (완전히 빈 텍스트)

이 문제의 원인은:
- 한글 폰트가 PDF에 임베딩될 때 사용된 인코딩 매핑(CMap) 문제
- 폰트 서브셋팅으로 일부 글자만 포함된 경우
- 라이브러리가 해당 폰트 인코딩을 지원 못하는 경우

함정 4: 합자(Ligature)와 특수문자

"fi", "fl" 같은 합자가 단일 문자로 저장됨
→ 추출 시 "fi" 같은 유니코드로 나옴
→ "find"가 "find"로 검색 안 됨

추출 결과 검증 코드

def validate_extraction(text: str) -> dict:
    """추출된 텍스트의 품질 검증"""
    issues = []

    # 1. 한글 깨짐 검사
    if any(ord(c) < 32 and c not in '\n\t' for c in text):
        issues.append("control_chars")

    # 2. 빈 텍스트
    if len(text.strip()) < 100:
        issues.append("too_short")

    # 3. 공백 없음 (붙어 있는 텍스트)
    words = text.split()
    avg_word_len = sum(len(w) for w in words) / max(len(words), 1)
    if avg_word_len > 30:
        issues.append("missing_spaces")

    # 4. 한글 비율
    korean_chars = sum(1 for c in text if 0xAC00 <= ord(c) <= 0xD7A3)
    if korean_chars > 0:
        korean_ratio = korean_chars / len(text)
        if korean_ratio < 0.05:
            issues.append("korean_likely_broken")

    return {
        "is_valid": len(issues) == 0,
        "issues": issues,
        "char_count": len(text),
        "word_count": len(words),
    }

 

3. 표(Table) 처리 — 구조를 보존하는 게 핵심

표는 PDF RAG의 가장 큰 도전입니다. 단순 텍스트로 변환하면 구조가 완전히 사라집니다.

왜 표가 문제인가

원본 표:
┌──────────┬─────────┬─────────┐
│ 제품      │ 가격     │ 재고    │
├──────────┼─────────┼─────────┤
│ Pro      │ 50,000  │ 120     │
│ Standard │ 30,000  │ 250     │
│ Basic    │ 10,000  │ 500     │
└──────────┴─────────┴─────────┘

단순 텍스트 추출:
"제품 가격 재고 Pro 50,000 120 Standard 30,000 250 Basic 10,000 500"

→ 어느 가격이 어느 제품인지 알 수 없음
→ "Pro 가격은?" 질문에 못 답함

표 처리의 3가지 접근

┌────────────────────────────────────────────────────────┐
│  표 처리 전략                                            │
│                                                        │
│  ❶ Markdown 표로 변환                                   │
│  | 제품 | 가격 | 재고 |                                 │
│  |------|------|------|                                 │
│  | Pro | 50,000 | 120 |                                │
│                                                        │
│  - LLM이 표 구조 잘 이해                                │
│  - 검색 시 컨텍스트 보존                                 │
│                                                        │
│  ❷ HTML 표로 변환                                       │
│  <table><tr><td>...</td></tr></table>                  │
│                                                        │
│  - LLM이 잘 이해                                        │
│  - Markdown보다 표현력 풍부 (병합 셀 등)                │
│                                                        │
│  ❸ 자연어 변환                                          │
│  "Pro 제품의 가격은 50,000원이며 재고는 120개입니다"    │
│  "Standard 제품의 가격은 30,000원이며 재고는 250개..."  │
│                                                        │
│  - 검색에 가장 유리                                     │
│  - LLM이 가장 잘 답변                                   │
│  - 표 변환 비용 (LLM 호출 필요)                         │
└────────────────────────────────────────────────────────┘

pdfplumber로 표 추출

import pdfplumber

with pdfplumber.open("doc.pdf") as pdf:
    for page in pdf.pages:
        tables = page.extract_tables()
        for table in tables:
            # table은 2D 리스트
            # [["제품", "가격", "재고"],
            #  ["Pro", "50,000", "120"], ...]

            # Markdown으로 변환
            header = "| " + " | ".join(table[0]) + " |"
            separator = "| " + " | ".join(["---"] * len(table[0])) + " |"
            rows = ["| " + " | ".join(row) + " |" for row in table[1:]]

            markdown_table = "\n".join([header, separator] + rows)
            print(markdown_table)

Camelot — 더 정밀한 표 추출

import camelot

# Lattice 모드: 선이 있는 표
tables = camelot.read_pdf("doc.pdf", pages='1-10', flavor='lattice')

# Stream 모드: 선 없는 표 (공백 기반)
tables = camelot.read_pdf("doc.pdf", pages='1-10', flavor='stream')

for table in tables:
    print(table.df)  # pandas DataFrame

LLM으로 자연어 변환

추출된 표를 LLM으로 자연어 설명으로 바꾸면 검색 품질이 크게 향상됩니다.

def table_to_natural_language(table_md: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": """
주어진 표를 자연어 문장으로 변환하세요.
각 행을 별도의 문장으로 만들고, 컬럼 이름과 값을 명확히 연결하세요.
"""},
            {"role": "user", "content": table_md}
        ]
    )
    return response.choices[0].message.content


# 사용
table_md = """
| 제품 | 가격 | 재고 |
|------|------|------|
| Pro | 50,000 | 120 |
| Standard | 30,000 | 250 |
"""

description = table_to_natural_language(table_md)
# "Pro 제품의 가격은 50,000원이며 재고는 120개입니다.
#  Standard 제품의 가격은 30,000원이며 재고는 250개입니다."

청크 분할 시 표 분리

def chunk_with_tables(elements: list) -> list[dict]:
    """표는 분할하지 않고 하나의 청크로"""
    chunks = []
    for element in elements:
        if element["type"] == "table":
            # 표는 통째로 하나의 청크
            chunks.append({
                "type": "table",
                "content": element["content"],
                "metadata": {"is_table": True}
            })
        else:
            # 일반 텍스트는 정상 분할
            text_chunks = recursive_split(element["content"], 1000)
            for tc in text_chunks:
                chunks.append({
                    "type": "text",
                    "content": tc,
                    "metadata": {"is_table": False}
                })
    return chunks

표는 절대 청크 중간에서 잘리면 안 됩니다.
 

4. 이미지와 차트 — OCR과 비전 모델

PDF의 이미지는 두 종류입니다. 스캔된 텍스트 이미지(OCR로 처리)와 다이어그램/차트(설명이 필요).

Tesseract로 OCR

from pdf2image import convert_from_path
import pytesseract

# PDF → 이미지
images = convert_from_path("scanned.pdf", dpi=300)

extracted_text = ""
for i, image in enumerate(images):
    # OCR (한국어 + 영어)
    text = pytesseract.image_to_string(image, lang='kor+eng')
    extracted_text += f"\n--- Page {i+1} ---\n{text}"

Tesseract의 한계

✅ 잘 됨:
- 깨끗한 스캔본
- 표준 폰트
- 흰 배경

❌ 어려움:
- 손글씨
- 회전된 텍스트
- 복잡한 레이아웃
- 흐릿한 스캔
- 배경에 무늬

Vision LLM으로 OCR 대체

GPT-4o, Claude, Gemini 같은 Vision LLM은 OCR보다 훨씬 정확하게 이미지에서 텍스트를 추출합니다.

from openai import OpenAI
import base64

client = OpenAI()

def vision_ocr(image_path: str) -> str:
    with open(image_path, "rb") as f:
        image_data = base64.b64encode(f.read()).decode()

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "이 이미지의 모든 텍스트를 정확히 추출하세요. 표가 있으면 마크다운 표로 변환하세요."},
                    {
                        "type": "image_url",
                        "image_url": {"url": f"data:image/png;base64,{image_data}"}
                    }
                ]
            }
        ]
    )
    return response.choices[0].message.content

차트와 다이어그램 처리

def describe_chart(image_path: str) -> str:
    """차트/다이어그램을 텍스트 설명으로"""
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": """
이 차트/다이어그램을 자세히 설명하세요.
- 차트 유형 (막대/선/원형 등)
- 표시된 데이터의 핵심 수치
- 트렌드와 비교
- 결론

검색에 활용될 것이므로 핵심 키워드를 포함하세요.
"""},
                    {
                        "type": "image_url",
                        "image_url": {"url": f"data:image/png;base64,{image_data}"}
                    }
                ]
            }
        ]
    )
    return response.choices[0].message.content

이렇게 생성한 설명을 청크로 인덱싱하면, "2024년 매출 추이"같은 질문에도 차트 정보를 검색할 수 있습니다.

비용 고려

Vision LLM 한 페이지당 비용 (GPT-4o):
- 입력: ~1,500 토큰 (이미지) = $0.004
- 출력: ~500 토큰 (설명) = $0.005
- 합계: ~$0.009/페이지

100페이지 PDF: $0.90 (약 1,200원)
1,000개 PDF: $900 (인덱싱 비용)

→ 인덱싱은 한 번만 하므로 감당 가능
→ 단, 정기 업데이트되는 문서는 비용 누적

 

5. 페이지 헤더/푸터/페이지 번호 — 노이즈 제거

거의 모든 PDF에는 헤더와 푸터가 있습니다. 이것들이 그대로 청크에 포함되면 검색을 망칩니다.

320x100

헤더/푸터의 문제

원본 페이지:
─────────────────────
회사 로고  |  내부용
─────────────────────
[본문 내용]
─────────────────────
ⓒ ACME 2024 | Page 23
─────────────────────

추출 시 모든 청크에 들어감:
"회사 로고 내부용 [본문 내용] ⓒ ACME 2024 Page 23"

100페이지 PDF면:
- "내부용"이 100번 반복
- "Page 1, Page 2, ... Page 100" 노이즈
- 검색 시 BM25 점수 망가짐

헤더/푸터 자동 검출

from collections import Counter

def detect_headers_footers(pages: list[str], threshold: float = 0.7) -> tuple[set, set]:
    """반복되는 첫/끝 줄 = 헤더/푸터"""

    # 각 페이지의 첫 줄과 끝 줄
    first_lines = []
    last_lines = []

    for page in pages:
        lines = page.strip().split('\n')
        if lines:
            first_lines.append(lines[0].strip())
            last_lines.append(lines[-1].strip())

    # 빈도 분석
    first_counter = Counter(first_lines)
    last_counter = Counter(last_lines)

    n_pages = len(pages)

    # 70% 이상 페이지에 등장 → 헤더/푸터
    headers = {line for line, count in first_counter.items() 
               if count / n_pages >= threshold and len(line) > 5}
    footers = {line for line, count in last_counter.items() 
               if count / n_pages >= threshold and len(line) > 5}

    return headers, footers


def clean_pages(pages: list[str], headers: set, footers: set) -> list[str]:
    """헤더/푸터 제거"""
    cleaned = []
    for page in pages:
        lines = page.strip().split('\n')
        if lines and lines[0].strip() in headers:
            lines = lines[1:]
        if lines and lines[-1].strip() in footers:
            lines = lines[:-1]
        cleaned.append('\n'.join(lines))
    return cleaned

페이지 번호 패턴 제거

import re

def remove_page_numbers(text: str) -> str:
    """페이지 번호 패턴 제거"""
    patterns = [
        r'^\s*\d{1,4}\s*$',          # "12" 단독
        r'^\s*-\s*\d{1,4}\s*-\s*$',  # "- 12 -"
        r'^\s*Page\s*\d+\s*(of\s*\d+)?\s*$',  # "Page 12"
        r'^\s*\d+\s*/\s*\d+\s*$',    # "12/100"
    ]

    lines = text.split('\n')
    filtered = []
    for line in lines:
        if not any(re.match(p, line, re.IGNORECASE) for p in patterns):
            filtered.append(line)
    return '\n'.join(filtered)

워터마크 제거

def remove_watermarks(text: str, watermarks: list[str]) -> str:
    """알려진 워터마크 텍스트 제거"""
    for wm in watermarks:
        text = text.replace(wm, "")
    return text


# 사용
common_watermarks = [
    "CONFIDENTIAL",
    "DRAFT",
    "내부용",
    "기밀",
    "사외비",
]
text = remove_watermarks(text, common_watermarks)

 

6. 멀티 컬럼 레이아웃 — 읽기 순서의 함정

기술 문서, 학술 논문, 잡지 등은 2단/3단 레이아웃이 흔합니다. 단순 추출은 좌→우 → 좌→우 가 아니라 위에서 아래로 읽어버립니다.

문제 예시

원본 (2단 레이아웃):

┌──────────────────────────┐
│ 1단                | 2단  │
│ 본문 시작...       | 다른 │
│ 더 많은 내용...    | 단의 │
│ 1단의 끝.          | 본문 │
│                    | 끝.  │
└──────────────────────────┘

올바른 읽기 순서:
"1단 본문 시작 → 더 많은 내용 → 1단의 끝 → 2단 본문 → 끝"

단순 추출 결과 (좌표 기반, 잘못된 순서):
"본문 시작 다른 더 많은 내용 단의 1단의 끝 본문 끝"
                ↑ 행 단위로 좌→우 읽음

멀티 컬럼 처리

import pdfplumber

def extract_with_columns(pdf_path: str) -> list[str]:
    """멀티 컬럼 인식 추출"""
    pages = []

    with pdfplumber.open(pdf_path) as pdf:
        for page in pdf.pages:
            # 페이지를 좌측/우측 절반으로 분리
            page_width = page.width

            left_half = page.crop((0, 0, page_width / 2, page.height))
            right_half = page.crop((page_width / 2, 0, page_width, page.height))

            left_text = left_half.extract_text() or ""
            right_text = right_half.extract_text() or ""

            # 좌측 → 우측 순서로
            pages.append(left_text + "\n" + right_text)

    return pages

Unstructured/Docling 등 고급 라이브러리

수동 컬럼 분리는 한계가 있습니다. 페이지마다 다른 레이아웃, 컬럼 수가 다를 수 있습니다. 이런 경우 레이아웃 분석을 자동으로 하는 라이브러리가 필요합니다.

# Unstructured (자동 레이아웃 분석)
from unstructured.partition.pdf import partition_pdf

elements = partition_pdf(
    "doc.pdf",
    strategy="hi_res",  # 고품질 모드
    infer_table_structure=True,
    extract_images_in_pdf=True,
)

# 자동으로 멀티 컬럼 인식, 표 추출, 이미지 분리
for el in elements:
    print(f"[{el.category}] {str(el)[:100]}")

hi_res 모드는 detectron2 같은 비전 모델을 사용해 페이지 레이아웃을 분석합니다. 정확하지만 느립니다.

 

7. 라이브러리 비교 — PyPDF, pdfplumber, Unstructured, Docling, Marker

각 라이브러리의 강점과 약점을 정리합니다.

PyPDF (PyPDF2)

from pypdf import PdfReader

reader = PdfReader("doc.pdf")
text = ""
for page in reader.pages:
    text += page.extract_text() + "\n"
장점:
✅ 가장 가벼움
✅ 의존성 적음
✅ 빠름

단점:
❌ 표 처리 약함
❌ 멀티 컬럼 미지원
❌ 한국어 처리 종종 실패
❌ 이미지 처리 못함

용도: 간단한 텍스트 PDF만, 빠른 프로토타이핑

pdfplumber

import pdfplumber

with pdfplumber.open("doc.pdf") as pdf:
    for page in pdf.pages:
        text = page.extract_text()
        tables = page.extract_tables()
장점:
✅ 표 추출 가능 (Markdown 변환 용이)
✅ 좌표 기반 정밀 처리
✅ 한국어 PyPDF보다 안정적

단점:
❌ 멀티 컬럼 자동 인식 안 됨
❌ 이미지 OCR 미지원
❌ 큰 PDF에서 느림

용도: 표가 많은 PDF, 일반 사내 문서

Unstructured

from unstructured.partition.pdf import partition_pdf

elements = partition_pdf(
    "doc.pdf",
    strategy="hi_res",  # 또는 "fast"
    infer_table_structure=True,
)
장점:
✅ 자동 요소 분류 (제목, 본문, 표, 리스트 등)
✅ 멀티 컬럼 자동 인식
✅ 이미지 추출 + OCR 통합
✅ 다양한 포맷 지원 (PDF, Word, HTML 등)

단점:
❌ 무거움 (detectron2 등 큰 모델)
❌ 느림 (페이지당 수 초)
❌ 설치 까다로움 (시스템 의존성)

용도: 프로덕션 레벨, 다양한 문서 처리

Docling (IBM)

from docling.document_converter import DocumentConverter

converter = DocumentConverter()
result = converter.convert("doc.pdf")
markdown = result.document.export_to_markdown()
장점:
✅ 최신 (2024년 IBM 출시)
✅ Markdown 출력이 깔끔함
✅ 표/그림 인식 우수
✅ Layout 보존 우수

단점:
❌ 한국어 데이터 부족할 수 있음
❌ 새로운 라이브러리, 안정성 검증 중

용도: 영어 문서, 깔끔한 Markdown 출력

Marker

# CLI
marker_single doc.pdf output.md

# Python
from marker.convert import convert_single_pdf
from marker.models import load_all_models

models = load_all_models()
full_text, images, metadata = convert_single_pdf("doc.pdf", models)
장점:
✅ 매우 정확 (LLM과 비전 모델 활용)
✅ 표/수식 인식 매우 우수
✅ 학술 논문에 특화

단점:
❌ GPU 필수 (CPU에서 매우 느림)
❌ 무거움
❌ 영어 위주

용도: 학술 논문, 수식 많은 문서

라이브러리 비교표

┌──────────────────────────────────────────────────────────┐
│  PDF 라이브러리 종합 비교                                   │
│                                                          │
│  라이브러리        텍스트  표    이미지  레이아웃  속도   │
│  ────────────    ─────  ─── ─────── ───────  ───       │
│  PyPDF             ★★    ★    ✗      ✗       ★★★★★    │
│  pdfplumber        ★★★   ★★★  ★      ★★      ★★★★     │
│  Unstructured     ★★★★  ★★★★ ★★★★   ★★★★    ★★       │
│  Docling           ★★★★  ★★★★ ★★★    ★★★★    ★★★      │
│  Marker            ★★★★  ★★★★★ ★★★    ★★★★★   ★ (GPU) │
│                                                          │
│  추천:                                                    │
│  - 시작/프로토: pdfplumber                               │
│  - 프로덕션: Unstructured 또는 Docling                   │
│  - 최고 품질: Marker (GPU 있을 때)                       │
└──────────────────────────────────────────────────────────┘

 

8. Vision LLM 활용 — GPT-4V/Claude로 직접 파싱

최근 가장 강력한 방법은 PDF 페이지를 이미지로 변환해서 Vision LLM에 통째로 보내는 것입니다.

기본 패턴

from pdf2image import convert_from_path
from openai import OpenAI
import base64

client = OpenAI()

def parse_pdf_with_vision(pdf_path: str) -> list[str]:
    """페이지별로 Vision LLM에 파싱 요청"""
    images = convert_from_path(pdf_path, dpi=200)

    parsed_pages = []
    for i, image in enumerate(images):
        # 이미지 → base64
        from io import BytesIO
        buf = BytesIO()
        image.save(buf, format='PNG')
        img_b64 = base64.b64encode(buf.getvalue()).decode()

        response = client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "text",
                            "text": """이 PDF 페이지의 모든 내용을 마크다운으로 변환하세요.

규칙:
1. 모든 텍스트를 정확히 추출
2. 표는 마크다운 표 형식으로
3. 이미지/차트는 설명을 [Image: ...] 형식으로
4. 헤더/푸터는 무시하고 본문만
5. 멀티 컬럼은 정확한 읽기 순서로
6. 제목 계층 구조 유지 (#, ##, ### 사용)
"""
                        },
                        {
                            "type": "image_url",
                            "image_url": {"url": f"data:image/png;base64,{img_b64}"}
                        }
                    ]
                }
            ],
            temperature=0,
        )
        parsed_pages.append(response.choices[0].message.content)

    return parsed_pages

Vision LLM의 강점

✅ 표를 마크다운으로 정확히 변환
✅ 멀티 컬럼 자연스럽게 처리
✅ 차트/다이어그램 설명 동시에 생성
✅ 헤더/푸터 자동 무시 (지시 따라)
✅ 한국어 완벽 처리
✅ 라이브러리 의존성 없음

Vision LLM의 약점

❌ 비용 ($0.005~0.01 / 페이지)
❌ 느림 (페이지당 5~15초)
❌ 정확도 100% 아님 (긴 페이지에서 누락 가능)
❌ 큰 PDF는 비용 폭증 (1,000페이지 = $5~10)

비용 효율적 사용 — Hybrid 접근

def hybrid_pdf_parse(pdf_path: str) -> list[str]:
    """단순 페이지는 일반 라이브러리, 복잡한 페이지만 Vision LLM"""
    import pdfplumber

    pages = []
    with pdfplumber.open(pdf_path) as pdf:
        images = convert_from_path(pdf_path, dpi=150)

        for i, page in enumerate(pdf.pages):
            text = page.extract_text() or ""
            tables = page.extract_tables()

            # 표가 많거나 텍스트 추출 실패 시 Vision LLM
            if len(tables) > 0 or len(text.strip()) < 100:
                vision_text = parse_with_vision(images[i])
                pages.append(vision_text)
            else:
                pages.append(text)

    return pages

이렇게 하면 비용은 줄이고 품질은 유지할 수 있습니다.
 

9. 문서 구조 보존 청크 분할 — Element-aware Chunking

추출된 문서를 청크로 나눌 때도 신경 쓸 점이 있습니다.

단순 청크 분할의 함정

# 나쁜 방식: 글자 수만 기준
def naive_chunk(text: str, size: int = 1000) -> list[str]:
    return [text[i:i+size] for i in range(0, len(text), size)]

# 결과: 표가 중간에 잘림, 단락이 끊김, 제목과 본문 분리

Element-Aware Chunking

문서 요소(제목, 단락, 표, 리스트)를 인식하고 적절히 묶어야 합니다.

def element_aware_chunk(elements: list[dict], max_size: int = 1000) -> list[dict]:
    """문서 요소 기반 스마트 청크 분할"""
    chunks = []
    current_chunk = {"content": "", "elements": []}

    for el in elements:
        # 표는 단독 청크
        if el["type"] == "table":
            if current_chunk["content"]:
                chunks.append(current_chunk)
                current_chunk = {"content": "", "elements": []}

            chunks.append({
                "content": el["content"],
                "elements": [el],
                "is_table": True,
            })
            continue

        # 제목은 다음 단락과 묶기
        if el["type"] == "heading":
            if current_chunk["content"]:
                chunks.append(current_chunk)
            current_chunk = {
                "content": el["content"] + "\n",
                "elements": [el],
            }
            continue

        # 일반 단락: 크기 체크하며 추가
        if len(current_chunk["content"]) + len(el["content"]) > max_size:
            chunks.append(current_chunk)
            current_chunk = {"content": el["content"], "elements": [el]}
        else:
            current_chunk["content"] += "\n" + el["content"]
            current_chunk["elements"].append(el)

    if current_chunk["content"]:
        chunks.append(current_chunk)

    return chunks

컨텍스트 유지 — 헤더 정보 청크에 포함

def add_context_to_chunks(chunks: list[dict], doc_title: str) -> list[dict]:
    """각 청크 앞에 문서 제목과 섹션 정보 추가"""
    enriched = []
    current_section = ""

    for chunk in chunks:
        # 청크에서 가장 최근 헤더 추적
        for el in chunk["elements"]:
            if el["type"] == "heading":
                current_section = el["content"]

        # 청크에 컨텍스트 추가
        prefix = f"[문서: {doc_title}]"
        if current_section:
            prefix += f"\n[섹션: {current_section}]"

        chunk["content_with_context"] = prefix + "\n\n" + chunk["content"]
        enriched.append(chunk)

    return enriched

이러면 청크 자체만 봐도 어떤 문서의 어느 섹션인지 알 수 있어서, 검색과 답변 품질이 올라갑니다.
 

10. 실전 PDF RAG 파이프라인 설계

지금까지의 모든 것을 종합한 프로덕션 파이프라인입니다.

import os
from pathlib import Path
from typing import Iterator
import pdfplumber
from pdf2image import convert_from_path
from openai import OpenAI
from qdrant_client import QdrantClient

client = OpenAI()
qdrant = QdrantClient(host="localhost", port=6333)


class PDFProcessor:
    def __init__(self, use_vision_for_complex: bool = True):
        self.use_vision = use_vision_for_complex

    def process(self, pdf_path: str) -> list[dict]:
        """전체 PDF 처리 파이프라인"""
        # 1. 헤더/푸터 검출
        all_pages_text = self._extract_text_pages(pdf_path)
        headers, footers = self._detect_headers_footers(all_pages_text)

        # 2. 페이지별 처리
        elements = []
        with pdfplumber.open(pdf_path) as pdf:
            images = convert_from_path(pdf_path, dpi=150) if self.use_vision else None

            for i, page in enumerate(pdf.pages):
                page_elements = self._process_page(
                    page, images[i] if images else None,
                    headers, footers, page_num=i+1
                )
                elements.extend(page_elements)

        # 3. 표를 자연어로 보완 (선택)
        elements = self._enrich_tables(elements)

        # 4. Element-aware 청크 분할
        chunks = self._element_aware_chunk(elements)

        # 5. 컨텍스트 추가
        doc_title = Path(pdf_path).stem
        chunks = self._add_context(chunks, doc_title)

        return chunks

    def _process_page(self, page, image, headers, footers, page_num):
        """단일 페이지 처리"""
        text = page.extract_text() or ""
        tables = page.extract_tables()

        # 추출 실패 또는 복잡한 페이지 → Vision LLM
        if (len(text.strip()) < 100 or len(tables) > 2) and image:
            return self._parse_with_vision(image, page_num)

        # 일반 처리
        elements = []

        # 헤더/푸터 제거된 텍스트
        cleaned_text = self._remove_headers_footers(text, headers, footers)
        if cleaned_text.strip():
            elements.append({
                "type": "text",
                "content": cleaned_text,
                "page": page_num,
            })

        # 표
        for table in tables:
            md_table = self._table_to_markdown(table)
            elements.append({
                "type": "table",
                "content": md_table,
                "page": page_num,
            })

        return elements

    def _parse_with_vision(self, image, page_num):
        """Vision LLM으로 페이지 파싱"""
        import base64
        from io import BytesIO

        buf = BytesIO()
        image.save(buf, format='PNG')
        img_b64 = base64.b64encode(buf.getvalue()).decode()

        response = client.chat.completions.create(
            model="gpt-4o",
            messages=[{
                "role": "user",
                "content": [
                    {"type": "text", "text": "이 페이지를 마크다운으로 정확히 변환하세요. 헤더/푸터/페이지번호 제외, 표는 마크다운 표로, 차트는 설명으로."},
                    {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}}
                ]
            }],
            temperature=0,
        )

        return [{
            "type": "vision_parsed",
            "content": response.choices[0].message.content,
            "page": page_num,
        }]

    def _enrich_tables(self, elements):
        """표를 자연어 설명으로 보완"""
        enriched = []
        for el in elements:
            enriched.append(el)
            if el["type"] == "table":
                # 표 + 자연어 설명을 별도 청크로
                description = self._table_to_natural_language(el["content"])
                enriched.append({
                    "type": "table_description",
                    "content": description,
                    "page": el["page"],
                })
        return enriched

    # ... (기타 헬퍼 메서드들)


def index_pdf(pdf_path: str, collection_name: str):
    """PDF를 처리하고 Qdrant에 인덱싱"""
    processor = PDFProcessor(use_vision_for_complex=True)
    chunks = processor.process(pdf_path)

    points = []
    for i, chunk in enumerate(chunks):
        # 임베딩
        embedding = client.embeddings.create(
            model="text-embedding-3-small",
            input=chunk["content_with_context"]
        ).data[0].embedding

        points.append({
            "id": f"{Path(pdf_path).stem}_{i}",
            "vector": embedding,
            "payload": {
                "content": chunk["content"],
                "content_with_context": chunk["content_with_context"],
                "doc_title": Path(pdf_path).stem,
                "page": chunk["elements"][0]["page"] if chunk.get("elements") else None,
                "is_table": chunk.get("is_table", False),
            }
        })

    qdrant.upsert(collection_name=collection_name, points=points)
    print(f"Indexed {len(chunks)} chunks from {pdf_path}")

 

마무리 — PDF는 "의도적으로 처리"해야 한다

PDF는 RAG의 가장 큰 적이지만, 정확히 이해하면 다룰 수 있습니다.

┌──────────────────────────────────────────────────────────┐
│  PDF RAG 파이프라인 핵심 원칙                              │
│                                                          │
│  ❶ "단순 추출"은 데모용일 뿐                              │
│     → 프로덕션에서는 의도적 처리 필수                     │
│                                                          │
│  ❷ 표는 절대 단순 텍스트로 두지 말라                      │
│     → 마크다운 변환 + 자연어 설명                         │
│                                                          │
│  ❸ Vision LLM은 비싸지만 강력한 무기                      │
│     → Hybrid로 선택적 사용                                │
│                                                          │
│  ❹ 청크 분할은 문서 구조를 따라야 한다                    │
│     → 표/제목/단락의 의미적 단위 보존                     │
│                                                          │
│  ❺ 청크에 컨텍스트(문서명, 섹션) 포함                     │
│     → 검색 품질 크게 향상                                 │
│                                                          │
│  ❻ 헤더/푸터/페이지번호는 노이즈                         │
│     → 자동 검출로 제거                                    │
└──────────────────────────────────────────────────────────┘

오늘 본 패턴들을 적용하면, 똑같은 PDF로도 RAG의 답변 품질이 극적으로 달라집니다. 단순히 PyPDF로 추출하던 것을 Vision LLM + Element-aware Chunking으로 바꾸기만 해도 사용자 만족도가 2배 이상 올라가는 것을 자주 봅니다.
RAG 품질 = 검색 품질 × 청크 품질. PDF를 잘 다루는 것은 청크 품질의 절반을 책임집니다.

반응형