pgvector oder Qdrant: Wann PostgreSQL als Vektordatenbank reicht

RAG-Tutorial mit Quarkus, LangChain4j und pgvector, dazu ein Benchmark mit einer Million Vektoren: halfvec, gefilterte Suche, Durchsatz und wo Qdrant vorne liegt.

Von 18 Min. Lesezeit

Die Wissensbasis von Singularity, meinem Astronomie-Assistenten, liegt in Qdrant. Qdrant läuft dort als lokaler Container neben Phoenix, gefüllt von einem Python-Ingest. Beim nächsten Projekt stellte sich die Frage neu. Dort liegen Konten, Profile und Pläne ohnehin in PostgreSQL, und die Wissensbasis umfasst ein paar hundert Abschnitte aus Wikipedia-Artikeln. Ein zweiter Datenbankdienst hätte bedeutet: eigener Container, eigene Sicherung und ein Ingest, der in zwei Systeme schreibt, ohne dass eine gemeinsame Transaktion beide zusammenhält.

Ich habe mich für pgvector entschieden und wollte danach wissen, wie weit diese Entscheidung trägt. Dieser Artikel hat deshalb zwei Teile. Der erste baut die Retrieval-Schicht einer RAG-Anwendung mit Quarkus, LangChain4j und pgvector auf und misst, was bei einem kleinen Korpus tatsächlich zählt. Der zweite lädt eine Million OpenAI-Embeddings in pgvector und in Qdrant und vergleicht Indexbau, Recall, Latenz, Durchsatz und gefilterte Suche unter gleichen Bedingungen. Code, Korpus, Messprogramm und alle Rohdaten liegen im Repository pgvector-vs-qdrant.

Verwendet habe ich Java 25, Quarkus 3.40.1 (die aktuelle LTS-Linie), PostgreSQL 18.6 mit pgvector 0.8.7 und Qdrant 1.19.1, alles auf einem Mac mit M5 Pro und 48 GB RAM, die Datenbanken in Docker Desktop mit 24 GB Speicher.

Was pgvector mitbringt

pgvector ist eine Erweiterung für PostgreSQL. Sie bringt Datentypen für Vektoren, Distanzoperatoren und zwei Indexarten für die approximative Suche mit. Für Embeddings sind vor allem zwei Typen relevant: vector speichert jede Komponente als 32-Bit-Float, halfvec als 16-Bit-Float. Dazu kommen sparsevec für dünn besetzte Vektoren und bit für binäre.

Gesucht wird mit Operatoren: <-> für die euklidische Distanz, <=> für die Kosinus-Distanz und <#> für das negative Skalarprodukt. Negativ deshalb, weil PostgreSQL Indizes nur aufsteigend sortiert durchlaufen kann. Eine Ähnlichkeitssuche ist damit gewöhnliches SQL:

SELECT id, title FROM chunk ORDER BY embedding <=> $1 LIMIT 5;

Ohne Index rechnet PostgreSQL die Distanz zu jeder Zeile aus und sortiert. Das ist exakt und bei kleinen Tabellen schnell genug. Für grössere Tabellen gibt es IVFFlat und HNSW. HNSW ist heute der Standard: ein Graph, in dem die Suche von Nachbar zu Nachbar springt, bis sie die Umgebung des Suchvektors erreicht hat. Er ist approximativ, findet also nicht garantiert die exakt nächsten Nachbarn. Wie gründlich gesucht wird, steuert hnsw.ef_search (Standard 40). Die Struktur des Graphen bestimmen beim Bau m (Standard 16) und ef_construction (Standard 64).

Eine Grenze, die man früh kennen sollte: Ein Index über vector ist auf 2000 Dimensionen beschränkt, über halfvec auf 4000. Viele aktuelle Embedding-Modelle liefern mehr als 2000 Dimensionen. Qwen3-Embedding-4B zum Beispiel, das ich lokal über Ollama laufen habe, erzeugt 2560. Für solche Modelle bleibt nur halfvec oder das Kürzen der Vektoren, sofern das Modell dafür trainiert ist.

Teil 1: Retrieval mit Quarkus und pgvector

