Blog / Tutorials

AI Document Processing Pipeline on a VPS: Summarize, Extract, Classify

By the NoctHost TeamJuly 8, 20263 min read

Sending sensitive documents to OpenAI or Anthropic for processing isn't always an option. Legal documents, medical records, financial reports, internal memos — these often can't leave your infrastructure.

A local document processing pipeline with Ollama handles all of this on your VPS. The documents never leave your server.

What We're Building

A Python service that accepts documents and runs three operations:

  • Summarization — condense long documents to key points
  • Extraction — pull structured data from unstructured text
  • Classification — categorize documents by type or topic

Requirements

  • VPS with 8 GB RAM
  • Ollama with Llama 3.3 8B
  • Python 3.10+

Setup

pip3 install ollama pdfplumber fastapi uvicorn --break-system-packages
ollama pull llama3.1:8b

The Processing Service

# processor.py
import ollama
import pdfplumber
import json
from pathlib import Path

MODEL = "llama3.1:8b"

def extract_text(file_path: str) -> str:
    path = Path(file_path)
    if path.suffix.lower() == '.pdf':
        with pdfplumber.open(file_path) as pdf:
            return "\n".join(page.extract_text() or "" for page in pdf.pages)
    else:
        return path.read_text()

def summarize(text: str, max_words: int = 150) -> str:
    response = ollama.generate(
        model=MODEL,
        prompt=f"""Summarize the following document in {max_words} words or less.
Focus on the key points and main conclusions.

Document:
{text[:4000]}

Summary:"""
    )
    return response['response'].strip()

def extract_structured(text: str, fields: list) -> dict:
    fields_str = "\n".join(f"- {f}" for f in fields)
    response = ollama.generate(
        model=MODEL,
        prompt=f"""Extract the following fields from the document.
Return ONLY valid JSON, no other text.

Fields to extract:
{fields_str}

Document:
{text[:4000]}

JSON:"""
    )
    try:
        raw = response['response'].strip()
        # Find JSON in response
        start = raw.find('{')
        end = raw.rfind('}') + 1
        return json.loads(raw[start:end])
    except:
        return {}

def classify(text: str, categories: list) -> str:
    cats = ", ".join(categories)
    response = ollama.generate(
        model=MODEL,
        prompt=f"""Classify the following document into exactly one of these categories: {cats}

Return ONLY the category name, nothing else.

Document:
{text[:2000]}

Category:"""
    )
    return response['response'].strip()

# Example usage
if __name__ == "__main__":
    text = extract_text("contract.pdf")

    print("Summary:")
    print(summarize(text))

    print("\nExtracted data:")
    data = extract_structured(text, [
        "party names",
        "contract date",
        "total value",
        "expiry date"
    ])
    print(json.dumps(data, indent=2))

    print("\nClassification:")
    category = classify(text, [
        "employment contract",
        "service agreement",
        "NDA",
        "purchase order",
        "other"
    ])
    print(category)

Building an API Around It

# api.py
from fastapi import FastAPI, UploadFile
from processor import extract_text, summarize, extract_structured, classify
import tempfile, os

app = FastAPI()

@app.post("/process")
async def process_document(
    file: UploadFile,
    operation: str = "summarize"
):
    with tempfile.NamedTemporaryFile(
        suffix=os.path.splitext(file.filename)[1],
        delete=False
    ) as tmp:
        tmp.write(await file.read())
        tmp_path = tmp.name

    try:
        text = extract_text(tmp_path)

        if operation == "summarize":
            result = summarize(text)
        elif operation == "extract":
            result = extract_structured(text, [
                "date", "author", "subject", "key points"
            ])
        elif operation == "classify":
            result = classify(text, [
                "invoice", "contract", "report", "email", "other"
            ])
        else:
            result = "Unknown operation"

        return {"result": result, "operation": operation}
    finally:
        os.unlink(tmp_path)

Run it:

uvicorn api:app --host 0.0.0.0 --port 8000

What it costs

The pipeline's footprint is the model plus a small FastAPI service, so the Standard plan (2 vCPU, 4 GB) handles a 7B model and overnight batches well; step up to the Pro plan (4 vCPU, 8 GB) for larger models or faster throughput. NoctHost bills hourly from a prepaid, crypto-funded balance, no card and no KYC, which suits a service that mostly runs in bursts.

Spin one up in about a minute

Email signup, pay with crypto, hourly billing. Trying a box costs cents — destroy it when you are done.

Deploy a server

Frequently asked

How long does processing take?
For a 10-page PDF at 5–10 tokens/second on CPU: summarization takes 30–60 seconds, extraction 20–40 seconds. For background batch jobs this is fine. For interactive use, plan accordingly.
Can I process larger documents?
The examples truncate to 4000 characters for the prompt. For longer documents, split into chunks, process each chunk, then combine results. This works well for summarization.
What languages does it support?
Llama 3.3 8B handles English well and has decent support for other major languages. For non-English documents, Qwen 2.5 7B has stronger multilingual performance.

Keep reading