Skip to content

Видобування PDF для RAG-пайплайнів у Python

Перетворіть PDF на структурований Markdown для свого RAG-пайплайну:

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,8 мс на сторінку з 100 % успіху. Жодного пропущеного документа у вашому індексі.

Чому якість видобування критична для RAG

Ваша система пошуку така ж добра, як і видобування перед нею:

  • Пропущений текст = пропущені відповіді. Бібліотека з 98,4 % успіху (pypdf) мовчки втрачає 61 документ у корпусі з 3 823 файлів. PDF Oxide проходить на 100 %.
  • Втрачена структура = слабкий чанкінг. Звичайний текст втрачає заголовки, таблиці та форматування — саме те, що робить можливим семантичне розбиття. Markdown усе це зберігає.
  • Повільне видобування = вузьке місце пайплайну. При 12,1 мс на сторінку (pypdf) або 23,2 мс (pdfplumber) обробка 100 000 сторінок триває хвилини. При 0,8 мс — 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)}")
# Передайте 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,8 мс на сторінку PDF Oxide опановує великі корпуси вмить:

from pdf_oxide import PdfDocument, PdfError
from pathlib import Path

pdf_files = list(Path("corpus/").glob("**/*.pdf"))
print(f"Обробляється PDF: {len(pdf_files)}...")

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 % успіху не доводиться вручну з’ясовувати, чому документи зникли з індексу.

Пов’язані сторінки