pgvector or Qdrant: When PostgreSQL Is Enough as a Vector Database
A RAG tutorial with Quarkus, LangChain4j and pgvector, plus a benchmark with one million vectors: halfvec, filtered search, throughput and where Qdrant wins.
The knowledge base of Singularity, my astronomy assistant, lives in Qdrant. Qdrant runs there as a local container next to Phoenix and is filled by a Python ingest. With the next project the question came up again. There, accounts, profiles and plans already live in PostgreSQL, and the knowledge base consists of a few hundred sections from Wikipedia articles. A second database service would have meant another container, another backup, and an ingest that writes to two systems without a shared transaction holding them together.
I chose pgvector and then wanted to know how far that decision carries. This article therefore has two parts. The first builds the retrieval layer of a RAG application with Quarkus, LangChain4j and pgvector and measures what actually matters for a small corpus. The second loads one million OpenAI embeddings into pgvector and into Qdrant and compares index build, recall, latency, throughput and filtered search under the same conditions. Code, corpus, benchmark and all raw data are in the pgvector-vs-qdrant repository.
I used Java 25, Quarkus 3.40.1 (the current LTS line), PostgreSQL 18.6 with pgvector 0.8.7 and Qdrant 1.19.1, all on a Mac with an M5 Pro and 48 GB of RAM, with the databases in Docker Desktop with 24 GB of memory.
What pgvector provides
pgvector is a PostgreSQL extension. It adds data types for vectors, distance operators and two index types for approximate search. Two types matter most for embeddings: vector stores each component as a 32-bit float, halfvec as a 16-bit float. There are also sparsevec for sparse vectors and bit for binary ones.
Search uses operators: <-> for Euclidean distance, <=> for cosine distance and <#> for the negative inner product. Negative because PostgreSQL can only walk an index in ascending order. A similarity search is therefore ordinary SQL:
SELECT id, title FROM chunk ORDER BY embedding <=> $1 LIMIT 5;
Without an index, PostgreSQL computes the distance to every row and sorts. That is exact and fast enough for small tables. For larger tables there are IVFFlat and HNSW. HNSW is the usual choice today: a graph in which the search hops from neighbour to neighbour until it reaches the neighbourhood of the query vector. It is approximate, so it does not guarantee the exact nearest neighbours. How thoroughly it searches is controlled by hnsw.ef_search (default 40). The structure of the graph is set at build time by m (default 16) and ef_construction (default 64).
One limit worth knowing early: an index on vector is restricted to 2,000 dimensions, on halfvec to 4,000. Many current embedding models produce more than 2,000 dimensions. Qwen3-Embedding-4B, for example, which I run locally through Ollama, produces 2,560. For such models the options are halfvec or truncating the vectors, if the model was trained for that.
Part 1: Retrieval with Quarkus and pgvector
The corpus for the tutorial consists of 42 English Wikipedia articles on astrophotography, filters, telescopes and object classes, each at a fixed revision, plus six short texts of my own. Together that is 48 documents with about 98,500 words. The questions are in German: 20 questions with annotated relevant sections, such as “Wofür brauche ich einen OIII-Filter?” (what do I need an OIII filter for?), and three questions the corpus cannot answer. The embedding model therefore also has to bridge the language gap.
Database and schema
For development and tests, Quarkus starts a PostgreSQL container itself through Dev Services. It only needs to know which image to use:
quarkus.datasource.db-kind=postgresql
quarkus.datasource.devservices.image-name=pgvector/pgvector:pg18@sha256:2358fcba…
quarkus.flyway.migrate-at-start=true
The image is pinned by digest so that tests do not silently move to a new pgvector version. Flyway creates the schema:
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);
Chunks hang off their document by foreign key, and that is where pgvector differs from a separate vector database. Delete a document and its chunks disappear in the same transaction. The tsv column is for the hybrid search further down. There is deliberately no HNSW index; the measurement shows why.
Embeddings in-process
The model is multilingual-e5-small, as a quantized ONNX export (118 MB, 384 dimensions). LangChain4j can run ONNX models directly in the JVM. No Ollama and no API key are needed, and a user’s question never leaves the process:
@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();
}
}
The prefixes query: and passage: are not cosmetic. The e5 models were trained with them, and without them the ranking gets worse. Embedding a question took 2.1 ms (median) on the Mac. The Gradle build downloads the model from a fixed revision on Hugging Face and checks its SHA-256.
Documents are split along their headings. Each section becomes one or more chunks of at most 250 words with 40 words of overlap. The first line of each chunk names document and section, so a chunk still says what it is about on its own. The result is 643 chunks.
Ingest in one transaction
Replacing a document with all its chunks is a single method:
@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 on document, then:
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();
}
}
}
A reader sees either the old or the new version of a document, never a mix. If the ingest fails, the old version stays. With Qdrant next to PostgreSQL you would have to take care of that yourself, for example with version numbers in the payload and a cleanup run. The full ingest of the corpus (chunking, embedding, writing) took 10 seconds.
PGvector comes from the com.pgvector:pgvector library and makes sure the vector is bound as a parameter of type vector rather than as a string.
Search
The vector search is a parameterised query with an optional topic filter:
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 ?
I used two metrics. Hit Rate@5 is the share of the 20 questions with at least one relevant section among the top five results. MRR@5 (mean reciprocal rank) also rewards how high the first relevant hit is: rank 1 counts 1, rank 2 counts 0.5, and so on.
| Search | Hit Rate@5 | MRR@5 | Latency p50 / p95 |
|---|---|---|---|
| Vector (cosine), exact | 0.95 | 0.90 | 0.92 / 1.17 ms |
| Hybrid (vector + full text, RRF) | 0.90 | 0.77 | 2.13 / 2.94 ms |
The pure vector search finds a relevant section for 19 of 20 questions, 17 of them at rank 1. The one miss is instructive: “Was ist Seeing und wie wirkt es sich auf meine Bilder aus?” (what is seeing and how does it affect my images?) ends up with texts about image noise. The loanword “Seeing” carries little weight in the sentence, “Bilder” (images) a lot.
Hybrid search made it worse here
Hybrid search is the standard recommendation for RAG: vector search for meaning, full-text search for exact terms like “OIII” or “Bortle”, both lists fused. In PostgreSQL it is a single query. Each search returns 50 candidates, and Reciprocal Rank Fusion (RRF) adds up 1 / (60 + rank) from both lists for every hit:
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;
The result was worse, not better. MRR dropped from 0.90 to 0.77, and two questions lost their hit entirely. Hybrid search helped with two questions: for the seeing question and for the H-alpha filter, the right section moved to rank 1. The full-text list shows the cause of the drop. The question becomes an OR search over its words, so “Was ist ein Apochromat?” (what is an apochromat?) becomes was or ist or ein or apochromat. The top three full-text hits contain only one of these words: “was”, the English past tense. For “Was ist ein planetarischer Nebel?” and the question about broadband and narrowband filters the list looks the same. RRF mixes these sections in between the good vector hits. A German text search configuration with stop words does not help, because the texts are English.
With questions in the language of the corpus this would probably look different. But the measurement shows that hybrid search is not an improvement to add without checking. Without a question set with annotated answers I would not have noticed the drop.
Do 643 chunks need an index?
For comparison I created an HNSW index with default parameters. It took 60 ms to build and was 1.3 MB in size.
| Search | p50 | p95 |
|---|---|---|
| exact (sequential scan) | 0.86 ms | 1.01 ms |
| HNSW | 0.36 ms | 0.40 ms |
HNSW saves half a millisecond and returns the same five hits in the same order for 22 of 23 questions. For one question the result differs. In a RAG application, where a language model then spends several seconds writing an answer, half a millisecond does not matter. At this size exact search is the better choice. If the corpus grows, the index can be added later in a migration without changing the query.
A threshold for “no source” barely works
A RAG system should notice when the corpus cannot answer a question. The obvious approach is a threshold on the distance of the best hit. For questions with an answer it was between 0.118 and 0.205, for the three questions without one (“Wie alt ist das Universum?”, how old is the universe?) it was 0.203, 0.215 and 0.229. The ranges overlap. e5 maps all texts into a narrow similarity range, so a fixed threshold does not separate them cleanly. It works as a guardrail; the final decision has to be made by the model based on the texts it receives.
Bind the vector as a typed parameter
ps.setObject(5, new PGvector(…)) binds the vector as a parameter of type vector. The alternative you often see is a string with a cast in the SQL: embedding <=> ?::vector. The difference matters as soon as PostgreSQL uses a generic plan for a prepared statement, that is, a plan built independently of the concrete parameter value. With a varchar parameter, the cast then runs per row:
-- typed: 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
-- as 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 instead of 0.6 ms, because the 384-dimensional vector is parsed from text 643 times. In my run with pgJDBC 42.7.14 this did not happen on its own, though: PostgreSQL estimated the generic plan with the cast as more expensive and stayed with individually planned executions. Whether and when the planner switches depends on the query and on cost estimates. With the typed parameter you are fast either way.
Part 2: One million vectors
With 643 chunks the choice of database does not matter. It becomes interesting once the corpus grows. For that I used the dataset dbpedia-entities-openai3-text-embedding-3-large-1536-1M: one million Wikipedia entries with ready-made embeddings from OpenAI’s text-embedding-3-large, truncated to 1,536 dimensions, under the MIT license. It was published by Qdrant itself. It is a common standard for this kind of comparison, and the embeddings are not tied to Qdrant. For pgvector, 1,536 dimensions fit under the 2,000 limit.
How the benchmark works
I held back every thousandth row as a query. 999,000 vectors are indexed, and 1,000 are used for searching. The benchmark computes the exact ten nearest neighbours of each query by brute force in Java, independently of both databases. That took 31 seconds on all cores. As a cross-check, an exact search in PostgreSQL without an index returned exactly the same results for 20 queries, at 1.3 seconds per query.
The central metric is recall@10: the share of the exact ten nearest neighbours that the approximate search actually returns. Further rules of the benchmark:
- Both databases run in Docker on the same machine, never at the same time.
- Both use the same HNSW parameters (m = 16, construction with 64 candidates) and the same distance. Because the OpenAI vectors are normalised to length 1, inner product is equivalent to cosine.
- Latencies are measured in the Java client, including JDBC or gRPC, after a warm-up pass.
The dataset has no metadata. For the filter tests, each vector therefore gets a synthetic field category from 0 to 999, computed as a hash of the ID. The filters category < 100, < 10 and < 1 match 10 %, 1 % and 0.1 % of the data, independently of the content.
Loading and index build
pgvector gets the data through binary COPY. The halfvec table is created as a copy with a cast: CREATE TABLE items_half AS SELECT id, category, embedding::halfvec(1536) …. Qdrant gets it over gRPC in batches of 500 points, with a payload index on category created before the upload. Only then can Qdrant take the filter into account when building the graph.
pgvector vector | pgvector halfvec | Qdrant | |
|---|---|---|---|
| Load | 30 s | 24 s (from vector) | 82 s |
| Index build | 202 s | 139 s | in parallel with loading, done after 238 s total |
| Table / data | 8.0 GB | 4.0 GB | 6.2 GB total |
| HNSW index | 7.8 GB | 3.9 GB | (included) |
pgvector’s HNSW index stores the vectors a second time. With vector the data therefore sits on disk twice, about 16 GB compared with 6.2 GB for Qdrant.
PostgreSQL needs memory for the index build. It is fastest when the whole graph fits into maintenance_work_mem. I set 12 GB and 8 parallel workers, and PostgreSQL reported no overflow. Docker has a trap here: parallel builds share memory through /dev/shm, and Docker provides only 64 MB by default. The compose file therefore sets shm_size: 14g.
Recall and latency without a filter
The search depth is called hnsw.ef_search in pgvector and hnsw_ef in Qdrant. I measured both at 40, 80, 160 and 320:
| ef | pgvector vector | pgvector halfvec | Qdrant |
|---|---|---|---|
| 40 | 0.914 / 3.2 ms | 0.917 / 1.8 ms | 0.967 / 2.5 ms |
| 80 | 0.952 / 5.0 ms | 0.956 / 2.7 ms | 0.984 / 4.7 ms |
| 160 | 0.973 / 8.0 ms | 0.973 / 4.1 ms | 0.993 / 5.9 ms |
| 320 | 0.983 / 14.1 ms | 0.982 / 7.0 ms | 0.997 / 11.7 ms |
Recall@10 / latency p50, one client, 1,000 queries each.
Two things stand out. First, halfvec loses no measurable recall but needs only half the memory and is almost twice as fast. Fewer bytes per vector means less memory bandwidth per distance computation. Anyone running pgvector with large embeddings should make halfvec the default.
Second, Qdrant reaches a higher recall at the same ef. The rows are therefore not directly comparable. Qdrant splits the collection into segments, 8 here, and searches each with the given ef. The fair comparison is at equal recall: for about 0.97, pgvector with halfvec needs 4.1 ms and Qdrant 2.5 ms. For about 0.98 it is 7.0 against 4.7 ms. For the latency of a single query, Qdrant is ahead.
Throughput under load
Single-query latency favours an engine that spreads one query across several cores. In an application with many concurrent users, the cores are busy anyway. So I also measured throughput with 1, 8 and 16 parallel clients, each at comparable recall:
| Recall@10 | Engine | 1 client | 8 clients | 16 clients |
|---|---|---|---|---|
| 0.973 | pgvector halfvec, ef 160 | 174/s | 845/s | 1,364/s |
| 0.973 | pgvector vector, ef 160 | 148/s | 580/s | 1,057/s |
| 0.967 | Qdrant, ef 40 | 395/s | 1,072/s | 1,167/s |
| 0.982 | pgvector halfvec, ef 320 | 123/s | 717/s | 992/s |
| 0.984 | Qdrant, ef 80 | 252/s | 595/s | 593/s |
With one client Qdrant is more than twice as fast. From 8 clients on its throughput barely grows, while pgvector keeps scaling up to 16 clients and ends up ahead at comparable recall. That fits the assumption that Qdrant processes a single query across several segments in parallel, so the cores are already occupied with few clients. PostgreSQL handles each query in one process.
A side observation: the first run with vector at ef 320, directly after the halfvec measurements, managed only 29 queries per second; the repeat managed 76. Both tables with their indexes take about 24 GB together, which was all the memory the Docker VM had. The halfvec run had pushed the vector data out of the cache. pgvector is fast as long as index and vectors are in memory, and at 1,536 dimensions that gets tight quickly.
Filtered search
This is where the biggest difference lies. A typical RAG query does not search the whole corpus, but only the documents of one tenant, one topic or one time range.
HNSW in pgvector filters after the graph search. The index returns the ef_search nearest candidates, then PostgreSQL discards all that do not satisfy the WHERE condition. At ef 40 and a filter matching 1 % of the data, less than one hit remains on average. Since version 0.8 there are iterative index scans for this: if the first pass finds too few, pgvector keeps searching the graph until there are enough hits or hnsw.max_scan_tuples (default 20,000) is reached. strict_order returns hits strictly sorted by distance, relaxed_order allows small deviations in order and achieves higher recall in return.
For the following table I forced the use of the HNSW index (enable_seqscan = off) to measure its behaviour, at ef 40:
| Filter | no iterative scan | strict_order | relaxed_order | Qdrant |
|---|---|---|---|---|
| 10 % | 0.406 / 2.8 ms | 0.873 / 5.6 ms | 0.926 / 6.4 ms | 0.961 / 2.5 ms |
| 1 % | 0.043 / 3.6 ms | 0.877 / 31.6 ms | 0.946 / 37.0 ms | 0.994 / 1.9 ms |
| 0.1 % | 0.004 / 3.4 ms | 0.673 / 52.1 ms | 0.685 / 64.0 ms | 1.000 / 0.8 ms |
Recall@10 / latency p50.
Without iterative scans, filtered search in pgvector is unusable, and silently so. At 1 %, all 1,000 queries returned fewer than ten results, most of them none at all, without an error or warning. With iterative scans the recall is fine again, but at ten times the latency. At 0.1 % even that is not enough: among 20,000 examined candidates only about 20 match on average, and they are often not the nearest ones.
The limit can be raised. With hnsw.max_scan_tuples = 100000, recall at 0.1 % rose to 0.958, but latency to 204 ms (median); with one million it was 0.970 and 218 ms. At 1 %, raising it changed little, since recall was already 0.946 with the default. By the way, the latencies of this repeat at 1 % and 20,000 candidates were a quarter lower than in the first run (27 against 37 ms), with identical recall. Such variation between runs is part of measuring on a laptop; the orders of magnitude stayed stable.
Qdrant works differently. The payload index on category estimates before the search how many points the filter matches. If there are few, Qdrant searches exactly these points instead of using the graph. For more frequent values, the graph search follows additional edges that Qdrant creates for indexed payload fields. This keeps the graph connected even under a filter. The result: the stricter the filter, the faster and more accurate Qdrant gets.
PostgreSQL has its own way for this, the ordinary B-tree index. With an index on category and no forced HNSW plan, the planner decides on its own:
| Filter | Plan | Recall@10 | p50 |
|---|---|---|---|
| 10 % | HNSW with relaxed_order | 0.913 | 3.2 ms |
| 1 % | B-tree bitmap scan, then exact sort | 1.000 | 34.4 ms |
| 0.1 % | B-tree bitmap scan, then exact sort | 1.000 | 3.7 ms |
For rare values, PostgreSQL therefore searches exactly among the matching rows. At 0.1 % that is fast enough at 3.7 ms and always complete. At 1 % it means reading and comparing about 10,000 rows of 6 KB each, which costs 34 ms. The pgvector documentation additionally recommends partial indexes for individual, frequently filtered values and partitioning for tenants.
The prepared statement trap
In my first filter run something was off. The filter value was a bind parameter (WHERE category < ?), and EXPLAIN showed a sequential scan for 0.1 %. Yet I measured 2.6 ms per query. A sequential scan over one million vectors takes more than a second.
The explanation: pgJDBC prepares a statement on the server after five executions, and PostgreSQL then switches to a generic plan. That plan is made without knowing the filter value, so without knowing that only 0.1 % of the rows match, and it picks the HNSW index. Recall and latency matched the later run with forced HNSW exactly. With the real value, the planner would have searched exactly. For the application this means: the same query returns complete results for the first calls and then, silently, almost none.
For the measurements above, the filter value is therefore a literal in the SQL. In an application the options are: always enable iterative scans so that the HNSW plan at least returns enough hits, set plan_cache_mode = force_custom_plan for the affected queries, or use partial indexes when there are only a few fixed filter values. In any case a test with selective filters that checks the number of hits should be part of the suite.
When pgvector, when Qdrant
The measurements do not produce a ranking, but a fairly clear boundary.
pgvector is enough when the application already uses PostgreSQL and the corpus fits in memory. With a few thousand to a few hundred thousand chunks, exact search or HNSW with halfvec is fast enough. You get transactions across data and index, foreign keys, backups with the existing tools and hybrid search in one query. With one million vectors at 1,536 dimensions, throughput under load was on par with Qdrant.
Qdrant pays off when filters are a central part of the search, for example with many tenants or fine-grained permissions, when single-query latency matters, or when the corpus grows so large that index and vectors no longer fit into the memory of a PostgreSQL instance. For the same data Qdrant needed less space than pgvector with vector and halfvec, and it returned exact results in under a millisecond for strict filters without me having to tune anything.
| Criterion | pgvector | Qdrant |
|---|---|---|
| Additional infrastructure | none | separate service, separate backup |
| Consistency with application data | transaction, foreign keys | application has to synchronise |
| Single-query latency (recall ~0.97) | 4.1 ms (halfvec) | 2.5 ms |
| Throughput, 16 clients (recall ~0.97) | 1,364/s | 1,167/s |
| Filtered search | only with iterative scans or B-tree, with traps | built in, fast and accurate |
| Storage for 1M × 1536 | 8 GB (halfvec) to 16 GB (vector) | 6.2 GB |
| Hybrid search | SQL with tsvector | sparse vectors, fusion in the query |
I did not measure the other common options. Chroma targets prototypes in the Python world and is only reachable from Java over HTTP. OpenSearch and Elasticsearch bring mature full-text search with analysers for many languages, but as JVM clusters they are heavyweight for a small corpus. Milvus and Weaviate, like Qdrant, are dedicated vector services. Within PostgreSQL, pgvectorscale and VectorChord are extensions with other index methods. Whether they solve the filter problems would be worth a test of its own.
Assessment
The benchmark has limits that are worth knowing when reading the numbers. It ran on a laptop in Docker Desktop, not on a server. Both databases ran with largely unchanged default settings; both could have been pushed further, Qdrant for example with quantization, PostgreSQL with more memory for the cache. The filter is synthetic and independent of the content; real metadata often correlates with the meaning of the texts, which can make the graph search easier or harder. I did not measure updates and deletes under load. That is exactly where the systems differ, though, because HNSW in PostgreSQL relies on VACUUM when there are many changes.
For the project that started all this, pgvector stays, and without an HNSW index. At 643 chunks, exact search finishes in a millisecond, and the transaction across document and chunks is worth more than any half millisecond. The more important finding from the first part is not about the database at all: the choice of model and a question set with annotated answers decided more about search quality than the question of where the vectors live. Hybrid search, the standard recommendation, made the result worse here.
If I were building an application with many tenants whose search is always filtered, I would choose Qdrant. Not because pgvector cannot do it, but because there it can go silently wrong in several places, and you have to know every one of them.