Zelf een RAG-pipeline bouwen op uw eigen VPS
Bouw een volledige RAG-pipeline op uw eigen server. Leer hoe u pgvector instelt, HNSW-indexen optimaliseert en lokale embedding-modellen gebruikt voor efficiënte data-opslag.
Hoe een self-hosted RAG-pipeline eruitziet
Een RAG-pipeline (retrieval augmented generation) bestaat uit vijf fasen: het opdelen van documenten in chunks, het embedden van deze chunks, het opslaan van de vectoren, het ophalen van de meest relevante chunks bij een vraag, en het versturen van die chunks naar een taalmodel dat het antwoord schrijft. Op een VPS die u al huurt, draaien de eerste vier fasen op de server zelf. PostgreSQL met de pgvector-extensie slaat de vectoren op, en een klein embedding-model dat via Ollama wordt aangeboden, zet tekst om in vectoren. Alleen de laatste fase hoeft de server te verlaten.
Deze splitsing vormt het uitgangspunt van deze handleiding. Het opdelen in chunks is puur CPU-werk. Embedding is een model met 137 miljoen parameters dat in enkele honderden megabytes aan RAM past. Opslag is een Postgres-tabel waarvan u de omvang kunt berekenen voordat u ook maar één rij schrijft. Voor een corpus van honderdduizenden chunks draait dit alles op een standaard VPS. Generatie is anders, omdat dit bij elke vraag kosten met zich meebrengt, voor altijd.
Welke onderdelen van een RAG-pipeline kosten daadwerkelijk geld
De end-to-end RAG-handleiding van DigitalOcean kiest voor een beheerde vectordatabase en een gehost embedding-model. Het kostenoverzicht in die handleiding is kwalitatief: cache herhaalde zoekopdrachten, houd het aantal opgehaalde chunks klein en gebruik reranking vóór de generatie. Dat advies is correct. Het slaat echter de optie over die de berekening fundamenteel verandert: het embedding-model draaien op de server waar u toch al voor betaalt.
Tel tokens in plaats van dollars, omdat aantallen tokens niet verouderen wanneer een prijslijst wijzigt. Neem een corpus van 100.000 chunks van elk 400 tokens, 10.000 vragen die daarover worden gesteld, 8 chunks die per antwoord naar het model worden gestuurd, een blok met vragen en instructies van 100 tokens, en antwoorden van 400 tokens.
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"
}
]Het embedden van het volledige corpus kost 40 miljoen tokens, en dit gebeurt eenmalig. Verspreid over die 10.000 vragen is dat 4,000 tokens per vraag. Bij honderdduizend vragen daalt dit naar 400. De generatiekosten dalen nooit. Dit kost 3,300 tokens aan input en 400 tokens aan output voor elke vraag die u ooit zult beantwoorden.
Het geld volgt dus de fase die zich herhaalt. Beheer de embedding-stap zelf, omdat u er eenmalig voor betaalt en de VPS toch al draait. Koop de generatie-stap in, omdat daar een beter model de investering waard is. Caching is om dezelfde reden belangrijk: een cache-hit slaat de enige fase over waarvan de kosten nooit worden afgeschreven. Het verschil tussen een KV-cache en een prompt-cache bepaalt welk deel daarvan u kunt hergebruiken. Een RAG-prompt heeft een stabiel instructieblok gevolgd door een wisselend chunk-blok; dit is de structuur die hier het meeste voordeel uit haalt.
Chunking: waarom een vaste grootte met overlap de juiste standaard is
Een chunk is de eenheid die u ophaalt; de grootte ervan bepaalt dus alles wat daarna volgt. De chunk moet klein genoeg zijn zodat de embedding over één onderwerp gaat. Een embedding is immers een enkel punt in de ruimte; een chunk die vier onderwerpen beslaat, komt ergens tussen die onderwerpen in te liggen en is daardoor voor geen van alle relevant. De chunk moet tegelijkertijd groot genoeg zijn om zelfstandig een vraag te beantwoorden, omdat het taalmodel alleen de chunk ziet en niet het omliggende document.
Begin met 300 woorden met een overlap van 50 woorden. Engels bevat ongeveer 1,3 tokens per woord, dus 300 woorden komt neer op ongeveer 400 tokens. De overlap is nodig omdat een zin die op een grens valt anders doormidden wordt gesneden, waardoor geen van beide helften de vraag kan beantwoorden.
Splits eerst op basis van structuur als de documenten gestructureerd zijn. Breek af bij koppen, daarna bij alinea's, en pas de regel voor de vaste grootte alleen toe binnen een sectie die nog steeds te lang is. Een chunk die midden in een zin begint, leest slecht in het uiteindelijke antwoord, omdat het model citeert wat u heeft aangeleverd.
Optimaliseer chunking pas als u het kunt meten. Een vaste grootte met overlap is deterministisch en goedkoop om opnieuw uit te voeren, wat het een uitgangspunt maakt dat u kunt verbeteren. Bouw eerst de scoring-query verderop in het proces en wijzig daarna telkens slechts één factor.
Embedding op dezelfde server en de kosten in RAM en latentie
curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-textnomic-embed-text bevat 137 miljoen parameters en is per augustus 2026 een download van 274 MB. Controleer wat het model teruggeeft voordat u er een tabelstructuur omheen ontwerpt.
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]))'Dit print 768. Uw kolomtype moet exact overeenkomen met dat getal.
Twee instellingen bij dit model zorgen vaak voor problemen.
Het taakvoorvoegsel is niet optioneel. De modelkaart van Nomic stelt dat de invoer "een taakinstructie-voorvoegsel moet bevatten". Documenten worden ingesloten met search_document: ervoor, vragen met search_query: . Laat u deze weg, dan treedt er geen fout op: u krijgt wel vectoren terug, maar de kwaliteit van de zoekresultaten keldert en er is geen logbestand dat u vertelt waarom.
Lange invoer wordt stilletjes afgekapt. Het /api/embed-eindpunt accepteert een truncate-veld met een standaardwaarde van true, en het model zoals verpakt door Ollama adverteert een context van 2K. Een tekstblok dat langer is dan dat, wordt bij de limiet afgesneden en alsnog ingesloten, waardoor het einde onvindbaar is. Stuur "truncate": false mee tijdens het testen, zodat een te groot tekstblok een foutmelding geeft in plaats van ongemerkt door te gaan.
Verwerk verzoeken in batches en houd het model in het geheugen.
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 accepteert een lijst; één verzoek met 32 tekstblokken is sneller dan 32 afzonderlijke verzoeken, omdat de HTTP-round-trip en de model-lookup slechts één keer plaatsvinden in plaats van 32 keer. keep_alive bepaalt hoe lang het model in het geheugen blijft na een verzoek; de standaardwaarde is 5 minuten. Wanneer deze tijd verloopt, moet bij het volgende verzoek opnieuw de laadtijd worden betaald.
Meet de twee getallen die ertoe doen op uw eigen server. Deze zijn afhankelijk van uw aantal vCPU's, dus gepubliceerde cijfers zullen nooit exact overeenkomen.
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 print de residentiële grootte van het geladen model; dit is het RAM-geheugen dat u heeft toegewezen zolang keep_alive het vasthoudt. De time-output gedeeld door de batchgrootte is uw aantal seconden per tekstblok. Vermenigvuldig dit met het aantal tekstblokken om de eenmalige indexeringskosten te berekenen. Op een systeem met alleen een CPU moet u er rekening mee houden dat een corpus van 100.000 tekstblokken eerder uren dan minuten in beslag neemt. Dat is acceptabel, omdat dit eenmalig gebeurt en 's nachts kan draaien onder nice -n 19. Als uren te lang is, is de werkelijke vraag of het huren van een GPU zichzelf terugverdient; dat is een break-evenberekening ten opzichte van API-tokens en geen kwestie van voorkeur.
Als de server al een chatmodel draait, is het embedding-model een tweede resident model en telt het RAM-gebruik op. Ollama draaien op een VPS behandelt de dimensionering voor generatieve taken, en wat een zelfgehost model doet bij gelijktijdige gebruikers beschrijft wat er gebeurt wanneer meerdere mensen tegelijkertijd vragen stellen. Het embedding-model is klein genoeg om naast beide te draaien.
Het indexeringsscript, van begin tot eind
Op Ubuntu 24.04 stopt een standaard pip install buiten een virtuele omgeving met error: externally-managed-environment, omdat de systeem-Python toebehoort aan 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() is aan u: alles wat uw bestanden of rijen doorloopt en een document-id en de bijbehorende tekst oplevert. Al het overige is de pipeline.
Opslag: het pgvector-schema en de omvang ervan
Ubuntu 24.04 levert postgresql-16-pgvector in versie 0.6.0, wat ouder is dan het halfvec-type. Gebruik de officiële repository van het PostgreSQL-project voor een actuele build.
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-pgvectorHet nummer in de pakketnaam moet overeenkomen met de major-versie van uw server. Maak vervolgens de rol, de database en de extensie aan.
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) moet overeenkomen met de output van het model. Voegt u een vector van 1024 dimensies in die kolom in, dan weigert Postgres dit met expected 768 dimensions, not 1024; dit is de meest duidelijke foutmelding in deze gehele pipeline. De gegenereerde fts-kolom kost niets om te onderhouden en biedt u later de mogelijkheid voor trefwoordzoekopdrachten.
De opslag is gebaseerd op rekenkunde. pgvector documenteert een vector als 4 * dimensions + 8 bytes en een halfvec als 2 * dimensions + 8. De onderstaande dimensieaantallen zijn de gepubliceerde outputgroottes van elk 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
}
]Bij 768 dimensies is elke vector 3,080 bytes, dus 100.000 chunks nemen 294 MiB aan vectordata in beslag. Hetzelfde corpus, geëncodeerd door een gehost model met 1536 dimensies, vereist 587 MiB, en de index daaroverheen groeit in verhouding mee. Half-precision halveert beide: halfvec(768) slaat dat corpus op in 147 MiB. Of dit ten koste gaat van de recall is een vraag die de onderstaande scoring-query in één run beantwoordt.
Deze cijfers hebben enkel betrekking op de vector-kolom. Tekst, rij-overhead en indexen komen daar nog bij, dus meet de werkelijke tabel.
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;Wilt u liever dezelfde extensie met een API en gebruikersaccounts eromheen, dan is een self-hosted Supabase-stack Postgres met pgvector reeds ingeschakeld, en werkt elke query in deze handleiding daar ongewijzigd.
Indexering: de HNSW-instellingen die ertoe doen
Sla de index over bij minder dan enkele duizenden rijen. Een exacte zoekopdracht leest elke rij; dit is bij die omvang snel genoeg en de recall is perfect. Voeg de index toe zodra een sequentiële scan niet langer snel genoeg is en begrijp de afweging: een benaderende index geeft benaderende resultaten terug.
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 en ef_construction = 64 zijn de standaardwaarden van pgvector. Het verhogen hiervan verbetert de recall, maar kost meer bouwtijd en indexgrootte. Gebruik vector_cosine_ops met de <=>-operator, tenzij u weet dat uw model vectoren met een eenheidslengte genereert, omdat cosinus-afstand de vectorlengte negeert terwijl het inwendig product dat niet doet.
Houd de opbouw in de gaten. Wanneer de graaf groter wordt dan maintenance_work_mem, meldt pgvector dit:
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL: Building will take significantly more time.Dit is geen fout en de opbouw wordt nog steeds voltooid, maar het proces schakelt over naar een veel langzamer pad. Verhoog maintenance_work_mem in de sessie die de index opbouwt en laat de standaardwaarde van de server ongewijzigd, omdat deze instelling per onderhoudsoperatie geldt en een hoge globale waarde ertoe kan leiden dat het systeem zonder geheugen komt te zitten. Volg een langdurige opbouw vanuit een tweede sessie.
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;Vergelijk vervolgens de voltooide index met het beschikbare geheugen op de server.
SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;Een HNSW-zoekopdracht doorloopt een graaf en raakt daardoor pagina's verspreid over de index aan, in plaats van een bereik te lezen. Een index die niet in het geheugen past, verandert elke query in schijf-reads, en die trage afhandeling is wat gebruikers merken. Dit is de enige regel voor het dimensioneren van de server: de index plus de rijen die u daadwerkelijk serveert, moeten in het RAM passen. free -m en de bovenstaande grootte zijn de twee getallen die u moet vergelijken.
Tijdens het uitvoeren van een query is hnsw.ef_search de instelling voor de recall, met een standaardwaarde van 40.
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;Een hogere waarde doorzoekt een groter deel van de graaf, vindt betere buren en kost meer latentie. Dit is een sessie-instelling, dus u kunt deze voor één query verhogen zonder de index aan te passen.
Als een query de index helemaal niet gebruikt, is dit zichtbaar in het queryplan.
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;Een sequentiële scan komt hier vaak door de opslag. Een vector met 768 dimensies is 3,080 bytes, wat meer is dan Postgres inline bewaart, waardoor de waarde naar de TOAST-tabel (de externe opslag voor te grote waarden) verplaatst. De eigen opmerking van pgvector is dat de planner externe opslag niet meetelt in de kostenramingen, waardoor een seriële scan goedkoper kan lijken dan deze in werkelijkheid is. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; houdt vectoren inline. Dit is van toepassing op rijen die na de wijziging worden geschreven, dus bestaande rijen vereisen een tabel-herschrijving.
Ophalen: één query, twee signalen
Vectorzoekopdrachten vinden tekst die dezelfde betekenis heeft als de vraag. Deze methode is minder effectief bij exacte reeksen: een onderdeelnummer, een foutcode of een achternaam. Zoeken op trefwoorden werkt precies andersom, en Postgres ondersteunt dit al. Combineer beide in één query in plaats van een tweede systeem te draaien.
Reciprocal rank fusion is de eenvoudigste methode om resultaten te combineren. Elk resultaat krijgt een 1 / (60 + rank) op basis van de lijst waarin het voorkomt, en de twee scores worden bij elkaar opgeteld. Normalisatie van scores is niet nodig, omdat de methode kijkt naar posities in plaats van afstanden.
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 is de embedding van de vraag, afkomstig van hetzelfde model en opgebouwd met het search_query: -voorvoegsel. $2 is de vraag als tekst. Beide worden vanuit uw applicatie gekoppeld. websearch_to_tsquery accepteert een echte gebruikersvraag zonder problemen met leestekens, wat bij to_tsquery wel het geval is. Nog een belangrijk punt: het toevoegen van een WHERE-filter boven op een HNSW-scan kan resulteren in minder rijen dan gevraagd, omdat de index eerst wordt doorzocht en het filter pas daarna wordt toegepast. SET hnsw.iterative_scan = relaxed_order; zorgt ervoor dat pgvector blijft scannen totdat het voldoende rijen heeft gevonden.
Hoe bepaalt u of de retrieval van goede kwaliteit is?
Dit is de stap die bijna elke RAG-handleiding overslaat, terwijl dit de enige stap is die aantoont of uw andere keuzes daadwerkelijk hebben geholpen. U heeft hiervoor geen evaluatieframework nodig. U heeft 30 vragen nodig en de id van het tekstfragment (chunk) dat elk van deze vragen beantwoordt.
Schrijf deze handmatig. Neem vragen die gebruikers daadwerkelijk stellen over dit corpus, voer ze uit, lees wat er terugkomt en noteer de id van het fragment dat als resultaat had moeten verschijnen. Dertig vragen zijn onvoldoende om kleine verschillen op te lossen. Ze zijn echter wel voldoende om relevante verschillen te detecteren, omdat deze aanzienlijk zijn.
CREATE TABLE gold (
id bigserial PRIMARY KEY,
question text NOT NULL,
chunk_id bigint NOT NULL REFERENCES chunks(id),
embedding vector(768) NOT NULL
);Embed elke vraag met het search_query: -voorvoegsel, sla deze op en beoordeel vervolgens de gehele set in één 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 gedeeld door questions is de recall at 10: hoe vaak het antwoord zich bevond binnen het venster dat u naar het model stuurt. MRR (mean reciprocal rank) berekent het gemiddelde van 1 gedeeld door de positie van het juiste fragment en telt een misser als nul; het beloont het dus als het antwoord op de eerste plaats staat in plaats van op de achtste. Beide getallen veranderen wanneer u de chunk size aanpast, het embedding model vervangt of keyword search toevoegt, waardoor u direct ziet in welke richting ze verschuiven.
Geef prioriteit aan recall at 10 boven alles, omdat de generator geen fragment kan gebruiken dat hij nooit heeft ontvangen. Wanneer de recall at 10 0,9 is en de antwoorden nog steeds onjuist zijn, ligt de fout bij de prompt of het model, niet bij de retrieval. Dat ene onderscheid bespaart dagen aan giswerk.
Controleer de index afzonderlijk. Approximate search gaat ten koste van recall, en pgvector laat u zien hoeveel: voer dezelfde query uit met exact search en vergelijk de ids.
BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;Negen van de tien overeenkomende ids betekent dat ef_search in orde is. Vier van de tien betekent dat u de waarde moet verhogen.
Reranking en generatie: waar een API zijn geld verdient
Een reranker is een ander type model. Het leest de vraag en één tekstfragment samen en geeft een score aan dat paar. Dit is effectiever dan het vergelijken van twee onafhankelijk berekende embeddings, maar het is veel te traag om over een volledig corpus uit te voeren. Dat is precies waarom het hier thuishoort. Het model bekijkt de 40 kandidaten die door de retrieval-stap zijn geretourneerd, in plaats van de 100.000 fragmenten in de tabel. Daarom brengt een gehoste reranking-API kosten in rekening voor 40 korte paren per vraag en filtert het de slechtste fout-positieven weg voordat ze de kostbare fase bereiken.
Generatie is de terugkerende kostenpost, en twee factoren beïnvloeden deze. Verstuur minder fragmenten; gebruik recall bij 10 om te bepalen met hoeveel fragmenten u kunt volstaan zonder antwoorden te verliezen. Houd het begin van de prompt byte-voor-byte stabiel, zodat de prompt-cache van een provider deze kan benutten, en plaats de opgehaalde fragmenten na dat stabiele gedeelte. Cache voltooide antwoorden ook per vraag, want de goedkoopste gegenereerde token is de token die u vorige week al heeft gegenereerd.
De server dimensioneren en wanneer dit niet meer volstaat
Elke dimensioneringsregel hier is gebaseerd op metingen in plaats van schattingen.
- RAM is de beperkende factor: de grootte van het resident model uit
ollama ps, plus de grootte van de HNSW-index, plusshared_buffers, met voldoende reserve voor verbindingen en de page cache. - Schijfruimte vereist tweemaal
pg_total_relation_size('chunks'), omdat het opnieuw opbouwen van een index beide kopieën tegelijkertijd in gebruik heeft. - CPU bepaalt de tijd voor het opnieuw indexeren, berekend op basis van uw gemeten seconden per chunk vermenigvuldigd met het aantal chunks.
- Opnieuw indexeren gebeurt vaker dan u verwacht, omdat het wijzigen van het embedding-model alle reeds opgeslagen vectoren ongeldig maakt.
Dit ontwerp volstaat niet meer op een punt dat u van tevoren kunt zien aankomen. Wanneer de HNSW-index niet langer in het beschikbare RAM past, verandert de query-latentie in schijf-seeks en is dit met geen enkele instelling meer te herstellen. Wanneer één tabel vele tenants bedient en elke query filtert op tenant, is het partitioneren van de tabel de oplossing, en dat is aanzienlijk werk. Wanneer indexeerschrijfacties en gebruikersquery's strijden om dezelfde server, verplaats dan de embedding-worker naar een tweede server voordat u de database verplaatst. Totdat een van deze situaties zich voordoet, is Postgres met pgvector op de VPS die u al huurt een geschikte oplossing voor productie, en de bovenstaande cijfers geven aan hoe ver de limiet verwijderd is.
FAQ
Kan ik een RAG-pipeline op één VPS draaien, of heb ik een vectordatabase nodig?
Eén VPS is voldoende voor corpora met honderdduizenden chunks. Bij 768 dimensies beslaan 100.000 chunks 294 MiB aan vectordata, plus de tekst en de HNSW-index; dit past in het werkgeheugen van een standaardabonnement. De beperking ligt bij het geheugen in plaats van het aantal rijen, omdat een HNSW-zoekopdracht door de index springt. De latentie neemt toe zodra de index niet meer in het RAM past. Vergelijk pg_relation_size op de index met free -m om te bepalen waar u staat.
Heb ik een GPU nodig om mijn documenten te embedden?
Niet als u eenmalig embedt en daarna alleen queries uitvoert. Een model met 137 miljoen parameters zoals nomic-embed-text draait op een CPU. Een volledige doorloop van een groot corpus duurt enkele uren, wat u 's nachts kunt laten draaien. Een GPU wordt relevant wanneer documenten continu binnenkomen, of wanneer u de generatie op dezelfde machine wilt uitvoeren. Meet de tijd van één batch tegen /api/embed op uw eigen server en vermenigvuldig dit met uw aantal chunks; het aantal vCPU's verschilt te sterk om een algemeen cijfer als referentie te gebruiken.
Waarom gebruikt mijn vector-query een sequentiële scan in plaats van de HNSW-index?
Lees het plan met EXPLAIN (ANALYZE, BUFFERS). De meest voorkomende oorzaak is opslag: pgvector merkt op dat de planner 'out-of-line' opslag niet meeneemt in de kostenramingen. Hierdoor lijkt een seriële scan goedkoper dan deze in werkelijkheid is. Een vector van 768 dimensies is 3,080 bytes groot en wordt daarom standaard in de TOAST-tabel opgeslagen. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; houdt nieuwe rijen inline. De andere twee oorzaken zijn een operator die niet overeenkomt met de index (een index gebouwd met vector_cosine_ops wordt alleen gebruikt door <=>) en een query zonder ORDER BY ... LIMIT, aangezien een benaderende index alleen dient voor geordende 'nearest neighbour'-queries.
Hoe weet ik of mijn retrieval goed presteert?
Stel een 'gold set' samen van 30 vragen, elk gekoppeld aan de id van de chunk die het antwoord bevat, en sla de embeddings van de vragen hierbij op. Meet vervolgens de 'recall at 10' (hoe vaak de juiste chunk in de top 10 verschijnt) en de MRR (die een hogere ranking beloont). Deze twee getallen vertellen u of een wijziging in chunk-grootte, embedding-model of 'rank fusion' heeft geholpen. Zonder deze cijfers past u instellingen aan op basis van uw indruk van een handvol antwoorden, wat onbetrouwbaar is.