Der Korpus für das Tutorial besteht aus 42 Artikeln der englischen Wikipedia zu Astrofotografie, Filtern, Teleskopen und Objektklassen, jeweils in fester Revision, und sechs eigenen Kurztexten. Zusammen sind das 48 Dokumente mit rund 98 500 Wörtern. Die Fragen dazu sind deutsch: 20 Fragen mit annotierten relevanten Abschnitten, etwa „Wofür brauche ich einen OIII-Filter?“, und drei Fragen, auf die der Korpus keine Antwort hat. Das Embedding-Modell muss also zusätzlich die Sprachgrenze überbrücken.

Datenbank und Schema

Für Entwicklung und Tests startet Quarkus über Dev Services selbst einen PostgreSQL-Container. Man muss ihm nur sagen, welches Image er nehmen soll:

quarkus.datasource.db-kind=postgresql
quarkus.datasource.devservices.image-name=pgvector/pgvector:pg18@sha256:2358fcba…
quarkus.flyway.migrate-at-start=true

Das Image ist per Digest fixiert, damit Tests nicht still auf eine neue pgvector-Version wechseln. Das Schema legt Flyway an:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE document (
    id      text PRIMARY KEY,
    title   text NOT NULL,
    topic   text NOT NULL,
    source  text NOT NULL,
    version text NOT NULL
);

CREATE TABLE chunk (
    document_id text        NOT NULL REFERENCES document (id) ON DELETE CASCADE,
    position    integer     NOT NULL,
    section     text        NOT NULL,
    text        text        NOT NULL,
    embedding   vector(384) NOT NULL,
    tsv         tsvector GENERATED ALWAYS AS (to_tsvector('simple', text)) STORED,
    PRIMARY KEY (document_id, position)
);

CREATE INDEX chunk_tsv_idx ON chunk USING gin (tsv);

Dass Chunks per Fremdschlüssel an ihrem Dokument hängen, ist der Punkt, an dem sich pgvector von einer separaten Vektordatenbank unterscheidet. Löscht man ein Dokument, verschwinden seine Chunks in derselben Transaktion. Die Spalte tsv ist für die Hybridsuche weiter unten. Einen HNSW-Index gibt es bewusst nicht, warum, zeigt die Messung.

Embeddings im Prozess

Als Modell dient multilingual-e5-small, quantisiert als ONNX-Export (118 MB, 384 Dimensionen). LangChain4j kann ONNX-Modelle direkt in der JVM ausführen. Es braucht also weder Ollama noch einen API-Schlüssel, und die Frage eines Nutzers verlässt den Prozess nicht:

@ApplicationScoped
public class Embedder {

    private final EmbeddingModel model;

    public Embedder(@ConfigProperty(name = "rag.model-dir") Path modelDir) {
        this.model = new OnnxEmbeddingModel(modelDir.resolve("model_quantized.onnx"),
                modelDir.resolve("tokenizer.json"), PoolingMode.MEAN);
    }

    public float[] query(String text) {
        return model.embed("query: " + text).content().vector();
    }

    public List<float[]> passages(List<String> texts) {
        List<TextSegment> segments = texts.stream()
                .map(text -> TextSegment.from("passage: " + text)).toList();
        return model.embedAll(segments).content().stream().map(Embedding::vector).toList();
    }
}

Die Präfixe query: und passage: sind keine Kosmetik. Die e5-Modelle wurden mit ihnen trainiert, und ohne sie sinkt die Qualität des Rankings. Eine Frage einzubetten dauerte auf dem Mac im Median 2,1 ms. Der Gradle-Build lädt das Modell aus einer festen Revision auf Hugging Face und prüft die SHA-256-Summe.

Zerlegt werden die Dokumente entlang ihrer Überschriften. Jeder Abschnitt wird zu einem oder mehreren Chunks mit höchstens 250 Wörtern und 40 Wörtern Überlappung. Die erste Zeile jedes Chunks nennt Dokument und Abschnitt, damit ein Chunk auch allein noch sagt, worum es geht. Daraus entstehen 643 Chunks.

Ingest in einer Transaktion

Ein Dokument mit allen Chunks zu ersetzen ist eine einzige Methode:

@Transactional
public void replace(Manifest.Entry entry, List<Chunker.Chunk> chunks, List<float[]> embeddings) {
    try (Connection c = dataSource.getConnection()) {
        // INSERT … ON CONFLICT (id) DO UPDATE auf document, dann:
        try (PreparedStatement ps = c.prepareStatement("DELETE FROM chunk WHERE document_id = ?")) {
            ps.setString(1, entry.id());
            ps.executeUpdate();
        }
        try (PreparedStatement ps = c.prepareStatement(
                "INSERT INTO chunk (document_id, position, section, text, embedding) VALUES (?, ?, ?, ?, ?)")) {
            for (int i = 0; i < chunks.size(); i++) {
                ps.setString(1, entry.id());
                ps.setInt(2, chunks.get(i).position());
                ps.setString(3, chunks.get(i).section());
                ps.setString(4, chunks.get(i).text());
                ps.setObject(5, new PGvector(embeddings.get(i)));
                ps.addBatch();
            }
            ps.executeBatch();
        }
    }
}

