RAG-Pipeline auf dem eigenen VPS aufbauen
Bauen Sie Chunking, Embeddings und Retrieval auf einem VPS: mit pgvector-Schema, HNSW-Größe, lokalem 137-Millionen-Parameter-Modell und SQL-Test.
Wie eine selbst gehostete RAG-Pipeline aufgebaut ist
Eine RAG-Pipeline (Retrieval-Augmented Generation) besteht aus fünf Phasen: Dokumente in Chunks aufteilen, die Chunks in Embeddings umwandeln, die Vektoren speichern, bei einer Frage die ähnlichsten Vektoren abrufen und diese Chunks an ein Sprachmodell senden, das die Antwort formuliert. Auf einem bereits gemieteten VPS laufen die ersten vier Phasen auf dem Server. PostgreSQL mit der Erweiterung pgvector speichert die Vektoren. Ein kleines, von Ollama bereitgestelltes Embedding-Modell wandelt Text in Vektoren um. Nur die letzte Phase muss den Server verlassen.
Diese Aufteilung ist die Grundlage dieses Leitfadens. Das Aufteilen in Chunks ist reine CPU-Arbeit. Das Embedding-Modell hat 137 Millionen Parameter und benötigt einige hundert Megabyte RAM. Der Speicherbedarf einer PostgreSQL-Tabelle lässt sich berechnen, bevor Sie die erste Zeile schreiben. Bei einem Korpus mit mehreren hunderttausend Chunks läuft all das auf einem gewöhnlichen VPS. Die Generierung ist anders, weil für jede Frage dauerhaft Kosten anfallen.
Welche Teile einer RAG-Pipeline verursachen tatsächlich Kosten
DigitalOceans End-to-End-RAG-Tutorial verwendet eine verwaltete Vektordatenbank und ein gehostetes Embedding-Modell. Der Kostenabschnitt bleibt qualitativ: Wiederholte Anfragen sollten zwischengespeichert werden, die Anzahl der abgerufenen Chunks sollte klein bleiben, und vor der Generierung sollte ein Reranking erfolgen. Diese Hinweise sind richtig. Nicht berücksichtigt wird jedoch die Option, die die Berechnung verändert: Sie können das Embedding-Modell auf dem Server ausführen, für den Sie ohnehin bereits bezahlen.
Zählen Sie Tokens statt Dollarbeträge. Tokenzahlen veralten nicht, wenn sich eine Preisliste ändert. Nehmen Sie ein Korpus mit 100,000 Chunks zu je 400 Tokens, 10,000 Fragen, 8 Chunks, die pro Antwort an das Modell gesendet werden, eine Frage mit einem 100 Token langen Frage- und Anweisungsblock sowie Antworten mit jeweils 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"
}
]Das Einbetten des gesamten Korpus umfasst 40 Millionen Tokens und erfolgt einmalig. Verteilt auf diese 10,000 Fragen sind das 4,000 Tokens pro Frage. Bei hunderttausend Fragen sinkt dieser Wert auf 400. Die Generierung sinkt nie. Für jede Frage, die Sie jemals beantworten, fallen 3,300 Tokens als Eingabe und 400 Tokens als Ausgabe an.
Die Kosten richten sich daher nach der Pipeline-Phase, die wiederholt ausgeführt wird. Betreiben Sie den Embedding-Schritt selbst, weil Sie ihn einmal bezahlen und der VPS ohnehin läuft. Kaufen Sie den Generierungsschritt ein, weil ein besseres Modell dort einen tatsächlichen Mehrwert bietet. Caching ist aus demselben Grund wichtig: Ein Cache-Treffer überspringt die einzige Phase, deren Kosten sich niemals amortisieren. Der Unterschied zwischen einem KV-Cache und einem Prompt-Cache bestimmt, welchen Teil Sie wiederverwenden können. Ein RAG-Prompt besteht aus einem stabilen Anweisungsblock, gefolgt von einem veränderlichen Chunk-Block. Diese Struktur profitiert am stärksten davon.
Chunking: Warum eine feste Größe mit Überlappung der richtige Standard ist
Ein Chunk ist die Einheit, die Sie abrufen. Seine Größe bestimmt daher alle nachgelagerten Schritte. Er muss klein genug sein, damit sein Embedding ungefähr einem Thema entspricht. Ein Embedding ist ein einzelner Punkt im Vektorraum. Ein Chunk, der vier Themen abdeckt, liegt daher zwischen diesen Themen und ist keinem davon besonders nah. Gleichzeitig muss er groß genug sein, um eine Frage selbstständig zu beantworten. Das Sprachmodell sieht den Chunk, nicht das umgebende Dokument.
Beginnen Sie mit 300 Wörtern und einer Überlappung von 50 Wörtern. Im Englischen entsprechen ungefähr 1.3 Token einem Wort. 300 Wörter sind daher etwa 400 Token. Die Überlappung ist erforderlich, weil ein Satz an einer Grenze sonst in zwei Teile geteilt wird. Keiner der beiden Teile beantwortet die Frage dann vollständig.
Teilen Sie zuerst anhand der Dokumentstruktur, sofern eine solche Struktur vorhanden ist. Teilen Sie an Überschriften und anschließend an Absätzen. Wenden Sie die Regel für die feste Größe nur innerhalb eines Abschnitts an, der weiterhin zu lang ist. Ein Chunk, der mitten in einem Satz beginnt, wirkt in der endgültigen Antwort unverständlich. Das Modell gibt wieder, was Sie ihm übergeben haben.
Optimieren Sie das Chunking erst, wenn Sie es messen können. Eine feste Größe mit Überlappung ist deterministisch und lässt sich kostengünstig erneut ausführen. Dadurch erhalten Sie eine Baseline, die Sie übertreffen können. Erstellen Sie zuerst die weiter unten beschriebene Bewertungsabfrage. Ändern Sie anschließend jeweils nur eine Sache.
Einbettung auf demselben Server sowie Kosten für RAM und Latenz
curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-textnomic-embed-text hat im August 2026 137 million parameters und einen Download von 274 MB. Prüfen Sie, was das Modell zurückgibt, bevor Sie darauf basierend eine Tabelle entwerfen.
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]))'Das gibt 768 aus. Der Spaltentyp muss genau zu diesem Wert passen.
Bei diesem Modell führen zwei Einstellungen häufig zu Problemen.
Der Aufgabenpräfix ist erforderlich. In der Model Card von Nomic steht, dass die Eingabe „must include a task instruction prefix“. Dokumente werden mit search_document: vorangestellt eingebettet, Fragen mit search_query: . Wenn Sie die Präfixe weglassen, schlägt nichts fehl: Sie erhalten Vektoren, die Retrieval-Qualität sinkt, und keine Logzeile erklärt Ihnen den Grund.
Lange Eingaben werden stillschweigend gekürzt. Der /api/embed-Endpoint verwendet ein Feld truncate. Der Standardwert ist true, und das von Ollama bereitgestellte Modell gibt einen 2K-Kontext an. Ein Chunk, der länger ist, wird am Limit abgeschnitten und trotzdem eingebettet. Sein Ende ist dann nicht durchsuchbar. Senden Sie beim Testen "truncate": false mit, damit ein zu großer Chunk fehlschlägt, statt unbemerkt verarbeitet zu werden.
Stellen Sie die Anfragen zu Batches zusammen, und halten Sie das Modell im Speicher.
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 akzeptiert eine Liste. Eine Anfrage mit 32 Chunks ist schneller als 32 einzelne Anfragen, weil der HTTP-Roundtrip und das Laden des Modells nur einmal statt 32-mal erfolgen. keep_alive steuert, wie lange das Modell nach einer Anfrage im Speicher bleibt. Der Standardwert beträgt 5 Minuten. Nach Ablauf dieser Zeit muss die nächste Anfrage die Ladezeit erneut abwarten.
Messen Sie die beiden relevanten Werte auf Ihrem eigenen Server. Sie hängen von der Anzahl Ihrer vCPUs ab. Daher wird keine veröffentlichte Zahl genau zu Ihrer Umgebung passen.
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 gibt die Größe des geladenen Modells im Arbeitsspeicher aus. Dieser RAM bleibt belegt, solange keep_alive das Modell im Speicher hält. Teilen Sie den Wert aus time durch die Batchgröße, um die Sekunden pro Chunk zu erhalten. Multiplizieren Sie diesen Wert mit der Anzahl der Chunks, um die einmaligen Kosten für die Indizierung zu berechnen. Bei einem reinen CPU-Betrieb müssen Sie für ein Corpus mit 100,000 Chunks eher mit Stunden als mit Minuten rechnen. Das ist unproblematisch, weil die Verarbeitung nur einmal erfolgt und über Nacht unter nice -n 19 laufen kann. Wenn Stunden nicht akzeptabel sind, lautet die eigentliche Frage, ob sich das Mieten einer GPU amortisiert. Das ist eine Break-even-Berechnung im Vergleich zu API-Tokens und keine Geschmacksfrage.
Wenn der Server bereits ein Chat-Modell bereitstellt, ist das Einbettungsmodell ein zweites residentes Modell. Der RAM-Bedarf addiert sich. Ollama auf einem VPS ausführen behandelt die Dimensionierung der Generierungsseite. Was ein selbst gehostetes Modell bei gleichzeitigen Benutzern leistet beschreibt, was geschieht, wenn mehrere Personen gleichzeitig Anfragen stellen. Das Einbettungsmodell ist klein genug, um neben beiden Modellen betrieben zu werden.
Das Indizierungsskript von Anfang bis Ende
Unter Ubuntu 24.04 wird ein einfaches pip install außerhalb einer virtuellen Umgebung mit error: externally-managed-environment beendet, weil die System-Python-Installation zu apt gehört.
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() stammt von Ihnen: Dabei kann es sich um beliebigen Code handeln, der Ihre Dateien oder Datensätze durchläuft und eine Dokument-ID sowie den zugehörigen Text liefert. Alles andere gehört zur Pipeline.
Speicher: das pgvector-Schema und sein Speicherbedarf
Ubuntu 24.04 liefert postgresql-16-pgvector in Version 0.6.0 aus. Diese Version ist älter als der Typ halfvec. Verwenden Sie für eine aktuelle Version das Repository des PostgreSQL-Projekts.
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-pgvectorDie Zahl im Paketnamen muss mit der Major-Version Ihres Servers übereinstimmen. Erstellen Sie anschließend die Rolle, die Datenbank und die 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) muss zum Ausgabeformat des Modells passen. Wenn Sie einen Vektor mit 1024 Dimensionen in diese Spalte einfügen, weist Postgres ihn mit expected 768 dimensions, not 1024 zurück. Das ist die eindeutigste Fehlermeldung in dieser gesamten Pipeline. Die generierte Spalte fts verursacht keinen zusätzlichen Pflegeaufwand und ermöglicht später eine Stichwortsuche.
Der Speicherbedarf lässt sich berechnen. pgvector dokumentiert einen vector mit 4 * dimensions + 8 Byte und einen halfvec mit 2 * dimensions + 8. Die unten aufgeführten Dimensionszahlen entsprechen jeweils der veröffentlichten Ausgabegröße des Modells.
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
}
]Bei 768 Dimensionen belegt jeder Vektor 3,080 Byte. 100,000 Chunks belegen damit 294 MiB an Vektordaten. Derselbe Korpus benötigt mit einem gehosteten Modell mit 1536 Dimensionen 587 MiB. Der darauf angelegte Index wächst proportional. Halbpräzision halbiert beides: halfvec(768) speichert diesen Korpus in 147 MiB. Ob dadurch Ihr Recall sinkt, beantwortet die unten stehende Scoring-Abfrage in einem Durchlauf.
Diese Werte beziehen sich ausschließlich auf die Vektorspalte. Text, Zeilen-Overhead und Indizes kommen hinzu. Messen Sie daher die tatsächliche Tabelle.
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;Wenn Sie lieber dieselbe Extension mit einer API und Benutzerkonten darum herum einsetzen möchten, ist ein selbst gehosteter Supabase-Stack PostgreSQL mit bereits aktivierter pgvector-Unterstützung. Jede Abfrage in diesem Leitfaden funktioniert dort unverändert.
Indexierung: Die relevanten HNSW-Einstellungen
Bei weniger als einigen tausend Zeilen sollten Sie auf den Index verzichten. Eine exakte Suche liest jede Zeile. Bei dieser Größe ist sie schnell genug und ihr Recall ist perfekt. Fügen Sie den Index hinzu, wenn der sequenzielle Scan nicht mehr schnell genug ist. Beachten Sie dabei den Zielkonflikt: Ein approximativer Index liefert ungefähr die richtigen Nachbarn.
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 und ef_construction = 64 sind die Standardwerte von pgvector. Höhere Werte verbessern den Recall, verlängern aber die Erstellungszeit und vergrößern den Index. Verwenden Sie vector_cosine_ops mit dem Operator <=>, sofern Sie nicht wissen, dass Ihr Modell Vektoren mit Einheitslänge erzeugt. Die Kosinusdistanz ignoriert die Vektorlänge, das innere Produkt dagegen nicht.
Überwachen Sie die Erstellung. Wenn der Graph über maintenance_work_mem hinauswächst, meldet pgvector dies:
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL: Building will take significantly more time.Das ist kein Fehler. Die Erstellung wird trotzdem abgeschlossen, fällt aber auf einen deutlich langsameren Pfad zurück. Erhöhen Sie maintenance_work_mem in der Session, die den Index erstellt, und lassen Sie den Serverstandard unverändert. Diese Einstellung gilt pro Wartungsvorgang. Ein hoher globaler Wert kann dazu führen, dass dem Server der Arbeitsspeicher ausgeht. Überwachen Sie eine lange Erstellung aus einer zweiten Session.
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;Vergleichen Sie anschließend den fertigen Index mit dem Arbeitsspeicher des Servers.
SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;Eine HNSW-Suche durchläuft einen Graphen. Deshalb greift sie auf über den Index verteilte Seiten zu, statt einen zusammenhängenden Bereich zu lesen. Passt ein Index nicht in den Arbeitsspeicher, wird jede Abfrage zu einem Festplattenzugriff. Die langsamen Ausreißer bei der Antwortzeit nehmen Benutzer wahr. Daraus ergibt sich die zentrale Dimensionierungsregel für den Server: Der Index und die tatsächlich bereitgestellten Zeilen sollten in den Arbeitsspeicher passen. free -m und die oben angegebene Größe sind die beiden zu vergleichenden Werte.
Zur Abfragezeit steuert hnsw.ef_search den Recall. Der Standardwert ist 40.
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;Ein höherer Wert durchsucht einen größeren Teil des Graphen, findet bessere Nachbarn und erhöht die Latenz. Die Einstellung gilt für die Session. Sie können sie daher für eine einzelne Abfrage erhöhen, ohne den Index zu ändern.
Wenn eine Abfrage den Index überhaupt nicht verwendet, ist dies im Ausführungsplan zu sehen.
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;Ein sequenzieller Scan liegt hier häufig am Speicherlayout. Ein Vektor mit 768 Dimensionen belegt 3,080 Bytes. Das ist mehr, als Postgres inline speichert. Der Wert wird deshalb in die TOAST-Tabelle verschoben, den ausgelagerten Speicher für zu große Werte. Laut dem Hinweis von pgvector berücksichtigt der Planner ausgelagerten Speicher nicht in seinen Kostenschätzungen. Dadurch kann ein sequenzieller Scan günstiger erscheinen, als er tatsächlich ist. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; hält Vektoren inline. Die Einstellung gilt für Zeilen, die nach der Änderung geschrieben werden. Für vorhandene Zeilen ist daher ein Rewrite der Tabelle erforderlich.
Retrieval: eine Abfrage, zwei Signale
Die Vektorsuche findet Text mit derselben Bedeutung wie die Frage. Bei exakten Zeichenfolgen ist sie schwach: bei einer Teilenummer, einem Fehlercode oder einem Nachnamen. Die Stichwortsuche verhält sich umgekehrt, und Postgres unterstützt sie bereits. Kombinieren Sie beide Verfahren in einer Abfrage, statt ein zweites System auszuführen.
Reciprocal rank fusion ist der einfachste funktionierende Kombinierer. Jedes Ergebnis erhält 1 / (60 + rank) aus jeder Liste, in der es vorkommt, und die beiden Werte werden addiert. Eine Score-Normalisierung ist nicht erforderlich, weil Positionen statt Distanzen ausgewertet werden.
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 ist das Embedding der Frage aus demselben Modell, erstellt mit dem Präfix search_query: . $2 ist die Frage als Text. Beide Werte werden aus Ihrer Anwendung gebunden. websearch_to_tsquery akzeptiert eine echte Benutzerfrage, ohne sich an Satzzeichen zu stören, was bei to_tsquery nicht der Fall ist. Außerdem wichtig: Wenn Sie zusätzlich zu einem HNSW-Scan einen WHERE-Filter verwenden, können weniger Zeilen zurückgegeben werden als angefordert, weil zuerst der Index durchsucht wird und der Filter anschließend greift. SET hnsw.iterative_scan = relaxed_order; sorgt dafür, dass pgvector den Scan fortsetzt, bis genügend Zeilen gefunden wurden.
Woran erkennen Sie, ob der Abruf gute Ergebnisse liefert?
Dies ist der Schritt, den fast jeder RAG-Leitfaden überspringt. Nur mit diesem Schritt erkennen Sie, ob die anderen Entscheidungen geholfen haben. Dafür benötigen Sie kein Evaluierungs-Framework. Sie benötigen 30 Fragen und die ID des Chunks, der jeweils die Antwort enthält.
Erstellen Sie die Fragen manuell. Verwenden Sie Fragen, die zu diesem Korpus tatsächlich gestellt werden. Führen Sie jede Frage aus, lesen Sie das Ergebnis und notieren Sie die ID des Chunks, der an erster Stelle hätte stehen sollen. Mit 30 Fragen lassen sich kleine Unterschiede nicht zuverlässig bewerten. Die relevanten Unterschiede werden jedoch sichtbar, weil sie deutlich ausfallen.
CREATE TABLE gold (
id bigserial PRIMARY KEY,
question text NOT NULL,
chunk_id bigint NOT NULL REFERENCES chunks(id),
embedding vector(768) NOT NULL
);Erzeugen Sie für jede Frage mit dem Präfix search_query: ein Embedding, speichern Sie es und bewerten Sie anschließend den gesamten Datensatz mit einer Abfrage.
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 geteilt durch questions ist Recall at 10. Dieser Wert gibt an, wie häufig sich die Antwort innerhalb des Fensters befand, das Sie an das Modell senden. MRR (Mean Reciprocal Rank) bildet den Durchschnitt aus 1 geteilt durch die Position des richtigen Chunks. Ein Fehlschlag zählt als 0. Dadurch wird ein Ranking belohnt, bei dem die Antwort an erster statt an achter Stelle steht. Beide Werte ändern sich, wenn Sie die Chunk-Größe ändern, das Embedding-Modell austauschen oder die Stichwortsuche ergänzen. Jetzt sehen Sie, in welche Richtung sie sich verändert haben.
Priorisieren Sie Recall at 10, weil der Generator keinen Chunk verwenden kann, den er nicht erhalten hat. Wenn Recall at 10 bei 0.9 liegt und die Antworten trotzdem falsch sind, liegt die Ursache im Prompt oder im Modell, nicht im Abruf. Diese Aufteilung erspart Ihnen tagelanges Raten.
Prüfen Sie den Index separat. Die approximative Suche verringert den Recall. pgvector zeigt Ihnen das Ausmaß: Führen Sie dieselbe Abfrage mit einer exakten Suche aus und vergleichen Sie die IDs.
BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;Neun von zehn übereinstimmenden IDs bedeuten, dass ef_search in Ordnung ist. Bei vier von zehn sollten Sie den Wert erhöhen.
Reranking und Generierung: Wo sich eine API bezahlt macht
Ein Reranker ist ein anderes Modell. Er verarbeitet die Frage und einen Chunk gemeinsam und bewertet dieses Paar. Das liefert bessere Ergebnisse als der Vergleich von zwei unabhängig berechneten Embeddings. Für ein gesamtes Korpus ist er jedoch viel zu langsam. Genau deshalb wird er an dieser Stelle eingesetzt. Er verarbeitet die 40 von der Suche zurückgegebenen Kandidaten und nicht die 100,000 Chunks in der Tabelle. Eine gehostete Reranking-API berechnet daher pro Frage nur 40 kurze Paare und entfernt die schlechtesten False Positives, bevor sie die teure Verarbeitungsstufe erreichen.
Die Generierung verursacht die laufenden Kosten. Zwei Stellschrauben beeinflussen sie. Senden Sie weniger Chunks. Verwenden Sie Recall at 10, um zu ermitteln, wie wenige Chunks Sie senden können, ohne Antworten zu verlieren. Halten Sie den Anfang des Prompts Byte für Byte unverändert, damit der Prompt-Cache eines Providers greifen kann. Platzieren Sie die abgerufenen Chunks hinter diesem unveränderten Teil. Cachen Sie fertige Antworten außerdem nach Frage. Das günstigste generierte Token ist das, das Sie bereits letzte Woche generiert haben.
Die Dimensionierung des Servers und wann das nicht mehr ausreicht
Jede Dimensionierungsregel hier basiert auf Messwerten und nicht auf Schätzungen.
- RAM ist der begrenzende Faktor: die residente Modellgröße aus
ollama psplus die Größe des HNSW-Index undshared_buffers, wobei zusätzlicher Speicher für Verbindungen und den Page Cache frei bleiben muss. - Für den Datenträger wird die doppelte Größe von
pg_total_relation_size('chunks')benötigt, weil beim Neuaufbau eines Index beide Kopien gleichzeitig vorhanden sind. - Die CPU bestimmt die Dauer der Neuindizierung. Maßgeblich sind die von Ihnen gemessenen Sekunden pro Chunk multipliziert mit der Anzahl der Chunks.
- Neuindizierungen finden häufiger statt als erwartet, weil eine Änderung des Embedding-Modells jeden bereits gespeicherten Vektor ungültig macht.
Dieses Design reicht ab einem absehbaren Punkt nicht mehr aus. Wenn der HNSW-Index nicht mehr in den verfügbaren RAM passt, wird die Abfragelatenz durch Zugriffe auf den Datenträger bestimmt, und keine Einstellung kann das beheben. Wenn eine Tabelle viele Mandanten bedient und jede Abfrage nach dem Mandanten filtert, wird die Partitionierung der Tabelle zur notwendigen Lösung. Das ist mit erheblichem Aufwand verbunden. Wenn sich Schreibzugriffe der Indizierung und Benutzerabfragen auf demselben Server gegenseitig beeinträchtigen, verschieben Sie den Embedding-Worker auf einen zweiten Server, bevor Sie die Datenbank verschieben. Solange keiner dieser Fälle eintritt, ist Postgres mit pgvector auf dem VPS, den Sie bereits mieten, für den Produktivbetrieb geeignet. Die obigen Zahlen zeigen, wie weit die Grenze noch entfernt ist.
FAQ
Kann ich eine RAG-Pipeline auf einem VPS ausführen, oder benötige ich eine Vektordatenbank?
Ein VPS reicht für Korpora mit mehreren hunderttausend Chunks aus. Bei 768 Dimensionen belegen 100,000 Chunks 294 MiB an Vektordaten, zusätzlich zum Text und zum HNSW-Index. Das passt in den Arbeitsspeicher eines gewöhnlichen Tarifs. Der begrenzende Faktor ist der Arbeitsspeicher und nicht die Anzahl der Zeilen, weil eine HNSW-Suche im Index an verschiedene Stellen springt. Die Latenz verschlechtert sich, sobald der Index nicht mehr in den Arbeitsspeicher passt. Vergleichen Sie pg_relation_size für den Index mit free -m. Dann wissen Sie, wo Sie stehen.
Benötige ich eine GPU, um meine Dokumente zu vektorisieren?
Nein, wenn Sie die Dokumente einmal vektorisieren und anschließend Abfragen ausführen. Ein Modell mit 137 Millionen Parametern wie nomic-embed-text läuft auf der CPU. Ein vollständiger Durchlauf über ein großes Korpus dauert mehrere Stunden und kann über Nacht ausgeführt werden. Eine GPU wird relevant, wenn kontinuierlich Dokumente hinzukommen oder wenn Sie die Generierung auf demselben Server ausführen möchten. Messen Sie auf Ihrem eigenen Server die Zeit für einen Batch mit /api/embed und multiplizieren Sie sie mit der Anzahl Ihrer Chunks. Die Anzahl der vCPUs unterscheidet sich zu stark, als dass eine veröffentlichte Vergleichszahl hilfreich wäre.
Warum verwendet meine Vektorabfrage einen sequentiellen Scan statt des HNSW-Index?
Lesen Sie den Ausführungsplan mit EXPLAIN (ANALYZE, BUFFERS). Die häufigste Ursache ist die Speicherung: pgvector weist darauf hin, dass der Planner Out-of-Line-Speicher in seinen Kostenschätzungen nicht berücksichtigt. Dadurch erscheint ein sequentieller Scan günstiger, als er tatsächlich ist. Ein Vektor mit 768 Dimensionen belegt 3,080 Bytes und wird daher standardmäßig in der TOAST-Tabelle gespeichert. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; hält neue Zeilen inline. Die beiden anderen Ursachen sind ein Operator, der nicht zum Index passt, und eine Abfrage ohne ORDER BY ... LIMIT. Ein mit vector_cosine_ops erstellter Index wird nur von <=> verwendet. Ein approximativer Index verarbeitet nur Abfragen nach dem Muster einer sortierten Suche nach den nächsten Nachbarn.
Woher weiß ich, ob meine Suche gute Ergebnisse liefert?
Erstellen Sie einen Gold-Datensatz mit 30 Fragen. Verknüpfen Sie jede Frage mit der ID des Chunks, der sie beantwortet, und speichern Sie die Embeddings der Fragen zusammen mit den Fragen. Messen Sie anschließend den Recall@10. Dieser Wert gibt an, wie häufig der richtige Chunk in den Top 10 erscheint. Messen Sie außerdem den MRR. Dieser Wert belohnt, wenn der richtige Chunk an erster Stelle steht. Diese beiden Zahlen zeigen, ob eine Änderung der Chunk-Größe, des Embedding-Modells oder der Rank-Fusion geholfen hat. Ohne diese Kennzahlen ändern Sie Einstellungen und verlassen sich auf Ihren Eindruck von einigen wenigen Antworten.