← Kembali ke Blog

Bikin Pipeline RAG Lokal dengan Ollama dan pgvector

Kebanyakan tutorial RAG diam-diam ngirim dokumen kamu ke OpenAI embeddings dan Pinecone. Artinya, setiap chunk yang kamu index jadi traffic yang kamu bayar, dan datanya keluar dari mesin kamu. Buat dokumen internal, catatan legal, atau apa pun yang nggak mau kamu bagi ke pihak ketiga, itu default yang salah.

Tutorial ini bikin alternatif yang 100% lokal. PostgreSQL dengan ekstensi pgvector buat nyimpen embeddings, dan Ollama ngejalanin baik model embedding maupun model chat. Nggak ada API key, nggak ada cloud, nggak ada data yang keluar dari mesin.

Hasil akhirnya sekitar 100 baris Python yang nge-ingest folder file teks, ngeindex, dan jawab pertanyaan dengan citation balik ke sumbernya. Polanya sama yang dipakai sistem RAG production, cuma tanpa biaya managed service.

Kenapa pgvector, bukan vector database khusus

pgvector ngubah PostgreSQL jadi vector store, jadi chunk kamu duduk di sebelah data relasional kamu. Itu ngilangin satu moving part dan satu deployment. Buat korpus sampai beberapa juta vektor, ini cukup cepat, dan kamu udah pasti paham Postgres. Store khusus kayak Qdrant atau Milvus mulai menang di korpus yang sangat besar, write throughput tinggi, dan opsi tuning kayak binary quantization. Mulai dari pgvector, pindah kalau benchmark beneran nuntut.

Prerequisites

  • Docker
  • Ollama terpasang (curl -fsSL https://ollama.com/install.sh | sh di Linux/macOS)
  • Python 3.10+

Langkah 1: Jalankan PostgreSQL dengan pgvector

Image pgvector/pgvector udah bawa ekstensinya.

# compose.yml
services:
  db:
    image: pgvector/pgvector:pg17
    environment:
      POSTGRES_PASSWORD: rag
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
docker compose up -d

Langkah 2: Tarik model

ollama pull nomic-embed-text   # embeddings, 768 dimensi, ~274MB
ollama pull llama3.2           # model chat buat generate (3B, jalan di CPU)

nomic-embed-text kecil dan cukup bagus buat teks Inggris maupun Indonesia. Kalau kamu ngeindex banyak konten Indonesia, bge-m3 biasanya menang di recall multibahasa, tapi butuh sekitar 1GB disk.

Langkah 3: Bikin schema

docker exec -it $(docker compose ps -q db) psql -U postgres -c "CREATE EXTENSION IF NOT EXISTS vector;"

Atau tempel ini ke psql:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
  id bigserial PRIMARY KEY,
  source text NOT NULL,
  chunk_index int NOT NULL,
  content text NOT NULL,
  embedding vector(768)
);

CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);

Dimensi vector(768)' harus cocok dengan model embedding kamu, dan 768 itu output nomic-embed-text`. Kalau ganti model, kamu ubah kolomnya dan re-ingest. Embeddings dari model yang beda nggak bisa dicampur.

Index HNSW yang bikin retrieval tetap cepat begitu tabelnya besar. Tanpa itu, Postgres full scan di setiap query.

Langkah 4: Ingest folder dokumen

pip install psycopg pgvector requests
# ingest.py
import sys
import psycopg
import requests
from pgvector.psycopg import register_vector

OLLAMA = "http://localhost:11434"
EMBED_MODEL = "nomic-embed-text"

DB_DSN = "postgresql://postgres:rag@localhost:5432/postgres"


def embed(texts):
    r = requests.post(f"{OLLAMA}/api/embed", json={"model": EMBED_MODEL, "input": texts})
    r.raise_for_status()
    return r.json()["embeddings"]


def chunk(text, size=800, overlap=100):
    return [text[i:i+size] for i in range(0, len(text) - overlap, size - overlap)]


def ingest(path):
    text = open(path, encoding="utf-8").read()
    chunks = chunk(text)
    with psycopg.connect(DB_DSN) as conn:
        register_vector(conn)
        with conn.cursor() as cur:
            for i, c in enumerate(chunks):
                (vec,) = embed([c])
                cur.execute(
                    "INSERT INTO chunks (source, chunk_index, content, embedding)"
                    " VALUES (%s, %s, %s, %s)",
                    (path, i, c, vec),
                )
    print(f"{path}: {len(chunks)} chunks")


for path in sys.argv[1:]:
    ingest(path)
