SSD Nodes Learn 🎉 VPS $5.50/aydan başlayan
Rehberler Matt ConnorYazan Matt Connor

Kendi VPS Sunucunuzda RAG Hattı Kurulumu

pgvector ve Ollama kullanarak kendi VPS sunucunuzda RAG hattı kurun. HNSW dizinleme, yerel embedding modelleri ve verimli vektör depolama için gereken SQL komutlarını öğrenin.

Self-hosted bir RAG hattı nasıl görünür

Bir RAG (retrieval augmented generation - geri getirme destekli üretim) hattı beş aşamadan oluşur: belgeleri parçalara ayırmak, bu parçaları vektörleştirmek (embedding), vektörleri depolamak, bir soru için en yakın olanları geri getirmek ve bu parçaları yanıtı yazacak bir dil modeline göndermek. Hâlihazırda kiraladığınız bir VPS üzerinde, ilk dört aşama sunucu içinde çalışır. pgvector eklentisine sahip PostgreSQL vektörleri tutar ve Ollama tarafından sunulan küçük bir embedding modeli metni vektörlere dönüştürür. Yalnızca son aşamanın sunucu dışına çıkması gerekir.

Bu ayrım, bu kılavuzun temel argümanıdır. Parçalara ayırma işlemi basit bir CPU işidir. Embedding, birkaç yüz megabayt RAM tüketen 137 milyon parametreli bir modeldir. Depolama ise tek bir satır yazmadan önce aritmetik hesaplamalarla boyutunu belirleyebileceğiniz bir Postgres tablosudur. Yüz binlerce parçadan oluşan bir veri kümesi için tüm bunlar standart bir VPS üzerinde çalışabilir. Üretim aşaması ise farklıdır; çünkü her soru için sürekli bir maliyet oluşturur.

RAG hattının hangi bölümleri maliyet yaratır

DigitalOcean'ın uçtan uca RAG eğitimi, yönetilen bir vektör veritabanı ve barındırılan bir embedding modeli kullanır; maliyet bölümü ise niteliksel tavsiyeler içerir: tekrarlanan sorguları önbelleğe alın, getirilen parça sayısını düşük tutun ve üretimden önce yeniden sıralama yapın. Bu tavsiyeler doğrudur. Ancak, maliyet hesabını değiştiren ve zaten ödemesini yaptığınız sunucu üzerinde embedding modelini çalıştırma seçeneğini göz ardı eder.

Dolar yerine token sayısını hesaplayın, çünkü fiyat listeleri değiştiğinde token sayıları sabit kalır. Her biri 400 token olan 100.000 parçalık bir külliyat, bu külliyata sorulan 10.000 soru, cevap başına modele gönderilen 8 parça, 100 tokenlık soru ve talimat bloğu ile 400 tokenlık cevapları temel alın.

ChartToken load for a 100,000 chunk corpus and 10,000 questions
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"
  }
]

Tüm külliyatın embedding işlemi 40 milyon tokendır ve bu işlem bir kez gerçekleşir. Bu işlem 10.000 soruya yayıldığında, soru başına 4,000 token düşer. Yüz bin soru sorarsanız bu sayı 400'e iner. Üretim maliyeti ise asla düşmez. Cevapladığınız her soruda 3,300 token giriş ve 400 token çıkış maliyeti oluşur.

Dolayısıyla maliyet, tekrarlanan aşamayı takip eder. Embedding adımını kendi altyapınızda tutun; çünkü bunun için bir kez ödeme yaparsınız ve VPS zaten çalışmaktadır. Üretim adımını ise satın alın; çünkü daha iyi bir modelin gerçek değer yarattığı yer burasıdır. Önbelleğe alma işlemi de aynı nedenle önemlidir: bir önbellek isabeti (cache hit), maliyeti asla amorti edilemeyen tek aşamayı atlamanızı sağlar. KV önbelleği ile istem önbelleği arasındaki fark, bu sürecin hangi yarısını yeniden kullanabileceğinizi belirler. Bir RAG istemi, sabit bir talimat bloğunu ve ardından değişen bir parça bloğunu içerir; bu yapı, önbelleğe almadan en çok fayda sağlayan formdur.

