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 秒で処理します。1 ページあたり 0.8 ms、成功率は 100% です。インデックスから抜け落ちる書類はゼロです。
RAG で抽出品質が重要な理由
検索システムの上限は、その前段にある抽出の品質で決まります。
- テキストの欠落 = 回答の欠落。成功率 98.4% のライブラリ(pypdf)は、3,823 ファイルのコーパスから 61 件を黙って取りこぼします。PDF Oxide は 100% を通します。
- 構造の喪失 = チャンキングの劣化。プレーンテキストは見出し、表、書式情報を捨ててしまいますが、これらこそがセマンティックチャンキングを支える要素です。Markdown ならそれらが残ります。
- 遅い抽出 = パイプラインのボトルネック。1 ページ 12.1 ms(pypdf)や 23.2 ms(pdfplumber)では、100K ページの処理に数分かかります。0.8 ms なら 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();
ページ単位
1 ページ 1 チャンク — シンプルで、ページ単位の文脈が保持されます。
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 を一括処理する
1 ページ 0.8 ms なら、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 からテキスト抽出 — プレーンテキスト抽出
- パフォーマンスベンチマーク — ベンチマーク結果の全容