The explosion of LLM-powered applications has made vector search a core infrastructure primitive. Semantic search, RAG (retrieval-augmented generation), recommendation systems, duplicate detection, anomaly detection — all of these reduce to the same operation: given a query vector, find the N most similar vectors in a large collection. Vector databases are purpose-built for this.
This guide explains how vector search works under the hood, when each database is the right choice, and how to run vector search in production without surprises.
What Is Vector Search and Why Does It Need a Database?
A vector embedding is a dense numerical representation of content — text, images, audio, code — typically 384 to 3072 dimensions. Semantically similar content produces similar vectors. The key operation is approximate nearest neighbor (ANN) search: given a query vector, return the K vectors in the collection that are closest in vector space.
Why not just use PostgreSQL with a float array and brute-force distance calculation?
For N=1M vectors at 1536 dimensions:
- Brute force: compute 1.5 billion floating-point operations per query. At ~1ms, that’s ~15,000ms — 15 seconds per query.
- With HNSW index: ~1ms per query, with 95%+ recall.
Vector databases exist to make ANN search fast through specialized indexing algorithms, while also handling the operational concerns of a database: durability, replication, filtering, and consistent reads.
Distance Metrics
The similarity between two vectors is measured by a distance function:
Cosine similarity (most common for text embeddings):
similarity = (A · B) / (||A|| × ||B||)
Measures the angle between vectors, ignoring magnitude. Range: [-1, 1]. Most text embedding models produce unit-normalized vectors, making cosine equivalent to dot product.
Dot product (fastest, used when vectors are normalized):
similarity = A · B = Σ(aᵢ × bᵢ)
When vectors are pre-normalized (unit length), dot product and cosine give identical rankings but dot product is 30-50% faster.
Euclidean distance (L2):
distance = √(Σ(aᵢ - bᵢ)²)
Measures actual spatial distance. Use when the magnitude of embeddings is meaningful (e.g., image features, some audio models).
Indexing Algorithms
The indexing algorithm determines the accuracy/speed/memory trade-off of your vector search.
HNSW (Hierarchical Navigable Small World)
HNSW is the dominant algorithm for in-memory vector search. It builds a multi-layer graph:
- Layer 0: all vectors connected to nearest neighbors (dense graph)
- Layer 1-N: progressively sparser graphs for long-range navigation
Query traversal: start at the top layer (few nodes, fast navigation to the right neighborhood), descend to layer 0, find exact nearest neighbors.
Key parameters:
M (connections per node): higher = better recall, more memory, slower build. Typical: 16–64.
ef_construction (build-time beam width): higher = better recall at build time, slower indexing. Typical: 100–400.
ef (query-time beam width): higher = better recall at query time, slower queries. Typical: 64–200.
Characteristics:
- Query time: O(log N) — excellent
- Memory: ~(M × 4 bytes × N) overhead on top of raw vectors
- 1M vectors at 1536 dims, M=16: ~6GB for vectors + ~0.1GB for index
- Recall@10: typically 95-99% with good ef settings
- Weakness: entire index must fit in RAM
IVF (Inverted File Index)
IVF clusters vectors using k-means, then searches only the nearest clusters at query time.
Key parameters:
nlist: number of clusters. Typical: sqrt(N) to 4×sqrt(N).
nprobe: number of clusters to search at query time. Higher = better recall, slower queries.
Characteristics:
- Supports out-of-core search (clusters stored on disk)
- Faster indexing than HNSW
- Recall is tunable via
nprobe (10% of nlist is a common starting point)
- Often combined with Product Quantization (IVF-PQ) to compress vectors and fit more in RAM
DiskANN
DiskANN (Microsoft Research) stores the full graph index on SSD, not RAM. Optimized for billion-scale datasets where the entire index won’t fit in memory.
Characteristics:
- Can index 1B+ vectors on commodity hardware
- Query latency: ~2-10ms (SSD seek time dominates)
- Memory requirement: only the compressed vectors in RAM, full graph on SSD
- Used by Azure Cognitive Search, Weaviate’s enterprise tier
ScaNN (Google)
Google’s Scalable Approximate Nearest Neighbor algorithm. Uses anisotropic quantization to reduce the vector space while preserving directional similarity. Excellent recall at high throughput.
pgvector: Vector Search in PostgreSQL
pgvector is a PostgreSQL extension that adds vector similarity search. If you’re already running PostgreSQL, it’s often the right first choice.
Installation and Setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
-- Enable the extension
CREATE EXTENSION IF NOT EXISTS vector;
-- Create a table with a vector column
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
content TEXT NOT NULL,
embedding VECTOR(1536), -- OpenAI ada-002 dimension
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create an HNSW index
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Or an IVF index (better for larger datasets where build time matters)
-- Must have data before creating IVF index
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100); -- lists ≈ sqrt(row_count)
|
Inserting and Querying
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
import psycopg2
from openai import OpenAI
import numpy as np
client = OpenAI()
conn = psycopg2.connect("postgresql://user:pass@localhost/mydb")
def embed(text: str) -> list[float]:
response = client.embeddings.create(
input=text,
model="text-embedding-3-small" # 1536 dims
)
return response.data[0].embedding
def index_document(content: str, metadata: dict = None):
embedding = embed(content)
with conn.cursor() as cur:
cur.execute(
"""INSERT INTO documents (content, embedding, metadata)
VALUES (%s, %s, %s)""",
(content, embedding, psycopg2.extras.Json(metadata or {}))
)
conn.commit()
def search(query: str, k: int = 5, filter_sql: str = None) -> list[dict]:
query_embedding = embed(query)
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
# Cosine similarity search with optional metadata filter
sql = """
SELECT
id,
content,
metadata,
1 - (embedding <=> %s::vector) AS similarity
FROM documents
{filter}
ORDER BY embedding <=> %s::vector
LIMIT %s
""".format(filter=f"WHERE {filter_sql}" if filter_sql else "")
cur.execute(sql, (query_embedding, query_embedding, k))
return cur.fetchall()
# Usage
index_document("Kubernetes is an open-source container orchestration system",
{"source": "docs", "category": "kubernetes"})
results = search("How do I deploy containers at scale?")
for r in results:
print(f"[{r['similarity']:.3f}] {r['content'][:80]}")
# Filtered search — only search within a category
results = search(
"container deployment",
filter_sql="metadata->>'category' = 'kubernetes'"
)
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
-- Set ef for query (higher = better recall, slower queries)
SET hnsw.ef_search = 100; -- Default 40, increase for better recall
-- For IVFFlat: how many clusters to search
SET ivfflat.probes = 10; -- Default 1! Increase for better recall
-- Check index is being used
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content, 1 - (embedding <=> '[...]'::vector) AS sim
FROM documents
ORDER BY embedding <=> '[...]'::vector
LIMIT 10;
-- Parallel query support
SET max_parallel_workers_per_gather = 4;
-- Memory for index build
SET maintenance_work_mem = '8GB';
-- Vacuum after bulk loads to update index statistics
ANALYZE documents;
|
When pgvector Is the Right Choice
- You already run PostgreSQL and don’t want another service
- Dataset is < 5M vectors (beyond this, dedicated databases are faster)
- You need complex SQL joins with your vector results
- ACID transactions matter (e.g., updating document + embedding atomically)
- Your team knows PostgreSQL operations
Qdrant is a purpose-built vector database written in Rust. It has the best performance-per-dollar of the self-hosted options and excellent Kubernetes support.
Running Qdrant
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
# Docker
docker run -p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant
# Docker Compose with persistence
cat > docker-compose.yml <<'EOF'
version: '3.8'
services:
qdrant:
image: qdrant/qdrant:latest
ports:
- "6333:6333"
- "6334:6334"
volumes:
- qdrant_storage:/qdrant/storage
environment:
QDRANT__SERVICE__GRPC_PORT: 6334
configs:
- source: qdrant_config
target: /qdrant/config/production.yaml
configs:
qdrant_config:
content: |
storage:
hnsw_index:
m: 16
ef_construct: 100
full_scan_threshold: 10000
volumes:
qdrant_storage:
EOF
docker compose up -d
|
Creating Collections and Indexing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
|
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, PointStruct,
Filter, FieldCondition, MatchValue, Range,
SearchRequest, PayloadSchemaType
)
import uuid
client = QdrantClient(host="localhost", port=6333)
# Create a collection
client.create_collection(
collection_name="documents",
vectors_config=VectorParams(
size=1536,
distance=Distance.COSINE,
# Enable quantization for 4x memory reduction
# with ~5% recall trade-off
on_disk=False, # Keep in RAM for speed
),
# Scalar quantization: compress float32 to int8 (4x less memory)
quantization_config={
"scalar": {
"type": "int8",
"quantile": 0.99,
"always_ram": True, # Keep quantized vectors in RAM
}
},
optimizers_config={
"default_segment_number": 4, # Parallel indexing segments
},
)
# Create payload index for fast filtering
client.create_payload_index(
collection_name="documents",
field_name="category",
field_schema=PayloadSchemaType.KEYWORD,
)
client.create_payload_index(
collection_name="documents",
field_name="created_at",
field_schema=PayloadSchemaType.DATETIME,
)
# Bulk insert with batching
def batch_upsert(vectors, payloads, batch_size=256):
for i in range(0, len(vectors), batch_size):
batch_vectors = vectors[i:i + batch_size]
batch_payloads = payloads[i:i + batch_size]
points = [
PointStruct(
id=str(uuid.uuid4()),
vector=vec,
payload=payload,
)
for vec, payload in zip(batch_vectors, batch_payloads)
]
client.upsert(collection_name="documents", points=points)
print(f"Upserted batch {i // batch_size + 1}")
# Search with filtering — Qdrant's killer feature
results = client.search(
collection_name="documents",
query_vector=embed("kubernetes deployment strategies"),
query_filter=Filter(
must=[
FieldCondition(
key="category",
match=MatchValue(value="kubernetes"),
),
FieldCondition(
key="score",
range=Range(gte=0.8), # Only high-quality documents
),
]
),
limit=10,
with_payload=True,
score_threshold=0.7, # Minimum similarity score
)
for hit in results:
print(f"[{hit.score:.3f}] {hit.payload['title']}")
|
Qdrant Sparse + Dense Hybrid Search
Hybrid search combines dense semantic search with sparse keyword search (BM25-style) for better retrieval:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
|
from qdrant_client.models import SparseVector, SparseVectorParams, NamedSparseVector, NamedVector
# Collection with both dense and sparse vectors
client.create_collection(
collection_name="hybrid_docs",
vectors_config={
"dense": VectorParams(size=1536, distance=Distance.COSINE),
},
sparse_vectors_config={
"sparse": SparseVectorParams(),
},
)
# Index a document with both vectors
from fastembed import SparseTextEmbedding, TextEmbedding
sparse_model = SparseTextEmbedding(model_name="prithivida/Splade_PP_en_v1")
dense_model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5")
text = "Kubernetes horizontal pod autoscaling guide"
dense_vec = list(dense_model.embed([text]))[0].tolist()
sparse_vec = list(sparse_model.embed([text]))[0]
client.upsert(
collection_name="hybrid_docs",
points=[PointStruct(
id=str(uuid.uuid4()),
vector={
"dense": dense_vec,
"sparse": SparseVector(
indices=sparse_vec.indices.tolist(),
values=sparse_vec.values.tolist()
),
},
payload={"text": text}
)]
)
# Hybrid query with Reciprocal Rank Fusion
from qdrant_client.models import Prefetch, FusionQuery, Fusion
results = client.query_points(
collection_name="hybrid_docs",
prefetch=[
Prefetch(
query=NamedVector(name="dense", vector=dense_query_vec),
limit=20,
),
Prefetch(
query=NamedSparseVector(name="sparse", vector=SparseVector(
indices=sparse_query.indices.tolist(),
values=sparse_query.values.tolist()
)),
limit=20,
),
],
query=FusionQuery(fusion=Fusion.RRF), # Reciprocal Rank Fusion
limit=10,
)
|
Weaviate: The ML-Native Database
Weaviate integrates directly with embedding model providers — it can generate embeddings for you via modules, rather than requiring pre-computed vectors.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
import weaviate
import weaviate.classes as wvc
client = weaviate.connect_to_local() # or connect_to_weaviate_cloud()
# Create a collection with automatic vectorization
client.collections.create(
name="Documents",
vectorizer_config=wvc.config.Configure.Vectorizer.text2vec_openai(
model="text-embedding-3-small",
),
generative_config=wvc.config.Configure.Generative.openai(
model="gpt-4o-mini",
),
properties=[
wvc.config.Property(name="content", data_type=wvc.config.DataType.TEXT),
wvc.config.Property(name="category", data_type=wvc.config.DataType.TEXT),
wvc.config.Property(name="score", data_type=wvc.config.DataType.NUMBER),
],
)
documents = client.collections.get("Documents")
# Insert — Weaviate calls OpenAI to generate the embedding automatically
with documents.batch.dynamic() as batch:
for doc in my_documents:
batch.add_object({
"content": doc["text"],
"category": doc["category"],
"score": doc["score"],
})
# Semantic search
results = documents.query.near_text(
query="container orchestration best practices",
limit=5,
filters=wvc.query.Filter.by_property("category").equal("kubernetes"),
return_metadata=wvc.query.MetadataQuery(score=True, distance=True),
)
# Generative search (RAG built-in)
results = documents.generate.near_text(
query="kubernetes networking",
limit=3,
grouped_task="Summarize these documents into a concise technical guide",
)
print(results.generated) # LLM-generated summary of retrieved docs
client.close()
|
Pinecone: Fully Managed, No Ops
Pinecone is a fully managed vector database — no servers to run, no indexes to tune at the infrastructure level. Pay per usage.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
from pinecone import Pinecone, ServerlessSpec
pc = Pinecone(api_key="your-api-key")
# Create a serverless index (AWS us-east-1)
pc.create_index(
name="documents",
dimension=1536,
metric="cosine",
spec=ServerlessSpec(
cloud="aws",
region="us-east-1"
)
)
index = pc.Index("documents")
# Upsert with namespaces (logical partitioning)
index.upsert(
vectors=[
{
"id": "doc-001",
"values": embed("some document content"),
"metadata": {
"category": "kubernetes",
"source": "docs",
"created_at": "2026-03-26",
}
}
],
namespace="production" # Logical partitioning within an index
)
# Query
results = index.query(
namespace="production",
vector=embed("container deployment"),
top_k=10,
filter={
"category": {"$eq": "kubernetes"},
"created_at": {"$gte": "2026-01-01"},
},
include_metadata=True,
)
for match in results.matches:
print(f"[{match.score:.3f}] {match.metadata.get('title', match.id)}")
|
Choosing the Right Vector Database
|
pgvector |
Qdrant |
Weaviate |
Pinecone |
| Hosting |
Self-hosted |
Self-hosted / Cloud |
Self-hosted / Cloud |
Fully managed |
| Best scale |
<5M vectors |
10M–1B+ vectors |
10M–1B+ vectors |
Unlimited |
| Query latency |
5–50ms |
1–10ms |
5–20ms |
10–50ms |
| Filtering |
Full SQL |
Excellent (indexed payload) |
Good |
Good |
| Hybrid search |
Manual |
Built-in (sparse+dense) |
Built-in |
Built-in |
| Ops burden |
Low (you know Postgres) |
Medium |
Medium |
None |
| Cost (1M vectors) |
~$0 extra |
Hosting costs |
Hosting costs |
~$70-140/mo |
| Transactions |
ACID (Postgres) |
Eventual |
Eventual |
Eventual |
| Auto-vectorize |
No |
No |
Yes |
No |
| Best for |
Existing Postgres users, SQL joins, < 5M |
Performance-critical, self-hosted |
ML-native apps, auto-embed |
No-ops, managed, fast start |
Decision guide:
- Running Postgres already and < 5M vectors? → pgvector
- Need maximum self-hosted performance, hybrid search, or > 10M vectors? → Qdrant
- Want auto-embedding + built-in RAG + no embedding pipeline? → Weaviate
- No ops team, want to focus on product, okay with managed cost? → Pinecone
Production Patterns
Chunking Strategy for RAG
How you split documents into chunks dramatically affects retrieval quality:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
from langchain.text_splitter import RecursiveCharacterTextSplitter
# Recursive splitting (respects document structure)
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # Characters per chunk
chunk_overlap=64, # Overlap to avoid losing context at boundaries
separators=["\n\n", "\n", ". ", " ", ""], # Split order
)
def chunk_and_index(document: str, doc_id: str, metadata: dict):
chunks = splitter.split_text(document)
for i, chunk in enumerate(chunks):
embedding = embed(chunk)
client.upsert(
collection_name="documents",
points=[PointStruct(
id=f"{doc_id}-chunk-{i}",
vector=embedding,
payload={
**metadata,
"chunk_index": i,
"total_chunks": len(chunks),
"text": chunk,
"doc_id": doc_id,
}
)]
)
|
Embedding Cache
Embedding generation is expensive and idempotent — cache aggressively:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
import hashlib
import redis
import json
cache = redis.Redis(host="redis", port=6379)
CACHE_TTL = 7 * 24 * 3600 # 1 week
def embed_cached(text: str, model: str = "text-embedding-3-small") -> list[float]:
# Deterministic cache key
key = f"embed:{model}:{hashlib.sha256(text.encode()).hexdigest()}"
cached = cache.get(key)
if cached:
return json.loads(cached)
embedding = embed(text) # Call OpenAI / local model
cache.setex(key, CACHE_TTL, json.dumps(embedding))
return embedding
|
The golden rule: pre-filter in the database, not in your application code. Applying metadata filters before vector search dramatically reduces the search space.
1
2
3
4
5
6
7
8
9
10
11
12
13
|
# Bad: fetch 1000 results, filter in Python
results = client.search(collection_name="docs", query_vector=vec, limit=1000)
filtered = [r for r in results if r.payload["user_id"] == current_user_id][:10]
# Good: filter in database, search smaller space
results = client.search(
collection_name="docs",
query_vector=vec,
query_filter=Filter(
must=[FieldCondition(key="user_id", match=MatchValue(value=current_user_id))]
),
limit=10,
)
|
For Qdrant, create payload indexes on all fields used in filters — this moves filtering from O(N) scan to O(log N) lookup.
Handling Index Updates
Vectors are immutable content-wise — you delete and re-insert when content changes. For document updates:
1
2
3
4
5
6
7
8
9
10
|
def update_document(doc_id: str, new_content: str, metadata: dict):
# Delete old chunks
client.delete(
collection_name="documents",
points_selector=Filter(
must=[FieldCondition(key="doc_id", match=MatchValue(value=doc_id))]
)
)
# Re-index with new content
chunk_and_index(new_content, doc_id, metadata)
|
Monitoring Vector Search Quality
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
# Track retrieval quality with a test set
TEST_QUERIES = [
("How do I configure RBAC in Kubernetes?", ["kubernetes-rbac-guide", "k8s-security"]),
("What is gradient descent?", ["ml-fundamentals", "optimization-guide"]),
]
def evaluate_recall_at_k(k=10):
hits = 0
for query, expected_ids in TEST_QUERIES:
results = search(query, k=k)
retrieved_ids = {r["doc_id"] for r in results}
if any(eid in retrieved_ids for eid in expected_ids):
hits += 1
recall = hits / len(TEST_QUERIES)
print(f"Recall@{k}: {recall:.2%}")
return recall
# Run periodically (e.g., after re-indexing or model changes)
evaluate_recall_at_k(10)
|
Quick Reference
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
# pgvector
CREATE EXTENSION vector;
CREATE TABLE t (id BIGSERIAL, v VECTOR(1536));
CREATE INDEX ON t USING hnsw (v vector_cosine_ops) WITH (m=16, ef_construction=64);
SELECT id, 1 - (v <=> '[...]'::vector) AS sim FROM t ORDER BY v <=> '[...]'::vector LIMIT 10;
# Qdrant Python
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
client = QdrantClient(host="localhost", port=6333)
client.create_collection("docs", vectors_config=VectorParams(size=1536, distance=Distance.COSINE))
client.upsert("docs", [PointStruct(id="1", vector=[...], payload={"category": "k8s"})])
results = client.search("docs", query_vector=[...], query_filter=Filter(must=[FieldCondition(key="category", match=MatchValue(value="k8s"))]), limit=10)
# Pinecone Python
from pinecone import Pinecone
pc = Pinecone(api_key="...")
index = pc.Index("my-index")
index.upsert(vectors=[{"id": "1", "values": [...], "metadata": {"cat": "k8s"}}])
results = index.query(vector=[...], top_k=10, filter={"cat": "k8s"}, include_metadata=True)
# Key distance operators (pgvector)
<=> cosine distance
<-> L2 (Euclidean) distance
<#> negative inner product (dot product similarity)
<+> L1 (Manhattan) distance
# Common embedding dimensions
text-embedding-3-small: 1536
text-embedding-3-large: 3072
BAAI/bge-small-en-v1.5: 384
nomic-embed-text: 768
all-MiniLM-L6-v2: 384
|
Vector databases are maturing fast. The choice that’s right for a 100K-document knowledge base (pgvector, simple and already there) is different from the right choice for a billion-document retrieval system (Qdrant with DiskANN, or a managed service). Start simple — pgvector on your existing Postgres instance handles the overwhelming majority of real-world RAG workloads. Migrate to a purpose-built database when you hit actual performance limits, not before.
Comments