Ein Leser sieht entweder die alte oder die neue Fassung eines Dokuments, nie eine Mischung. Bricht der Ingest ab, bleibt die alte Fassung stehen. Mit Qdrant neben PostgreSQL müsste man dafür selbst sorgen, etwa mit Versionsnummern in der Payload und einem Aufräumlauf. Der komplette Ingest des Korpus (Chunking, Embedding, Schreiben) dauerte 10 Sekunden.

PGvector stammt aus der Bibliothek com.pgvector:pgvector und sorgt dafür, dass der Vektor als Parameter vom Typ vector gebunden wird statt als String.

Suche

Die Vektorsuche ist eine parametrisierte Abfrage mit optionalem Filter auf das Thema:

SELECT c.document_id, d.title, c.section, c.text, c.embedding <=> ? AS score
FROM chunk c JOIN document d ON d.id = c.document_id
WHERE d.topic = ?
ORDER BY score
LIMIT ?

Gemessen habe ich mit zwei Kennzahlen. Die Hit Rate@5 gibt an, bei wie vielen der 20 Fragen mindestens ein relevanter Abschnitt unter den ersten fünf Treffern ist. Der MRR@5 (Mean Reciprocal Rank) bewertet zusätzlich, wie weit oben der erste relevante Treffer steht: Rang 1 zählt 1, Rang 2 zählt 0,5 und so weiter.

SucheHit Rate@5MRR@5Latenz p50 / p95
Vektor (Kosinus), exakt0,950,900,92 / 1,17 ms
Hybrid (Vektor + Volltext, RRF)0,900,772,13 / 2,94 ms

Die reine Vektorsuche findet bei 19 von 20 Fragen eine relevante Stelle, bei 17 davon auf Rang 1. Die eine Lücke ist lehrreich: „Was ist Seeing und wie wirkt es sich auf meine Bilder aus?“ landet bei Texten über Bildrauschen. Das Lehnwort „Seeing“ trägt im Satz wenig Gewicht, „Bilder“ dafür viel.

Hybridsuche hat hier geschadet

Hybridsuche gilt als Standardempfehlung für RAG: Vektorsuche für die Bedeutung, Volltextsuche für exakte Begriffe wie „OIII“ oder „Bortle“, beide Listen zusammengeführt. In PostgreSQL ist das eine einzige Abfrage. Beide Suchen liefern je 50 Kandidaten, die Reciprocal Rank Fusion (RRF) addiert für jeden Treffer 1 / (60 + Rang) aus beiden Listen:

WITH vec AS (
    SELECT document_id, position, row_number() OVER (ORDER BY embedding <=> ?) AS rank
    FROM chunk ORDER BY embedding <=> ? LIMIT 50
), txt AS (
    SELECT c.document_id, c.position, row_number() OVER (ORDER BY ts_rank_cd(c.tsv, q) DESC) AS rank
    FROM chunk c, websearch_to_tsquery('simple', ?) q
    WHERE c.tsv @@ q
    ORDER BY ts_rank_cd(c.tsv, q) DESC LIMIT 50
), fused AS (
    SELECT document_id, position, sum(1.0 / (60 + rank)) AS score
    FROM (SELECT * FROM vec UNION ALL SELECT * FROM txt) r
    GROUP BY document_id, position
)
SELECT f.document_id, c.section, c.text, f.score
FROM fused f JOIN chunk c USING (document_id, position)
ORDER BY f.score DESC LIMIT 5;

