Vấn đề thực tế: Ngày hệ thống tìm kiếm vector chạm trần hiệu năng
Sáu tháng trước, đội ngũ của chúng tôi đã triển khai một hệ thống tri thức RAG cấp doanh nghiệp xây dựng trên PostgreSQL và pgvector. Trên môi trường staging, hiệu năng hoạt động cực kỳ ấn tượng. Các truy vấn khoảng cách cosine (cosine distance) trên 40.000 ticket hỗ trợ khách hàng trả về kết quả chỉ trong khoảng 18 mili-giây. Chúng tôi vô cùng hào hứng: không cần vận hành một database vector độc lập, không lo độ trễ đồng bộ dữ liệu, và chi phí hạ tầng được tối giản tối đa.
Thế nhưng, bài toán quy mô thực tế ập đến. Chỉ trong vòng ba tháng, các pipeline thu thập dữ liệu tự động đã nạp tới 6,5 triệu tài liệu vào một bảng PostgreSQL duy nhất, mỗi bản ghi mang một embedding 1536 chiều từ mô hình text-embedding-3-large của OpenAI. Độ trễ sụt giảm nghiêm trọng gần như chỉ sau một đêm. Thời gian truy vấn P95 vọt từ 20ms lên mức không thể chấp nhận được là 2,8 giây. Vào giờ cao điểm, cụm database 32 vCPU luôn bị quá tải ở mức 98% công suất, và lỗi timeout của connection pool bắt đầu lan truyền sang hàng loạt ứng dụng client.
Dashboard giám sát hiển thị rõ một kịch bản thảm họa: thông lượng truy vấn tụt dốc từ 140 QPS xuống vỏn vẹn 12 QPS. Các thao tác I/O ngẫu nhiên làm nghẽn dàn ổ cứng NVMe 8.000 IOPS, và chiến lược đánh index mặc định hoàn toàn tê liệt.
Phân tích nguyên nhân gốc rễ: Vì sao cách tìm kiếm mặc định đổ vỡ ở quy mô lớn
Phần lớn các nhóm khi mới bắt đầu với pgvector đều dựa vào sequential scan hoặc tạo một index IVFFlat. Dưới đây là lý do tại sao cả hai cách tiếp cận này đều thất bại trước khối lượng công việc hàng triệu dòng trong môi trường production:
1. Nghịch lý giữa tốc độ và độ chính xác (Recall-Speed Dilemma) của IVFFlat
IVFFlat chia nhỏ các vector thành các ô Voronoi thông qua thuật toán phân cụm k-means (lists = N). Khi truy vấn, engine chỉ quét qua một tập hợp con các cụm, được xác định bởi tham số ivfflat.probes.
Trên tập dữ liệu 6,5 triệu dòng của chúng tôi, để đạt mức recall chấp nhận được trên 92%, chúng tôi buộc phải tăng probes từ 10 lên 64. Điều này gây ra hiện tượng khuếch đại đọc đĩa (disk read amplification) nghiêm trọng, kéo thời gian phản hồi chậm chạp chẳng khác nào quét toàn bộ bảng (full table scan). Tồi tệ hơn, khi các lượt ghi dữ liệu mới diễn ra liên tục hàng ngày, hiện tượng trôi tâm cụm (centroid drift) đã làm giảm recall xuống còn 78%, trừ khi chúng tôi rebuild lại toàn bộ index.
2. Cạn kiệt bộ nhớ khả dụng (Working Memory Starvation)
Index B-Tree có bố cục bộ nhớ gọn gàng và dễ dự đoán. Ngược lại, đồ thị tìm kiếm láng giềng gần đúng (ANN) thì không như vậy. Việc duyệt đồ thị đòi hỏi phải nhảy liên tục qua các con trỏ bộ nhớ phân tán trên các block không liền kề. Khi kích thước index vượt quá dung lượng shared_buffers của PostgreSQL, mỗi bước nhảy trên đồ thị đều kích hoạt một lượt đọc đĩa ngẫu nhiên. Index vector của chúng tôi phình to lên tới 42 GB—trong khi máy chủ chỉ có 64 GB RAM và 16 GB shared buffers. Tình trạng thrashing ổ đĩa là điều không thể tránh khỏi.
3. Phình to dữ liệu trên bảng đơn khối (Monolithic Table Bloat)
Kho lưu trữ tài liệu của chúng tôi gộp chung dữ liệu multi-tenant của doanh nghiệp trong suốt ba năm tài chính. Việc truy vấn trên một bảng không phân vùng chứa 6,5 triệu dòng buộc PostgreSQL phải duyệt qua đồ thị của cả những tenant hoàn toàn không liên quan và các bản ghi lưu trữ cũ trước khi áp dụng bộ lọc metadata.
Đánh giá các lựa chọn: Ba hướng đi kiến trúc
Để chấm dứt tình trạng timeout trên production, chúng tôi đã thử nghiệm ba phương án trong đợt benchmark kéo dài bốn tuần trên AWS RDS:
Lựa chọn 1: Chuyển đổi sang Vector DB chuyên dụng (Qdrant / Pinecone / Milvus)
Sử dụng một vector database chuyên dụng dường như là giải pháp phổ biến. Tuy nhiên, nó kéo theo nhiều rào cản kỹ thuật lớn: phải đồng bộ transaction qua cơ chế dual-write, chịu độ trễ eventual consistency giữa dữ liệu quan hệ và embedding, cùng chi phí SaaS phát sinh ước tính khoảng 1.800 USD/tháng. Việc mất đi tính đảm bảo ACID giao dịch nguyên bản cũng khiến phương án này khó được thông qua trong các đợt audit tuân thủ doanh nghiệp.
Lựa chọn 2: Nâng cấp phần cứng theo chiều dọc kết hợp tinh chỉnh IVFFlat
Chúng tôi nâng RAM máy chủ từ 64 GB lên 256 GB và lên lịch chạy lại job phân cụm IVFFlat hàng tuần. Biện pháp tạm thời này giúp giảm độ trễ xuống còn 450ms. Tuy nhiên, các lệnh khóa độc quyền (exclusive lock) làm gián đoạn pipeline nạp dữ liệu gần 90 phút mỗi cuối tuần, và chỉ số recall vẫn suy giảm vào giữa tuần.
Lựa chọn 3: Đánh index HNSW kết hợp khai báo phân vùng bảng (Declarative Table Partitioning)
pgvector đã hỗ trợ đồ thị Hierarchical Navigable Small World (HNSW) nguyên bản. HNSW xây dựng đồ thị skip graph nhiều tầng trực tiếp trong tầng lưu trữ của Postgres, loại bỏ hoàn toàn nhu cầu bảo trì phân cụm định kỳ. Kết hợp HNSW với cơ chế phân vùng theo dải (range) hoặc danh sách (list) có sẵn giúp mỗi đồ thị luôn nhỏ gọn, cô lập và được cache tối ưu trong bộ nhớ.
Giải pháp: Ứng dụng HNSW và phân vùng trong môi trường Production
Chúng tôi đã triển khai kiến trúc này lên production và ghi nhận kết quả tức thì. Độ trễ P95 giảm ngoạn mục từ 2.800ms xuống còn 14ms trên tập dữ liệu hơn 8 triệu vector đang hoạt động. Chỉ số read IOPS giảm tới 85%, và mức sử dụng CPU duy trì ổn định dưới 30% trong giờ cao điểm.
Dưới đây là các bước triển khai chi tiết.
Bước 1: Kích hoạt extension pgvector
Đảm bảo phiên bản PostgreSQL của bạn đang chạy pgvector từ 0.5.0 trở lên, sau đó kích hoạt extension:
CREATE EXTENSION IF NOT EXISTS vector;
Bước 2: Tạo các phân vùng khai báo (Declarative Partitions)
Chọn partition key phù hợp với mẫu truy vấn của bạn, chẳng hạn như timestamp tạo bản ghi hoặc ranh giới giữa các tenant. Trong kiến trúc của chúng tôi, việc phân vùng theo dải thời gian theo quý đã giới hạn kích thước mỗi file index ở mức khoảng 8 GB, hoàn toàn vừa vặn trong RAM:
-- Bảng chính được phân vùng theo khoảng thời gian
CREATE TABLE enterprise_documents (
id BIGSERIAL,
tenant_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
content TEXT NOT NULL,
metadata JSONB DEFAULT '{}'::jsonb,
embedding vector(1536) NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- Các phân vùng theo quý
CREATE TABLE enterprise_documents_2025_q1 PARTITION OF enterprise_documents
FOR VALUES FROM ('2025-01-01 00:00:00+00') TO ('2025-04-01 00:00:00+00');
CREATE TABLE enterprise_documents_2025_q2 PARTITION OF enterprise_documents
FOR VALUES FROM ('2025-04-01 00:00:00+00') TO ('2025-07-01 00:00:00+00');
Bước 3: Tối ưu bộ nhớ và khởi tạo index HNSW
Quá trình xây dựng đồ thị HNSW đòi hỏi bộ nhớ scratch riêng biệt. Hãy tạm thời tăng maintenance_work_mem cho phiên làm việc tạo index, đồng thời bật chế độ build đa luồng nếu CPU cho phép:
-- Cấp phát bộ nhớ build index và cấu hình xử lý song song
SET maintenance_work_mem = '8GB';
SET max_parallel_maintenance_workers = 4;
-- Tạo index HNSW cho từng phân vùng
CREATE INDEX idx_docs_2025_q1_hnsw_cosine
ON enterprise_documents_2025_q1
USING hnsw (embedding vector_cosine_ops)
WITH (m = 24, ef_construction = 128);
CREATE INDEX idx_docs_2025_q2_hnsw_cosine
ON enterprise_documents_2025_q2
USING hnsw (embedding vector_cosine_ops)
WITH (m = 24, ef_construction = 128);
Các lựa chọn cấu hình quan trọng:
- m = 24: Thiết lập số liên kết hai chiều tối đa trên mỗi node (mặc định là 16). Nâng thông số này lên 24 hoặc 32 sẽ cải thiện đáng kể độ chính xác (recall) đối với các vector 1536 chiều, với chi phí tăng thêm khoảng 15% thời gian build.
- ef_construction = 128: Kiểm soát kích thước hàng đợi ứng viên động được khám phá trong quá trình dựng index. Giá trị 128 tạo sự cân bằng hợp lý giữa tốc độ tạo index và độ chính xác của top-10 láng giềng gần nhất.
Bước 4: Thực thi truy vấn độ chính xác cao trên môi trường Production
Tinh chỉnh hnsw.ef_search trong thời gian chạy (runtime) để cân đối giữa độ trễ truy vấn và recall. Điều tối quan trọng là luôn đưa partition key vào mệnh đề WHERE để PostgreSQL có thể kích hoạt cơ chế loại bỏ phân vùng không liên quan (partition pruning):
import psycopg2
def search_knowledge_base(query_vector, tenant_id, start_date, limit=5):
conn = psycopg2.connect("dbname=rag_db user=rag_user host=localhost")
with conn.cursor() as cur:
# Thiết lập độ rộng tìm kiếm ứng viên cho session này
cur.execute("SET LOCAL hnsw.ef_search = 80;")
query = """
SELECT id, content, metadata, 1 - (embedding <=> %s::vector) AS similarity
FROM enterprise_documents
WHERE created_at >= %s
AND tenant_id = %s
ORDER BY embedding <=> %s::vector
LIMIT %s;
"""
cur.execute(query, (query_vector, start_date, tenant_id, query_vector, limit))
results = cur.fetchall()
conn.close()
return results
Bài học thực tế sau 6 tháng vận hành
- Partition pruning là yêu cầu bắt buộc: Nếu thiếu bộ lọc theo
created_at, query planner sẽ phải quét index của mọi phân vùng. Hãy kiểm tra bằng lệnhEXPLAIN (ANALYZE, BUFFERS)để đảm bảo cơ chế loại bỏ phân vùng hoạt động chính xác. - Làm nóng cache (warm cache) sau khi khởi động lại: Chạy câu lệnh
SELECT pg_prewarm('idx_docs_2025_q2_hnsw_cosine');sau các đợt bảo trì database để nạp trước các tầng đồ thị đang hoạt động vào RAM trước khi lượng truy vấn ập đến. - Dự trù dung lượng ghi WAL: Quá trình khởi tạo index HNSW phát sinh thao tác ghi đĩa rất lớn. Việc build một index 8 GB sẽ sinh ra khoảng 12–16 GB dữ liệu WAL. Hãy theo dõi sát sao độ trễ replication và đảm bảo ổ đĩa lưu trữ WAL archive luôn có dung lượng dự phòng dồi dào.
Sự kết hợp giữa đánh index HNSW và kỹ thuật phân vùng khai báo mang lại tốc độ truy vấn 15ms ngang ngửa các vector database chuyên dụng. Tuyệt vời hơn cả, bạn vẫn giữ trọn vẹn khả năng backup chuẩn của PostgreSQL, tính năng khôi phục tại thời điểm cụ thể (PITR) cùng các truy vấn JOIN dữ liệu quan hệ đồng nhất trên một hệ thống duy nhất.