Chunking: Neden sabit boyut ve örtüşme doğru varsayılan değerdir

Chunk, geri çağırdığınız birimdir; bu nedenle boyutu, sonraki tüm süreçleri belirler. Embedding'in tek bir konuyu temsil etmesi için chunk yeterince küçük olmalıdır. Embedding uzayda tek bir noktadır; bu yüzden dört farklı konuyu kapsayan bir chunk, bu konuların arasında kalır ve hiçbirine yakın olmaz. Aynı zamanda, dil modeli dokümanın tamamını değil yalnızca chunk'ı gördüğü için, chunk tek başına bir soruyu yanıtlayabilecek kadar büyük olmalıdır.

Başlangıç için 50 kelimelik örtüşme ile 300 kelimelik bir boyut kullanın. İngilizce yaklaşık olarak kelime başına 1.3 token değerindedir, dolayısıyla 300 kelime yaklaşık 400 token eder. Örtüşme gereklidir çünkü sınır çizgisine denk gelen bir cümle aksi takdirde ikiye bölünür ve parçaların hiçbiri soruyu yanıtlayamaz.

Dokümanların bir yapısı varsa, önce bu yapıya göre bölün. Başlıklardan, ardından paragraflardan ayırın ve sabit boyut kuralını yalnızca hala çok uzun olan bölümlerin içinde uygulayın. Bir cümlenin ortasında başlayan bir chunk, nihai yanıtta kötü görünür; çünkü model, kendisine verdiğiniz metni alıntılayarak geri sunar.

Ölçüm yapmadan chunking ayarlarını değiştirmeyin. Örtüşmeli sabit boyut yöntemi deterministiktir ve tekrar çalıştırılması maliyetsizdir; bu da onu geçebileceğiniz bir temel (baseline) haline getirir. Önce puanlama sorgusunu oluşturun, ardından her seferinde yalnızca tek bir şeyi değiştirin.

Aynı sunucu üzerinde gömme işlemi ve RAM ile gecikme maliyeti

curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text

nomic-embed-text, Ağustos 2026 itibarıyla 137 milyon parametreye ve 274 MB indirme boyutuna sahiptir. Bir tablo tasarlamadan önce ne döndürdüğünü kontrol edin.

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]))'

Bu, 768 çıktısını verir. Sütun türünüzün bu sayı ile tam olarak eşleşmesi gerekir.

Bu modelde kullanıcıları yanıltan iki ayar bulunmaktadır.

Görev öneki isteğe bağlı değildir. Nomic model kartı, girdinin "bir görev talimatı öneki içermesi gerektiğini" belirtir. Belgeler önüne search_document: , sorular ise search_query: eklenerek gömülmelidir. Bunları çıkarırsanız hiçbir hata almazsınız: vektörler döner ancak arama kalitesi düşer ve hiçbir günlük kaydı size nedenini söylemez.

Uzun girdiler sessizce kırpılır. /api/embed uç noktası bir truncate alanı alır ve varsayılan değeri true'dir; Ollama tarafından paketlenen model ise 2K bağlam (context) sunar. Bu sınırdan daha uzun bir parça, sınırda kesilir ve yine de gömülür; bu nedenle parçanın son kısmı aranamaz hale gelir. Test yaparken "truncate": false gönderin; böylece aşırı büyük bir parça sessizce geçmek yerine hata verir.

İstekleri toplu (batch) gönderin ve modeli bellekte tutun.

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/null

