Milvus năm 2025

Làm mới bài cũ về Milvus: API hiện tại, HNSW so với IVF, và một demo tìm ảnh trên một triệu vector chạy gọn trong Docker.

5 May 2026

Mấy năm trước mình có viết một bài về Milvus, nhưng API hồi đó đã lỗi thời gần hết. Bài này mình viết lại từ đầu theo cách dùng hiện tại, kèm một demo tìm ảnh tương tự trên một triệu vector chạy trong Docker. Mình sẽ cố gắng giải thích tại sao lại chọn index này thay vì index kia, chứ không chỉ dán lệnh cho xong.

Milvus là gì?

Về lý thuyết, Milvus là một vector database: một cơ sở dữ liệu chuyên để lưu và truy vấn vector embedding. Khác với database quan hệ truyền thống nơi ta hỏi “tìm dòng có id = 42”, với vector database ta hỏi “tìm 10 vector gần nhất với vector truy vấn này”. Phép “gần nhất” ở đây là approximate nearest neighbor (ANN), gần đúng chứ không tuyệt đối, và chính chữ “approximate” đó là toàn bộ lý do vector database tồn tại.

Tại sao lại cần gần đúng? Vì tìm chính xác k vector gần nhất trong hàng triệu vector chiều cao (ví dụ 768 hoặc 1536 chiều) bằng cách so từng cặp là brute force, độ phức tạp O(N * d) cho mỗi truy vấn. Với một triệu vector 768 chiều, mỗi câu query phải làm gần một tỉ phép nhân. Chấp nhận sai số vài phần trăm để đổi lấy tốc độ nhanh hơn hàng trăm lần: đó là giao kèo cốt lõi của ANN.

Milvus đóng gói phần ANN đó lại, thêm vào lớp lưu trữ, phân mảnh (sharding), sao lưu, lọc theo metadata, và một giao diện để ta không phải tự cài HNSW hay IVF bằng tay. Đổi lại, nó là một hệ phân tán khá nặng: bản đầy đủ chạy trên nhiều thành phần (etcd, MinIO, message queue). May là có bản Milvus Lite và bản standalone bằng Docker cho việc học và làm demo.

Một chút lịch sử

Milvus ra đời khoảng năm 2019 từ Zilliz, và sau đó được trao cho LF AI & Data Foundation (một nhánh của Linux Foundation). Giai đoạn 1.x nó còn khá đơn giản. Bước ngoặt là bản 2.x, viết lại theo kiến trúc tách rời tính toán và lưu trữ (compute và storage), nhắm tới quy mô lớn và khả năng scale ngang.

Điểm khiến API cũ trong bài trước của mình hết dùng được là gói PyMilvus. Bản cũ dùng lối Collection, connections.connect() khá dài dòng. Từ khoảng cuối chu kỳ 2.4 trở đi, PyMilvus giới thiệu lớp MilvusClient, gom hầu hết thao tác vào một đối tượng duy nhất. Bài này mình dùng lối MilvusClient vì nó là cách được khuyến nghị hiện tại và ngắn hơn nhiều.

HNSW so với IVF

Đây là quyết định quan trọng nhất khi dùng Milvus, quan trọng hơn cả việc chọn Milvus hay đối thủ. Có hai họ index phổ biến, và chúng đại diện cho hai triết lý khác nhau.

IVF: chia không gian thành ô

IVF (Inverted File) hoạt động theo kiểu phân cụm. Lúc build index, Milvus chạy k-means để chia toàn bộ vector thành nlist cụm, mỗi cụm có một tâm (centroid). Khi truy vấn, thay vì so với tất cả, ta chỉ so với vector nằm trong nprobe cụm gần vector truy vấn nhất.

  • nlist: số cụm khi build. Nhiều cụm thì mỗi cụm nhỏ, quét nhanh, nhưng dễ bỏ sót vector nằm sát ranh giới cụm.
  • nprobe: số cụm sẽ quét khi tìm. Tăng nprobe thì recall tăng, tốc độ giảm. Đây là núm vặn recall/tốc độ lúc chạy.

IVF thường đi kèm nén để tiết kiệm RAM. IVF_FLAT giữ nguyên vector gốc (chính xác nhất, tốn RAM nhất). IVF_SQ8 nén mỗi chiều xuống 1 byte. IVF_PQ dùng product quantization, nén rất mạnh nhưng đánh đổi độ chính xác. Điểm mạnh của họ IVF là thời gian build nhanhtốn ít bộ nhớ hơn.

HNSW: đồ thị nhiều tầng

