12k
All articles

So implementieren Sie Vektorsuche in Postgres

Implementieren Sie Vektorsuche in Postgres mit pgvector: Embeddings speichern, per Kosinusdistanz abfragen, mit HNSW oder IVFFlat indexieren und mit Volltextsuche kombinieren.

OpenReplay Team
OpenReplay Team
So implementieren Sie Vektorsuche in Postgres

pgvector fügt Postgres einen Spaltentyp vector hinzu, und eine Ähnlichkeitsabfrage ist ein ORDER BY auf einem Distanzoperator, gefolgt von einem LIMIT.

Das Thema kommt üblicherweise auf, nachdem eine Suche im Help-Center für „cancel my subscription” nichts zurückliefert, weil der Artikel „Ending your plan” heißt – und daraufhin jemand vorschlägt, neben dem ohnehin schon betriebenen Postgres eine verwaltete Vektordatenbank aufzusetzen. Dieser zweite Dienst ist nur selten notwendig. Der Rest dieses Artikels zeigt den gesamten Weg in SQL: die Extension aktivieren, die Spalte passend zum Embedding-Modell dimensionieren, Vektoren speichern, per Cosinus-Distanz abfragen, mit HNSW oder IVFFlat indizieren, Vektorergebnisse gegen gewöhnliche Tabellen joinen – und schließlich die Abfragen, für die Vektorsuche das falsche Werkzeug ist und die stattdessen von der Postgres-Volltextsuche übernommen werden sollten.

Die wichtigsten Erkenntnisse

  • Die Zahl in vector(n) muss der Länge der Vektoren entsprechen, die Ihr Embedding-Modell erzeugt; Vektoren aus unterschiedlichen Modellen lassen sich nicht sinnvoll vergleichen.
  • Der Operator <=> liefert die Cosinus-Distanz, aufsteigendes Sortieren stellt also den nächstgelegenen Treffer nach vorn; für die Cosinus-Ähnlichkeit ziehen Sie den Wert von 1 ab.
  • HNSW und IVFFlat sind beide approximative Indizes; eine exakte Suche erhalten Sie genau dann, wenn auf der Spalte kein Vektorindex liegt.
  • Der Planner zieht einen Vektorindex nur in Betracht, wenn die Abfrage ein ORDER BY direkt auf einem Distanzoperator in aufsteigender Reihenfolge sowie ein LIMIT enthält.
  • Die Vektorsuche findet Zeilen mit derselben Bedeutung, die Volltextsuche findet Zeilen mit denselben Wörtern – produktive Suche führt in der Regel beides aus und fusioniert die Ranglisten.

Wofür wird Vektorsuche eingesetzt?

Vektorsuche gleicht Bedeutung ab: Eine Abfrage nach „cancel my subscription” kann also ein Dokument mit dem Titel „Ending your plan” zurückgeben, obwohl beide kein einziges Wort teilen. Jeder Text wird von einem Embedding-Modell in eine Zahlenliste fester Länge umgewandelt, wobei Texte mit ähnlicher Bedeutung in diesem Raum dicht beieinanderliegen. Suchen bedeutet, die gespeicherten Vektoren zu finden, die dem Abfragevektor am nächsten liegen. Hintergründe zur Funktionsweise von Embeddings finden Sie unter Vector Databases Explained.

Der Nutzen zeigt sich an drei Stellen: Site-Search, die Paraphrasen verkraftet, das Matching von Support-Tickets (frühere Tickets wie dieses finden) und Retrieval für RAG, wo die nächstgelegenen Dokumente einem Sprachmodell als Kontext übergeben werden – beschrieben in der Einführung zu RAG für Web-Apps.

pgvector aktivieren und eine Vektorspalte anlegen

pgvector wird einmal pro Datenbank mit CREATE EXTENSION vector aktiviert, der Spaltentyp lautet vector(n), wobei n die Anzahl der Dimensionen ist. Version 0.8.6 unterstützt Postgres 13 und neuer; da Postgres 13 den Community-Support hinter sich hat, ist 14 oder neuer die praktische Untergrenze.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id        bigserial PRIMARY KEY,
  title     text NOT NULL,
  body      text NOT NULL,
  embedding vector(1536)   -- replace 1536 with your embedding model's output size
);

-- or, on a table you already have:
ALTER TABLE documents ADD COLUMN embedding vector(1536);

Die Zahl in vector(n) muss der Länge der Vektoren entsprechen, die Ihr Embedding-Modell erzeugt. OpenAIs text-embedding-3-small liefert standardmäßig 1536-dimensionale Vektoren. Vektoren aus unterschiedlichen Modellen lassen sich nicht sinnvoll vergleichen – ein Modellwechsel bedeutet also, die gesamte Spalte neu zu embedden.