input bir liste kabul eder. 32 parçayı tek bir istekte göndermek, 32 ayrı istek göndermekten daha verimlidir; çünkü HTTP gidiş-dönüş süresi ve model arama işlemi 32 kez yerine bir kez gerçekleşir. keep_alive, modelin bir istekten sonra ne kadar süre bellekte kalacağını kontrol eder ve varsayılan değer 5 dakikadır. Süre dolduğunda, bir sonraki istek tekrar yükleme süresini beklemek zorunda kalır.

Kendi sunucunuzda önemli olan iki değeri ölçün. Bu değerler vCPU sayınıza bağlıdır, bu nedenle yayınlanan hiçbir rakam sizin durumunuzla tam eşleşmeyecektir.

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/null

ollama ps, yüklü modelin bellekte kapladığı alanı gösterir; bu, keep_alive modeli tuttuğu sürece ayırdığınız RAM miktarıdır. time çıktısının toplu iş boyutuna bölünmesi, parça başına saniye cinsinden sürenizi verir. Toplam indeksleme maliyetini bulmak için bunu parça sayısıyla çarpın. Yalnızca CPU kullanılan bir planda, 100.000 parçalık bir külliyatın indekslenmesinin dakikalar değil saatler sürmesini bekleyin. Bu kabul edilebilir bir durumdur çünkü işlem bir kez yapılır ve nice -n 19 altında gece boyunca çalışabilir. Eğer saatler sürmesi kabul edilemezse, asıl soru bir GPU kiralamanın maliyetini karşılayıp karşılamadığıdır; bu da bir tercih meselesi değil, API token maliyetlerine karşı bir başa baş noktası hesaplamasıdır.

Sunucu halihazırda bir sohbet modeli çalıştırıyorsa, gömme modeli ikinci bir yerleşik model olur ve RAM kullanımı artar. Ollama'yı bir VPS üzerinde çalıştırmak, üretim tarafındaki boyutlandırmayı ele alır; kendi kendine barındırılan bir modelin eşzamanlı kullanıcılar altındaki davranışı ise birden fazla kişi aynı anda soru sorduğunda ne olacağını açıklar. Gömme modeli, diğerinin yanında çalışacak kadar küçüktür.

İndeksleme betiği, baştan sona

Ubuntu 24.04 üzerinde, sanal bir ortam dışında çalıştırılan düz bir pip install, error: externally-managed-environment hatasıyla durur; çünkü sistem Python'ı apt paket yöneticisine aittir.

python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvector
import 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() size aittir: dosyalarınızı veya satırlarınızı tarayan, bir belge kimliği ve metnini döndüren her şey. Geri kalan her şey veri hattıdır (pipeline).

Depolama: pgvector şeması ve boyutlandırma

Ubuntu 24.04, postgresql-16-pgvector paketini 0.6.0 sürümüyle sunar; bu sürüm halfvec türünden daha eskidir. Güncel bir derleme için PostgreSQL projesinin kendi deposunu kullanın.

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-pgvector

Paket adındaki sayı, sunucunuzun ana sürümüyle eşleşmelidir. Ardından rolü, veritabanını ve eklentiyi oluşturun.

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), modelin çıktısıyla eşleşmelidir. Bu sütuna 1024 boyutlu bir vektör eklerseniz, Postgres bunu expected 768 dimensions, not 1024 hatasıyla reddeder; bu, tüm süreçteki en net hata mesajıdır. Oluşturulan fts sütununun bakım maliyeti yoktur ve ileride anahtar kelime araması yapmanıza olanak tanır.

Depolama hesabı aritmetiktir. pgvector, bir vector türünü 4 * dimensions + 8 bayt, bir halfvec türünü ise 2 * dimensions + 8 olarak tanımlar. Aşağıdaki boyut sayıları, her modelin yayınlanan çıktı boyutudur.

ChartVector column size per 100,000 chunks, by embedding dimension
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
  }
]