python ingest.py ./docs/*.txt

Overlap itu penting. Kata kayak "caching" atau "auth" bisa pas jatuh di batas chunk dan hilang. Overlap kecil bikin potongan itu muncul di dua chunk, jadi retrieval tetap nemu.

Langkah 5: Query dengan semantic search

# query.py
import sys
import psycopg
import requests
from pgvector.psycopg import register_vector

OLLAMA = "http://localhost:11434"
DB_DSN = "postgresql://postgres:rag@localhost:5432/postgres"


def embed(text):
    r = requests.post(f"{OLLAMA}/api/embed", json={"model": "nomic-embed-text", "input": [text]})
    r.raise_for_status()
    return r.json()["embeddings"][0]


def retrieve(query, top_k=5):
    q = embed(query)
    with psycopg.connect(DB_DSN) as conn:
        register_vector(conn)
        with conn.cursor() as cur:
            cur.execute(
                "SELECT source, chunk_index, content FROM chunks"
                " ORDER BY embedding <=> %s LIMIT %s",
                (q, top_k),
            )
            return cur.fetchall()


if __name__ == "__main__":
    for source, idx, content in retrieve(sys.argv[1]):
        print(f"[{idx}] {source}
{content}
")
python query.py "how do I disable rate limiting?"

<=> itu cosine distance. Embeddings dari Ollama udah dinormalisasi ke unit length, jadi cosine dan L2 ngasih ranking yang sama di sini. Cosine default paling aman buat text store umum.

Langkah 6: Generate jawaban yang berlandaskan chunk

# ask.py
import sys
import requests
from query import retrieve

OLLAMA = "http://localhost:11434"
CHAT_MODEL = "llama3.2"


def ask(question):
    rows = retrieve(question)
    context = "

".join(f"[{i+1}] {content}" for i, (_, _, content) in enumerate(rows))
    messages = [
        {
            "role": "system",
            "content": (
                "Answer strictly from the provided context. If the context does not "
                "contain the answer, say so. Cite sources as [1], [2], etc."
            ),
        },
        {"role": "user", "content": f"Context:
{context}

Question: {question}"},
    ]
    r = requests.post(
        f"{OLLAMA}/api/chat", json={"model": CHAT_MODEL, "messages": messages, "stream": False}
    )
    return r.json()["message"]["content"]


if __name__ == "__main__":
    print(ask(sys.argv[1]))
python ask.py "what are the rate limits for the API?"

System prompt ini tempat RAG hidup atau mati. Maksa model jawab cuma dari context dan nolak kalau jawabannya nggak ada, itu pembeda antara asisten yang berguna dan pembohong yang percaya diri. Dua aturan itu jangan pernah dilewatin.

Setelan yang perlu disetel

  • Ukuran chunk. 500-1000 karakter dengan overlap 10-15% itu start yang wajar. Chunk lebih besar nyimpen lebih banyak konteks tapi nyimpen kurang presisi. Re-ingest dan bandingin jawaban di pertanyaan uji kamu sendiri.
  • top_k. Naikin kalau jawaban keliatan tipis, turunin kalau model kebawa chunk yang nggak relevan.
  • Ganti model. llama3.2 jalan di CPU tapi pelan di pertanyaan besar. Di mesin dengan GPU oke, llama3.1:8b ngasih jawaban yang keliatan jelas lebih bagus.

Kapan pindah dari pgvector

pgvector ngehandle banyak hal. Kalau korpus kamu lewat sejuta vektor atau butuh latency recall yang lebih rendah di skala besar, lihat vector store khusus (Qdrant, Milvus, Weaviate) atau binary quantization dan tuning HNSW dari pgvector yang memperpanjang batasnya sebelum kamu migrasi. Jangan pindah karena hype. Pindah kalau benchmark di data kamu sendiri nunjukin gap yang nyata.

Langkah selanjutnya

  • Tambah full-text search (tsvector) dan campur skornya dengan cosine similarity buat hybrid retrieval. Keyword persis kayak kode error sering ketemu lebih akurat lewat full text search daripada embedding.
  • Bikin langkah ingest idempotent dengan upsert yang key-nya `(source, chunk_index)' biar jalan ulang file yang sama nggak nge-duplikat baris.
  • Simpan pertanyaan dan sumber yang ke-retrieve, biar kamu bisa lacak apa yang dilihat model dan tuning prompt kalau jawabannya melenceng.

Referensi

Butuh Bantuan Implementasi?

Saya membantu tim mendesain dan membangun infrastruktur cloud scalable, pipeline DevOps, dan sistem production-grade.

Konsultasi Gratis