Cara Membuat Pipeline RAG di VPS Sendiri
Bangun pipeline RAG di satu VPS dengan pgvector, indeks HNSW, model embedding lokal Ollama, dan SQL untuk membuktikan retrieval bekerja.
Seperti apa pipeline RAG yang di-hosting sendiri
Pipeline RAG (retrieval augmented generation) memiliki lima tahap: membagi dokumen menjadi chunk, membuat embedding untuk setiap chunk, menyimpan vektor, mengambil vektor terdekat untuk sebuah pertanyaan, lalu mengirimkan chunk tersebut ke model bahasa untuk menulis jawaban. Pada VPS yang sudah Anda sewa, empat tahap pertama berjalan di server tersebut. PostgreSQL dengan ekstensi pgvector menyimpan vektor, sedangkan model embedding berukuran kecil yang disediakan oleh Ollama mengubah teks menjadi vektor. Hanya tahap terakhir yang harus berjalan di luar server.
Pembagian ini menjadi dasar panduan ini. Pembagian dokumen menjadi chunk hanya memerlukan kerja CPU. Embedding menggunakan model dengan 137 million parameter yang menempati beberapa ratus megabyte RAM. Penyimpanan menggunakan tabel Postgres yang ukurannya dapat dihitung dengan aritmetika sebelum Anda menulis satu baris pun. Untuk korpus yang terdiri atas ratusan ribu chunk, semua tahap tersebut dapat berjalan pada VPS biasa. Generasi berbeda, karena tahap ini menimbulkan biaya pada setiap pertanyaan, tanpa batas waktu.
Bagian mana dari pipeline RAG yang benar-benar memerlukan biaya
Tutorial end-to-end RAG dari DigitalOcean menggunakan vector database terkelola dan model embedding yang di-host. Bagian biayanya bersifat kualitatif: cache untuk kueri berulang, batasi jumlah chunk yang diambil, dan lakukan reranking sebelum generasi. Saran tersebut benar. Namun, tutorial itu tidak membahas opsi yang mengubah perhitungannya, yaitu menjalankan model embedding pada server yang sudah Anda bayar.
Hitung token, bukan dolar, karena jumlah token tidak menjadi kedaluwarsa ketika daftar harga berubah. Gunakan contoh corpus yang berisi 100,000 chunk dengan masing-masing 400 token, 10,000 pertanyaan, 8 chunk yang dikirim ke model untuk setiap jawaban, blok pertanyaan dan instruksi sebanyak 100 token, serta jawaban sebanyak 400 token.
The data behind this chart
[
{
"label": "Embed the corpus (once)",
"tokens_millions": 40,
"tokens_per_question": "4,000"
},
{
"label": "Embed each question",
"tokens_millions": 0.2,
"tokens_per_question": "20"
},
{
"label": "Generation input",
"tokens_millions": 33,
"tokens_per_question": "3,300"
},
{
"label": "Generation output",
"tokens_millions": 4,
"tokens_per_question": "400"
}
]Embedding seluruh corpus memerlukan 40 juta token, dan proses ini hanya dilakukan sekali. Jika dibagi ke 10,000 pertanyaan, jumlahnya menjadi 4,000 token per pertanyaan. Jika jumlah pertanyaan mencapai 100,000, jumlahnya turun menjadi 400. Biaya generasi tidak pernah turun. Setiap pertanyaan yang akan Anda jawab selalu memerlukan 3,300 token input dan 400 token output.
Jadi, biaya mengikuti tahap yang berulang. Jalankan sendiri tahap embedding karena Anda hanya membayarnya sekali dan VPS tetap berjalan. Gunakan layanan berbayar untuk tahap generasi karena model yang lebih baik memberikan nilai nyata pada tahap ini. Caching penting karena alasan yang sama: cache hit melewati satu-satunya tahap yang biayanya tidak pernah teramortisasi. Perbedaan antara KV cache dan prompt cache menentukan bagian mana yang dapat digunakan kembali. Prompt RAG memiliki blok instruksi yang tetap, diikuti blok chunk yang berubah-ubah. Struktur ini paling banyak memperoleh manfaat dari caching.
Chunking: mengapa ukuran tetap dengan overlap merupakan default yang tepat
Chunk adalah unit yang Anda ambil, sehingga ukurannya menentukan semua proses berikutnya. Ukurannya harus cukup kecil agar embedding-nya berfokus pada satu hal, karena embedding merupakan satu titik dalam ruang. Chunk yang mencakup empat topik akan berada di antara keempat topik tersebut dan tidak dekat dengan satu pun di antaranya. Ukurannya juga harus cukup besar agar dapat menjawab pertanyaan secara mandiri, karena language model hanya melihat chunk, bukan dokumen di sekitarnya.
Mulailah dengan 300 kata dan overlap sebanyak 50 kata. Teks bahasa Inggris rata-rata menggunakan sekitar 1.3 token per kata, sehingga 300 kata setara dengan sekitar 400 token. Overlap diperlukan karena kalimat yang jatuh pada batas chunk akan terpecah menjadi dua. Jika tidak ada overlap, tidak satu pun dari kedua bagian tersebut dapat menjawab pertanyaan.
Pisahkan berdasarkan struktur terlebih dahulu jika dokumen memiliki struktur. Pisahkan berdasarkan heading, lalu berdasarkan paragraf. Terapkan aturan ukuran tetap hanya di dalam section yang masih terlalu panjang. Chunk yang dimulai di tengah kalimat akan terbaca buruk dalam jawaban akhir karena model mengutip kembali teks yang Anda berikan.
Jangan menyetel chunking sebelum Anda dapat mengukurnya. Ukuran tetap dengan overlap bersifat deterministik dan murah untuk dijalankan kembali. Dengan demikian, pendekatan ini dapat menjadi baseline yang dapat Anda lampaui. Bangun query untuk scoring di bagian berikutnya terlebih dahulu, lalu ubah satu hal setiap kali.
Embedding pada server yang sama serta dampaknya terhadap RAM dan latensi
curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-textnomic-embed-text memiliki 137 juta parameter dan ukuran unduhan 274 MB per Agustus 2026. Periksa hasil yang dikembalikannya sebelum merancang tabel berdasarkan hasil tersebut.
curl -s http://127.0.0.1:11434/api/embed \
-d '{"model": "nomic-embed-text", "input": "search_document: hello"}' |
python3 -c 'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'Perintah itu mencetak 768. Tipe kolom Anda harus sama persis dengan angka tersebut.
Dua pengaturan pada model ini sering menimbulkan masalah.
Awalan tugas tidak bersifat opsional. Kartu model Nomic menyatakan bahwa input "harus menyertakan awalan instruksi tugas". Dokumen disematkan dengan search_document: di bagian depan, sedangkan pertanyaan menggunakan search_query: . Jika awalan tersebut dihilangkan, tidak ada proses yang gagal: Anda tetap mendapatkan vektor, kualitas retrieval menurun, dan tidak ada baris log yang menjelaskan penyebabnya.
Input panjang dipotong secara diam-diam. Endpoint /api/embed menerima field truncate dengan nilai default true, sedangkan model yang dikemas oleh Ollama menyatakan konteks 2K. Chunk yang lebih panjang dari batas tersebut akan dipotong pada batas itu dan tetap disematkan, sehingga bagian akhirnya tidak dapat dicari. Kirim "truncate": false selama pengujian agar chunk yang terlalu besar gagal, bukan diteruskan.
Kirim permintaan secara batch dan pertahankan model tetap berada di memori.
curl -s http://127.0.0.1:11434/api/embed -d '{
"model": "nomic-embed-text",
"input": ["search_document: first chunk", "search_document: second chunk"],
"keep_alive": "30m"
}' > /dev/nullinput menerima sebuah list. Satu permintaan yang membawa 32 chunk lebih cepat daripada 32 permintaan karena round trip HTTP dan pencarian model hanya dilakukan sekali, bukan 32 kali. keep_alive mengatur berapa lama model tetap berada di memori setelah sebuah permintaan, dan nilai defaultnya adalah 5 menit. Setelah masa tersebut berakhir, permintaan berikutnya harus membayar waktu pemuatan model lagi.
Ukur dua angka yang penting pada server Anda sendiri. Nilainya bergantung pada jumlah vCPU, sehingga tidak ada angka yang dipublikasikan yang akan sama persis.
ollama ps
time curl -s http://127.0.0.1:11434/api/embed \
-d '{"model":"nomic-embed-text","input":"search_document: ... one real chunk ..."}' > /dev/nullollama ps mencetak ukuran resident model yang dimuat. Nilai tersebut adalah RAM yang Anda alokasikan selama keep_alive mempertahankan model itu di memori. Bagi output time dengan ukuran batch untuk mendapatkan durasi dalam detik per chunk. Kalikan dengan jumlah chunk untuk mendapatkan biaya waktu satu kali proses indexing. Pada penggunaan CPU saja, perkirakan corpus berisi 100,000 chunk memerlukan waktu berjam-jam, bukan beberapa menit. Hal itu wajar karena proses tersebut hanya dilakukan sekali dan dapat dijalankan semalaman menggunakan nice -n 19. Jika waktu berjam-jam tidak dapat diterima, pertanyaan sebenarnya adalah apakah menyewa GPU akan sepadan dengan biayanya. Itu merupakan perhitungan titik impas terhadap token API, bukan masalah preferensi.
Jika server tersebut sudah melayani model chat, model embedding menjadi model resident kedua dan penggunaan RAM akan terakumulasi. Menjalankan Ollama pada VPS membahas penentuan ukuran untuk sisi generasi, sedangkan cara kerja model yang di-host sendiri saat digunakan oleh pengguna secara bersamaan membahas kondisi ketika beberapa orang mengirim pertanyaan pada saat yang sama. Model embedding cukup kecil untuk berjalan berdampingan dengan salah satunya.
Skrip indexing dari awal hingga akhir
Pada Ubuntu 24.04, pip install biasa di luar virtual environment berhenti dengan error: externally-managed-environment karena Python sistem dikelola oleh apt.
python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvectorimport json, urllib.request
import psycopg
from pgvector.psycopg import register_vector
from pgvector import Vector
OLLAMA = "http://127.0.0.1:11434/api/embed"
MODEL = "nomic-embed-text"
def embed(texts, prefix="search_document: "):
payload = {"model": MODEL,
"input": [prefix + t for t in texts],
"truncate": False,
"keep_alive": "30m"}
req = urllib.request.Request(OLLAMA, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req) as resp:
return json.load(resp)["embeddings"]
def split(text, size=300, overlap=50):
words = text.split()
step = size - overlap
return [" ".join(words[i:i + size]) for i in range(0, len(words), step)]
with psycopg.connect("dbname=rag user=rag") as conn:
register_vector(conn)
for doc_id, text in documents(): # your loader
pieces = split(text)
for start in range(0, len(pieces), 32):
batch = pieces[start:start + 32]
vectors = embed(batch)
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO chunks (doc_id, seq, body, embedding)"
" VALUES (%s, %s, %s, %s)",
[(doc_id, start + i, body, Vector(vec))
for i, (body, vec) in enumerate(zip(batch, vectors))])
conn.commit()documents() adalah bagian yang Anda buat sendiri: apa pun yang membaca file atau baris data Anda lalu menghasilkan ID dokumen dan teksnya. Bagian lainnya merupakan pipeline.
Penyimpanan: skema pgvector dan ukurannya
Ubuntu 24.04 merilis postgresql-16-pgvector pada versi 0.6.0, yang lebih lama daripada tipe halfvec. Gunakan repositori milik proyek PostgreSQL untuk mendapatkan build terbaru.
sudo apt update && sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17 postgresql-17-pgvectorAngka pada nama paket harus sesuai dengan versi mayor server Anda. Selanjutnya, buat role, database, dan extension.
sudo -u postgres createuser --pwprompt rag
sudo -u postgres createdb --owner rag rag
sudo -u postgres psql -d rag -c 'CREATE EXTENSION vector;'CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id text NOT NULL,
seq int NOT NULL,
body text NOT NULL,
embedding vector(768) NOT NULL,
fts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);
CREATE INDEX chunks_fts ON chunks USING gin (fts);vector(768) harus sesuai dengan output model. Jika Anda memasukkan vector berdimensi 1024 ke kolom tersebut, Postgres akan menolaknya dengan expected 768 dimensions, not 1024. Ini adalah pesan error paling jelas dalam seluruh pipeline ini. Kolom fts yang dibuat secara otomatis tidak memerlukan biaya pemeliharaan dan memungkinkan pencarian berdasarkan kata kunci di kemudian hari.
Kebutuhan penyimpanan dapat dihitung secara aritmetis. Dokumentasi pgvector menyebutkan bahwa vector berukuran 4 * dimensions + 8 byte dan halfvec berukuran 2 * dimensions + 8. Jumlah dimensi di bawah ini adalah ukuran output yang dipublikasikan untuk setiap model.
The data behind this chart
[
{
"label": "384 (all-minilm)",
"bytes_per_vector": "1,544",
"vector_mib_per_100k": 147,
"halfvec_mib_per_100k": 74
},
{
"label": "768 (nomic-embed-text)",
"bytes_per_vector": "3,080",
"vector_mib_per_100k": 294,
"halfvec_mib_per_100k": 147
},
{
"label": "1024 (mxbai-embed-large)",
"bytes_per_vector": "4,104",
"vector_mib_per_100k": 391,
"halfvec_mib_per_100k": 196
},
{
"label": "1536 (hosted API model)",
"bytes_per_vector": "6,152",
"vector_mib_per_100k": 587,
"halfvec_mib_per_100k": 294
}
]Pada 768 dimensi, setiap vector berukuran 3,080 byte. Dengan demikian, 100,000 chunk menyimpan 294 MiB data vector. Corpus yang sama jika dibuat embedding-nya oleh model hosted berdimensi 1536 memerlukan 587 MiB, dan index untuknya juga bertambah secara proporsional. Presisi setengah mengurangi keduanya hingga setengah: halfvec(768) menyimpan corpus tersebut dalam 147 MiB. Apakah hal ini mengurangi recall Anda dijawab oleh query penilaian di bawah ini dalam satu kali eksekusi.
Angka tersebut hanya mencakup kolom vector. Teks, overhead baris, dan index menambah kebutuhan di atasnya, jadi ukur tabel yang sebenarnya.
SELECT pg_size_pretty(pg_total_relation_size('chunks')) AS total,
pg_size_pretty(pg_relation_size('chunks')) AS heap,
count(*) AS n_rows
FROM chunks;Jika Anda menginginkan extension yang sama dengan API dan akun pengguna, stack Supabase self-hosted menyediakan Postgres dengan pgvector yang sudah diaktifkan. Semua query dalam panduan ini dapat digunakan di sana tanpa perubahan.
Pengindeksan: pengaturan HNSW yang penting
Untuk jumlah baris di bawah beberapa ribu, jangan gunakan indeks. Pencarian eksak membaca setiap baris. Pada ukuran tersebut, prosesnya cukup cepat dan recall-nya sempurna. Tambahkan indeks ketika sequential scan tidak lagi cukup cepat. Pahami komprominya: indeks aproksimasi mengembalikan tetangga yang kira-kira benar.
SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 3;
CREATE INDEX chunks_embedding ON chunks
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);m = 16 dan ef_construction = 64 adalah nilai default pgvector. Menaikkannya meningkatkan recall, tetapi menambah waktu pembuatan dan ukuran indeks. Gunakan vector_cosine_ops dengan operator <=>, kecuali Anda tahu bahwa model menghasilkan vektor dengan panjang unit. Cosine distance mengabaikan panjang vektor, sedangkan inner product tidak.
Pantau proses pembuatannya. Ketika graf melebihi maintenance_work_mem, pgvector akan memberi tahu Anda:
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL: Building will take significantly more time.Itu bukan error. Proses pembuatan tetap selesai, tetapi beralih ke jalur yang jauh lebih lambat. Naikkan maintenance_work_mem dalam session yang digunakan untuk membuat indeks, tetapi biarkan nilai default server tetap rendah. Pengaturan tersebut berlaku per operasi maintenance. Nilai global yang tinggi dapat membuat server kehabisan memory. Pantau proses pembuatan yang lama dari session kedua.
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;Setelah selesai, bandingkan indeks tersebut dengan memory yang tersedia di server.
SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;Pencarian HNSW menelusuri graf. Karena itu, prosesnya mengakses page yang tersebar di seluruh indeks, bukan membaca suatu rentang. Indeks yang tidak muat di memory akan membuat setiap query membaca dari disk. Pengguna akan merasakan dampaknya pada latency tail yang lambat. Itulah aturan sizing untuk server: indeks dan baris yang benar-benar Anda layani harus muat dalam RAM. free -m dan ukuran di atas adalah dua angka yang perlu dibandingkan.
Saat query dijalankan, hnsw.ef_search menjadi pengatur recall. Nilai default-nya adalah 40.
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;Nilai yang lebih tinggi akan menelusuri lebih banyak bagian graf, menemukan tetangga yang lebih baik, dan menambah latency. Ini adalah pengaturan session. Anda dapat menaikkannya untuk satu query tanpa mengubah indeks.
Jika query sama sekali tidak menggunakan indeks, hal itu terlihat pada plan.
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;Sequential scan dalam kasus ini sering disebabkan oleh storage. Vektor dengan dimensi 768 berukuran 3,080 byte. Ukuran tersebut melebihi kapasitas yang disimpan Postgres secara inline, sehingga nilainya dipindahkan ke tabel TOAST, yaitu penyimpanan di luar baris untuk nilai berukuran besar. Catatan pgvector menyebutkan bahwa planner tidak menghitung storage di luar baris dalam estimasi biayanya. Akibatnya, serial scan dapat terlihat lebih murah daripada biaya sebenarnya. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; membuat vektor tetap inline. Pengaturan ini berlaku untuk baris yang ditulis setelah perubahan. Baris yang sudah ada memerlukan table rewrite.
Pengambilan: satu kueri, dua sinyal
Pencarian vektor menemukan teks yang memiliki makna sama dengan pertanyaan. Pencarian ini lemah dalam menangani string yang harus sama persis, seperti nomor komponen, kode error, atau nama keluarga. Pencarian kata kunci memiliki karakteristik sebaliknya, dan Postgres sudah mendukungnya. Gabungkan keduanya dalam satu kueri, bukan dengan menjalankan sistem kedua.
Reciprocal rank fusion adalah penggabung paling sederhana yang dapat digunakan. Setiap hasil memperoleh 1 / (60 + rank) dari setiap daftar tempat hasil tersebut muncul, lalu kedua skor dijumlahkan. Metode ini tidak memerlukan normalisasi skor karena yang digunakan adalah posisi, bukan jarak.
WITH semantic AS (
SELECT id, row_number() OVER (ORDER BY distance) AS rank
FROM (SELECT id, embedding <=> $1 AS distance
FROM chunks ORDER BY embedding <=> $1 LIMIT 40) s
),
keyword AS (
SELECT id, row_number() OVER (ORDER BY score DESC) AS rank
FROM (SELECT c.id, ts_rank_cd(c.fts, q) AS score
FROM chunks c, websearch_to_tsquery('english', $2) q
WHERE c.fts @@ q
ORDER BY score DESC LIMIT 40) k
)
SELECT c.id, c.body,
coalesce(1.0 / (60 + s.rank), 0) + coalesce(1.0 / (60 + k.rank), 0) AS rrf
FROM (SELECT id FROM semantic UNION SELECT id FROM keyword) u
JOIN chunks c ON c.id = u.id
LEFT JOIN semantic s ON s.id = u.id
LEFT JOIN keyword k ON k.id = u.id
ORDER BY rrf DESC
LIMIT 8;$1 adalah embedding pertanyaan yang dibuat dari model yang sama, menggunakan prefix search_query: . $2 adalah pertanyaan dalam bentuk teks. Keduanya diikat dari aplikasi Anda. websearch_to_tsquery menerima pertanyaan pengguna yang sebenarnya tanpa gagal karena tanda baca, sedangkan to_tsquery tidak. Ada satu hal lain yang perlu diketahui: menambahkan filter WHERE di atas pemindaian HNSW dapat mengembalikan baris lebih sedikit daripada yang diminta karena indeks dicari terlebih dahulu, lalu filter diterapkan. SET hnsw.iterative_scan = relaxed_order; membuat pgvector terus memindai hingga jumlah baris yang cukup ditemukan.
Bagaimana cara mengetahui apakah retrieval berjalan dengan baik?
Ini adalah langkah yang hampir selalu dilewati oleh panduan RAG, padahal hanya langkah ini yang dapat menunjukkan apakah pilihan lain memberikan hasil yang lebih baik. Anda tidak memerlukan framework evaluasi. Anda memerlukan 30 pertanyaan dan id chunk yang menjawab setiap pertanyaan.
Tulis pertanyaan tersebut secara manual. Gunakan pertanyaan yang benar-benar diajukan orang tentang korpus ini, jalankan masing-masing pertanyaan, baca hasilnya, lalu catat id chunk yang seharusnya berada di peringkat pertama. Tiga puluh pertanyaan tidak akan menyelesaikan perbedaan kecil. Namun, jumlah ini akan menangkap perbedaan yang penting karena perbedaannya biasanya besar.
CREATE TABLE gold (
id bigserial PRIMARY KEY,
question text NOT NULL,
chunk_id bigint NOT NULL REFERENCES chunks(id),
embedding vector(768) NOT NULL
);Sematkan setiap pertanyaan dengan prefix search_query: , simpan hasilnya, lalu beri skor pada seluruh kumpulan tersebut dalam satu query.
WITH hits AS (
SELECT g.id,
min(r.rank) FILTER (WHERE r.id = g.chunk_id) AS hit_rank
FROM gold g
CROSS JOIN LATERAL (
SELECT top.id, row_number() OVER (ORDER BY top.distance) AS rank
FROM (SELECT c.id, c.embedding <=> g.embedding AS distance
FROM chunks c
ORDER BY c.embedding <=> g.embedding
LIMIT 10) top
) r
GROUP BY g.id
)
SELECT count(*) AS questions,
count(hit_rank) AS found_in_top_10,
round(avg(coalesce(1.0 / hit_rank, 0)), 3) AS mrr
FROM hits;found_in_top_10 dibagi questions adalah recall at 10: seberapa sering jawaban berada di dalam window yang dikirim ke model. MRR (mean reciprocal rank) menghitung rata-rata 1 dibagi posisi chunk yang benar dan menghitung hasil yang tidak ditemukan sebagai nol. Dengan demikian, metrik ini lebih menghargai jawaban yang berada di peringkat pertama daripada peringkat kedelapan. Kedua angka tersebut berubah saat Anda mengubah ukuran chunk, mengganti model embedding, atau menambahkan pencarian keyword. Kini Anda dapat melihat arah perubahannya.
Utamakan recall at 10 di atas hal lain, karena generator tidak dapat menggunakan chunk yang tidak pernah diterimanya. Jika recall at 10 bernilai 0.9 tetapi jawabannya masih salah, masalahnya terdapat pada prompt atau model, bukan pada retrieval. Pemisahan ini dapat menghemat waktu berhari-hari yang biasanya terbuang untuk menebak-nebak.
Periksa index secara terpisah. Approximate search mengurangi recall, dan pgvector menunjukkan seberapa besar pengurangannya. Jalankan query yang sama dengan exact search, lalu bandingkan id-nya.
BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;Jika sembilan dari sepuluh id sama, berarti ef_search sudah memadai. Jika hanya empat dari sepuluh yang sama, naikkan nilainya.
Reranking dan generasi: tempat API menghasilkan nilainya
Reranker adalah jenis model yang berbeda. Model ini membaca pertanyaan dan satu chunk secara bersamaan, lalu memberi skor pada pasangan tersebut. Pendekatan ini lebih baik daripada membandingkan dua embedding yang dihitung secara terpisah. Namun, reranker terlalu lambat untuk dijalankan pada seluruh corpus. Karena itu, reranker digunakan pada tahap ini. Model hanya memproses 40 kandidat yang dikembalikan oleh retrieval, bukan 100,000 chunk dalam tabel. Dengan demikian, API reranking terkelola mengenakan biaya untuk 40 pasangan pendek per pertanyaan dan menyingkirkan false positive terburuk sebelum kandidat tersebut mencapai tahap yang lebih mahal.
Generasi adalah biaya berulang, dan dua pengaturan memengaruhinya. Kirim lebih sedikit chunk. Gunakan recall at 10 untuk mengetahui jumlah minimum chunk yang dapat dikirim tanpa kehilangan jawaban. Pertahankan bagian awal prompt tetap sama byte demi byte agar prompt cache milik provider dapat digunakan, lalu letakkan chunk hasil retrieval setelah bagian yang stabil tersebut. Cache juga jawaban yang sudah selesai berdasarkan pertanyaan, karena token hasil generasi yang paling murah adalah token yang sudah Anda hasilkan minggu lalu.
Menentukan ukuran server dan kapan konfigurasi ini tidak lagi memadai
Setiap aturan penentuan ukuran di sini didasarkan pada pengukuran, bukan perkiraan.
- RAM adalah batas utama: ukuran model yang berada di memori dari
ollama ps, ditambah ukuran indeks HNSW danshared_buffers, dengan ruang cadangan untuk koneksi dan page cache. - Disk memerlukan dua kali ukuran
pg_total_relation_size('chunks'), karena saat indeks dibangun ulang, kedua salinan tersimpan secara bersamaan. - CPU menentukan waktu reindex, berdasarkan jumlah detik per chunk yang Anda ukur dikalikan jumlah chunk.
- Reindex lebih sering terjadi daripada yang Anda perkirakan, karena perubahan embedding model membuat semua vector yang sudah tersimpan menjadi tidak valid.
Desain ini akan mencapai batas yang dapat Anda antisipasi. Saat indeks HNSW tidak lagi muat dalam RAM yang dapat Anda beli, latensi query berubah menjadi operasi pencarian pada disk, dan tidak ada pengaturan yang dapat memulihkannya. Saat satu tabel melayani banyak tenant dan setiap query memfilter berdasarkan tenant, tabel tersebut perlu dipartisi, dan itu membutuhkan pekerjaan nyata. Saat penulisan indeks dan query pengguna saling berebut resource pada server yang sama, pindahkan embedding worker ke server kedua sebelum memindahkan database. Sebelum salah satu kondisi tersebut terjadi, Postgres dengan pgvector pada VPS yang sudah Anda sewa merupakan solusi production, dan angka di atas menunjukkan seberapa jauh Anda dari batas tersebut.
FAQ
Dapatkah saya menjalankan pipeline RAG pada satu VPS, atau apakah saya memerlukan database vektor?
Satu VPS cukup untuk korpus yang terdiri dari ratusan ribu chunk. Pada 768 dimensi, 100,000 chunk menggunakan 294 MiB data vektor, ditambah teks dan indeks HNSW. Jumlah tersebut masih dapat ditampung dalam RAM paket biasa. Batasnya adalah memori, bukan jumlah baris, karena pencarian HNSW berpindah-pindah di dalam indeks. Latensi menurun setelah indeks tidak lagi dapat dimuat dalam RAM. Bandingkan pg_relation_size pada indeks dengan free -m untuk mengetahui kondisi Anda.
Apakah saya memerlukan GPU untuk membuat embedding dokumen?
Tidak, jika embedding dibuat satu kali dan kueri dijalankan setelahnya. Model dengan 137 juta parameter seperti nomic-embed-text dapat berjalan pada CPU. Pemrosesan penuh korpus besar dapat berlangsung selama beberapa jam dan dijalankan semalaman. GPU mulai diperlukan ketika dokumen masuk secara terus-menerus atau ketika Anda ingin menjalankan proses generasi pada server yang sama. Ukur waktu satu batch terhadap /api/embed pada server Anda sendiri, lalu kalikan dengan jumlah chunk. Jumlah vCPU terlalu beragam sehingga angka yang dipublikasikan tidak cukup membantu.
Mengapa kueri vektor saya menggunakan sequential scan, bukan indeks HNSW?
Baca query plan dengan EXPLAIN (ANALYZE, BUFFERS). Penyebab yang umum adalah penyimpanan. pgvector mencatat bahwa planner tidak menghitung penyimpanan out-of-line dalam estimasi biayanya. Akibatnya, serial scan terlihat lebih murah daripada biaya sebenarnya. Vektor berdimensi 768 berukuran 3,080 byte, sehingga secara default disimpan dalam tabel TOAST. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; mempertahankan baris baru agar tetap inline. Dua penyebab lainnya adalah operator yang tidak sesuai dengan indeks. Indeks yang dibuat dengan vector_cosine_ops hanya digunakan oleh <=>. Penyebab lainnya adalah kueri tanpa ORDER BY ... LIMIT, karena indeks aproksimasi hanya melayani kueri nearest neighbour yang diurutkan.
Bagaimana cara mengetahui apakah retrieval saya sudah baik?
Buat gold set yang berisi 30 pertanyaan. Pasangkan setiap pertanyaan dengan id chunk yang menjawabnya, lalu simpan embedding pertanyaan tersebut. Setelah itu, ukur recall at 10, yaitu seberapa sering chunk yang benar muncul dalam 10 hasil teratas, serta MRR, yang memberikan nilai lebih tinggi jika chunk tersebut berada pada peringkat pertama. Kedua angka tersebut menunjukkan apakah perubahan pada ukuran chunk, model embedding, atau rank fusion memberikan hasil yang lebih baik. Tanpa metrik tersebut, Anda hanya mengubah pengaturan dan mengandalkan kesan dari beberapa jawaban.