768 boyutta her vektör 3,080 bayttır; dolayısıyla 100.000 parça, 294 MiB vektör verisi tutar. Aynı külliyatın 1536 boyutlu bir barındırılan model tarafından gömülmesi 587 MiB gerektirir ve üzerindeki dizin de buna oranla büyür. Yarım hassasiyet (half precision) her ikisini de yarıya indirir: halfvec(768), bu külliyatı 147 MiB içinde saklar. Bunun geri çağırma (recall) başarısını etkileyip etkilemediği, aşağıdaki puanlama sorgusuyla tek seferde yanıtlanabilir.

Bu rakamlar yalnızca vektör sütununu kapsar. Metin, satır ek yükü ve dizinler bunun üzerine eklenir; bu nedenle gerçek tablo boyutunu ölçün.

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;

Aynı eklentiyi API ve kullanıcı hesaplarıyla birlikte kullanmak isterseniz, kendi kendine barındırılan bir Supabase yığını, pgvector halihazırda etkinleştirilmiş bir Postgres sunucusudur ve bu kılavuzdaki her sorgu orada hiçbir değişiklik yapılmadan çalışır.

İndeksleme: Önemli HNSW ayarları

Birkaç bin satırın altındaki veriler için indeks kullanmayın. Tam arama (exact search) her satırı okur; bu ölçekte yeterince hızlıdır ve geri çağırma (recall) oranı mükemmeldir. Sıralı tarama (sequential scan) hızı yetersiz kaldığında indeksi ekleyin ve şu ödünleşimi unutmayın: yaklaşık bir indeks, yaklaşık olarak doğru komşuları döndürür.

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 ve ef_construction = 64, pgvector'ün varsayılan değerleridir. Bu değerleri artırmak geri çağırma oranını iyileştirir ancak indeks oluşturma süresini ve boyutunu artırır. Modelinizin birim uzunlukta vektörler ürettiğinden emin değilseniz <=> operatörü ile birlikte vector_cosine_ops kullanın; çünkü kosinüs uzaklığı vektör uzunluğunu göz ardı ederken, iç çarpım (inner product) bunu yapmaz.

Oluşturma sürecini izleyin. Grafik maintenance_work_mem değerini aştığında, pgvector bunu bildirir:

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.

Bu bir hata değildir ve oluşturma işlemi tamamlanır, ancak süreç çok daha yavaş bir yola girer. İndeksi oluşturan oturumda maintenance_work_mem değerini artırın ve sunucu varsayılanını değiştirmeyin; çünkü bu ayar bakım işlemi bazında çalışır ve yüksek bir genel değer, sunucunun bellek yetersizliğine düşmesine neden olabilir. Uzun süren bir oluşturma işlemini ikinci bir oturumdan takip edin.

SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;

Ardından tamamlanan indeksi sunucudaki bellek miktarıyla karşılaştırın.

SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;

HNSW araması bir grafik üzerinde ilerler, bu nedenle bir aralığı okumak yerine indeksin farklı yerlerine dağılmış sayfaları ziyaret eder. Belleğe sığmayan bir indeks, her sorguyu disk okuma işlemine dönüştürür ve kullanıcıların fark ettiği yavaşlık da bu kuyruk kısmıdır. Sunucu boyutlandırması için tek bir kural vardır: indeks ve aktif olarak sunduğunuz satırlar RAM'e sığmalıdır. free -m ve yukarıdaki boyut, karşılaştırmanız gereken iki değerdir.

Sorgu anında, hnsw.ef_search geri çağırma oranını belirleyen ayardır ve varsayılan değeri 40'tır.

BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;

Daha yüksek bir değer, grafiğin daha büyük bir kısmını tarar, daha iyi komşular bulur ancak gecikmeyi artırır. Bu bir oturum ayarıdır, dolayısıyla indekse dokunmadan tek bir sorgu için bu değeri artırabilirsiniz.

Bir sorgu indeksi hiç kullanmıyorsa, plan bunu gösterir.

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;