Embeddings aus beliebigen Modellen speichern

Embeddings werden wie jeder andere Spaltenwert geschrieben: Erzeugen Sie den Vektor im Anwendungscode und übergeben Sie ihn als Bind-Parameter mit Cast auf vector. pgvector ruft für Sie kein Modell auf, und jede Sprache mit einem Postgres-Treiber funktioniert.

INSERT INTO documents (title, body, embedding)
VALUES ($1, $2, $3::vector);

-- re-embed an existing row without creating a duplicate
INSERT INTO documents (id, title, body, embedding)
VALUES ($1, $2, $3, $4::vector)
ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding;

Für ein initiales Backfill empfiehlt pgvector, per COPY ... FROM STDIN WITH (FORMAT BINARY) in großen Mengen zu laden und die Indizes erst nach dem Laden der Daten anzulegen, nicht vorher.

Welchen pgvector-Distanzoperator sollten Sie verwenden?

Verwenden Sie <=> für Text-Embeddings, sofern die Dokumentation Ihres Modells nichts anderes vorgibt. Er liefert die Cosinus-Distanz, aufsteigendes Sortieren stellt also den nächstgelegenen Treffer nach vorn; für die Anzeige der Cosinus-Ähnlichkeit ziehen Sie das Ergebnis von 1 ab.

-- five closest documents; $1 is the query embedding
SELECT id, title, 1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 5;

pgvector unterstützt insgesamt sechs Distanzoperatoren, und jeder benötigt eine passende Index-Operatorklasse:

OperatorMisstEinsatz, wennOpclass
<=>Cosinus-DistanzText-Embeddings; der übliche Standardvector_cosine_ops
<->L2-Distanz (euklidisch)der Betrag Bedeutung trägtvector_l2_ops
<#>negatives inneres ProduktVektoren bereits auf Länge 1 normiert sindvector_ip_ops
<+>L1-Distanz (Manhattan)selten für Text; nur HNSWvector_l1_ops

<#> gibt das innere Produkt mit umgekehrtem Vorzeichen zurück. Postgres scannt Indizes ausschließlich in aufsteigender Reihenfolge, daher sorgt die negative Form dafür, dass die kleinste Zahl dem nächstgelegenen Treffer entspricht; multiplizieren Sie mit -1, um das reine innere Produkt zurückzuerhalten. Sind Ihre Vektoren bereits auf Länge 1 normiert, ist das innere Produkt die schnellste Option für die exakte Suche. <~> (Hamming) und <%> (Jaccard) existieren für Binärvektoren und sind hier nicht Thema.

HNSW oder IVFFlat – womit indizieren?

Ohne Index vergleicht pgvector den Abfragevektor mit jeder einzelnen Zeile und liefert die exakten nächsten Nachbarn. Ein HNSW- oder IVFFlat-Index macht daraus eine approximative Suche: deutlich schneller, findet die meisten der tatsächlichen Nachbarn und kann andere Zeilen zurückgeben als die exakte Abfrage. Beide Indextypen sind approximativ. Die exakte Suche ist schlicht das, was passiert, wenn auf der Spalte überhaupt kein Vektorindex liegt.

Nehmen Sie standardmäßig HNSW, sofern Build-Zeit oder Speicherbedarf Sie nicht zu IVFFlat zwingen. Die Opclass muss zu dem Operator passen, mit dem Sie abfragen.

-- production: avoid blocking writes
CREATE INDEX CONCURRENTLY documents_embedding_hnsw
  ON documents USING hnsw (embedding vector_cosine_ops);

-- IVFFlat alternative; create only after the table has data
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
HNSWIVFFlat
StrukturGraph mit mehreren EbenenVektoren in Listen einsortiert
Kompromiss Geschwindigkeit/Recallbesserschlechter
Build-Zeit und Speicherlangsamer, mehrschneller, weniger
Build auf leerer Tabelleja, kein Trainingsschrittnein; Zentroide stammen aus den zur Build-Zeit vorhandenen Daten
Stellschraube bei der Abfragehnsw.ef_search (Standard 40)ivfflat.probes (Standard 1; Startwert sqrt(lists))

HNSW hat keinen Trainingsschritt, der Index kann also gebaut werden, bevor auch nur eine einzige Zeile in der Tabelle liegt. IVFFlat hat einen: Seine Listen ergeben sich aus den Daten, die beim Anlegen des Index vorhanden sind – geben Sie ihm daher zuerst eine repräsentative Menge an Zeilen. ef_search bzw. probes mit SET LOCAL innerhalb einer Transaktion zu erhöhen, verbessert den Recall für eine einzelne Abfrage auf Kosten der Geschwindigkeit.

