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 | shdi 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.2jalan di CPU tapi pelan di pertanyaan besar. Di mesin dengan GPU oke,llama3.1:8bngasih 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.