HNSW (Hierarchical Navigable Small World) là một dạng đồ thị. Mỗi vector là một đỉnh, được nối với các vector lân cận thành một đồ thị “thế giới nhỏ” có nhiều tầng, tầng trên thưa để nhảy xa, tầng dưới dày để tinh chỉnh. Truy vấn là đi bộ trên đồ thị: bắt đầu ở tầng cao, tham lam đi về phía vector truy vấn, rồi tụt dần xuống tầng thấp.

  • M: số cạnh tối đa mỗi đỉnh. M lớn thì đồ thị dày, recall cao, nhưng tốn RAM và build lâu.
  • efConstruction: độ rộng tìm kiếm lúc build. Lớn thì đồ thị chất lượng hơn, build chậm hơn.
  • ef (hoặc efSearch): độ rộng tìm kiếm lúc query. Đây là núm recall/tốc độ lúc chạy, tương tự nprobe của IVF.

HNSW thường cho recall cao và độ trễ thấp nhất ở cùng mức recall, nên nó là lựa chọn mặc định hợp lý cho phần lớn ứng dụng. Cái giá là tốn RAM (phải giữ cả đồ thị lẫn vector trong bộ nhớ) và build lâu hơn IVF.

Vậy chọn cái nào?

Một cách nghĩ đơn giản:

  1. Dữ liệu vừa phải, cần độ trễ thấp và recall cao, có đủ RAM: chọn HNSW.
  2. Dữ liệu rất lớn, RAM là ràng buộc, chấp nhận recall thấp hơn chút: chọn IVF_PQ hoặc IVF_SQ8.
  3. Chỉ vài chục nghìn vector: đôi khi FLAT (brute force, chính xác 100%) là đủ và đơn giản nhất. Đừng tối ưu sớm.

Với demo một triệu vector bên dưới, mình chọn HNSW vì một triệu vector vẫn nằm gọn trong RAM của một máy để bàn bình thường, và mình muốn recall cao để kết quả tìm ảnh nhìn cho ra hồn.

Chạy Milvus standalone trong Docker

Milvus có sẵn script biên dịch cấu hình Docker Compose. Cách nhanh nhất để có một node standalone:

# Tải script cài đặt chính thức
curl -sfL https://raw.githubusercontent.com/milvus-io/milvus/master/scripts/standalone_embed.sh -o standalone_embed.sh

# Khởi động Milvus standalone
bash standalone_embed.sh start

Lệnh này dựng một container Milvus lắng nghe ở cổng 19530 (gRPC) và 9091 (health/metrics). Kiểm tra sống chưa:

curl http://localhost:9091/healthz

Nếu bạn chỉ muốn thử cực nhanh mà không cần Docker, có thể dùng Milvus Lite (nhúng thẳng vào tiến trình Python, dữ liệu lưu trong một file cục bộ). Nhưng Milvus Lite hợp cho tập nhỏ; với một triệu vector mình khuyên chạy bản standalone trong Docker.

Demo: tìm ảnh trên một triệu vector

Ý tưởng: biến mỗi ảnh thành một vector bằng một mô hình embedding, nạp vào Milvus, rồi cho một ảnh truy vấn để tìm những ảnh giống nhất. Ở đây mình tách phần trích xuất đặc trưng ra khỏi phần Milvus, vì phần embedding tùy thuộc mô hình bạn dùng (CLIP, ResNet, hay bất kỳ mô hình vision nào). Điều Milvus quan tâm chỉ là: một vector float32 có số chiều cố định.

Giả sử ta đã có embedding dạng CLIP ViT-B/32, tức 512 chiều, chuẩn hóa L2. Cài client:

pip install pymilvus numpy

Tạo collection và index

import numpy as np
from pymilvus import MilvusClient, DataType

client = MilvusClient(uri="http://localhost:19530")

DIM = 512
COLLECTION = "image_search"

# Xóa nếu đã tồn tại, để chạy lại cho sạch
if client.has_collection(COLLECTION):
    client.drop_collection(COLLECTION)

# Định nghĩa schema: id + vector + đường dẫn ảnh làm metadata
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=DIM)
schema.add_field("path", DataType.VARCHAR, max_length=512)

# Tham số index HNSW + metric COSINE (hợp với embedding đã chuẩn hóa)
index_params = client.prepare_index_params()
index_params.add_index(
    field_name="embedding",
    index_type="HNSW",
    metric_type="COSINE",
    params={"M": 16, "efConstruction": 200},
)

client.create_collection(
    collection_name=COLLECTION,
    schema=schema,
    index_params=index_params,
)

Một điểm dễ sai: metric_type. Nếu embedding đã chuẩn hóa L2 thì COSINE và tích vô hướng (IP) cho thứ hạng như nhau, nhưng nếu chưa chuẩn hóa mà lại dùng IP thì kết quả sẽ lệch. Chọn metric đúng với cách bạn tạo vector, đừng để mặc định quyết định thay.

