Skip to content

Python RAG 파이프라인용 PDF 추출

RAG 파이프라인에 쓸 PDF를 구조화된 Markdown으로 추출합니다.

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("paper.pdf")
md = doc.to_markdown_all(detect_headings=True, include_images=False)
# 청크로 나누고 임베딩을 만든 뒤 벡터 데이터베이스에 저장

WASM

import { WasmPdfDocument } from "pdf-oxide-wasm";

const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdownAll();
// 청크로 나누고 임베딩을 만든 뒤 벡터 데이터베이스에 저장
doc.free();

Rust

use pdf_oxide::PdfDocument;

let mut doc = PdfDocument::open("paper.pdf")?;
let md = doc.to_markdown_all(true)?;
// 청크로 나누고 임베딩을 만든 뒤 벡터 데이터베이스에 저장

Go

package main

import (
    "log"
    pdfoxide "github.com/yfedoseev/pdf_oxide/go"
)

func main() {
    doc, err := pdfoxide.Open("paper.pdf")
    if err != nil { log.Fatal(err) }
    defer doc.Close()

    md, _ := doc.ToMarkdownAll()
    _ = md // 청크로 나누고 임베딩을 만든 뒤 벡터 데이터베이스에 저장
}

C#

using PdfOxide;

using var doc = PdfDocument.Open("paper.pdf");
var md = doc.ToMarkdownAll();
// 청크로 나누고 임베딩을 만든 뒤 벡터 데이터베이스에 저장

PDF Oxide는 3,830개의 PDF를 3.1초에 처리합니다. 페이지당 0.8ms에 통과율 100%입니다. 인덱스에서 사라지는 문서는 없습니다.

RAG에서 추출 품질이 중요한 이유

검색 시스템의 성능 상한선은 그 앞단의 추출 품질입니다.

  • 누락된 텍스트 = 누락된 답변. 통과율 98.4%인 라이브러리(pypdf)는 3,823개 파일 코퍼스에서 61개를 조용히 빠뜨립니다. PDF Oxide는 100%를 통과합니다.
  • 잃어버린 구조 = 부실한 청킹. 일반 텍스트는 시맨틱 청킹을 가능케 하는 제목, 표, 서식을 잃어버립니다. Markdown은 이를 보존합니다.
  • 느린 추출 = 파이프라인 병목. 페이지당 12.1ms(pypdf)나 23.2ms(pdfplumber)라면 100K 페이지 처리에 수 분이 걸립니다. 0.8ms라면 80초면 끝납니다.

설치

pip install pdf_oxide

빠른 시작: PDF에서 벡터 데이터베이스까지

from pdf_oxide import PdfDocument, PdfError
from pathlib import Path

def extract_documents(pdf_dir: str) -> list[dict]:
    """디렉터리 내 모든 PDF를 구조화된 청크로 추출."""
    documents = []
    for pdf_path in Path(pdf_dir).glob("*.pdf"):
        try:
            doc = PdfDocument(str(pdf_path))
            for i in range(doc.page_count()):
                md = doc.to_markdown(i,
                    detect_headings=True,
                    include_images=False
                )
                if md.strip():
                    documents.append({
                        "content": md,
                        "source": pdf_path.name,
                        "page": i,
                    })
        except PdfError as e:
            print(f"{pdf_path.name} 건너뜀: {e}")
    return documents

docs = extract_documents("research-papers/")
print(f"{len(docs)}개의 청크를 PDF에서 추출했습니다")
# docs를 임베딩 모델과 벡터 스토어에 전달

청킹 전략

제목 단위(시맨틱 청킹)

Markdown 출력을 제목 단위로 쪼개 의미 있는 청크를 만듭니다.

Python

import re
from pdf_oxide import PdfDocument

doc = PdfDocument("paper.pdf")
md = doc.to_markdown_all(detect_headings=True, include_images=False)

# ## 제목 기준으로 분할
chunks = re.split(r'\n(?=## )', md)
chunks = [c.strip() for c in chunks if c.strip()]

WASM

const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdownAll();

