Startseite / Artikel / Wir haben unsere Vektordatenbank abgeschafft: pgvector bewältigt 40 Mio. Embeddings problemlos

Wir haben unsere Vektordatenbank abgeschafft: pgvector bewältigt 40 Mio. Embeddings problemlos

Ein Migrationsbericht: ein kostenpflichtiger Vector Store wird auf 40 Millionen Zeilen durch pgvector ersetzt, mit Backfill-Methode, dem Recall-Fehler, der sich hinter den Dashboards versteckte, und wann man nicht wechseln sollte.

1132 Wörter

Was die zweite Datenbank wirklich gekostet hat

Die Rechnung bestand aus elf Rechnungen über rund viertausend Dollar im Monat, dazu ein Sync-Worker, der immer wieder jemanden nachts weckte. Ein zweiter Anbieter verwahrte eine Liste von Zahlen, die eigentlich neben Zeilen gehörte, die dem Unternehmen ohnehin schon gehörten. Diese Rechnung elfmal freizugeben war leicht. In einem Meeting laut auszusprechen, was sie bedeutete, erzeugte die Stille, die entsteht, wenn alle es längst wissen und keiner als Erster etwas sagen will.

Trotzdem war das Geld nicht der Hauptposten. Das war die Naht zwischen beiden Systemen. Zwei Datenbanken bedeuten zwei Quellen der Wahrheit, also einen Sync-Worker, also eine Dead Letter Queue, also eine Entwicklerin, die um 3 Uhr nachts auf dem Handy JSON liest. So sah die Architektur im echten Betrieb aus, nicht so, wie sie im Designdokument gezeichnet war:

app ──> postgres        (rows, permissions, deletes)
   │
   └────> vector store    (embeddings)
              ▲
              │
        sync worker ──> DLQ ──> me, awake, coffee going cold

Löschte ein Nutzer ein Dokument, entfernte Postgres die Zeile in derselben Transaktion. Der Vector Store entfernte seine Kopie, sobald der Worker hinterherkam, sodass gelöschte Inhalte etwa neunzig Sekunden lang noch auffindbar waren. Das ist kein Latenzproblem. Das ist ein Support-Ticket mit angehängter Rechtsprüfung.

Eine Tabelle, ein Index, kein Worker

Der Ersatz war eine einzige Tabelle mit einem einzigen Index und ohne Hintergrundprozess:

create extension vector;

create table docs (
  id bigserial primary key,
  team_id int not null,
  body text,
  emb vector(768)
);

Überzeugt hat das Team die Abfrage, denn sie ist einfaches SQL:

select id, body
from docs
where team_id = 42
order by emb <=> $1
limit 10;

Ein Embedding kann nicht von seiner Zeile abdriften, wenn es in dieser Zeile lebt. Eine ganze Kategorie von Bugs verschwand, und mit ihr das Runbook, das dafür geschrieben worden war.

40 Millionen Zeilen migrieren, während das Produkt weiterläuft

Migrationsberichte überspringen diese Phase meistens, obwohl sie die meiste Zeit gekostet hat. Der Backfill verwendete kein OFFSET. Tiefe Paginierung über vierzig Millionen Zeilen verkommt zu einem sequenziellen Scan, und der Job wird mit jedem Fortschritt langsamer, also das Gegenteil dessen, was ein langer Job braucht. Die Worker liefen den Primärschlüssel in Bereichen ab und speicherten als Checkpoint die zuletzt abgeschlossene id:

select id, body from docs
where emb is null and id > $1
order by id
limit 2000;

Jeder Batch umfasste zweitausend Zeilen, acht Worker liefen gleichzeitig, und eine bewusste Pause zwischen den Batches hielt die Replikationsverzögerung im Rahmen. Beobachtet wurde der Rückstand der Replikas, nicht der Durchsatz, denn die Replikas bedienten die ganze Zeit über Live-Traffic.

Der Index wurde zuletzt gebaut. Legt man HNSW vor dem Laden an, muss der Graph beim Eintreffen der Daten ständig neu geschrieben werden, und der Aufbau wird dann nie in einem erträglichen Zeitrahmen fertig.

set maintenance_work_mem = '16GB';
set max_parallel_maintenance_workers = 7;

create index concurrently on docs
using hnsw (emb_h halfvec_cosine_ops)
with (m = 16, ef_construction = 64);

