Area 02 // RAGLesson 36 / 14017 min read
Vector databases, HNSW and pgvector
A vector database stores embeddings with their text and metadata and finds the nearest ones to a query in milliseconds, using approximate indexes like HNSW; pgvector adds the same ability to ordinary Postgres, and metadata filters narrow results by things like date, customer or permissions.
Think of it like this
Analogy
A vector database is a huge shop where every product is placed on a giant map by what it is like, and the staff can instantly walk you to "the 5 products most like this one". Checking every shelf in the shop for each customer would take forever. Instead, the shop keeps a network of signposts between nearby shelves (HNSW), so staff hop from signpost to signpost straight to the right area.
Metadata filters are telling them "only things in my size and under £50". pgvector is adding that same map and those signposts to the shop's existing stock system (Postgres), instead of building a second shop.
The problem it solves
After embedding your chunks you have thousands or millions of vectors. For every question, you need the few nearest ones, in milliseconds, often restricted to one customer's data or recent documents. Comparing the query with every vector works at a few thousand, but becomes too slow at millions, and you also need to store text and metadata, update and delete chunks, and filter safely.
That's the job of a vector database (or a normal database with vector support).
How it works: vector databases
- Store a record per chunk: an ID, the embedding vector, the chunk text, and metadata (document ID, source, date, tenant, permissions).
- Query by similarity: "give me the k records whose vectors are nearest to this query vector", using cosine, dot product or Euclidean distance.
- Exact (brute-force) search compares against every vector. Perfect results, fine for up to roughly tens of thousands of vectors.
- Approximate nearest neighbour (ANN) indexes skip most comparisons. They might miss a few true neighbours (lower recall) but are hundreds of times faster at scale.
- Upserts and deletes keep the index in sync with your documents (ingestion).
- Common choices (as of November 2026): pgvector (Postgres), Qdrant, Weaviate, Milvus, Pinecone (managed), Chroma and LanceDB (embedded, easy local start), and Elasticsearch / OpenSearch (search engines with vector support, great for hybrid search).
How it works: HNSW indexes
- HNSW (hierarchical navigable small world) links each vector to a handful of its nearest neighbours, forming a graph.
- It builds several layers: the top layer has few points with long-distance links (motorways); lower layers are denser (local streets); the bottom layer has every point.
- To search, start at the top, greedily hop to whichever neighbour is closer to the query, drop down a layer, repeat, and finally explore a candidate list at the bottom to collect the top k.
- Build-time knobs:
m(links per node, more = better recall, more memory) andef_construction(effort while building). pgvector defaults: m = 16, ef_construction = 64. - Query-time knob:
ef_search(candidate list size; pgvector default 40). Raise it for better recall, lower it for speed. - Trade-offs: HNSW is fast and accurate but uses a lot of memory and is slower to build; IVF-style indexes are smaller but usually less accurate at the same speed.
How it works: pgvector
- Enable it once per database:
CREATE EXTENSION vector;(as of November 2026 the latest release is 0.8.7). - Add a column:
embedding vector(1536)(the dimension must match your embedding model). - Insert vectors like any other column.
- Query nearest neighbours:
ORDER BY embedding <=> '[...]' LIMIT 5. Operators:<=>cosine distance,<->L2 distance,<#>negative inner product. - Add an HNSW index for speed:
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops); - Join and filter with normal SQL: vectors live next to your users, tenants and permissions tables, inside transactions.
How it works: metadata filtering
- Store filterable fields on each chunk: tenant/customer ID, document type, language, date, access groups.
- Filter at query time: "nearest 5 chunks where tenant_id = 42 and year ≥ 2025".
- Know the ANN trap: approximate indexes find nearest candidates first and filter afterwards. If only 10% of vectors match the filter, most candidates get thrown away and you get too few results (pgvector's docs: with ef_search 40 and 10% selectivity, about 4 rows).
- Fixes: pgvector's iterative index scans (
SET hnsw.iterative_scan = strict_order;orrelaxed_order), a higheref_search, partial indexes or partitions per tenant, or vector databases with built-in filtered search. - Use filters for security, not just relevance: permission filters must be applied on the server for every query (access control in RAG).
See it in code
- Install
pip install numpy- Needs
- Python 3.10+. The pgvector SQL needs Postgres with the vector extension (e.g. the pgvector/pgvector Docker image).
What a vector store does in 20 lines (brute-force search plus filters), then the same thing in pgvector.
import numpy as np
class TinyVectorStore:
"""What a vector database does, minus the clever index: store, filter, rank."""
def __init__(self):
self.vectors, self.items = [], []
def add(self, vector, text, **metadata):
v = np.asarray(vector, dtype=float)
self.vectors.append(v / np.linalg.norm(v)) # normalise once, so dot = cosine
self.items.append({"text": text, **metadata})
def search(self, query, k=2, where=None):
q = np.asarray(query, dtype=float); q /= np.linalg.norm(q)
scores = np.array(self.vectors) @ q # cosine similarity with every vector
order = np.argsort(-scores)
hits = [(float(scores[i]), self.items[i]) for i in order
if not where or all(self.items[i].get(k2) == v for k2, v in where.items())]
return hits[:k]
db = TinyVectorStore()
db.add([0.9, 0.1, 0.0], "Refunds within 30 days (UK)", country="UK", year=2026)
db.add([0.8, 0.2, 0.1], "Refunds within 14 days (DE)", country="DE", year=2026)
db.add([0.9, 0.0, 0.1], "Refunds within 60 days (UK, old)", country="UK", year=2023)
db.add([0.0, 0.1, 0.9], "Shipping takes 3 days", country="UK", year=2026)
query = [1.0, 0.1, 0.0] # "refund policy" as a vector
print("no filter :", [(round(s, 2), i["text"]) for s, i in db.search(query)])
print("UK, 2026 only:", [(round(s, 2), i["text"]) for s, i in db.search(query, where={"country": "UK", "year": 2026})])no filter : [(1.0, 'Refunds within 30 days (UK)'), (0.99, 'Refunds within 60 days (UK, old)')]
UK, 2026 only: [(1.0, 'Refunds within 30 days (UK)'), (0.01, 'Shipping takes 3 days')]Without a filter, the outdated 2023 policy ranks second. With the filter it's gone, though notice the second result is now an irrelevant shipping chunk with score 0.01. Real systems also drop results below a tuned similarity threshold.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id text NOT NULL,
tenant_id int NOT NULL,
content text NOT NULL,
embedding vector(1536) -- must match the embedding model's dimension
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops); -- fast cosine search
SET hnsw.iterative_scan = strict_order; -- keep scanning when filters remove candidates
-- The 5 most similar chunks for tenant 42 ($1 = the query embedding from your app)
SELECT doc_id, content, 1 - (embedding <=> $1) AS cosine_similarity
FROM chunks
WHERE tenant_id = 42
ORDER BY embedding <=> $1
LIMIT 5;A real example
Example
One Postgres for a SaaS support assistant
A B2B SaaS company with 3,000 customers built a support assistant over each customer's own documents. They already ran Postgres, so they added pgvector instead of a new database: chunks live in a table with tenant_id, and every query filters by the logged-in user's tenant. Row-level security in Postgres means even a buggy query can't read another tenant's chunks.
With 4 million chunks, a few large tenants dominated. Their first filtered queries sometimes returned only 1–2 chunks for small tenants; enabling iterative index scans fixed it. At 40 million chunks they began evaluating a dedicated vector database, but pgvector's "one database, normal SQL, transactions" simplicity carried them a long way.
The easy way
Start here
Prototype with Chroma or LanceDB (they run inside your Python process, no server). If you already use Postgres, use pgvector from day one: one CREATE EXTENSION, one column, one index. Move to a dedicated vector database only when scale or features demand it.
When you grow: the production setup
At scale, teams tune HNSW parameters against recall targets, quantise vectors to cut memory, shard by tenant or time, run hybrid search in the same engine, back up and replicate the index like any database, monitor query latency and recall, and keep a re-indexing path for embedding model changes.
Trade-offs
What you gain
- Millisecond similarity search over millions of vectors.
- Metadata filters for freshness, tenants and permissions.
- pgvector keeps vectors next to your data with SQL, joins and transactions.
What it costs you
- ANN indexes trade a little recall for speed and use lots of memory.
- Filtered ANN search needs care to return enough results.
- Another system to operate (unless you use pgvector or a managed service).
When not to use it
- Under roughly tens of thousands of chunks: brute-force numpy or an embedded store is simpler and exact.
- You need exact keyword or structured queries: use a search engine or SQL (often alongside vectors).
Common mistakes
- Forgetting the vector column dimension must match the embedding model.
- Using an ANN index with selective filters and silently getting too few results.
- Filtering permissions in application code after retrieval instead of in the query.
- Picking a new vector database before checking whether pgvector in your existing Postgres is enough.
- Never measuring the index's recall against exact search.
Interview questions
Try answering out loud first, then open the model answer.
How does HNSW work and what are its trade-offs?
Model answer
HNSW builds a multi-layer proximity graph: each node links to its m nearest neighbours, with sparse upper layers for long jumps and a dense bottom layer. Search enters at the top, greedily moves to closer neighbours, descends layers, and explores a candidate list of size ef_search at the bottom. It gives high recall at low latency, but uses significant memory, is slower to build than IVF indexes, and needs tuning of m, ef_construction and ef_search.
pgvector or a dedicated vector database?
Model answer
pgvector if you already run Postgres, want vectors alongside relational data with SQL joins, transactions and row-level security, and your scale is up to tens of millions of vectors with reasonable latency needs. A dedicated vector database when you need very large scale, high QPS, advanced filtered search, built-in hybrid search, multi-tenancy features, or managed operations. Measure latency and recall on your workload before deciding.
How do you handle metadata filtering with approximate indexes?
Model answer
Understand that post-filtering an ANN result can starve results under selective filters. Options: iterative or filtered index scans (pgvector iterative scans), increasing ef_search, partitioning or separate indexes per tenant, pre-filtering then exact search for small subsets, or engines with filter-aware ANN. For security filters (tenant, ACL), enforce them in the query itself, never only in application code afterwards.
Quiz
Pick an answer to see whether it’s right and why.
0 of 6 answered
Key terms
- Vector database
- A database that stores embeddings with their text and metadata and quickly finds the ones nearest to a query vector.
- Approximate nearest neighbour (ANN)
- Search methods that find almost-the-nearest vectors very fast by skipping most comparisons, trading a little recall for speed.
- HNSW
- Hierarchical navigable small world. A popular ANN index that links vectors into a layered graph and searches it greedily.
- pgvector
- A Postgres extension that adds a vector column type, distance operators and HNSW/IVF indexes, so Postgres can do similarity search.
- Metadata filtering
- Restricting search results by fields stored with each chunk, such as tenant, date, language or access group.
- Recall
- The share of the truly relevant items that a search (or classifier) actually finds.
Before and after
Progress is saved in this browser only.