Das Ergebnis war schlechter, nicht besser. Der MRR fiel von 0,90 auf 0,77, zwei Fragen verloren ihren Treffer ganz. Geholfen hat die Hybridsuche bei zwei Fragen: Bei der Seeing-Frage und beim H-Alpha-Filter stand danach der richtige Abschnitt auf Rang 1. Die Ursache der Verschlechterung zeigt die Volltextliste. Die Frage wird zu einer ODER-Suche über ihre Wörter, bei „Was ist ein Apochromat?“ also was or ist or ein or apochromat. Die drei besten Volltexttreffer enthalten von diesen Wörtern nur eines: „was“, die englische Vergangenheitsform. Bei „Was ist ein planetarischer Nebel?“ und der Frage nach Breit- und Schmalbandfiltern sieht die Liste genauso aus. RRF mischt diese Abschnitte zwischen die guten Vektortreffer. Eine deutsche Textsuchkonfiguration mit Stoppwörtern hilft dagegen nicht, weil die Texte englisch sind.

Mit Fragen in der Sprache des Korpus sähe das vermutlich anders aus. Die Messung zeigt aber, dass Hybridsuche keine Verbesserung ist, die man ungeprüft einbaut. Ohne ein Fragen-Set mit annotierten Antworten hätte ich die Verschlechterung nicht bemerkt.

Braucht man bei 643 Chunks einen Index?

Zum Vergleich habe ich einen HNSW-Index mit Standardparametern angelegt. Er war in 60 ms gebaut und 1,3 MB gross.

Suchep50p95
exakt (Sequential Scan)0,86 ms1,01 ms
HNSW0,36 ms0,40 ms

HNSW spart eine halbe Millisekunde und liefert bei 22 von 23 Fragen dieselben fünf Treffer in derselben Reihenfolge. Bei einer Frage weicht das Ergebnis ab. Für eine RAG-Anwendung, in der danach ein Sprachmodell mehrere Sekunden lang eine Antwort schreibt, ist die halbe Millisekunde bedeutungslos. Die exakte Suche ist bei dieser Grösse die bessere Wahl. Wächst der Korpus, lässt sich der Index später per Migration ergänzen, ohne dass sich die Abfrage ändert.

Ein Schwellwert für „keine Quelle“ funktioniert nur knapp

Ein RAG-System sollte merken, wenn der Korpus eine Frage nicht beantworten kann. Naheliegend ist ein Schwellwert auf die Distanz des besten Treffers. Bei den Fragen mit Antwort lag sie zwischen 0,118 und 0,205, bei den drei Fragen ohne Antwort („Wie alt ist das Universum?“) bei 0,203, 0,215 und 0,229. Die Bereiche überlappen. e5 bildet alle Texte in einen engen Ähnlichkeitsbereich ab, ein fester Schwellwert trennt deshalb nicht sauber. Er taugt als Leitplanke, die Entscheidung muss am Ende das Modell anhand der gelieferten Texte treffen.

Den Vektor typisiert binden

ps.setObject(5, new PGvector(…)) bindet den Vektor als Parameter vom Typ vector. Die Alternative, die man oft sieht, ist ein String mit Cast in der SQL: embedding <=> ?::vector. Der Unterschied wird relevant, sobald PostgreSQL für ein vorbereitetes Statement einen generischen Plan verwendet, also einen Plan, der unabhängig vom konkreten Parameterwert erstellt wird. Mit einem varchar-Parameter steht der Cast dann pro Zeile im Plan:

-- typisiert: PREPARE typed(vector) AS … ORDER BY c.embedding <=> $1
Seq Scan on public.chunk c (actual time=0.007..0.559 rows=643.00 loops=1)
  Output: document_id, (embedding <=> $1)
Execution Time: 0.597 ms

-- als Text: PREPARE text(varchar) AS … ORDER BY c.embedding <=> $1::vector
Seq Scan on public.chunk c (actual time=0.020..10.358 rows=643.00 loops=1)
  Output: document_id, (embedding <=> ($1)::vector)
Execution Time: 10.402 ms

10,4 statt 0,6 ms, weil der 384-dimensionale Vektor 643-mal aus Text geparst wird. In meinem Messlauf mit pgJDBC 42.7.14 trat das allerdings nicht von selbst auf: PostgreSQL schätzte den generischen Plan mit Cast als teurer ein und blieb bei individuell geplanten Ausführungen. Ob und wann der Planer umschaltet, hängt von der Abfrage und den Kostenschätzungen ab. Mit dem typisierten Parameter ist man auf beiden Wegen schnell.

Teil 2: Eine Million Vektoren