Buradaki sıralı tarama genellikle depolama ile ilgilidir. 768 boyutlu bir vektör 3,080 bayttır; bu, Postgres'in satır içinde (inline) tutabileceğinden fazladır, bu yüzden değer TOAST tablosuna (büyük değerler için satır dışı depolama alanı) taşınır. pgvector'ün kendi notuna göre, planlayıcı maliyet tahminlerinde satır dışı depolamayı hesaba katmaz; bu da sıralı taramanın olduğundan daha ucuz görünmesine neden olabilir. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN;, vektörleri satır içinde tutar. Bu ayar, değişiklikten sonra yazılan satırlar için geçerlidir; bu nedenle mevcut satırların tablo yeniden yazımı (table rewrite) ile güncellenmesi gerekir.

Veri çekme: tek sorgu, iki sinyal

Vektör araması, soruyla aynı anlama gelen metinleri bulur. Parça numarası, hata kodu veya soyadı gibi tam eşleşme gerektiren durumlarda zayıftır. Anahtar kelime araması ise bunun tam tersidir ve Postgres bunu halihazırda yapabilmektedir. İkinci bir sistem çalıştırmak yerine, bunları tek bir sorguda birleştirin.

Reciprocal rank fusion, çalışan en basit birleştirme yöntemidir. Her sonuç, yer aldığı her listeden 1 / (60 + rank) alır ve bu iki puan toplanır. Mesafeler yerine sıralamaları okuduğu için puan normalizasyonuna ihtiyaç duymaz.

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, aynı modelden elde edilen ve search_query: önekiyle oluşturulan sorunun embedding değeridir. $2 ise metin halindeki sorudur. Her ikisi de uygulamanızdan bağlanır. websearch_to_tsquery, noktalama işaretleri nedeniyle hata vermeden gerçek bir kullanıcı sorusunu kabul eder; to_tsquery bunu yapamaz. Bilmeniz gereken bir diğer nokta şudur: HNSW taraması üzerine bir WHERE filtresi eklemek, istediğinizden daha az satır döndürebilir; çünkü önce indeks taranır ve filtre daha sonra uygulanır. SET hnsw.iterative_scan = relaxed_order;, pgvector'ün yeterli sayıda satıra ulaşana kadar taramaya devam etmesini sağlar.

Getirme işleminin başarısı nasıl ölçülür?

Bu adım, neredeyse tüm RAG rehberlerinin atladığı, ancak diğer seçimlerinizin işe yarayıp yaramadığını gösteren tek bölümdür. Bir değerlendirme çerçevesine (evaluation framework) ihtiyacınız yoktur. İhtiyacınız olan tek şey 30 soru ve her birini yanıtlayan parçanın (chunk) kimliğidir (id).

Bunları elle yazın. İnsanların bu veri kümesi hakkında gerçekten sorduğu soruları alın, her birini çalıştırın, dönen sonucu okuyun ve doğru parçanın kimliğini kaydedin. 30 soru küçük farkları çözmeyecektir. Ancak önemli olan büyük farkları yakalayacaktır.

CREATE TABLE gold (
  id        bigserial PRIMARY KEY,
  question  text   NOT NULL,
  chunk_id  bigint NOT NULL REFERENCES chunks(id),
  embedding vector(768) NOT NULL
);

Her soruyu search_query: ön ekiyle gömün (embed), depolayın ve ardından tüm seti tek bir sorguda puanlayın.

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 değerinin questions değerine bölümü, 10'da geri çağırma (recall at 10) oranını verir: yani cevabın modele gönderdiğiniz pencere içinde ne sıklıkla yer aldığıdır. MRR (ortalama karşılıklı sıra), doğru parçanın konumunun 1'e bölümünün ortalamasını alır ve başarısızlığı sıfır olarak sayar; bu nedenle cevabın sekizinci sırada değil, birinci sırada yer almasını ödüllendirir. Parça boyutunu değiştirdiğinizde, gömme modelini (embedding model) takas ettiğinizde veya anahtar kelime araması eklediğinizde her iki sayı da değişir; böylece hangi yöne hareket ettiklerini görebilirsiniz.

