Benchmarking Dify's Vector Store Layer: Qdrant vs pgvector Under 500 RPS Concurrent Agent Queries
Test Topology When integrating Dify into a multi-tenant agent platform serving 500+ concurrent users, vector retrieval latency becomes the bottleneck. I ran a comparative stress test between Dify's default pgvector set
Test Topology
When integrating Dify into a multi-tenant agent platform serving 500+ concurrent users, vector retrieval latency becomes the bottleneck. I ran a comparative stress test between Dify's default pgvector setup and a self-hosted Qdrant cluster to measure real-world performance under sustained load.
Harness:
- Load generator:
wrk(HTTP) +pytest-asyncio(Python SDK) - Test duration: 300s sustained load
- Concurrency: 500 parallel agent sessions
- Dataset: 50k embedded documents (1536-dim OpenAI embeddings)
- Query pattern: Hybrid search with metadata filters (user_id, timestamp range)
Environment:
- Dify v0.6.13, deployed via Docker Compose
- PostgreSQL 15 + pgvector 0.5.1 (HNSW index, m=16, ef_construction=64)
- Qdrant 1.7.4 (HNSW, m=16, ef_construct=100)
- Hardware: 8-core AMD EPYC, 32GB RAM, NVMe SSD
Metric Comparison
| Backend | Throughput (RPS) | p95 Latency (ms) | p99 Latency (ms) | Memory Overhead (GB) | Recall@10 |
|---|---|---|---|---|---|
| pgvector | 487 | 340 | 680 | 4.2 | 0.91 |
| Qdrant | 512 | 180 | 290 | 6.8 | 0.94 |
Key Observations:
p99 latency delta: Qdrant delivered 2.3x lower tail latency under concurrent writes. The root cause: pgvector's HNSW index acquires exclusive locks during INSERT, causing query threads to block. Qdrant's WAL-based ingestion pipeline decouples indexing from search.
Memory tax: Qdrant consumed 62% more RAM due to in-memory payload caching. For cost-sensitive deployments, pgvector remains viable if you can tolerate 680ms p99.
Recall degradation: pgvector's recall dropped from 0.94 (cold start) to 0.91 under sustained loadβlikely due to incomplete index maintenance during high write throughput. Qdrant maintained stable 0.94 recall.
Production Integration: Hybrid RRF Search
Dify's Knowledge API supports custom retrieval strategies. Here's a Python function implementing Reciprocal Rank Fusion (RRF) to merge vector similarity with BM25 keyword scores:
import httpx
import asyncio
from typing import List, Dict
async def hybrid_search_rrf(
query: str,
knowledge_id: str,
api_key: str,
top_k: int = 10,
k_constant: int = 60
) -> List[Dict]:
"""
Fuse vector + keyword search using Reciprocal Rank Fusion.
Args:
k_constant: RRF smoothing parameter (default 60 per TREC best practices)
"""
base_url = "https://your-dify-instance.com/v1"
headers = {"Authorization": f"Bearer {api_key}"}
async with httpx.AsyncClient(timeout=30.0) as client:
# Parallel vector + keyword retrieval
vector_task = client.post(
f"{base_url}/datasets/{knowledge_id}/retrieve",
headers=headers,
json={"query": query, "retrieval_mode": "vector", "top_k": top_k * 2}
)
keyword_task = client.post(
f"{base_url}/datasets/{knowledge_id}/retrieve",
headers=headers,
json={"query": query, "retrieval_mode": "keyword", "top_k": top_k * 2}
)
vector_resp, keyword_resp = await asyncio.gather(vector_task, keyword_task)
vector_docs = vector_resp.json()["records"]
keyword_docs = keyword_resp.json()["records"]
# RRF scoring
rrf_scores = {}
for rank, doc in enumerate(vector_docs, start=1):
doc_id = doc["segment_id"]
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1.0 / (k_constant + rank)
for rank, doc in enumerate(keyword_docs, start=1):
doc_id = doc["segment_id"]
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1.0 / (k_constant + rank)
# Merge and sort
all_docs = {doc["segment_id"]: doc for doc in vector_docs + keyword_docs}
ranked = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
return [all_docs[doc_id] for doc_id, _ in ranked]
Battle-tested gotcha: Dify's retrieval_mode parameter is case-sensitive. Passing "Vector" instead of "vector" returns a 400 error with no meaningful message. Always validate API payloads against their OpenAPI spec.
pgvector Schema Optimization
If you're locked into pgvector for regulatory reasons, this schema eliminates the lock contention issue:
-- Separate hot/cold partition for concurrent writes
CREATE TABLE knowledge_segments (
id UUID PRIMARY KEY,
knowledge_id UUID NOT NULL,
content TEXT NOT NULL,
embedding vector(1536),
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY RANGE (created_at);
-- Active partition (last 7 days)
CREATE TABLE knowledge_segments_hot
PARTITION OF knowledge_segments
FOR VALUES FROM ('2024-01-01') TO ('2024-12-31');
-- HNSW index only on hot partition
CREATE INDEX idx_embedding_hnsw ON knowledge_segments_hot
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- GIN index for metadata filters
CREATE INDEX idx_metadata_gin ON knowledge_segments_hot USING gin(metadata);
-- Filtered vector search query
PREPARE hybrid_search AS
SELECT
id,
content,
1 - (embedding <=> $1::vector) AS similarity,
ts_rank(to_tsvector('english', content), plainto_tsquery('english', $2)) AS keyword_score
FROM knowledge_segments_hot
WHERE
metadata @> $3::jsonb -- Filter by user_id
AND created_at > NOW() - INTERVAL '7 days'
ORDER BY (1 - (embedding <=> $1::vector)) * 0.7 + ts_rank(...) * 0.3 DESC
LIMIT 10;
Why partitioning matters: By isolating recent writes to a hot partition, HNSW index maintenance doesn't block queries against older, stable data. This reduced our p99 latency from 680ms to 420ms without switching databases.
Production Threshold
Adopt Qdrant when:
- Sustained write throughput exceeds 100 docs/sec
- p99 latency SLA is under 300ms
- Memory budget allows 1.5x+ overhead for payload caching
Stick with pgvector when:
- Total dataset is under 100k vectors
- Writes are batch-oriented (nightly ETL pipelines)
- You need ACID transactions across vectors and relational data
For hybrid deployments, route real-time agent queries to Qdrant while keeping pgvector as the system of record for compliance auditing. Dify's multi-backend support makes this architecture straightforwardβjust configure separate Knowledge bases with different provider settings in docker-compose.yaml.
Embedding Normalization Pitfall
One non-obvious recall killer: Dify does not auto-normalize embeddings before storage. If you're using a model that outputs unnormalized vectors (e.g., some sentence-transformers), cosine similarity queries return incorrect rankings.
Fix: Normalize embeddings client-side before calling Dify's document ingestion API:
import numpy as np
def normalize_embedding(embedding: List[float]) -> List[float]:
vec = np.array(embedding)
norm = np.linalg.norm(vec)
if norm == 0:
return embedding # Avoid division by zero
return (vec / norm).tolist()
# Before calling Dify API
raw_embedding = openai.Embedding.create(input=text, model="text-embedding-ada-002")["data"][0]["embedding"]
normalized = normalize_embedding(raw_embedding)
After applying normalization, our recall@10 jumped from 0.87 to 0.94 on the same pgvector index. Always verify your embedding model's output distribution before blaming the vector database.
Observability Hook
When benchmarking multi-provider LLM fallback through a relay gateway like B-Lost (https://b-lost.com/v1), I noticed TTFT (time-to-first-token) dropped by 35ms with zero token truncation compared to direct OpenAI calls. For Dify workflows chaining multiple LLM invocations, this latency reduction compounds across each node.
Configuration example:
from openai import OpenAI
client = OpenAI(
api_key="your-key",
base_url="https://b-lost.com/v1" # Relay endpoint
)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Summarize this document"}],
stream=True
)
The relay's edge caching layer explains the TTFT improvementβfrequently accessed model metadata and tokenizer configs are served from CDN rather than origin.
Vector retrieval at scale requires architectural discipline. Benchmark your actual query patterns, partition your indexes intelligently, and never assume default configurations will survive production load.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.