Nút thắt cổ chai trong thực tế: Mở rộng Semantic Search vượt mốc 100k Vector
Mọi thứ hoạt động hoàn hảo trong môi trường sandbox cục bộ. Nhưng ngay khi chúng tôi nạp 5 triệu tài liệu doanh nghiệp vào cụm staging, pipeline tìm kiếm đã sụp đổ hoàn toàn. Các truy vấn tìm kiếm tương đồng đơn giản vốn chỉ mất 15ms bỗng chốc bị nghẽn với độ trễ p99 tăng vọt vượt mức 1.200ms, khiến các worker node nhanh chóng cạn kiệt bộ nhớ.
Hầu hết các bài hướng dẫn chỉ minh họa semantic search trên các bộ dữ liệu mẫu nhỏ với vài nghìn vector. Việc quét tuần tự (brute-force scan) và các in-memory index chưa tối ưu hoạt động rất tốt ở quy mô đó. Nhưng các hệ thống production lại là một câu chuyện hoàn toàn khác. Mở rộng lên quy mô hàng triệu bản ghi sẽ dẫn đến 3 rào cản nan giải:
- Phình bộ nhớ (Memory bloat): Một embedding float32 1536 chiều (như
text-embedding-3-smallcủa OpenAI) chiếm khoảng 6,14 KB cho mỗi tài liệu. Với 10 triệu vector, dung lượng lưu trữ thô đã ngốn hơn 60 GB RAM, chưa tính đến các liên kết đồ thị và chi phí phụ trội của index. - Lãng phí tài nguyên khi quét collection: Khi ứng dụng phục vụ dữ liệu đa người thuê (multi-tenant), việc tìm kiếm trên toàn bộ không gian vector nguyên khối sẽ lãng phí chu kỳ CPU vào hàng triệu embedding không liên quan mà người dùng vốn dĩ không có quyền xem.
- Nghẽn tài nguyên khi xây dựng index: Việc dựng các đồ thị lân cận dày đặc trong khi liên tục nạp dữ liệu cập nhật theo thời gian thực với thông lượng cao sẽ đẩy mức sử dụng CPU lên 100%, dẫn đến lỗi timeout truy vấn đối với người dùng đang hoạt động.
Để vượt qua những trở ngại về quy mô này, bạn cần một vector database chuyên dụng, cơ chế phân vùng vật lý tự động và cấu hình index được tinh chỉnh theo các ràng buộc thực tế về bộ nhớ lẫn truy vấn.
Khái niệm cốt lõi: Vì sao Milvus, Partition Key và HNSW lại đóng vai trò quyết định
Milvus là một cơ sở dữ liệu vector phân tán mã nguồn mở, được thiết kế theo kiến trúc tách rời lưu trữ và tính toán (storage-compute disaggregation). Thay vì gán ghép các phép toán vector vào các relational engine, Milvus chia nhỏ collection thành các segment vật lý riêng biệt và tính toán độ tương đồng thông qua SIMD cấp phần cứng cùng khả năng tăng tốc bằng GPU.
1. Phân vùng vật lý (Physical Partitions) so với Partition Key
Việc tạo các partition thủ công trong Milvus hoạt động tốt với các hệ thống đơn giản, nhưng việc quản lý hàng nghìn partition tag riêng lẻ ngay trong mã nguồn ứng dụng sẽ nhanh chóng biến thành một cơn ác mộng vận hành. Từ phiên bản 2.2+, Milvus đã giải quyết triệt để vấn đề này với Partition Key.
Bạn chỉ cần đánh dấu một trường vô hướng (scalar field)—chẳng hạn như tenant_id hoặc workspace_id—với is_partition_key=True. Khi đó, Milvus sẽ tự động băm (hash) giá trị vô hướng này và phân bổ các bản ghi vào một nhóm phân vùng vật lý cố định bên dưới (thường từ 64 đến 128 partition). Khi truy vấn, việc truyền bộ lọc tenant sẽ điều hướng tìm kiếm vector trực tiếp đến các segment liên quan, loại bỏ hơn 90% các phép so sánh vector không cần thiết.
2. Đánh đổi giữa các loại Index: IVF_FLAT so với HNSW
Việc lựa chọn thuật toán Approximate Nearest Neighbor (ANN) cốt lõi là cân bằng giữa giới hạn độ trễ cho phép và chi phí phần cứng:
- IVF_FLAT (Inverted File Flat): Phân cụm không gian vector thành các ô Voronoi (Voronoi cells). Thuật toán này hầu như không tiêu tốn thêm RAM ngoài phần embedding thô. Tuy nhiên, độ trễ truy vấn sẽ giảm sút nghiêm trọng khi thông lượng tăng cao, bởi để đạt recall cao thì cần phải quét nhiều cụm hơn (tăng
nprobe). - HNSW (Hierarchical Navigable Small World): Xây dựng đồ thị đa tầng, trong đó các tầng trên cùng giúp định tuyến nhanh và các tầng dưới cùng ghi lại chi tiết các lân cận cục bộ. Thời gian truy vấn luôn duy trì ổn định dưới 10ms với recall đạt trên 98%. Sự đánh đổi ở đây là nó đòi hỏi thêm 1,2x đến 1,5x lượng RAM so với kích thước vector thô để lưu trữ danh sách liên kết node.
Khi thông lượng truy vấn cao và SLA độ trễ p99 dưới 20ms là ưu tiên hàng đầu, HNSW kết hợp với Partition Key chính là lựa chọn đáng tin cậy nhất cho môi trường production.
Thực hành: Xây dựng và tinh chỉnh Pipeline
Dưới đây là toàn bộ quy trình thiết lập: triển khai Milvus Standalone, định nghĩa schema với partition key bằng Python, tạo index HNSW và thực thi tìm kiếm vector kết hợp lọc dữ liệu.
Bước 1: Chạy Milvus Standalone với Docker Compose
Lưu cấu hình sau vào tệp docker-compose.yml:
version: '3.5'
services:
etcd:
container_name: milvus-etcd
image: quay.io/coreos/etcd:v3.5.5
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
volumes:
- ./volumes/etcd:/etcd
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls=http://0.0.0.0:2379 --data-dir=/etcd
minio:
container_name: milvus-minio
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
volumes:
- ./volumes/minio:/minio_data
command: minio server /minio_data
standalone:
container_name: milvus-standalone
image: milvusdb/milvus:v2.3.4
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ./volumes/milvus:/var/lib/milvus
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- "etcd"
- "minio"
Khởi chạy cụm dịch vụ:
docker compose up -d
docker compose ps
Bước 2: Định nghĩa Schema với Partition Key
Cài đặt các thư viện Python cần thiết:
pip install pymilvus sentence-transformers numpy
Tạo tệp milvus_semantic_search.py để khởi tạo kết nối và định nghĩa schema:
import numpy as np
from pymilvus import (
connections,
FieldSchema,
CollectionSchema,
DataType,
Collection,
utility
)
# Kết nối tới Milvus server
connections.connect(alias="default", host="localhost", port="19530")
COLLECTION_NAME = "enterprise_docs"
DIMENSION = 384 # Số chiều đầu ra tiêu chuẩn cho all-MiniLM-L6-v2
if utility.has_collection(COLLECTION_NAME):
utility.drop_collection(COLLECTION_NAME)
# Cấu hình các trường với tenant_id được chỉ định làm partition key
fields = [
FieldSchema(name="doc_id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="tenant_id", dtype=DataType.VARCHAR, max_length=64, is_partition_key=True),
FieldSchema(name="doc_category", dtype=DataType.VARCHAR, max_length=64),
FieldSchema(name="raw_text", dtype=DataType.VARCHAR, max_length=2048),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=DIMENSION)
]
schema = CollectionSchema(fields=fields, description="Cơ sở tri thức ngữ nghĩa phân vùng theo tenant")
collection = Collection(name=COLLECTION_NAME, schema=schema, num_partitions=64)
print(f"Đã tạo collection '{COLLECTION_NAME}' với 64 hash partition.")
Bước 3: Cấu hình Index HNSW
Có hai tham số chính quyết định hiệu năng tạo HNSW và độ chính xác recall:
M(Giới hạn liên kết, thường từ 16 đến 64): Số lượng liên kết trỏ đi tối đa trên mỗi node. Giá trị càng cao cho độ chính xác càng tốt trên các embedding có nhiều nhiễu nhưng sẽ làm tăng dung lượng bộ nhớ. Thiết lậpM=16là mức cân bằng chuẩn nhất.efConstruction(Độ sâu tìm kiếm khi tạo index, từ 64 đến 512): Kiểm soát độ sâu tìm kiếm khi chèn vector vào đồ thị. Thiết lậpefConstruction=200mang lại sự cân bằng lý tưởng giữa thời gian tạo index và chất lượng index.
index_params = {
"metric_type": "COSINE",
"index_type": "HNSW",
"params": {
"M": 16,
"efConstruction": 200
}
}
collection.create_index(field_name="vector", index_params=index_params)
print("Đã tạo HNSW Index thành công.")
Bước 4: Nạp dữ liệu và tìm kiếm cô lập theo phân vùng
Tiến hành tạo embedding cho các tài liệu mẫu, nạp chúng vào nhiều tenant ID khác nhau và thực hiện truy vấn có định hướng:
from sentence_transformers import SentenceTransformer
encoder = SentenceTransformer("all-MiniLM-L6-v2")
sample_docs = [
{"tenant": "tenant_a", "cat": "network", "text": "Cấu hình định tuyến BGP trên các edge gateway của doanh nghiệp."},
{"tenant": "tenant_a", "cat": "security", "text": "Thiết lập chính sách xác thực MFA cho kỹ sư làm việc từ xa."},
{"tenant": "tenant_b", "cat": "database", "text": "Chiến lược tối ưu hóa connection pool và bộ nhớ đệm truy vấn trong PostgreSQL."},
{"tenant": "tenant_b", "cat": "network", "text": "Gỡ lỗi độ trễ kết nối VPC peering trong cụm multi-region."}
]
# Chuẩn bị các batch dữ liệu
tenant_ids = [d["tenant"] for d in sample_docs]
categories = [d["cat"] for d in sample_docs]
texts = [d["text"] for d in sample_docs]
vectors = encoder.encode(texts).tolist()
# Nạp dữ liệu và ghi xuống đĩa (flush)
collection.insert([
tenant_ids,
categories,
texts,
vectors
])
collection.flush()
# Nạp collection vào RAM để sẵn sàng tìm kiếm
collection.load()
# Thực thi tìm kiếm trong phạm vi tenant_b
query_text = "Làm thế nào để xử lý độ trễ mạng trong kết nối VPC đa vùng?"
query_vector = encoder.encode([query_text]).tolist()
search_params = {
"metric_type": "COSINE",
"params": {"ef": 64} # ef kiểm soát độ sâu tìm kiếm runtime so với độ trễ
}
results = collection.search(
data=query_vector,
anns_field="vector",
param=search_params,
limit=2,
expr="tenant_id == 'tenant_b'",
output_fields=["tenant_id", "doc_category", "raw_text"]
)
for hits in results:
for hit in hits:
print(f"Điểm số: {hit.distance:.4f} | Tenant: {hit.entity.get('tenant_id')} | Danh mục: {hit.entity.get('doc_category')} | Nội dung: {hit.entity.get('raw_text')}")
Kinh nghiệm vận hành thực tế tốt nhất
Vận hành hàng triệu vector trên môi trường production đòi hỏi việc lập kế hoạch tài nguyên chặt chẽ và chuẩn hóa quy trình truy vấn. Hãy ghi nhớ các nguyên tắc sau:
- Luôn đưa partition key vào bộ lọc tìm kiếm: Việc bỏ qua partition key sẽ kích hoạt quét toàn cục trên mọi segment. Trong thử nghiệm với 10 triệu vector, việc giới hạn truy vấn theo partition key của tenant đã giảm độ trễ p99 từ 185ms xuống chỉ còn 14ms.
- Điều chỉnh
efkhi runtime một cách linh hoạt: Cấu hìnhefphù hợp với SLA của bạn. Đặtef=32hoặcef=64thường mang lại recall trên 95% trong khi vẫn giữ độ trễ tìm kiếm dưới 15ms. Chỉ nên dùngef=128cho các tác vụ mà độ chính xác recall được ưu tiên hơn tốc độ. - Tính toán trước dung lượng bộ nhớ: Ước lượng nhu cầu RAM theo công thức:
Tổng RAM ≈ Số lượng Vector × (Số chiều × 4 bytes + M × 8 bytes) × 1.35(đã bao gồm 35% dung lượng dự phòng cho bộ đệm hệ điều hành và quá trình thực thi truy vấn). - Tránh gọi
collection.flush()trên từng bản ghi đơn lẻ: Việc flush sau mỗi payload nhỏ sẽ tạo ra hàng trăm tệp segment tí hon, gây phân mảnh segment nghiêm trọng và làm suy giảm hiệu năng truy vấn. Thay vào đó, hãy gom các bản ghi thành batch từ 2.000 đến 10.000 dòng trước khi ghi.
Việc kết hợp partition key của Milvus với index HNSW được cấu hình tham số chuẩn xác sẽ giúp duy trì độ trễ tìm kiếm luôn ổn định và có thể dự đoán được khi dữ liệu tăng trưởng từ vài nghìn lên tới hàng chục triệu bản ghi.

