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%이므로 인덱스에서 문서가 빠진 이유를 수동으로 조사할 필요가 없습니다.
관련 문서
- PDF를 Markdown으로 — Markdown 변환 상세
- 배치 처리 — 병렬 처리 패턴
- 스캔 PDF OCR — OCR 설정과 사용
- PDF에서 텍스트 추출 — 일반 텍스트 추출
- 성능 벤치마크 — 벤치마크 결과 전체