Home / AI & GenAI / Vector Databases

AI & GenAICheat sheetIntermediate

Milvus index types cheat sheet: HNSW, IVF, DiskANN, AUTOINDEX and the build and search parameters for each

Which Milvus index to choose for your data size, memory budget and recall target, how to create it with pymilvus, which search parameter controls recall for each one, and the commands to inspect and rebuild indexes.

Deepak Bhardwaj4 min read

Applies toMilvus 2.4-2.6, pymilvus MilvusClient API; parameter names are the same in Attu and the REST API

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.