Python PDF Markdown 변환
PDF를 Markdown으로 바꾸는 작업은 최근의 문서 처리 파이프라인에서 가장 중요한 단계 중 하나예요. LLM 애플리케이션이나 RAG 파이프라인을 만들 때든, 단지 읽기 편한 포맷으로 보관할 때든, Python에서 PDF를 Markdown으로 변환해 두면 어디서든 쓸 수 있는 구조화된 출력이 생깁니다.
PDF를 Markdown으로 바꿔야 하는 이유
Markdown은 AI와 문서 워크플로의 사실상 표준 교환 포맷이 되었어요. PDF를 Markdown으로 변환할 가치가 있는 이유는 이렇습니다.
LLM 컨텍스트 창은 구조화된 텍스트와 가장 잘 맞습니다. GPT-4, Claude, Llama 같은 대형 언어 모델은 원문 추출 텍스트보다 잘 정돈된 Markdown을 받았을 때 확연히 나은 결과를 만들어냅니다. 제목은 모델에 문서의 지도를 주고, 굵은 글씨나 기울임 같은 서식은 평문 텍스트라면 버려질 의미를 그대로 담아줍니다.
RAG 파이프라인에는 제목이 살아 있는 깔끔한 청크 텍스트가 필요해요. 검색 증강 생성(RAG) 시스템은 문서를 청크로 쪼개 임베딩을 만들고 질의 시점에 가장 관련 있는 부분을 꺼냅니다. Markdown 제목은 자연스러운 청크 경계가 돼 주기 때문에 ##로 분리하면 제목이 포함된 의미 단위의 섹션 청크가 바로 만들어지죠. 평문 텍스트로 뽑으면 이런 경계가 사라져서 단락 길이나 문장 수 같은 휴리스틱에 의존하게 됩니다.
Markdown은 구조를 보존하면서도 여전히 평문이에요. 제목, 글머리 기호, 번호 목록, 표, 굵은 글씨, 기울임 같은 요소가 사람과 기계 모두 읽을 수 있는 형태로 변환에서 살아남습니다. Markdown 파일은 결국 텍스트 파일이니까, 버전 관리, 전체 텍스트 검색, 모든 프로그래밍 언어와 자연스럽게 어울립니다.
대안은 모두 한 수 아래입니다. 평문 텍스트 추출은 모든 구조를 잃어버려서 제목이 본문과 구분이 안 되고, 표는 어지러운 줄로 무너지고, 목록은 계층이 사라집니다. HTML 변환은 구조를 유지하지만 용량이 크게 불어나요 — 2KB짜리 Markdown이 중첩 <div> 태그, CSS 클래스, 이스케이프된 엔티티가 들어간 15KB HTML이 되기도 합니다. Markdown은 그 사이에서 가장 좋은 균형점에 있어요. 구조적이고, 가볍고, 어디서든 지원됩니다.
빠른 시작
PDF 한 페이지를 깔끔한 Markdown으로 만드는 데 세 줄이면 돼요.
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("paper.pdf")
md = doc.to_markdown(0, detect_headings=True)
print(md)
WASM
import { WasmPdfDocument } from "pdf-oxide-wasm";
const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdown(0);
console.log(md);
doc.free();
Rust
use pdf_oxide::PdfDocument;
let mut doc = PdfDocument::open("paper.pdf")?;
let md = doc.to_markdown(0, true)?;
println!("{}", md);
Go
package main
import (
"fmt"
"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, err := doc.ToMarkdown(0)
if err != nil { log.Fatal(err) }
fmt.Println(md)
}
C#
using PdfOxide;
using var doc = PdfDocument.Open("paper.pdf");
Console.WriteLine(doc.ToMarkdown(0));
PDF Oxide는 폰트 크기 클러스터링으로 제목을 감지하고, 굵게·기울임 서식을 유지하며, 표를 GFM 문법으로 바꿔줍니다. 원한다면 이미지도 함께 임베딩할 수 있어요. Markdown 변환을 내장한 Python PDF 라이브러리는 PDF Oxide뿐입니다.
설치
pip install pdf_oxide
문서 전체 변환
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("book.pdf")
md = doc.to_markdown_all(detect_headings=True)
with open("book.md", "w", encoding="utf-8") as f:
f.write(md)
WASM
const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdownAll();
console.log(md);
doc.free();
Rust
let mut doc = PdfDocument::open("book.pdf")?;
let md = doc.to_markdown_all(true)?;
std::fs::write("book.md", &md)?;
Go
doc, _ := pdfoxide.Open("book.pdf")
defer doc.Close()
md, _ := doc.ToMarkdownAll()
_ = os.WriteFile("book.md", []byte(md), 0644)
C#
using var doc = PdfDocument.Open("book.pdf");
File.WriteAllText("book.md", doc.ToMarkdownAll());
to_markdown_all()은 모든 페이지를 변환해서 --- 구분자로 이어 붙입니다.
변환 옵션
| 파라미터 | 기본값 | 설명 |
|---|---|---|
detect_headings |
True |
폰트 크기를 #, ##, ### 제목으로 매핑 |
preserve_layout |
False |
시각적 위치 보존 |
include_images |
True |
이미지를 출력에 포함 |
embed_images |
True |
base64 데이터 URI로 임베딩 |
image_output_dir |
None |
이미지를 이 디렉터리에 저장 |
제목만 (이미지 제외)
doc = PdfDocument("paper.pdf")
md = doc.to_markdown(0, detect_headings=True, include_images=False)
이미지를 디렉터리에 저장
doc = PdfDocument("report.pdf")
md = doc.to_markdown(0,
detect_headings=True,
embed_images=False,
image_output_dir="output/images"
)
with open("output/report.md", "w") as f:
f.write(md)
RAG / LLM 파이프라인 연동
RAG 파이프라인에서는 Markdown이 이상적인 포맷이에요. 제목이 자연스러운 청크 경계를 만들어 주고, 구조를 유지하는 덕분에 평문에선 사라질 정보도 남길 수 있죠.
제목 단위로 청크화
Python
from pdf_oxide import PdfDocument
import re
doc = PdfDocument("paper.pdf")
md = doc.to_markdown_all(detect_headings=True)
# 의미 단위 청크를 만들려고 제목 기준으로 분리
chunks = re.split(r'\n(?=#{1,3} )', md)
chunks = [chunk.strip() for chunk in chunks if chunk.strip()]
for i, chunk in enumerate(chunks):
print(f"Chunk {i}: {chunk[:80]}...")
WASM
const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdownAll();
// 의미 단위 청크를 만들려고 제목 기준으로 분리
const chunks = md.split(/\n(?=#{1,3} )/).filter(c => c.trim());
chunks.forEach((chunk, i) => {
console.log(`Chunk ${i}: ${chunk.slice(0, 80)}...`);
});
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();
for (i, chunk) in chunks.iter().enumerate() {
println!("Chunk {}: {}...", i, &chunk[..chunk.len().min(80)]);
}
Go
doc, _ := pdfoxide.Open("paper.pdf")
defer doc.Close()
md, _ := doc.ToMarkdownAll()
re := regexp.MustCompile(`\n(?=#{1,3} )`)
for i, chunk := range re.Split(md, -1) {
chunk = strings.TrimSpace(chunk)
if chunk == "" { continue }
if len(chunk) > 80 { chunk = chunk[:80] }
fmt.Printf("Chunk %d: %s...\n", i, chunk)
}
C#
using var doc = PdfDocument.Open("paper.pdf");
var md = doc.ToMarkdownAll();
var chunks = Regex.Split(md, @"\n(?=#{1,3} )")
.Select(c => c.Trim())
.Where(c => c.Length > 0)
.ToList();
for (int i = 0; i < chunks.Count; i++)
{
var preview = chunks[i].Length > 80 ? chunks[i][..80] : chunks[i];
Console.WriteLine($"Chunk {i}: {preview}...");
}
페이지 단위 청크화
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("report.pdf")
chunks = []
for i in range(doc.page_count()):
md = doc.to_markdown(i, detect_headings=True, include_images=False)
chunks.append({
"page": i,
"content": md,
"source": "report.pdf"
})
WASM
const doc = new WasmPdfDocument(bytes);
const chunks = [];
for (let i = 0; i < doc.pageCount(); i++) {
const md = doc.toMarkdown(i);
chunks.push({ page: i, content: md, source: "report.pdf" });
}
doc.free();
Rust
let mut doc = PdfDocument::open("report.pdf")?;
let mut chunks = Vec::new();
for i in 0..doc.page_count()? {
let md = doc.to_markdown(i, true)?;
chunks.push((i, md));
}
Go
doc, _ := pdfoxide.Open("report.pdf")
defer doc.Close()
type Chunk struct {
Page int
Content string
Source string
}
n, _ := doc.PageCount()
chunks := make([]Chunk, 0, n)
for i := 0; i < n; i++ {
md, _ := doc.ToMarkdown(i)
chunks = append(chunks, Chunk{Page: i, Content: md, Source: "report.pdf"})
}
C#
using var doc = PdfDocument.Open("report.pdf");
var chunks = Enumerable.Range(0, doc.PageCount)
.Select(i => new { Page = i, Content = doc.ToMarkdown(i), Source = "report.pdf" })
.ToList();
벡터 DB용 일괄 변환
from pdf_oxide import PdfDocument, PdfError
from pathlib import Path
pdf_dir = Path("documents/")
documents = []
for pdf_path in pdf_dir.glob("*.pdf"):
try:
doc = PdfDocument(str(pdf_path))
md = doc.to_markdown_all(detect_headings=True, include_images=False)
documents.append({
"source": pdf_path.name,
"content": md,
"pages": doc.page_count()
})
except PdfError as e:
print(f"Skipped {pdf_path.name}: {e}")
print(f"Converted {len(documents)} PDFs")
페이지당 0.8ms 속도라면, 벡터 DB용 PDF 수천 개를 변환하는 것도 분이 아니라 초 단위로 끝납니다.
제목 감지 원리
PDF Oxide는 페이지 안에서 발견되는 폰트 크기를 클러스터링해 제목 레벨을 추정해요.
- 폰트 크기와 굵기 메타데이터를 가진 모든 텍스트 스팬을 추출합니다
- 크기로 스팬을 클러스터링합니다 — 가장 자주 나오는 크기가 본문이에요
- 더 크거나 굵은 크기를 큰 순서대로
#,##,###제목으로 매핑합니다 - 인라인 굵은 글씨(
**text**)와 기울임(*text*) 서식을 유지합니다
이 방식은 학술 논문, 보고서, 일반 문서에서 잘 작동해요. 폰트 구성이 독특한 PDF에서는 제목 감지를 끄고 쓰세요.
md = doc.to_markdown(0, detect_headings=False)
LLM과 RAG 파이프라인을 위한 PDF → Markdown
PDF Oxide의 내장 Markdown 변환은 AI 워크플로를 염두에 두고 설계됐습니다. 감지된 제목 계층이 곧 의미 구조에 대응돼서 후속 처리가 단순해지죠.
LLM에 Markdown 넘기기
PDF를 변환한 뒤 요약, Q&A, 분석을 위해 그대로 언어 모델에 넘기면 돼요.
from pdf_oxide import PdfDocument
doc = PdfDocument("quarterly-report.pdf")
md = doc.to_markdown_all(detect_headings=True, include_images=False)
# 어떤 LLM API로도 보낼 수 있다 — Markdown 구조가 모델이
# 문서 구성을 이해하는 데 도움을 준다
prompt = f"""다음 문서를 요약해 주세요. 주요 섹션을 식별하기 위해
제목 구조에 주목해 주세요.
{md}
"""
# response = llm_client.generate(prompt)
PDF Oxide가 제목 계층(#, ##, ###)을 유지해 주니까, LLM은 섹션 제목과 본문을 구분하고 섹션 단위로 이해한 요약을 만들어 냅니다. 평문 텍스트 추출로는 모델이 섹션이 어디서 시작하고 끝나는지 직접 추측해야 합니다.
RAG용 제목 단위 청크화
Markdown 제목에서 분리하면 임베딩이 잘되고 검색도 정확하게 걸리는 의미 있는 청크가 나옵니다.
from pdf_oxide import PdfDocument
import re
doc = PdfDocument("technical-manual.pdf")
md = doc.to_markdown_all(detect_headings=True, include_images=False)
# 제목 경계에서 청크로 분리
chunks = re.split(r'\n(?=#{1,3} )', md)
chunks = [c.strip() for c in chunks if c.strip()]
# 각 청크의 첫 줄이 제목 — 메타데이터로 쓰기 좋다
for chunk in chunks:
lines = chunk.split('\n', 1)
title = lines[0].lstrip('#').strip()
body = lines[1].strip() if len(lines) > 1 else ""
# embed_and_store(title=title, content=body, source="technical-manual.pdf")
이렇게 하면 섹션 하나가 곧 청크 하나이니 응집력이 있고, 제목이 검색 메타데이터로 쓰이며, 길이도 비교적 고르게 나옵니다(저자들이 보통 섹션을 비슷한 길이로 쓰니까요). PDF Oxide의 제목 감지는 별다른 설정 없이 이 구조를 만들어 줘요 — 폰트 크기 클러스터링 알고리즘이 자동으로 제목 레벨을 찾습니다.
PDF Oxide가 AI 파이프라인에 맞는 이유
페이지당 0.8ms라는 속도는, 인덱싱 시점뿐 아니라 질의 시점에도 문서를 즉석에서 변환할 수 있을 만큼 빠릅니다. 느린 도구로는 엄두도 못 낼 워크플로가 가능해지죠.
- 온디맨드 변환: 사용자가 PDF를 업로드하는 순간 Markdown으로 변환해도 지연이 느껴지지 않아요
- 재처리: 청크 전략을 바꿨을 때 모든 PDF를 다시 변환해 RAG 인덱스를 갱신 — 수천 페이지도 초 단위로 끝납니다
- 스트리밍 파이프라인: 큐로 들어오는 PDF를 바로바로 변환해서 병목이 쌓이지 않습니다
배치 처리
디렉터리 하나 통째의 PDF를 모두 Markdown 파일로 변환합니다.
from pdf_oxide import PdfDocument
from pathlib import Path
for pdf_path in Path("documents/").glob("*.pdf"):
doc = PdfDocument(str(pdf_path))
md_parts = []
for i in range(doc.page_count()):
md_parts.append(doc.to_markdown(i, detect_headings=True))
md_path = pdf_path.with_suffix(".md")
md_path.write_text("\n\n".join(md_parts))
print(f"Converted {pdf_path.name} -> {md_path.name}")
페이지당 밀리초 이하 속도라면 PDF 수백 개를 일괄 변환해도 초 단위로 끝나요. 파일이 수천 개에 달하는 프로덕션 워크로드라면 배치 처리 가이드에서 병렬 처리 패턴을 확인하세요.
PDF → Markdown: PDF Oxide 대 대안
| 도구 | 속도 | 내장 여부 | 제목 감지 | 표 보존 |
|---|---|---|---|---|
| PDF Oxide | 0.8ms | Yes | Yes | Yes |
| pymupdf4llm | 55.5ms (69× 느림) | No (별도 패키지) | Yes | Yes |
| marker | 약 500ms 이상 | No (별도 도구) | Yes | Yes |
| pdfplumber + 직접 구현 | 약 23ms 이상 | No (수동) | No | 수동 |
| pypdf + 직접 구현 | 약 12ms 이상 | No (수동) | No | No |
PDF Oxide는 빠른 Markdown 변환을 내장한 유일한 Python PDF 라이브러리입니다. 폰트 크기 클러스터링으로 제목을 감지하고, 표를 GitHub Flavored Markdown으로 변환하며, 인라인 서식까지 to_markdown() 한 번의 호출로 모두 처리해요.
pymupdf4llm은 AGPL 라이선스의 PyMuPDF에 더해 별도 pymupdf4llm 패키지까지 필요합니다. PDF Oxide보다 69배 느리고, 독점 애플리케이션과 맞지 않을 수 있는 카피레프트 라이선스 의무도 따라붙습니다.
marker는 라이브러리가 아니라 독립 도구입니다. 레이아웃 감지에 딥러닝 모델을 쓰기 때문에 복잡한 레이아웃에는 정확하지만 속도는 몇 자릿수 단위로 느리고, 성능을 제대로 내려면 GPU 메모리도 많이 필요합니다.
pdfplumber와 pypdf는 Markdown 변환 기능 자체가 없어요. 제목 감지, 표 재구성, Markdown 포맷 조립을 모두 직접 구현해야 하는데, PDF Oxide가 기본으로 제공하는 것을 재현하는 데도 상당한 엔지니어링 공수가 듭니다.
관련 페이지
- Markdown 변환 API — 전체 API 레퍼런스
- RAG 파이프라인용 PDF — RAG 통합 가이드
- PDF 텍스트 추출 — 평문 추출
- 배치 처리 — 병렬 처리 패턴