Nạp một triệu vector

Ở đây mình sinh vector ngẫu nhiên để minh họa hình dạng dữ liệu và cách chèn theo lô (batch). Trong thực tế bạn thay np.random.random bằng embedding thật của ảnh.

TOTAL = 1_000_000
BATCH = 10_000

for start in range(0, TOTAL, BATCH):
    n = min(BATCH, TOTAL - start)
    vecs = np.random.random((n, DIM)).astype(np.float32)
    # Chuẩn hóa L2 cho khớp với metric COSINE
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)

    rows = [
        {
            "id": start + i,
            "embedding": vecs[i].tolist(),
            "path": f"/data/img/{start + i}.jpg",
        }
        for i in range(n)
    ]
    client.insert(collection_name=COLLECTION, data=rows)
    print(f"đã chèn {start + n}/{TOTAL}")

# Đẩy dữ liệu từ buffer xuống lớp lưu trữ bền
client.flush(COLLECTION)

Chèn theo lô cỡ vài nghìn tới vài chục nghìn dòng là hợp lý: lô quá nhỏ thì tốn round-trip mạng, lô quá lớn thì tốn RAM và dễ chạm giới hạn kích thước message. Sau khi chèn xong nhớ flush để dữ liệu được ghi bền, rồi load collection vào bộ nhớ trước khi tìm:

client.load_collection(COLLECTION)

Truy vấn ảnh giống nhất

# Vector truy vấn: trong thực tế là embedding của ảnh người dùng đưa vào
query = np.random.random((1, DIM)).astype(np.float32)
query /= np.linalg.norm(query, axis=1, keepdims=True)

results = client.search(
    collection_name=COLLECTION,
    data=query.tolist(),
    limit=10,
    search_params={"metric_type": "COSINE", "params": {"ef": 64}},
    output_fields=["path"],
)

for hit in results[0]:
    print(hit["id"], round(hit["distance"], 4), hit["entity"]["path"])

Núm cần vặn ở đây là ef. Với ef=64 bạn được tốc độ tốt; nếu recall chưa đủ, tăng dần lên 128, 256 và đo lại. Nguyên tắc: ef phải lớn hơn hoặc bằng limit, và tăng ef luôn đánh đổi độ trễ.

Lọc theo metadata

Điểm mà một vector database ăn đứt việc tự cài HNSW ở nhà là lọc kết hợp. Ví dụ chỉ tìm trong ảnh thuộc một thư mục:

results = client.search(
    collection_name=COLLECTION,
    data=query.tolist(),
    limit=10,
    filter='path like "/data/img/1%"',
    search_params={"metric_type": "COSINE", "params": {"ef": 64}},
    output_fields=["path"],
)

Cần lưu ý: lọc quá gắt (chỉ còn vài phần trăm dữ liệu thỏa điều kiện) có thể làm recall của ANN tụt, vì đồ thị HNSW được xây trên toàn bộ dữ liệu chứ không riêng phần đã lọc. Đây là một góc khuất hay bị bỏ qua khi ghép filter với vector search, và cũng là chỗ các vector database khác nhau ở chất lượng cài đặt.

Vài điều đọng lại

Nếu phải rút gọn: Milvus không phải thứ để cài cho mọi dự án. Với vài chục nghìn vector, một thư viện như FAISS nhúng thẳng trong tiến trình, hoặc thậm chí một numpy brute force, có khi đơn giản và đủ nhanh hơn. Milvus bắt đầu đáng giá khi bạn cần: dữ liệu vượt quá RAM một máy, cập nhật liên tục, lọc metadata phức tạp, hoặc nhiều dịch vụ cùng truy vấn một kho vector.

Còn về HNSW so với IVF, thú thật mình không nghĩ có câu trả lời “đúng” cho mọi trường hợp. Cách trung thực nhất vẫn là dựng cả hai trên đúng dữ liệu của bạn, đo recall và độ trễ ở vài mức tham số, rồi chọn theo con số chứ không theo lời khuyên trên mạng (kể cả bài này). Vector search là một lĩnh vực mà trực giác hay đánh lừa ta, và cái giá của việc đo đạc thường rẻ hơn cái giá của việc chọn sai index rồi phải build lại một triệu vector lúc nửa đêm.

Tài liệu tham khảo

  • Milvus documentation, https://milvus.io/docs
  • PyMilvus documentation, https://milvus.io/docs/quickstart.md
  • Malkov & Yashunin, “Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs” (bài báo gốc về HNSW)
  • FAISS wiki, “The index factory” (giải thích IVF, PQ, Flat), https://github.com/facebookresearch/faiss/wiki
  • LF AI & Data Foundation, trang dự án Milvus