Bei 643 Chunks ist die Wahl der Datenbank egal. Interessant wird sie erst, wenn der Korpus wächst. Dafür habe ich den Datensatz dbpedia-entities-openai3-text-embedding-3-large-1536-1M verwendet: eine Million Wikipedia-Einträge mit fertigen Embeddings von OpenAIs text-embedding-3-large, auf 1536 Dimensionen gekürzt, unter MIT-Lizenz. Veröffentlicht hat ihn Qdrant selbst. Er ist ein verbreiteter Standard für solche Vergleiche, und die Embeddings sind nicht an Qdrant gebunden. Für pgvector passen 1536 Dimensionen unter die Grenze von 2000.

Aufbau der Messung

Jede tausendste Zeile habe ich als Suchanfrage zurückgehalten. Indexiert werden 999 000 Vektoren, gesucht wird mit 1000. Die exakten zehn nächsten Nachbarn jeder Anfrage berechnet das Messprogramm per Brute Force in Java, unabhängig von beiden Datenbanken. Das dauerte 31 Sekunden auf allen Kernen. Als Gegenprobe lieferte eine exakte Suche in PostgreSQL ohne Index für 20 Anfragen exakt dieselben Ergebnisse, bei 1,3 Sekunden pro Anfrage.

Die zentrale Kennzahl ist der Recall@10: der Anteil der exakten zehn nächsten Nachbarn, den die approximative Suche tatsächlich zurückgibt. Weitere Regeln der Messung:

  • Beide Datenbanken laufen in Docker auf derselben Maschine, nie gleichzeitig.
  • Beide verwenden dieselben HNSW-Parameter (m = 16, Konstruktion mit 64 Kandidaten) und dasselbe Distanzmass. Weil die OpenAI-Vektoren auf Länge 1 normiert sind, ist das Skalarprodukt gleichbedeutend mit Kosinus.
  • Latenzen sind im Java-Client gemessen, also inklusive JDBC beziehungsweise gRPC, nach einem Aufwärmdurchgang.

Der Datensatz hat keine Metadaten. Für die Filtertests bekommt jeder Vektor deshalb ein synthetisches Feld category von 0 bis 999, berechnet als Hash der ID. Die Filter category < 100, < 10 und < 1 treffen damit 10 %, 1 % und 0,1 % der Daten, unabhängig vom Inhalt.

Laden und Indexbau

pgvector bekommt die Daten per binärem COPY. Die halfvec-Tabelle entsteht als Kopie mit Cast: CREATE TABLE items_half AS SELECT id, category, embedding::halfvec(1536) …. Qdrant bekommt sie über gRPC in Batches zu 500 Punkten, mit einem Payload-Index auf category, der vor dem Upload angelegt wird. Nur dann kann Qdrant den Filter beim Aufbau des Graphen berücksichtigen.

pgvector vectorpgvector halfvecQdrant
Laden30 s24 s (aus vector)82 s
Indexbau202 s139 sparallel zum Laden, fertig nach 238 s gesamt
Tabelle / Daten8,0 GB4,0 GB6,2 GB gesamt
HNSW-Index7,8 GB3,9 GB(enthalten)

Der HNSW-Index von pgvector speichert die Vektoren noch einmal. Mit vector liegen die Daten deshalb zweimal auf der Platte, rund 16 GB gegenüber 6,2 GB bei Qdrant.

Für den Indexbau braucht PostgreSQL Speicher. Er ist am schnellsten, wenn der ganze Graph in maintenance_work_mem passt. Ich habe 12 GB und 8 parallele Worker eingestellt, und PostgreSQL meldete keinen Überlauf. In Docker gibt es dabei eine Falle: Parallele Builds teilen den Speicher über /dev/shm, und Docker stellt standardmässig nur 64 MB bereit. In der Compose-Datei steht deshalb shm_size: 14g.

Recall und Latenz ohne Filter

Die Suchtiefe heisst bei pgvector hnsw.ef_search, bei Qdrant hnsw_ef. Ich habe beide mit 40, 80, 160 und 320 gemessen:

efpgvector vectorpgvector halfvecQdrant
400,914 / 3,2 ms0,917 / 1,8 ms0,967 / 2,5 ms
800,952 / 5,0 ms0,956 / 2,7 ms0,984 / 4,7 ms
1600,973 / 8,0 ms0,973 / 4,1 ms0,993 / 5,9 ms
3200,983 / 14,1 ms0,982 / 7,0 ms0,997 / 11,7 ms

Recall@10 / Latenz p50, ein Client, je 1000 Anfragen.