10'da geri çağırma oranını her şeyin üzerinde tutun, çünkü üretici model (generator) kendisine hiç ulaşmayan bir parçayı kullanamaz. 10'da geri çağırma oranı 0.9 iken cevaplar hala yanlışsa, hata getirme işleminde değil, istemde (prompt) veya modeldedir. Bu ayrım, günlerce sürecek tahmin yürütme zahmetinden sizi kurtarır.

Dizini ayrı olarak kontrol edin. Yaklaşık arama (approximate search) geri çağırma oranından ödün verir ve pgvector size bunun ne kadar olduğunu gösterir: aynı sorguyu tam arama (exact search) ile çalıştırın ve kimlikleri karşılaştırın.

BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

On kimlikten dokuzunun ortak olması, ef_search değerinin iyi olduğunu gösterir. On kimlikten dördünün ortak olması ise bu değeri yükseltmeniz gerektiği anlamına gelir.

Yeniden sıralama ve üretim: API'nin kazanç sağladığı nokta

Yeniden sıralayıcı (reranker), farklı bir model türüdür. Soruyu ve bir veri parçasını birlikte okuyarak bu ikiliyi puanlar; bu yöntem, bağımsız olarak hesaplanan iki gömme (embedding) vektörünü karşılaştırmaktan daha başarılıdır ancak tüm bir veri kümesi üzerinde çalıştırılamayacak kadar yavaştır. Tam olarak bu yüzden burada yer alır. Veri tabanındaki 100.000 parçayı değil, geri getirme (retrieval) aşamasından dönen 40 adayı görür. Bu nedenle barındırılan bir yeniden sıralama API'si, soru başına 40 kısa çift üzerinden ücretlendirme yapar ve en kötü yanlış pozitifleri, maliyetli aşamaya ulaşmadan önce eler.

Üretim (generation) süreci yinelenen bir maliyet kalemidir ve bu maliyeti iki temel kaldıraç etkiler. Daha az parça gönderin; yanıt kaybı yaşamadan ne kadar az veriyle çalışabileceğinizi belirlemek için recall at 10 metriğini kullanın. İstemin (prompt) ön kısmını bayt düzeyinde sabit tutun; böylece sağlayıcının istem önbelleği (prompt cache) eşleşme sağlayabilir ve getirilen parçaları bu sabit kısmın arkasına yerleştirin. Ayrıca tamamlanmış yanıtları soru bazında önbelleğe alın; çünkü en ucuz üretilen token, geçen hafta ürettiğiniz tokendır.

Sunucu boyutlandırma ve sınırların aşılması

Buradaki her boyutlandırma kuralı, tahmin etmek yerine ölçümlemeniz gereken değerlerdir.

  • RAM, bağlayıcı kısıttır: ollama ps değerinden gelen yerleşik model boyutu, HNSW dizin boyutu ve shared_buffers toplamına, bağlantılar ve sayfa önbelleği (page cache) için ek pay bırakılarak ulaşılır.
  • Disk, pg_total_relation_size('chunks') değerinin iki katını gerektirir; çünkü bir dizini yeniden oluşturmak her iki kopyayı da aynı anda tutar.
  • CPU, yeniden dizinleme süresini belirler; bu süre, ölçülen parça başına saniye değerinin toplam parça sayısıyla çarpılmasıyla bulunur.
  • Yeniden dizinleme beklediğinizden daha sık gerçekleşir, çünkü embedding modelini değiştirmek depolanan tüm vektörleri geçersiz kılar.