// ## 제목 기준으로 분할
const chunks = md.split(/\n(?=## )/).filter(c => c.trim());
doc.free();

Rust

let mut doc = PdfDocument::open("paper.pdf")?;
let md = doc.to_markdown_all(true)?;

let chunks: Vec<&str> = md.split("\n## ")
    .map(|c| c.trim())
    .filter(|c| !c.is_empty())
    .collect();

Go

doc, _ := pdfoxide.Open("paper.pdf")
defer doc.Close()

md, _ := doc.ToMarkdownAll()

var chunks []string
for _, c := range strings.Split(md, "\n## ") {
    c = strings.TrimSpace(c)
    if c != "" { chunks = append(chunks, c) }
}

C#

using var doc = PdfDocument.Open("paper.pdf");
var md = doc.ToMarkdownAll();

var chunks = md.Split("\n## ")
    .Select(c => c.Trim())
    .Where(c => c.Length > 0)
    .ToList();

페이지 단위

페이지마다 청크 하나 — 단순하고 페이지 단위 맥락을 보존합니다.

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("manual.pdf")
chunks = []
for i in range(doc.page_count()):
    md = doc.to_markdown(i, detect_headings=True, include_images=False)
    if md.strip():
        chunks.append({"content": md, "page": i})

WASM

const doc = new WasmPdfDocument(bytes);
const chunks = [];
for (let i = 0; i < doc.pageCount(); i++) {
    const md = doc.toMarkdown(i);
    if (md.trim()) {
        chunks.push({ content: md, page: i });
    }
}
doc.free();

Rust

let mut doc = PdfDocument::open("manual.pdf")?;
let mut chunks = Vec::new();
for i in 0..doc.page_count()? {
    let md = doc.to_markdown(i, true)?;
    if !md.trim().is_empty() {
        chunks.push((i, md));
    }
}

Go

doc, _ := pdfoxide.Open("manual.pdf")
defer doc.Close()

type Chunk struct{ Page int; Content string }
var chunks []Chunk

n, _ := doc.PageCount()
for i := 0; i < n; i++ {
    md, _ := doc.ToMarkdown(i)
    if strings.TrimSpace(md) != "" {
        chunks = append(chunks, Chunk{Page: i, Content: md})
    }
}

C#

using var doc = PdfDocument.Open("manual.pdf");
var chunks = Enumerable.Range(0, doc.PageCount)
    .Select(i => new { Page = i, Content = doc.ToMarkdown(i) })
    .Where(c => !string.IsNullOrWhiteSpace(c.Content))
    .ToList();

고정 크기 + 오버랩

긴 텍스트를 오버랩을 두고 고정 크기 청크로 나눕니다.

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("book.pdf")
full_text = doc.to_markdown_all(detect_headings=True, include_images=False)

chunk_size = 1000  # 문자 수
overlap = 200
chunks = []

for start in range(0, len(full_text), chunk_size - overlap):
    chunk = full_text[start:start + chunk_size]
    if chunk.strip():
        chunks.append(chunk)

WASM

const doc = new WasmPdfDocument(bytes);
const fullText = doc.toMarkdownAll();

const chunkSize = 1000;
const overlap = 200;
const chunks = [];

for (let start = 0; start < fullText.length; start += chunkSize - overlap) {
    const chunk = fullText.slice(start, start + chunkSize);
    if (chunk.trim()) chunks.push(chunk);
}
doc.free();

Rust

let mut doc = PdfDocument::open("book.pdf")?;
let full_text = doc.to_markdown_all(true)?;

let chunk_size = 1000;
let overlap = 200;
let mut chunks = Vec::new();
let mut start = 0;

while start < full_text.len() {
    let end = (start + chunk_size).min(full_text.len());
    let chunk = &full_text[start..end];
    if !chunk.trim().is_empty() {
        chunks.push(chunk.to_string());
    }
    start += chunk_size - overlap;
}

Go

doc, _ := pdfoxide.Open("book.pdf")
defer doc.Close()

full, _ := doc.ToMarkdownAll()

const chunkSize, overlap = 1000, 200
var chunks []string
for start := 0; start < len(full); start += chunkSize - overlap {
    end := start + chunkSize
    if end > len(full) { end = len(full) }
    chunk := full[start:end]
    if strings.TrimSpace(chunk) != "" {
        chunks = append(chunks, chunk)
    }
}

C#

using var doc = PdfDocument.Open("book.pdf");
var full = doc.ToMarkdownAll();

const int chunkSize = 1000, overlap = 200;
var chunks = new List<string>();
for (int start = 0; start < full.Length; start += chunkSize - overlap)
{
    var end = Math.Min(start + chunkSize, full.Length);
    var chunk = full[start..end];
    if (!string.IsNullOrWhiteSpace(chunk))
        chunks.Add(chunk);
}

수천 개 PDF 배치 처리

페이지당 0.8ms라면 PDF Oxide는 대규모 코퍼스도 금세 처리합니다.

from pdf_oxide import PdfDocument, PdfError
from pathlib import Path

pdf_files = list(Path("corpus/").glob("**/*.pdf"))
print(f"{len(pdf_files)}개의 PDF 처리 중...")

all_chunks = []
errors = 0

for pdf_path in pdf_files:
    try:
        doc = PdfDocument(str(pdf_path))
        md = doc.to_markdown_all(
            detect_headings=True,
            include_images=False
        )
        if md.strip():
            all_chunks.append({
                "content": md,
                "source": str(pdf_path),
                "pages": doc.page_count(),
            })
    except PdfError:
        errors += 1

print(f"{len(all_chunks)}개 문서 추출, 오류 {errors}건")

스캔 PDF를 파이프라인에서 처리

코퍼스에는 스캔 이미지 PDF가 섞여 있을 수 있습니다. 이럴 때는 OCR을 폴백으로 사용합니다.

from pdf_oxide import PdfDocument

doc = PdfDocument("mixed-corpus-file.pdf")
text = doc.extract_text(0)

if len(text.strip()) < 50:
    # 스캔된 페이지일 가능성이 큼 — OCR 사용
    text = doc.extract_text_ocr(0)

자세한 설정은 OCR 가이드를 참고하세요.

일반 텍스트 대신 Markdown을 쓰는 이유

특성 일반 텍스트 Markdown
제목 계층 잃음 보존(#, ##, ###)
평평해짐 GFM 표 문법
굵게/기울임 잃음 **굵게**, *기울임*
시맨틱 청킹 어려움 제목 기준 분할
LLM 이해도 낮음 높음(구조화된 입력)

Markdown은 LLM에 문서 구조에 대한 맥락을 더 풍부하게 제공해 검색과 생성 품질을 끌어올립니다.

대규모 환경에서의 성능

코퍼스 크기 PDF Oxide pypdf pdfplumber
1,000 페이지 0.8초 12.1초 23.2초
10,000 페이지 8초 121초 232초
100,000 페이지 80초 1,210초 2,320초
통과율 100% 98.4% 98.8%

통과율이 100%이므로 인덱스에서 문서가 빠진 이유를 수동으로 조사할 필요가 없습니다.

관련 문서