Home / AI & GenAI / Vector Databases

AI & GenAITroubleshootingAdvanced

Milvus deletes don't free space and searches slow down: segments, delete logs, compaction and garbage collection

After deleting or upserting millions of entities, Milvus row counts look wrong, object storage keeps growing, memory doesn't drop and searches get slower. Understand how Milvus deletes work, check segment and compaction state, and get storage and latency back under control.

Deepak Bhardwaj5 min read

Applies toMilvus 2.4-2.6 standalone and distributed, MinIO or S3-compatible storage, pymilvus MilvusClient

Symptoms

  • You deleted half a collection, but get_collection_stats row count barely changed.
  • MinIO/S3 usage grows every week although data volume is stable.
  • Query node memory stays high after large deletes.
  • Searches and filtered queries are slower than before a bulk upsert.

How Milvus stores and deletes data

  • Data is written into segments. Growing segments accept inserts; when full or flushed they become sealed and get indexed.
  • A delete doesn't rewrite the segment. It writes a delete log (a tombstone). Searches must check every result against these delete logs.
  • An upsert is a delete plus an insert, so heavy upserts create tombstones too.
  • Compaction rewrites segments without deleted rows and merges small segments. Only then is space and memory freed.
  • Garbage collection later removes the old segment files from object storage, after a safety delay.

So: delete → tombstone (instant, logical) → compaction (later, physical) → GC (later still, storage).

Step 1: confirm what you're looking at

from pymilvus import MilvusClient
client = MilvusClient(uri="http://milvus:19530", token="root:<password>")

client.get_collection_stats(collection_name="kb_chunks")      # row_count may include deleted rows until compaction
client.query(collection_name="kb_chunks", filter="", output_fields=["count(*)"])   # live count, excludes deleted

The count(*) query is the number of visible entities. If it's right and stats are higher, deletes worked and compaction hasn't caught up yet.

Step 2: look at segments

Attu (Milvus web UI) shows segments per collection with state, row counts and size. With pymilvus' ORM utility:

from pymilvus import connections, utility
connections.connect(uri="http://milvus:19530", token="root:<password>")
for s in utility.get_query_segment_info("kb_chunks"):
    print(s.segmentID, s.num_rows, s.state, s.index_name)

Warning signs:

Observation Meaning
Hundreds or thousands of small segments Frequent tiny flushes; too many small segments slows search
Large segments with many deletes Waiting for compaction
Many growing segments Inserts not reaching seal/flush; often too many manual flushes or small writes

Step 3: trigger and watch compaction

Milvus compacts automatically (mix compaction when deleted ratio passes a threshold, merge of small segments, and periodic clustering compaction if configured). After a large planned delete, trigger it:

job_id = client.compact(collection_name="kb_chunks")
print(client.get_compaction_state(job_id=job_id))      # Executing → Completed

Compaction needs data node (or streaming/compaction node, depending on version) CPU and object storage bandwidth. On a busy cluster it can take hours; run it off-peak.

Relevant settings in milvus.yaml (names from the default config; verify in your version):

Setting Effect
dataCoord.compaction.enableAutoCompaction Must be true
dataCoord.compaction.single.ratio.threshold Deleted-row ratio that triggers rewriting a segment
dataCoord.segment.maxSize Target segment size (MB); larger = fewer segments
dataCoord.gc.dropTolerance / gc.interval How long before dropped files are removed

If auto compaction was disabled during a bulk load and never re-enabled, that's your cause.

Step 4: wait for garbage collection

After compaction, old segment files stay in object storage until GC removes them (by default after a tolerance period, often around a day). Storage dropping a day or so after compaction is normal. If it never drops:

  • Check data coordinator logs for GC errors (permissions to delete objects in the bucket).
  • Check that the backup tool or another process isn't holding the same prefix.
  • Bucket versioning keeps deleted objects as old versions; add a lifecycle rule to expire non-current versions.
kubectl logs -n milvus <datacoord-or-mixcoord-pod> | grep -iE "gc|garbage|remove" | tail -20

Step 5: fix the write pattern

Pattern Problem Better
client.flush() after every small insert Thousands of tiny segments Let Milvus flush automatically; flush only at end of bulk loads
Upsert of all chunks on every document save Tombstones for unchanged data Hash chunks; upsert only changed ones
Delete then re-insert a whole collection nightly Constant compaction churn Build a new collection, switch the alias, drop the old one
Single-row inserts Many RPCs, small growing segments Batch inserts (thousands of rows)

For full refreshes, the alias switch is far cheaper than delete + insert: dropping a collection removes its segments without compaction.

client.alter_alias(collection_name="kb_chunks_20261028", alias="kb_chunks_live")
client.drop_collection(collection_name="kb_chunks_20261027")

Step 6: check search after cleanup

After compaction completes:

  • Segment count should drop and segments should be larger.
  • Search latency should return to baseline.
  • Query node memory should fall once the compacted segments are loaded and old ones released.

If latency stays high, look at filters (missing scalar indexes) and search parameters, not compaction.

Decision table

Finding Action
count(*) correct, stats high Normal; wait for or trigger compaction
Many small segments Stop per-insert flushes; compact
Compaction never runs Re-enable auto compaction; check data node health
Storage doesn't drop after compaction + GC delay GC errors, bucket versioning, permissions
Nightly delete/reload Switch to new collection + alias

Prevent it next time

  • Never call flush() per write; batch inserts.
  • Use diff-based ingestion so unchanged chunks aren't upserted.
  • Use alias switching for full rebuilds.
  • Graph segment count, compaction backlog and object storage size per collection.