Zwei Dinge fallen auf. Erstens verliert halfvec keinen messbaren Recall, braucht aber nur halb so viel Speicher und ist fast doppelt so schnell. Weniger Bytes pro Vektor heisst weniger Speicherbandbreite pro Distanzberechnung. Wer pgvector mit grossen Embeddings betreibt, sollte halfvec als Standard nehmen.

Zweitens erreicht Qdrant bei gleichem ef einen höheren Recall. Die Werte sind deshalb nicht zeilenweise vergleichbar. Qdrant teilt die Collection in Segmente auf, hier 8, und durchsucht jedes mit dem angegebenen ef. Fair ist der Vergleich bei gleichem Recall: Für rund 0,97 braucht pgvector mit halfvec 4,1 ms, Qdrant 2,5 ms. Für rund 0,98 sind es 7,0 gegen 4,7 ms. Bei der Latenz einer einzelnen Anfrage liegt Qdrant also vorne.

Durchsatz unter Last

Die Latenz einer einzelnen Anfrage bevorzugt eine Engine, die eine Anfrage intern auf mehrere Kerne verteilt. In einer Anwendung mit vielen gleichzeitigen Nutzern sind die Kerne aber ohnehin ausgelastet. Deshalb habe ich zusätzlich den Durchsatz mit 1, 8 und 16 parallelen Clients gemessen, jeweils bei vergleichbarem Recall:

Recall@10Engine1 Client8 Clients16 Clients
0,973pgvector halfvec, ef 160174/s845/s1364/s
0,973pgvector vector, ef 160148/s580/s1057/s
0,967Qdrant, ef 40395/s1072/s1167/s
0,982pgvector halfvec, ef 320123/s717/s992/s
0,984Qdrant, ef 80252/s595/s593/s

Mit einem Client ist Qdrant mehr als doppelt so schnell. Ab 8 Clients wächst sein Durchsatz kaum noch, während pgvector bis 16 Clients weiter skaliert und bei vergleichbarem Recall am Ende vorne liegt. Das passt zu der Annahme, dass Qdrant eine einzelne Anfrage über mehrere Segmente parallel bearbeitet und die Kerne schon mit wenigen Clients belegt sind. PostgreSQL bearbeitet jede Anfrage in einem Prozess.

Eine Beobachtung am Rand: Den ersten Lauf mit vector und ef 320 direkt nach den halfvec-Messungen schaffte pgvector nur mit 29 Anfragen pro Sekunde, die Wiederholung mit 76. Beide Tabellen samt Indizes belegen zusammen rund 24 GB, so viel Speicher hatte die Docker-VM insgesamt. Der halfvec-Lauf hatte den Cache für die vector-Daten verdrängt. pgvector ist schnell, solange Index und Vektoren im Speicher liegen, und das wird bei 1536 Dimensionen schnell eng.

Gefilterte Suche

Hier liegt der grösste Unterschied. Eine typische RAG-Abfrage sucht nicht im ganzen Korpus, sondern nur in den Dokumenten eines Mandanten, eines Themas oder eines Zeitraums.

HNSW in pgvector filtert nach der Graphsuche. Der Index liefert die ef_search nächsten Kandidaten, danach verwirft PostgreSQL alle, die die WHERE-Bedingung nicht erfüllen. Bei ef 40 und einem Filter, der 1 % der Daten trifft, bleibt im Mittel weniger als ein Treffer übrig. Seit Version 0.8 gibt es dafür iterative Index-Scans: Findet der erste Durchgang zu wenig, sucht pgvector im Graphen weiter, bis genug Treffer da sind oder hnsw.max_scan_tuples (Standard 20 000) erreicht ist. strict_order liefert die Treffer streng nach Distanz sortiert, relaxed_order erlaubt kleine Abweichungen in der Reihenfolge und erreicht dafür einen höheren Recall.

Für die folgende Tabelle habe ich die Nutzung des HNSW-Index erzwungen (enable_seqscan = off), um sein Verhalten zu messen, ef 40:

Filterohne iterativen Scanstrict_orderrelaxed_orderQdrant
10 %0,406 / 2,8 ms0,873 / 5,6 ms0,926 / 6,4 ms0,961 / 2,5 ms
1 %0,043 / 3,6 ms0,877 / 31,6 ms0,946 / 37,0 ms0,994 / 1,9 ms
0,1 %0,004 / 3,4 ms0,673 / 52,1 ms0,685 / 64,0 ms1,000 / 0,8 ms