Der Fehler, den kein Dashboard zeigte

Die Suche ging auf Postgres live, und die Dashboards sahen großartig aus: flache Latenz, null Fehler. Gefeiert hat niemand, weil scheinbar nichts passiert war. Vier Tage später meldete ein Kunde, dass ein Dokument, das er nachweislich hochgeladen hatte, nicht in den Ergebnissen auftauchte.

Die Ursache: hnsw.ef_search war nie gesetzt worden. Der Parameter stand auf seinem Standardwert, einer Kandidatenliste, die für einen Graphen dieser Größe zu schmal war, und der Recall war auf knapp über 90 % abgerutscht. Jedes Dashboard maß, ob die Abfrage zurückkam. Keines maß, ob sie das Richtige zurückgab.

Die Lösung war, den Wert auf 100 zu setzen und dann eine Reihe von Werten auszuprobieren, um herauszufinden, wo der Recall stagniert. Danach kam die Prüfung, die von Anfang an hätte da sein sollen: ein eingefrorener Satz Testabfragen mit jeweils bekannter richtiger Antwort und ein Alarm, wenn die Übereinstimmungsrate sinkt.

set hnsw.ef_search = 100;

Recall ist eine Einstellung, keine Eigenschaft der Datenbank. Wenn Sie aus all dem nur einen Satz mitnehmen, dann diesen.

Gemessene Ergebnisse

Der Test lief auf vierzig Millionen Zeilen mit 768 Dimensionen auf sechzehn virtuellen CPUs, Lesezugriffe bedienten die Replikas.

                        vector store     pgvector
  p50 latency              18 ms           24 ms
  p95 latency              41 ms           57 ms
  recall at 10             0.985           0.981
  filtered p95            190 ms           61 ms
  index size                 n/a           45 GB
  monthly cost            $4,100            $280

Ungefilterte Abfragen sind sechs Millisekunden langsamer. Innerhalb eines Modellaufrufs von neunhundert Millisekunden fällt dieser Unterschied nicht auf. Wichtiger ist der gefilterte Fall, denn dort liegt das eigentliche Argument. Suchen in einem echten Produkt werden nach Team, Berechtigung oder Datum gefiltert. Im alten Aufbau holte man eine größere Kandidatenmenge als nötig aus dem einen System und prüfte sie im anderen. Mit einer einzigen Datenbank kann der Planer Filter und Sortierreihenfolge in einem Zug abwägen und entscheiden, wie er sie ausführt.

Einwände, die Sie vermutlich gerade formulieren

HNSW kommt mit vielen Aktualisierungen schlecht zurecht. Das stimmt. Diese Dokumente werden einmal geschrieben und ständig gelesen, also bleibt der Graph stabil, aber wenn Sie Ihre Embeddings stündlich neu schreiben, sollten Sie gut darüber nachdenken.

Index-Builds sind teuer. Stimmt auch. Dieser dauerte etwas über eine Stunde, und weil er parallel lief, konnten die Schreibzugriffe durchgehend weiterlaufen.

Sehr selektive Filter können die Graphtraversierung stark beeinträchtigen. Wieder richtig, und der ehrliche Rat lautet, Ihre eigenen Filterformen zu testen, statt irgendeiner Tabelle zu vertrauen, auch dieser nicht.

Wann Sie bleiben sollten, wo Sie sind

Behalten Sie Ihren aktuellen Aufbau, wenn Sie bei über hundert Millionen Vektoren liegen und weiter wachsen, wenn Sie bei ungefilterter Suche Latenzen im einstelligen Millisekundenbereich brauchen oder wenn niemand im Team sich mit den Speichereinstellungen von Postgres befassen will. Dieser Weg tauscht eine Anbieterrechnung gegen Ihre eigene Aufmerksamkeit.

Für alle anderen bringt die Datenbank bereits eine Funktion mit, für deren Miete sie gerade jemanden bezahlen.

Was entfernt wurde

Zweitausendvierhundert Zeilen Synchronisationscode sind weg, dazu ein Dienst, ein Alarmkanal und ein Runbook. Die beste Infrastrukturentscheidung des vergangenen Jahres war eine Löschung. Lesen Sie Ihre eigene Rechnung. Sie wissen wahrscheinlich, was darauf steht.