Der Planner zieht einen Vektorindex nur in Betracht, wenn die Abfrage ein ORDER BY direkt auf einem Distanzoperator mit aufsteigender Sortierung sowie ein LIMIT enthält; ORDER BY 1 - (embedding <=> $1) DESC nutzt ihn nicht. Bei einer kleinen Tabelle kann der Planner trotzdem einen Sequential Scan bevorzugen – prüfen Sie das mit EXPLAIN. Um zu messen, was der Index Sie an Recall gekostet hat, erzwingen Sie eine exakte Suche und vergleichen Sie die beiden Ergebnismengen:

BEGIN;
SET LOCAL enable_indexscan = off;  -- exact search for comparison
SELECT id FROM documents ORDER BY embedding <=> $1::vector LIMIT 5;
COMMIT;

Brauchen Sie eine separate Vektordatenbank?

Wenn Sie ohnehin Postgres betreiben, brauchen Sie vermutlich keine separate Vektordatenbank: pgvector liefert Ihnen Vektorsuche mit denselben Backups, denselben Transaktionen und der Möglichkeit, Vektorergebnisse in einer einzigen Abfrage gegen normale Tabellen zu joinen. Ein Dokument und sein Embedding werden in einem einzigen INSERT geschrieben – es gibt also keine Sync-Pipeline zwischen zwei Speichern und kein Fehlerbild verwaister Vektoren. Vektoren durchlaufen das Write-Ahead-Log wie jede andere Spalte, sodass Replikate und Point-in-Time-Restores sie ohne Zusatzaufwand mitnehmen.

Konkret wird das beim Join. Die nächstgelegenen Support-Tickets zu finden, beschränkt auf Enterprise-Kunden, ist ein einziges Statement:

SELECT t.id, t.subject, c.plan, t.embedding <=> $1::vector AS distance
FROM tickets t
JOIN customers c ON c.id = t.customer_id
WHERE c.plan = 'enterprise'
ORDER BY t.embedding <=> $1::vector
LIMIT 10;

Approximative Indizes haben einen Haken: Zuerst wird der Index gescannt, und anschließend wird die WHERE-Klausel auf das angewendet, was er zurückgeliefert hat – ein selektiver Filter kann Ihnen also weniger Zeilen übriglassen, als LIMIT angefordert hat. Ein B-Tree auf der Filterspalte liefert oft schnelle exakte Ergebnisse; iterative Scans und partielle Indizes decken den Rest ab.

Dedizierte Vektordatenbanken haben ihre Berechtigung weiterhin bei Milliarden von Vektoren oder extremen Schreibraten. Darunter ist der zusätzliche Dienst reiner Betriebsaufwand ohne für Nutzer sichtbaren Vorteil.

Wo Vektorsuche an ihre Grenzen stößt: Hybride Suche mit Volltext

Die Vektorsuche findet Zeilen mit derselben Bedeutung, die Volltextsuche findet Zeilen mit denselben Wörtern – produktive Suche führt in der Regel beides aus und fusioniert die beiden Ranglisten. Exakte Bezeichner, Produktcodes, Fehlerzeichenketten und Personennamen haben für ein Embedding-Modell keine brauchbare „Bedeutung”, und eine Abfrage nach SKU-4471 sollte die Zeile mit genau diesem Token zurückgeben, nicht Zeilen über ähnliche Produkte. Solche Abfragen verlangen nach der Postgres-Volltextsuche, die die pgvector-Dokumentation für hybride Abfragen mit der Vektorsuche kombiniert.

Beginnen Sie mit einer gespeicherten generierten tsvector-Spalte und einem GIN-Index darauf:

ALTER TABLE documents
  ADD COLUMN body_tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;

CREATE INDEX ON documents USING gin (body_tsv);

Kombinieren Sie anschließend die beiden Rankings per Reciprocal Rank Fusion, analog zum RRF-Beispiel von pgvector selbst:

-- $1 = query embedding, $2 = raw query text
WITH semantic AS (
  SELECT id, RANK() OVER (ORDER BY embedding <=> $1::vector) AS rnk
  FROM documents
  ORDER BY embedding <=> $1::vector
  LIMIT 20
),
lexical AS (
  SELECT id, RANK() OVER (ORDER BY ts_rank_cd(body_tsv, q) DESC) AS rnk
  FROM documents, plainto_tsquery('english', $2) AS q
  WHERE body_tsv @@ q
  ORDER BY ts_rank_cd(body_tsv, q) DESC
  LIMIT 20
)
SELECT COALESCE(s.id, l.id) AS id,
       COALESCE(1.0 / (60 + s.rnk), 0.0) + COALESCE(1.0 / (60 + l.rnk), 0.0) AS score