Recall@10 / Latenz p50.

Ohne iterativen Scan ist gefilterte Suche in pgvector unbrauchbar, und zwar still. Bei 1 % lieferten alle 1000 Anfragen weniger als zehn Ergebnisse, die meisten gar keins, ohne Fehler oder Warnung. Mit iterativem Scan stimmt der Recall wieder, kostet aber das Zehnfache an Zeit. Bei 0,1 % reicht auch das nicht: Unter 20 000 untersuchten Kandidaten sind im Mittel nur rund 20 passende, und das sind oft nicht die nächsten.

Die Grenze lässt sich anheben. Mit hnsw.max_scan_tuples = 100000 stieg der Recall bei 0,1 % auf 0,958, die Latenz aber auf 204 ms im Median; mit einer Million waren es 0,970 und 218 ms. Bei 1 % änderte das Anheben kaum etwas, dort lag der Recall schon mit dem Standardwert bei 0,946. Die Latenzen dieser Wiederholung lagen bei 1 % und 20 000 Kandidaten mit 27 ms übrigens ein Viertel unter dem ersten Lauf (37 ms), bei identischem Recall. Solche Schwankungen zwischen Läufen gehören zu einer Messung auf einem Laptop dazu; die Grössenordnungen blieben stabil.

Qdrant geht anders vor. Der Payload-Index auf category schätzt vor der Suche, wie viele Punkte der Filter trifft. Sind es wenige, durchsucht Qdrant genau diese Punkte exakt, statt den Graphen zu nutzen. Bei häufigeren Werten folgt die Graphsuche zusätzlichen Kanten, die Qdrant für indexierte Payload-Felder anlegt. Damit bleibt der Graph auch unter einem Filter zusammenhängend. Das Ergebnis: Je strenger der Filter, desto schneller und genauer wird Qdrant.

PostgreSQL hat dafür einen eigenen Weg, den gewöhnlichen B-Tree-Index. Mit einem Index auf category und ohne erzwungenen HNSW-Plan entscheidet der Planer selbst:

FilterPlanRecall@10p50
10 %HNSW mit relaxed_order0,9133,2 ms
1 %B-Tree-Bitmap-Scan, dann exakte Sortierung1,00034,4 ms
0,1 %B-Tree-Bitmap-Scan, dann exakte Sortierung1,0003,7 ms

Bei seltenen Werten sucht PostgreSQL also exakt unter den passenden Zeilen. Das ist bei 0,1 % mit 3,7 ms schnell genug und immer vollständig. Bei 1 % sind es rund 10 000 Zeilen mit je 6 KB, die gelesen und verglichen werden müssen, das kostet 34 ms. Die pgvector-Dokumentation empfiehlt für einzelne, häufig gefilterte Werte zusätzlich partielle Indizes und für Mandanten Partitionierung.

Die Falle mit dem Prepared Statement

Bei meinem ersten Messlauf für die Filter stimmte etwas nicht. Der Filterwert war ein Bind-Parameter (WHERE category < ?), und EXPLAIN zeigte für 0,1 % einen Sequential Scan. Gemessen habe ich aber 2,6 ms pro Anfrage. Ein Sequential Scan über eine Million Vektoren dauert über eine Sekunde.

Die Erklärung: pgJDBC bereitet ein Statement nach fünf Ausführungen serverseitig vor, und PostgreSQL wechselt danach auf einen generischen Plan. Der wird ohne Kenntnis des Filterwerts geplant, also ohne zu wissen, dass nur 0,1 % der Zeilen passen, und nimmt den HNSW-Index. Recall und Latenz stimmten exakt mit der späteren Messung mit erzwungenem HNSW überein. Mit echtem Wert hätte der Planer exakt gesucht. Für die Anwendung heisst das: Dieselbe Abfrage liefert bei den ersten Aufrufen vollständige Ergebnisse und danach still fast keine mehr.

Für die Messungen oben steht der Filterwert deshalb als Literal in der SQL. In einer Anwendung sind die Optionen: iterative Scans immer einschalten, damit der HNSW-Plan zumindest genug Treffer liefert, plan_cache_mode = force_custom_plan für die betroffenen Abfragen setzen, oder bei wenigen festen Filterwerten mit partiellen Indizes arbeiten. Ein Test mit selektiven Filtern, der die Anzahl der Treffer prüft, sollte in jedem Fall dazugehören.

Wann pgvector, wann Qdrant