Bu tasarım, yaklaşmakta olduğunu görebileceğiniz bir noktada yetersiz kalmaya başlar. HNSW dizini satın alabileceğiniz RAM miktarına sığmadığında, sorgu gecikmesi disk arama işlemlerine (disk seeks) dönüşür ve hiçbir ayar bunu düzeltemez. Tek bir tablo birçok kiracıya hizmet veriyorsa ve her sorgu kiracıya göre filtreleniyorsa, tabloyu bölümlemek (partitioning) çözüm haline gelir ve bu ciddi bir iştir. Dizinleme yazma işlemleri ile kullanıcı sorguları aynı sunucu üzerinde çakıştığında, veritabanını taşımadan önce embedding işleyicisini ikinci bir sunucuya aktarın. Bunlardan biri gerçekleşene kadar, halihazırda kiraladığınız VPS üzerinde pgvector ile Postgres kullanmak üretim ortamı için geçerli bir yanıttır ve yukarıdaki sayılar size sınırın ne kadar uzakta olduğunu gösterir.

FAQ

RAG hattını tek bir VPS üzerinde çalıştırabilir miyim, yoksa bir vektör veritabanına ihtiyacım var mı?

Yüz binlerce parçadan oluşan veri setleri için tek bir VPS yeterlidir. 768 boyutlu 100.000 parça, 294 MiB vektör verisi ile metin ve HNSW indeksini kapsar; bu da standart bir planın RAM kapasitesine sığar. Sınır, satır sayısından ziyade bellektir; çünkü HNSW araması indeks üzerinde atlamalı çalıştığından, indeks RAM'e sığmadığı anda gecikme süresi artar. İndeks üzerindeki pg_relation_size ile free -m değerlerini karşılaştırarak durumunuzu analiz edebilirsiniz.

Belgelerimi vektörleştirmek (embedding) için GPU'ya ihtiyacım var mı?

Eğer vektörleştirme işlemini bir kez yapıp ardından sorgulama yapacaksanız, hayır. nomic-embed-text gibi 137 milyon parametreli bir model CPU üzerinde çalışabilir ve büyük bir veri setinin tamamının işlenmesi, gece boyunca sürecek birkaç saatlik bir işlemdir. GPU, belgeler sürekli geliyorsa veya aynı sunucuda metin üretimi (generation) yapacaksanız önem kazanır. Kendi sunucunuzda /api/embed ile bir grubun süresini ölçün ve bunu parça sayınızla çarpın; vCPU sayıları çok değişken olduğundan genel geçer bir rakam yanıltıcı olabilir.

Vektör sorgum neden HNSW indeksi yerine sıralı tarama (sequential scan) kullanıyor?

Sorgu planını EXPLAIN (ANALYZE, BUFFERS) ile inceleyin. Yaygın neden depolama biçimidir: pgvector, planlayıcının maliyet tahminlerinde satır dışı (out-of-line) depolamayı hesaba katmadığını, bu durumun sıralı taramayı olduğundan daha ucuz gösterdiğini belirtir. 768 boyutlu bir vektör 3,080 bayt olduğundan varsayılan olarak TOAST tablosunda saklanır. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN;, yeni satırları satır içi (inline) tutar. Diğer iki neden ise indeksle eşleşmeyen bir operatör kullanımı (vector_cosine_ops ile oluşturulan bir indeks yalnızca <=> tarafından kullanılır) ve ORDER BY ... LIMIT içermeyen bir sorgudur; çünkü yaklaşık indeksler yalnızca sıralı en yakın komşu sorgularına hizmet eder.

Geri getirme (retrieval) kalitemin iyi olup olmadığını nasıl anlarım?

Her biri cevabı içeren parçanın kimliğiyle eşleştirilmiş 30 soruluk bir "altın set" oluşturun ve soru vektörlerini bunlarla birlikte saklayın. Ardından, doğru parçanın ilk 10 sonuç içinde ne sıklıkla yer aldığını gösteren "recall at 10" değerini ve parçayı ilk sırada getirene daha yüksek puan veren MRR değerini ölçün. Parça boyutu, embedding modeli veya rank fusion üzerindeki bir değişikliğin işe yarayıp yaramadığını bu iki sayı belirler. Bunlar olmadan sadece ayarları değiştirir ve birkaç cevaba bakarak kişisel izleniminize güvenirsiniz.