Semua tutorial RAG yang kamu ikutin akan ngirim dokumen kamu ke API provider. Bagus kalau korpusnya publik. Masalah kalau itu kontrak, tiket support, atau runbook internal kamu. Setiap potongan dokumen keluar dari mesin kamu dan balik dalam bentuk tagihan token.
Pipeline lokal mengubah itu semua. Embedding, vector search, dan LLM semuanya jalan di hardware kamu sendiri, gratis, tanpa ada yang diupload ke mana-mana. Panduan ini ngebangun semuanya dari nol pakai Ollama dan pgvector, dua tool open source, dan jalan di laptop biasa.
Prasyarat
- Ollama terinstall dan jalan (https://ollama.com)
- Docker, buat pake image resmi pgvector
- Kurang lebih 4GB RAM kosong dan beberapa GB disk
- Python 3 dengan psycopg2 dan requests
pip install psycopg2-binary requests
Langkah 1: Jalankan pgvector
pgvector itu satu image Postgres dengan extension vector yang udah kebawa. Kamu nggak perlu build apa-apa, tinggal jalanin.
docker run -d \
--name pgvector \
-e POSTGRES_PASSWORD=localrag \
-e POSTGRES_DB=ragdb \
-p 5432:5432 \
-v pgdata:/var/lib/postgresql/data \
pgvector/pgvector:pg16
Named volume -v pgdata:... nyimpen data kamu saat container restart.
Langkah 2: Bikin tabel dan index
docker exec -it pgvector psql -U postgres -d ragdb
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
source TEXT,
chunk_index INT,
content TEXT,
embedding vector(768)
);
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
Kolom vector(768) karena itu jumlah dimensi yang dikeluarkan model embedding di bawah. Index pake vector_cosine_ops, jadi pencarian jalan "terdekat berdasarkan cosine distance".
Ada dua opsi index. IVFFlat minta tabel terisi dulu sebelum index dibuat, karena dia melatih daftar cluster. HNSW nggak. Buat setup RAG baru pake HNSW, dan baru ganti ke IVFFlat kalau memory kamu sempit. Operatornya sama, jadi perubahannya tinggal hnsw ke ivfflat dengan WITH (lists = 100).
Langkah 3: Tarik model
ollama pull nomic-embed-text
ollama pull llama3.2
nomic-embed-text itu encoder 137M parameter, 768 dimensi, sekitar 274MB. Khusus embedding. Jangan coba buat chat sama dia. llama3.2 adalah model chat kecil yang kita pake buat jawab, dan jalan di CPU.
Langkah 4: Ingest dokumen (ingest.py)
Dua detail penting sebelum kode.
Pertama, endpoint API. Ollama punya /api/embed (yang sekarang, bisa batch) dan /api/embeddings yang lebih lama, dan di dokumentasi resmi udah ditandai superseded. Pake /api/embed.
Kedua, task prefix. nomic-embed-text dilatih dengan prefix di depan teks: search_document: di depan teks yang kamu simpen, dan search_query: di depan pertanyaan yang kamu cari. Ollama ngirim input kamu apa adanya dan nggak akan nambahin prefix buat kamu. Dilewatin aja, kualitas retrieval-nya diam-diam turun.
import os
import sys
import requests
import psycopg2
OLLAMA_URL = "http://localhost:11434"
EMBED_MODEL = "nomic-embed-text"
CHUNK_SIZE = 500
CHUNK_OVERLAP = 50
DB = psycopg2.connect(
host="localhost", port=5432,
dbname="ragdb", user="postgres", password="localrag",
)
def embed(texts):
r = requests.post(
f"{OLLAMA_URL}/api/embed",
json={"model": EMBED_MODEL, "input": texts},
)
r.raise_for_status()
return r.json()["embeddings"]
def chunk(text):
chunks, start = [], 0
while start < len(text):
chunks.append(text[start:start + CHUNK_SIZE])
start += CHUNK_SIZE - CHUNK_OVERLAP
return chunks
def ingest_file(path):
text = open(path).read()
chunks = chunk(text)
vectors = embed(["search_document: " + c for c in chunks])
cur = DB.cursor()
for i, (c, v) in enumerate(zip(chunks, vectors)):
cur.execute(
"INSERT INTO documents (source, chunk_index, content, embedding) "
"VALUES (%s, %s, %s, %s)",
(path, i, c, v),
)
DB.commit()
print(f" {path} -> {len(chunks)} chunks")
if __name__ == "__main__":
for path in sys.argv[1:]:
ingest_file(path)
Jalanin dengan file markdown dan text kamu:
python ingest.py README.md catatan/runbook.md
Perhatiin batch-nya: /api/embed nerima list, jadi satu request HTTP nutupin semua chunk satu file. Ini kemenangan kecepatan paling gede di ingest RAG mana pun.
Langkah 5: Tanya (query.py)
Sisi query pake pemanggilan embed yang sama, cari dengan cosine distance pake <=>, terus kasih chunk teratas ke model chat.
import sys
import requests
import psycopg2
OLLAMA_URL = "http://localhost:11434"
EMBED_MODEL = "nomic-embed-text"
LLM_MODEL = "llama3.2"
TOP_K = 4
DB = psycopg2.connect(
host="localhost", port=5432,
dbname="ragdb", user="postgres", password="localrag",
)
def embed(text):
r = requests.post(
f"{OLLAMA_URL}/api/embed",
json={"model": EMBED_MODEL, "input": [text]},
)
r.raise_for_status()
return r.json()["embeddings"][0]
def retrieve(question):
vec = embed("search_query: " + question)
cur = DB.cursor()
cur.execute(
"SELECT content FROM documents "
"ORDER BY embedding <=> %s::vector LIMIT %s",
(vec, TOP_K),
)
return [row[0] for row in cur.fetchall()]
def ask(question):
context = "\n\n---\n\n".join(retrieve(question))
prompt = (
"Answer using only the context below. If the context lacks the "
"answer, say so.\n\n"
f"Context:\n{context}\n\n"
f"Question: {question}\nAnswer:"
)
r = requests.post(
f"{OLLAMA_URL}/api/generate",
json={"model": LLM_MODEL, "prompt": prompt, "stream": False},
)
r.raise_for_status()
return r.json()["response"]
if __name__ == "__main__":
print(ask(" ".join(sys.argv[1:])))
<=> itu operator cosine distance di pgvector, dan dia ngebalikin jarak bukan kemiripan, jadi makin kecil makin bagus dan ORDER BY ... LIMIT milih chunk yang paling deket.
python query.py "Script deploy-nya ngapain?"
Knob tuning
- Ukuran chunk. 500 karakter dengan overlap 50 itu awal yang wajar; dokumen dengan seksi panjang bisa naik ke 800. nomic-embed-text terima sampai 8192 token, jadi chunk kegedean biasanya bukan masalah yang kamu kira. Test 400 dan 800, pilih yang jawabannya lebih bagus.
- top_k. 3 sampai 6 chunk cukup buat kebanyakan pertanyaan. Konteks kebanyakan malah bikin jawaban ngambang.
- Truncation diam-diam.
/api/embedmotong input ke context model secara default. Kasihtruncate: falsebiar dapat error alih-alih vector yang kepotong tanpa kabar.
Masalah yang sering muncul
| Masalah | Penyebab | Perbaikan |
|---|---|---|
| Hasil kosong | Dimensi vector nggak cocok sama kolom | Embedding harus pas sama vector(768) |
| Retrieval lemah | Task prefix ketinggalan | search_document: pas nyimpen, search_query: pas nanya |
KeyError embedding |
Kode lama di endpoint baru | /api/embed ngebalikin embeddings (jamak) |
| Vector kepotong | truncate default-nya true |
Set truncate: false |
Langkah selanjutnya
- Filter by metadata sebelum pencarian, contoh
WHERE source = 'runbook.md', biar retrieval fokus ke satu kumpulan dokumen. - Ganti ke
nomic-embed-text-v2-moeyang multilingual kalau korpus kamu bukan bahasa Inggris. - Tambah reranker atau langkah MMR sederhana kalau chunk yang mirip-mirip ramein hasil teratas.
- Ingest batch satu folder dan jaga model tetap hangat dengan
ollama serve.