Pick an index
| Index | Memory | Build speed | Recall/latency | Use when |
|---|---|---|---|---|
FLAT | Raw vectors | None | Exact, slow at scale | < 100k vectors, or ground truth for testing |
IVF_FLAT | ≈ raw | Fast | Good with enough nprobe | Medium data, quick rebuilds |
IVF_SQ8 | ~25% of raw | Fast | Slightly lower recall | Memory-tight, large data |
IVF_PQ | Much smaller | Medium | Lower recall; rerank recommended | Very large data, cost-driven |
HNSW | Raw + graph | Slow | Best latency/recall in memory | Default for most production search |
DISKANN | Small; data on NVMe | Slow | Good recall, higher latency than HNSW | Data larger than RAM, fast local SSD available |
AUTOINDEX | Chosen by Milvus | — | Balanced default | When you don't want to tune (on Zilliz Cloud, usually the only option) |
GPU indexes (GPU_CAGRA, GPU_IVF_FLAT…) | GPU memory | Very fast | Very high throughput | GPU query nodes, high QPS |
Newer releases add more types (for example HNSW variants with quantization, and RaBitQ-based IVF). Check the index list for your version before choosing.
Metric types
metric_type | Use with |
|---|---|
COSINE | Most text embeddings |
IP | Normalised embeddings (same ranking as cosine) |
L2 | Models trained for Euclidean distance |
HAMMING, JACCARD | Binary vectors |
BM25 | Sparse full-text search fields (2.5+) |
The metric is fixed at index build. Search must use the same metric.
Create an index
from pymilvus import MilvusClient
client = MilvusClient(uri="http://milvus:19530", token="root:<password>")
params = client.prepare_index_params()
params.add_index(
field_name="embedding",
index_type="HNSW",
metric_type="COSINE",
params={"M": 16, "efConstruction": 200},
)
params.add_index(field_name="tenant_id", index_type="INVERTED") # scalar index for filters
client.create_index(collection_name="kb_chunks", index_params=params)
Build and search parameters
| Index | Build params | Search param | Starting point |
|---|---|---|---|
HNSW | M (4–64), efConstruction (8–512) | ef (≥ top-k) | M 16, efConstruction 200, ef 64–128 |
IVF_FLAT, IVF_SQ8 | nlist (clusters) | nprobe | nlist ≈ 4×√rows; nprobe 16–64 |
IVF_PQ | nlist, m (sub-quantizers; divides dims), nbits | nprobe | m = dims/8 or dims/16 |
DISKANN | (mostly config-level) | search_list (≥ top-k) | 100 |
AUTOINDEX | — | level (higher = more recall) where supported | default |
Search with parameters:
res = client.search(
collection_name="kb_chunks",
data=[qvec],
anns_field="embedding",
limit=10,
filter='tenant_id == 42 and lang == "en"',
search_params={"metric_type": "COSINE", "params": {"ef": 128}},
output_fields=["doc_id", "title"],
)
Raising the search parameter (ef, nprobe, search_list) improves recall at the cost of latency. It doesn't need a rebuild, which makes it the first knob to turn.
Filter expressions
| Expression | Meaning |
|---|---|
tenant_id == 42 | Equality |
year >= 2025 and year < 2027 | Range |
source in ["jira", "confluence"] | Set |
title like "VPN%" | Prefix match |
array_contains(tags, "network") | Array field contains |
meta["team"] == "sre" | JSON field |
Add INVERTED (or BITMAP for low-cardinality fields) scalar indexes on fields you filter often.
Inspect indexes
client.list_indexes(collection_name="kb_chunks")
client.describe_index(collection_name="kb_chunks", index_name="embedding")
# shows index_type, params, total_rows, indexed_rows, pending_index_rows, state
pending_index_rows > 0 means recently inserted data is still being indexed (it's searched by brute force in growing segments meanwhile).
Change an index
An index can't be altered in place for type or build params:
client.release_collection(collection_name="kb_chunks")
client.drop_index(collection_name="kb_chunks", index_name="embedding")
client.create_index(collection_name="kb_chunks", index_params=new_params)
client.load_collection(collection_name="kb_chunks")
The collection is unavailable between release and load. For zero downtime, build a new collection with the new index, load it, and switch an alias:
client.alter_alias(collection_name="kb_chunks_v2", alias="kb_chunks_live")
Have applications always query the alias, never the physical collection name.
Measure recall
Compare against exact results on a sample:
exact = client.search(..., search_params={"metric_type": "COSINE"}, ...) # on a FLAT-indexed copy
approx = client.search(..., search_params={"metric_type": "COSINE", "params": {"ef": 64}})
recall = len(set(exact_ids) & set(approx_ids)) / len(exact_ids)
Run it over a few hundred real queries, not one.
Quick decisions
| Need | Choose |
|---|---|
| Low latency, fits in RAM | HNSW |
| Doesn't fit in RAM, NVMe available | DISKANN or mmap + HNSW |
| Lowest memory, accept lower recall | IVF_SQ8 / IVF_PQ + rerank |
| Very high QPS, GPUs available | GPU_CAGRA |
| Fast rebuilds | IVF_FLAT |
Prevent mistakes next time
- Always query through an alias so index changes become a switch, not an outage.
- Record index type and params in the collection description or your IaC.
- Tune the search parameter first; rebuild only when that isn't enough.
- Scalar-index every field used in filters.