Blog / Tutorials

Build a Private RAG System on a VPS with Ollama and pgvector

By the NoctHost TeamJuly 6, 20263 min read

RAG — retrieval-augmented generation — lets you feed your own documents to an LLM so it can answer questions about them. Instead of relying on what the model was trained on, it searches your document collection and uses that context to generate answers.

The standard approach is to use a cloud service like Pinecone for vector storage and OpenAI for embeddings and generation. Everything works, but your documents leave your server.

This guide builds the same thing locally: pgvector for vector storage, Ollama for embeddings and generation, and a simple Python script to tie it together.

Architecture

Your documents
        ↓
Ollama (embeddings)  →  pgvector (storage)
        ↓
Query  →  pgvector (search)  →  Ollama (generation)  →  Answer

Everything runs on your VPS. No external APIs required.

What You Need

  • VPS with 8 GB RAM
  • Ollama installed with a 7B model
  • Docker for PostgreSQL with pgvector

Step 1: Set Up PostgreSQL with pgvector

mkdir ~/rag && cd ~/rag
nano docker-compose.yml
version: '3.8'
services:
  postgres:
    image: pgvector/pgvector:pg16
    restart: always
    environment:
      POSTGRES_DB: rag
      POSTGRES_USER: rag
      POSTGRES_PASSWORD: changeme
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
docker compose up -d

Step 2: Install Python Dependencies

apt install -y python3-pip
pip3 install psycopg2-binary ollama --break-system-packages

Step 3: Create the Database Schema

# setup_db.py
import psycopg2

conn = psycopg2.connect(
    host="localhost", database="rag",
    user="rag", password="changeme"
)
cur = conn.cursor()

cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
cur.execute("""
    CREATE TABLE IF NOT EXISTS documents (
        id SERIAL PRIMARY KEY,
        content TEXT,
        embedding vector(768),
        source TEXT
    )
""")
cur.execute("""
    CREATE INDEX IF NOT EXISTS documents_embedding_idx
    ON documents USING ivfflat (embedding vector_cosine_ops)
""")

conn.commit()
cur.close()
conn.close()
print("Database ready")
python3 setup_db.py

Step 4: Index Your Documents

# index.py
import ollama
import psycopg2

conn = psycopg2.connect(
    host="localhost", database="rag",
    user="rag", password="changeme"
)
cur = conn.cursor()

def index_document(text, source):
    response = ollama.embeddings(
        model="nomic-embed-text",
        prompt=text
    )
    embedding = response['embedding']
    cur.execute(
        "INSERT INTO documents (content, embedding, source) VALUES (%s, %s, %s)",
        (text, embedding, source)
    )
    conn.commit()

# Pull the embedding model first
# ollama pull nomic-embed-text

# Example: index a document
with open("your-document.txt") as f:
    content = f.read()

# Split into chunks (simple approach)
chunks = [content[i:i+500] for i in range(0, len(content), 500)]
for i, chunk in enumerate(chunks):
    index_document(chunk, f"document-chunk-{i}")
    print(f"Indexed chunk {i+1}/{len(chunks)}")

cur.close()
conn.close()

Step 5: Query Your Documents

# query.py
import ollama
import psycopg2

conn = psycopg2.connect(
    host="localhost", database="rag",
    user="rag", password="changeme"
)
cur = conn.cursor()

def query(question):
    # Get embedding for the question
    response = ollama.embeddings(
        model="nomic-embed-text",
        prompt=question
    )
    q_embedding = response['embedding']

    # Find similar documents
    cur.execute("""
        SELECT content FROM documents
        ORDER BY embedding <=> %s::vector
        LIMIT 3
    """, (q_embedding,))

    context = "\n\n".join([row[0] for row in cur.fetchall()])

    # Generate answer
    prompt = f"""Based on the following context, answer the question.

Context:
{context}

Question: {question}

Answer:"""

    response = ollama.generate(
        model="llama3.1:8b",
        prompt=prompt
    )
    return response['response']

print(query("What are the main points of the document?"))

Running this on NoctHost

The generation model sets the size: a 7B model plus Postgres and pgvector runs comfortably on the Pro plan (4 vCPU, 8 GB), while a lighter embedding-only pipeline fits the Standard plan (2 vCPU, 4 GB). NoctHost bills hourly from a prepaid, crypto-funded balance, no card and no KYC, so you can prototype a RAG stack and tear it down without a monthly bill.

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

Which embedding model should I use?
nomic-embed-text is the standard choice for Ollama — it's fast, produces 768-dimension embeddings, and works well for most document types. Pull it with ollama pull nomic-embed-text.
How large should my document chunks be?
500–1000 characters is a good starting point. Smaller chunks are more precise but miss context. Larger chunks include more context but dilute relevance.
Can I use this with PDFs?
Yes. Use pdfplumber or PyMuPDF to extract text from PDFs before indexing. The indexing step is the same once you have the text.

Keep reading