FROM semantic s
FULL OUTER JOIN lexical l ON l.id = s.id
ORDER BY score DESC
LIMIT 10;

Jede Liste steuert 1 / (k + rank) bei, sodass ein Dokument nahe der Spitze einer der beiden Listen gut abschneidet und eines, das in beiden vorkommt, am besten. Die Konstante k = 60 stammt von Cormack, Clarke und Büttcher, die sie in einer Pilotstudie festgelegt und berichtet haben, dass der exakte Wert nicht entscheidend ist. Betrachten Sie die Abfrage als Anschauungsbeispiel und bestätigen Sie mit EXPLAIN (ANALYZE, BUFFERS), dass auf Ihren Daten sowohl der HNSW- als auch der GIN-Index genutzt wird.

Womit Sie anfangen

Die gesamte Implementierung besteht aus einer Spalte, einem Operator und einem Index – alles innerhalb der Datenbank, die Sie ohnehin sichern und replizieren. Legen Sie die vector(n)-Spalte passend zu Ihrem Modell an, schreiben Sie die Abfrage ORDER BY ... <=> ... LIMIT zunächst ohne Index, fügen Sie dann HNSW hinzu und prüfen Sie den Recall-Unterschied mit enable_indexscan = off. Sobald die semantischen Ergebnisse stimmen, binden Sie die Volltextseite ein – andernfalls findet der erste Nutzer, der eine Bestellnummer einfügt, prompt die Lücke.

FAQs

Kann pgvector Embeddings mit mehr als 2.000 Dimensionen indizieren?

Mit dem Standard-Typ vector allein nicht. HNSW und IVFFlat indizieren Vektorspalten bis 2.000 Dimensionen, während die Spalte selbst bis zu 16.000 speichert. Für größere Embeddings casten Sie in einem Ausdrucksindex auf halvec (indizierbar bis 4.000 Dimensionen), nutzen binäre Quantisierung mit Re-Ranking (bis 64.000), indizieren einen Subvektor oder fordern vom Modell weniger Ausgabedimensionen an – was OpenAIs text-embedding-3-Modelle über einen dimensions-Parameter unterstützen.

Muss ich einen pgvector-Index nach dem Einfügen neuer Zeilen neu aufbauen?

Bei HNSW nicht: Neue Zeilen werden beim Einfügen in den Graphen aufgenommen – deshalb lässt sich der Index auch auf einer leeren Tabelle bauen. IVFFlat verhält sich anders. Seine Listenzentroide werden zur Build-Zeit einmalig per k-Means berechnet und ändern sich danach nie mehr, sodass der Recall abnehmen kann, wenn die Tabelle wächst oder sich ihre Verteilung verschiebt. Bauen Sie IVFFlat-Indizes nach großen Ladevorgängen oder Verteilungsänderungen mit REINDEX INDEX CONCURRENTLY neu auf.

Warum liefert eine Abfrage nach dem Hinzufügen eines HNSW-Index weniger Zeilen als LIMIT?

Weil ein HNSW-Scan höchstens hnsw.ef_search Kandidaten zurückgibt – standardmäßig 40 – und jeder WHERE-Filter erst danach auf diese Kandidaten angewendet wird. Ein LIMIT über 40, ein selektiver Filter oder tote Tupel können das Ergebnis allesamt zu kurz ausfallen lassen. Erhöhen Sie hnsw.ef_search per SET LOCAL, oder aktivieren Sie ab pgvector 0.8.0 iterative Index-Scans, indem Sie hnsw.iterative_scan auf strict_order setzen, sodass der Scan fortgesetzt wird, bis genügend Zeilen passen.

Kann ich Embeddings aus zwei verschiedenen Modellen in derselben Tabelle speichern?

Ja, aber nicht in einer gemeinsamen indizierten Spalte. Verwenden Sie pro Modell eine eigene vector(n)-Spalte, jeweils passend zur Ausgabegröße dieses Modells und jeweils mit eigenem Index. pgvector erlaubt auch eine untypisierte Vektorspalte mit gemischten Dimensionen, aber ein Index kann nur Zeilen einer einzigen Dimension abdecken – realisiert als Ausdrucksindex mit Cast plus partiellem WHERE auf dem Modellbezeichner. Distanzen über Modelle hinweg sind bedeutungslos.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.