Aus den Messungen ergibt sich keine Rangliste, aber eine recht klare Grenze.

pgvector reicht, wenn die Anwendung ohnehin PostgreSQL verwendet und der Korpus in den Speicher passt. Bei einigen tausend bis einigen hunderttausend Chunks ist exakte Suche oder HNSW mit halfvec schnell genug. Man bekommt Transaktionen über Daten und Index, Fremdschlüssel, Backups mit den vorhandenen Werkzeugen und Hybridsuche in einer Abfrage. Bei einer Million Vektoren mit 1536 Dimensionen lag der Durchsatz unter Last auf dem Niveau von Qdrant.

Qdrant lohnt sich, wenn Filter ein zentraler Teil der Suche sind, etwa bei vielen Mandanten oder feingranularen Berechtigungen, wenn die Latenz einzelner Anfragen zählt oder wenn der Korpus so gross wird, dass Index und Vektoren nicht mehr in den Speicher einer PostgreSQL-Instanz passen. Qdrant brauchte für dieselben Daten weniger Platz als pgvector mit vector und halfvec und lieferte bei strengen Filtern exakte Ergebnisse in unter einer Millisekunde, ohne dass ich dafür etwas einstellen musste.

KriteriumpgvectorQdrant
Zusätzliche Infrastrukturkeineeigener Dienst, eigene Sicherung
Konsistenz mit AnwendungsdatenTransaktion, FremdschlüsselAnwendung muss synchronisieren
Latenz einzelner Anfrage (Recall ~0,97)4,1 ms (halfvec)2,5 ms
Durchsatz, 16 Clients (Recall ~0,97)1364/s1167/s
Gefilterte Suchenur mit iterativen Scans oder B-Tree, Falleneingebaut, schnell und genau
Speicher bei 1 Mio. × 15368 GB (halfvec) bis 16 GB (vector)6,2 GB
HybridsucheSQL mit tsvectorSparse-Vektoren, Fusion in der Abfrage

Die anderen verbreiteten Optionen habe ich nicht gemessen. Chroma ist für Prototypen im Python-Umfeld gedacht und aus Java nur über HTTP erreichbar. OpenSearch und Elasticsearch bringen ausgereifte Volltextsuche mit Analysatoren für viele Sprachen mit, sind als JVM-Cluster für einen kleinen Korpus aber schwergewichtig. Milvus und Weaviate zielen wie Qdrant auf eigene Vektor-Dienste. Innerhalb von PostgreSQL gibt es mit pgvectorscale und VectorChord Erweiterungen, die andere Indexverfahren mitbringen. Ob sie die Filterprobleme lösen, wäre einen eigenen Test wert.

Einordnung

Die Messung hat Grenzen, die man beim Lesen der Zahlen kennen sollte. Sie lief auf einem Laptop in Docker Desktop, nicht auf einem Server. Beide Datenbanken liefen mit weitgehend unveränderten Standardeinstellungen; ausgereizt hätte man beide weiter, Qdrant etwa mit Quantisierung, PostgreSQL mit mehr Speicher für den Cache. Der Filter ist synthetisch und unabhängig vom Inhalt; echte Metadaten korrelieren oft mit der Bedeutung der Texte, was die Graphsuche erleichtern oder erschweren kann. Updates und Löschungen unter Last habe ich nicht gemessen. Gerade dort unterscheiden sich die Systeme aber, weil HNSW in PostgreSQL bei vielen Änderungen auf VACUUM angewiesen ist.

Für das Projekt, das den Anlass gab, bleibt es bei pgvector, und zwar ohne HNSW-Index. Bei 643 Chunks ist die exakte Suche in einer Millisekunde fertig, und die Transaktion über Dokument und Chunks ist mehr wert als jede halbe Millisekunde. Die wichtigere Erkenntnis aus dem ersten Teil betrifft gar nicht die Datenbank: Modellwahl und ein Fragen-Set mit annotierten Antworten haben mehr über die Qualität der Suche entschieden als die Frage, wo die Vektoren liegen. Die Hybridsuche, die als Standardempfehlung gilt, hat das Ergebnis hier verschlechtert.

Würde ich eine Anwendung mit vielen Mandanten bauen, deren Suche immer gefiltert ist, würde ich Qdrant nehmen. Nicht weil pgvector es nicht kann, sondern weil es dort an mehreren Stellen still falsch werden kann und man jede davon kennen muss.