Your enriched, embedded chunks need a home, and the internet will happily sell you seventeen different opinions on which one. This lesson is deliberately unglamorous, because the store matters less than the tuning, and the tuning matters less than measuring recall on your own real data rather than trusting a vendor’s benchmark numbers. But there are genuine decision criteria worth knowing, and one truly hard problem, filtered search, that meaningfully separates the engines from each other in practice.
pgvector versus dedicated engines
The 2026 landscape settled into two sane defaults for most teams, plus a long tail of niche players solving specific edge cases:
pgvector, a Postgres extension rather than a standalone database, is the boring choice that keeps winning small and mid-size deployments, and “boring” here is a genuine compliment given how much operational maturity it inherits for free. Your chunks are ordinary rows; vectors are a column on those rows; ACL tags, timestamps, and source metadata are more columns alongside them; and every filter established back in Module 7 is simply a WHERE clause that participates in real, cost-based query planning exactly like any other Postgres query. You inherit backups, replication, transactions, and whatever Postgres expertise your team has already built up over years, rather than needing to develop new operational muscle for an entirely separate system. Version 0.8 and later closed most of the historical gaps that used to push people away: iterative index scans fixed the worst of the filtered-search failures described below, and the halfvec type, storing 16-bit floats instead of 32-bit, halved memory usage with minimal accuracy cost. Its honest ceiling: single-node RAM bounds the size of the graph you can hold, roughly the 10-million-vector neighborhood depending on your chosen dimensions, and it offers no built-in BM25-grade lexical search, since Postgres full-text search is serviceable but not competitive with a dedicated lexical engine.
Dedicated engines, Qdrant, Weaviate, Milvus, and managed offerings like Vespa or Turbopuffer at the more specialized end, earn their added complexity specifically at scale: horizontal sharding past what fits on a single node’s RAM, native quantization with the rescoring flow covered in Lesson 9 built in rather than bolted on afterward, first-class hybrid search with server-side fusion (which Lesson 11 covers), multi-vector and sparse-vector support for advanced retrieval patterns, and filter-aware graph traversal that was designed into the system from the start rather than patched in later.
The selection heuristic this course uses from here on: default to Qdrant. It has no meaningful downside versus pgvector at any scale this course discusses, it runs embedded for local development exactly as easily as it runs as a cluster in production, and choosing it up front means you never hit pgvector’s ~10-million-vector ceiling mid-project and have to migrate under pressure. Reach for pgvector specifically in the narrow case where a vector column living inside your existing Postgres database, sharing transactions with your relational data and inheriting your team’s existing Postgres operational muscle, matters more than retrieval performance or built-in hybrid search. Migrating between vector stores later is far cheaper than migrating embedding models, since it’s fundamentally a copy job: the vectors themselves don’t change, only where they’re stored and queried from.
HNSW: the three knobs that matter
Every engine mentioned above defaults to HNSW, hierarchical navigable small worlds, a layered proximity graph that you search greedily from coarse upper layers down to fine lower ones. The algorithm exposes many tunable parameters in total, but three of them account for practically all the outcomes you’ll actually observe in production:
- m (set at build time): edges per node in the graph. More edges means a better-connected graph, a higher achievable recall ceiling, but also proportionally more RAM, since memory scales roughly with m. The range that matters in practice: 16 to 64. High-dimensional or heavily quantized setups tend to want the upper half of that range to compensate for the information lost to quantization.
- ef_construction (set at build time): the candidate frontier size explored while building the graph. Higher values build a measurably better graph structure, but building takes proportionally longer. Useful range: 100 to 400; pushing it further than where your own recall measurements plateau just burns build hours for no benefit.
- ef_search (set at query time): the candidate frontier size explored while querying an already-built graph. This is your live, adjustable recall-versus-latency dial, changeable per request with zero rebuild required, which makes it by far the most practically useful of the three parameters for day-to-day operation.
# Qdrant: build and query with explicit HNSW settings.
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, HnswConfigDiff, Datatype,
Filter, FieldCondition, MatchValue, MatchAny,
)
client = QdrantClient(url="http://localhost:6333")
client.create_collection(
collection_name="chunks",
# Datatype.FLOAT16 = 16-bit floats: half the RAM, negligible recall cost,
# the same tradeoff pgvector's halfvec type makes.
vectors_config=VectorParams(
size=1024, distance=Distance.COSINE, datatype=Datatype.FLOAT16,
),
hnsw_config=HnswConfigDiff(m=24, ef_construct=200),
)
# Per-request recall/latency dial, no rebuild required:
results = client.query_points(
collection_name="chunks",
query=query_vector,
search_params={"hnsw_ef": 80},
# The filter rides in the same query, evaluated by the same engine:
query_filter=Filter(
must=[
FieldCondition(key="acl_tags", match=MatchAny(any=user_acl_tags)), # Module 7
FieldCondition(key="source", match=MatchAny(any=allowed_sources)), # metadata filter
]
),
limit=40,
)
Tune these parameters by direct measurement on your own data, never by folklore borrowed from someone else’s corpus and query distribution. Take 500 real queries representative of your actual traffic, compute the exact top-10 results by brute-force comparison against every vector (perfectly fine to do offline, since it’s not latency-sensitive), then sweep ef_search across a range of values and plot recall@10 against measured p95 latency at each setting. Almost every corpus shows a clear knee in that curve, often somewhere between 60 and 150, past which further recall gains go essentially flat while latency keeps climbing steadily. Sit your production setting just past that knee, write the chosen value down next to your m and ef_construction settings in your infrastructure documentation, and re-run the entire sweep whenever the corpus grows by roughly an order of magnitude, since the knee’s location shifts as the graph grows larger and denser.
Filtered search: the honest hard problem
Enterprise queries are essentially never unfiltered in practice; at an absolute minimum they carry an ACL predicate enforcing who’s allowed to see what, as Module 7 establishes as non-negotiable. And filters genuinely break naive HNSW in a way that’s worth understanding rather than just working around blindly: the graph walk finds the globally nearest neighbors first, purely by vector distance, and if a strict filter then discards 99 percent of those neighbors afterward because they fail the permission or metadata check, you’re left with scraps, possibly far fewer results than the k you actually asked for, exactly as the quiz above walks you through proving to yourself.
Engines handle this problem in three distinct ways, and it’s worth knowing which approach a given engine takes before you trust its benchmark numbers, since unfiltered benchmarks can look identical across engines that behave very differently once real filters are applied:
- Filter-during-traversal: skip non-matching nodes while walking the graph itself, keeping the candidate frontier full of genuinely valid, filter-passing candidates throughout the search rather than discovering the mismatch only at the end (Qdrant, Weaviate, and pgvector’s iterative scans all approximate this behavior).
- Adaptive planning: when the query planner detects the filter is very selective, abandon the graph traversal entirely and exact-scan just the filtered subset directly, which can be faster than graph traversal when the subset is small enough; when the filter is loose instead, fall back to using the graph as normal (pgvector 0.8 and later, several dedicated engines).
- Partitioning: physically separate high-cardinality boundaries into their own collections ahead of time, so the filter becomes “which index to query” rather than “which rows within one index to keep.” This is also, not coincidentally, the correct multi-tenancy isolation model, and Module 7 returns to this exact pattern from the security angle.
The operational rule this analysis leaves you with, and the single most important sentence in this lesson: always benchmark recall with production-shaped filters actually applied, never on unfiltered queries alone. An engine that scores a beautiful 0.98 recall on unfiltered benchmark queries and a much less impressive 0.71 once your real ACL mix is applied is, for your actual purposes, a 0.71 engine, and choosing it based on the unfiltered number alone is choosing based on a number you will never see in production.
Store chosen, graph tuned against measured recall, and filters treated honestly rather than benchmarked away. But dense vectors alone still miss an entire, embarrassingly common class of queries, things like “error AUTH-4012” or “the Kepler-7 proposal”, and fixing that requires admitting that 1970s-era lexical search never actually stopped being useful. That’s hybrid search, and it’s next.