Sau bài Embedding và semantic search, câu hỏi tiếp theo của mọi dự án là: vector nằm ở đâu khi dữ liệu lớn dần, bị sửa, bị xoá và phải lọc theo cửa hàng hay theo danh mục? Một vector database không chỉ là chỉ mục tìm láng giềng gần nhất; nó còn lo lưu trữ bền, cập nhật, lọc theo thuộc tính, truy cập đồng thời và sao lưu.

Bài này đặt ba lựa chọn vector database phổ biến lên cùng một bộ dữ liệu: FAISS (thư viện), pgvector (extension của PostgreSQL) và Qdrant (máy chủ chuyên dụng). Cả ba đều dùng thuật toán HNSW, nên khác biệt nằm ở phần quanh thuật toán. Mọi con số do chúng tôi đo trên Apple M4 và dán nguyên văn từ tệp output.

Vector database giải quyết gì mà mảng NumPy không giải quyết

Ở bài trước, 48 mục FAQ nằm trong một mảng NumPy và duyệt toàn bộ vẫn trả lời trong chớp mắt. Bài toán đổi khi vector lên hàng triệu và nhiều người cùng ghi, cùng đọc. Một vector database cần: tìm gần đúng (ANN), lưu bền, xoá và cập nhật từng bản ghi, lọc theo metadata, truy cập đồng thời có phân quyền, nhân bản và sao lưu.

Ba ứng viên vector database nằm ở ba vị trí khác nhau. FAISS (Meta) là thư viện chạy trong tiến trình của bạn, rất nhanh, nhưng phần lớn năng lực trên do bạn tự viết. pgvector thêm kiểu vector và hai loại chỉ mục vào PostgreSQL, nên thừa hưởng giao dịch, sao lưu, phân quyền và SQL. Qdrant là máy chủ viết bằng Rust, chuyên cho vector kèm payload, giao tiếp qua REST hoặc gRPC.

HNSW: thuật toán chung của mọi vector database

HNSW (Hierarchical Navigable Small World) do Malkov và Yashunin đề xuất năm 2016. Đây là chỉ mục phổ biến nhất và có mặt trong cả ba hệ thống được so sánh ở bài này. Mỗi vector được gán ngẫu nhiên một tầng cao nhất theo phân phối giảm theo hàm mũ, nên tầng trên rất thưa còn tầng dưới chứa mọi điểm. Khi tìm, thuật toán vào từ tầng trên cùng, nhảy xa tới vùng gần câu hỏi rồi xuống dần các tầng dày hơn, giống danh sách nhảy (skip list), cho độ phức tạp xấp xỉ logarit theo số vector.

Ý nghĩaFAISSpgvectorQdrant
Số liên kết tối đa mỗi nútMm (mặc định 16)m
Độ rộng tìm khi dựng đồ thịefConstructionef_construction (mặc định 64)ef_construct
Độ rộng tìm khi truy vấnefSearchhnsw.ef_search (mặc định 40)hnsw_ef

Giá trị mặc định lấy từ tài liệu pgvector. m và ef_construction lớn cho đồ thị tốt hơn nhưng dựng chậm, tốn bộ nhớ; ef_search lớn cho recall cao hơn nhưng mỗi truy vấn chậm hơn. Đây là nút vặn quan trọng nhất có thể chỉnh theo từng truy vấn hoặc phiên mà không phải dựng lại chỉ mục.

Bộ dữ liệu và cách đo vector database

Để so sánh công bằng, chúng tôi dùng tập fashion-mnist-784-euclidean của ANN-Benchmarks: 60.000 vector 784 chiều, khoảng cách L2, kèm đáp án láng giềng thật. Lấy 1.000 câu hỏi và đo recall@10: trong 10 kết quả trả về, bao nhiêu nằm trong 10 láng giềng thật. Môi trường: PostgreSQL 17 với pgvector 0.8.0 (ảnh pgvector/pgvector:0.8.0-pg17) và Qdrant 1.12.4 chạy trong Docker, FAISS 1.9.0 trong tiến trình Python:

docker run -d --name pgv -e POSTGRES_PASSWORD="$PG_LOCAL_PASSWORD" -p 5433:5432 pgvector/pgvector:0.8.0-pg17
docker run -d --name qdr -p 6333:6333 qdrant/qdrant:v1.12.4

Giới hạn của phép đo: truy vấn gọi tuần tự bằng một luồng; FAISS chạy cùng tiến trình, còn pgvector và Qdrant chịu thêm chi phí mạng và giao thức. Đây là độ trễ cho một khách hàng, không phải thông lượng tối đa khi nhiều kết nối song song, và không nên khái quát cho dữ liệu của bạn.

FAISS: vector database nhanh nhất, nhưng chỉ là thư viện

Script bench_faiss.py dựng Flat, IVFFlat và HNSW rồi quét tham số. Output thật (rút gọn các dòng trung gian, đủ bản trong bench_faiss_output.txt):

chỉ mục                             xây (s)  recall     QPS  p50 ms  p99 ms
Flat (duyệt toàn bộ)                   0.47   1.000     145    6.16   17.89
IVFFlat nlist=256 nprobe=4             0.66   0.945    6666    0.12    0.41
IVFFlat nlist=256 nprobe=16            0.66   0.999    1907    0.44    1.82
HNSW M=16 efC=200 efSearch=10         57.30   0.939    7989    0.08    0.36
HNSW M=16 efC=200 efSearch=50         57.30   0.997    7115    0.13    0.26
HNSW M=16 efC=200 efSearch=200        57.30   0.999    3384    0.29    0.45

Duyệt toàn bộ cho recall 1,000 ở 6 ms mỗi truy vấn: ở quy mô này cách ngây thơ vẫn dùng được. HNSW chạm recall 0,997 ở khoảng 0,13 ms, nhanh hơn Flat chừng 47 lần ở p50, nhưng dựng mất 57 giây so với 0,47 giây. IVFFlat dựng chỉ 0,66 giây và đạt 0,999 khi nprobe=16, đáng cân nhắc khi dữ liệu ít thay đổi. QPS và p99 dao động giữa các lần chạy vì máy chia sẻ cho nhiều tác vụ, nên hãy đọc xu hướng chứ đừng đọc từng dòng.

Cái giá của FAISS khi làm vector database nằm ngoài bảng. update_delete.py thử xoá vector ở HNSW và ở Flat bọc IndexIDMap2:

FAISS IndexHNSWFlat.remove_ids: lỗi -> Error in virtual size_t faiss::Index::remove_ids(const IDSelector &) at ...
FAISS Flat+IDMap2, trước khi xoá, top-3: [42, 4160, 3464]
FAISS Flat+IDMap2, sau remove_ids(42), top-3: [4160, 3464, 822] | ntotal = 4999

IndexHNSWFlat không hỗ trợ xoá: wiki FAISS nêu rõ HNSW không cho gỡ vector vì sẽ phá đồ thị. Muốn xoá, bạn tự giữ danh sách "đã xoá" để lọc kết quả hoặc dựng lại chỉ mục định kỳ. FAISS cũng không có xác thực hay lọc thuộc tính theo kiểu cơ sở dữ liệu. Vì vậy nó chưa phải vector database đầy đủ: nó hợp với tập tĩnh nạp một lần (bản dựng ban đêm, thử nghiệm) hơn là làm lõi của dịch vụ có dữ liệu ghi liên tục.

pgvector: vector database nằm trong PostgreSQL

Với pgvector, vector là một cột và chỉ mục tạo bằng SQL quen thuộc:

CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE items (id int PRIMARY KEY, embedding vector(784));
CREATE INDEX items_hnsw ON items USING hnsw (embedding vector_l2_ops)
  WITH (m = 16, ef_construction = 200);
SET hnsw.ef_search = 40;
SELECT id FROM items ORDER BY embedding <-> $1 LIMIT 10;

Toán tử <-> là khoảng cách L2; còn <=> (cosine) và <#> (tích vô hướng âm). Output thật của bench_pgvector.py (rút gọn):

nạp 60000 dòng: 4.6s | PostgreSQL 17 | pgvector 0.8.0 | bảng 240 MB
chỉ mục                             xây (s)  kích cỡ  recall     QPS  p50 ms  p99 ms
không chỉ mục (quét tuần tự)            0.0        -   1.000       7  116.64  354.87
HNSW m=16 efC=200 ef_search=10         78.1   234 MB   0.972     733    1.05    5.15
HNSW m=16 efC=200 ef_search=40         78.1   234 MB   0.997     582    1.43    7.30
HNSW m=16 efC=200 ef_search=200        78.1   234 MB   0.999     180    4.37   24.22
IVFFlat lists=245 probes=4              6.4   236 MB   0.950     271    3.10   14.41
IVFFlat lists=245 probes=16             6.4   236 MB   0.999     114    7.73   26.17

Chỉ mục HNSW kéo truy vấn từ 116 ms (quét cả bảng) xuống 1,4 ms với recall 0,997 ở ef_search=40. Con số này chậm hơn FAISS trong tiến trình khoảng 10 lần, chủ yếu do mạng, giao thức PostgreSQL và bộ lập kế hoạch, không phải do thuật toán. Đổi lại vector database này mang theo toàn bộ PostgreSQL; xoá và cập nhật là SQL thường, có giao dịch:

pgvector, sau DELETE id=42, top-3: [4160, 3464, 822]
pgvector, sau UPDATE id=7 thành vector của 42, top-3: [7, 4160, 3464]

Hai điểm cần cảnh giác. Thứ nhất, thời gian dựng: 78 giây cho 60.000 vector ở ef_construction=200, và chèn dữ liệu mới cũng tốn thêm. Thứ hai, theo tài liệu, kiểu vector chỉ lập chỉ mục được tối đa 2.000 chiều; embedding lớn hơn cần halfvec hoặc giảm chiều.

Qdrant: vector database chuyên dụng với payload

Qdrant là vector database dạng máy chủ riêng; nó lưu mỗi điểm gồm id, vector và payload JSON. Trình tối ưu hoá chạy nền dựng HNSW cho các segment đủ lớn, nên bench_qdrant.py đợi collection về trạng thái green trước khi đo. Output thật (rút gọn):

nạp + dựng chỉ mục 60000 điểm: 25.8s | segments=7 | indexed_vectors=59000
HNSW m=16 efC=200 hnsw_ef=10         0.987     169    4.71   25.53
HNSW m=16 efC=200 hnsw_ef=40         0.999     213    4.01   15.10
HNSW m=16 efC=200 hnsw_ef=200        1.000     192    4.43   18.12
exact=True (quét toàn bộ, 100 câu)   1.000     103    7.58   36.73

Qdrant đạt recall cao từ hnsw_ef=20 và dựng nhanh hơn HNSW của FAISS (57 giây, một luồng) và của pgvector (78 giây): 25,8 giây gồm cả nạp dữ liệu, nhờ dựng nhiều segment song song. Chưa so với IVFFlat, vốn dựng nhanh hơn.

Độ trễ khoảng 4 ms của vector database này cao hơn pgvector trong phép đo của chúng tôi; phần lớn là do gửi vector 784 số qua JSON bằng REST. gRPC thường giảm chi phí này nhưng chúng tôi chưa đo nên không khẳng định mức giảm. Dòng indexed_vectors=59000 trên 60.000 điểm cho thấy một segment nhỏ chưa có HNSW và được quét tuần tự, đó là hành vi thiết kế. Xoá và upsert cho kết quả giống pgvector:

Qdrant, sau delete id=42, top-3: [4160, 3464, 822]
Qdrant, sau upsert id=7 thành vector của 42, top-3: [7, 4160, 3464]

Lọc theo metadata: nơi vector database khác nhau nhiều nhất

Truy vấn thực tế gần như luôn kèm điều kiện: tenant, danh mục, còn hàng. HNSW thuần tuý không biết điều kiện, nên mỗi hệ thống xử lý một kiểu. bench_filter.py gán mỗi vector nhãn id % 100 (dữ liệu tổng hợp để đo cơ chế, không phải thuộc tính thật) và chỉ truy vấn nhóm 7, khoảng 1% dòng. Đáp án chính xác tính bằng NumPy trên đúng tập đã lọc.

600 / 60000 vector thuộc nhóm 7 (1.0%); 300 câu hỏi, k=10
cấu hình                                                 số KQ TB  recall  p50 ms  p99 ms
pgvector, kế hoạch mặc định (planner chọn quét tuần tự)      10.0   1.000    2.48    2.78
pgvector HNSW ef_search=40 iterative_scan=off                 0.3   0.035    0.62    0.88
pgvector HNSW ef_search=40 iterative_scan=relaxed_order      10.0   1.000    4.37    7.88
pgvector HNSW ef_search=40 iterative_scan=strict_order       10.0   0.998    4.53    7.81
Qdrant hnsw_ef=40 + payload index                            10.0   1.000    2.09    3.29

Dòng thứ hai là lỗi tinh vi nhất của mọi vector database dùng chỉ mục xấp xỉ: khi ép PostgreSQL dùng chỉ mục HNSW, trung bình chỉ 0,3 kết quả thay vì 10, recall 0,035. Nguyên nhân theo tài liệu pgvector: chỉ mục xấp xỉ áp WHERE sau khi quét. Với ef_search=40, chỉ mục trả 40 ứng viên gần nhất toàn bảng rồi lọc bỏ dòng không thuộc nhóm 7; nhóm chỉ chiếm 1% nên còn rất ít. explain_filter.py cho thấy điều đó (rút gọn):

--- ép dùng chỉ mục (enable_seqscan=off)
    Limit  (cost=1292.45..3329.38 rows=10 width=12)
      ->  Index Scan using items_hnsw on items  (cost=1292.45..125137.52 rows=608 width=12)
            Order By: (embedding <-> '[...]'::vector)
            Filter: (grp = 7)
iterative_scan=off: số KQ trung bình 0.35 | câu trả về <10 dòng: 300 | câu trả về 0 dòng: 210
iterative_scan=relaxed_order: số KQ trung bình 10.00 | câu trả về <10 dòng: 0 | câu trả về 0 dòng: 0

Ở chế độ off, 210/300 truy vấn trả về 0 dòng: chức năng tìm kiếm trông bình thường nhưng âm thầm trả trang trống. Với nhóm 1%, bộ lập kế hoạch tự chọn quét tuần tự (đúng, vì chỉ 600 dòng) nên người dùng không gặp lỗi; lỗi lộ ra khi điều kiện đủ chọn lọc để planner chọn chỉ mục, hoặc khi bạn ép chỉ mục như chúng tôi làm.

pgvector 0.8.0 thêm iterative index scan (hnsw.iterative_scan): khi chưa đủ dòng sau lọc, chỉ mục quét tiếp đến khi đủ hoặc chạm hnsw.max_scan_tuples (mặc định 20.000). strict_order giữ đúng thứ tự khoảng cách, relaxed_order cho phép lệch nhẹ để recall tốt hơn. Cả hai đều trả đủ 10 dòng với recall 0,998–1,000, giá phải trả là độ trễ khoảng 4,4 ms thay vì 0,6 ms.

Qdrant tiếp cận khác: bạn tạo payload index cho trường cần lọc. Theo tài liệu Qdrant, HNSW chỉ có thêm các cạnh nhận biết bộ lọc khi được dựng sau khi payload index tồn tại, và payload index còn giúp ước lượng cardinality để chọn chiến lược tìm. Ở phép đo, Qdrant trả đủ 10 kết quả, recall 1,000, p50 2,09 ms.

Lưu ý: bench_filter.py tạo payload index sau khi nạp dữ liệu, tức là phép đo của chúng tôi mắc đúng lỗi vừa nêu. Con số 1,000 phản ánh cả đường truy vấn có lọc của Qdrant, chưa phải HNSW có cạnh lọc; với nhóm 1% (600 điểm) nhiều khả năng bộ lập kế hoạch quét trực tiếp qua payload index. Chúng tôi chỉ đo kết quả, không chứng minh cơ chế nội bộ. Khi đánh giá vector database có lọc, hãy đo số kết quả trả về chứ không chỉ độ trễ.

Dung lượng và bộ nhớ của vector database

Script footprint.py đo trên cùng 60.000 vector, dữ liệu thô 179 MiB:

dữ liệu thô float32: 179 MiB
FAISS HNSW M=16      file chỉ mục: 188 MiB
pgvector bảng items (heap + TOAST)       245 MB
pgvector chỉ mục items_hnsw              234 MB
Qdrant collection items trên đĩa         235M

Chỉ mục HNSW của pgvector (234 MB) lớn hơn cả dữ liệu thô vì sao chép vector vào trang chỉ mục: bảng cộng chỉ mục xấp xỉ gấp đôi. HNSW của FAISS chỉ cộng 9 MiB đồ thị vì giữ một bản vector duy nhất. Số của Qdrant là cả thư mục collection (vector, đồ thị, payload). Khi vector database lên hàng chục triệu vector, các hệ số này quyết định chi phí máy chủ và là lý do cân nhắc lượng tử hoá; bài này không đo lượng tử hoá.

Chọn vector database nào cho từng tình huống

Tình huốngThử trướcLý do
Tập tĩnh, nạp một lần, chạy trong một dịch vụFAISSNhanh nhất, không thêm máy chủ
Đã có PostgreSQL, vài triệu vector trở xuốngpgvectorGiao dịch, JOIN với bảng đơn hàng, sao lưu sẵn có
Nhiều tenant, lọc phức tạp, ghi liên tụcQdrantPayload, lọc trong chỉ mục, nhân bản và phân mảnh tích hợp
Cần xoá chính xác từng bản ghipgvector hoặc QdrantFAISS HNSW không xoá từng vector

Quy tắc thận trọng khi chọn vector database: nếu đã vận hành PostgreSQL, bắt đầu bằng pgvector và chỉ chuyển sang vector database riêng khi đo được nút thắt thật (độ trễ, kích thước chỉ mục, tải ghi, tính năng lọc). Thêm một hệ thống nghĩa là thêm giám sát, sao lưu, nâng cấp và một điểm có thể hỏng.

Khi nào không cần vector database

  • Dưới vài chục nghìn vector (chưa cần vector database): duyệt toàn bộ bằng NumPy hoặc IndexFlat đủ nhanh (6 ms ở 60.000 vector, một luồng) và chính xác 100%.
  • Truy vấn chủ yếu là từ khoá hay mã sản phẩm: BM25 hoặc LIKE chính xác hơn; cân nhắc tìm lai.
  • Cần giải thích vì sao ra kết quả: khoảng cách vector khó giải thích hơn từ khoá khớp.

Lỗi thường gặp khi dùng vector database

  1. Chỉ nhìn độ trễ của vector database, bỏ qua recall: iterative_scan=off có độ trễ thấp nhất bảng nhưng recall 0,035.
  2. So FAISS trong tiến trình với dịch vụ qua mạng rồi kết luận thuật toán nào nhanh hơn: đó là so kiến trúc.
  3. Metric không khớp chỉ mục: chỉ mục vector_l2_ops mà truy vấn dùng <=> sẽ không dùng được chỉ mục.
  4. Đặt ef_search nhỏ hơn k hoặc quên chỉnh sau khi tăng k.
  5. Tạo payload index sau khi nạp dữ liệu (Qdrant) hay quên ANALYZE sau khi nạp hàng loạt (PostgreSQL).
  6. Dựng IVFFlat trên bảng còn trống: tâm cụm chọn từ dữ liệu có sẵn nên chất lượng kém.
  7. Khái quát từ một cấu hình: số trong bài đo một tập dữ liệu, một máy, một luồng gọi.

Checklist chọn vector database

  • Trước khi chọn vector database, đếm số vector hiện tại và dự báo 12 tháng; ghi số chiều và độ đo.
  • Thử từng điều kiện lọc thật ở độ chọn lọc 1%, 10%, 50%; đo cả số kết quả trả về.
  • Xác định tần suất xoá và cập nhật; cần xoá từng bản ghi thì loại FAISS HNSW thuần.
  • Chốt recall chấp nhận được (thường 0,95–0,99) rồi mới chọn ef_search; đo p50 và p99.
  • Tính dung lượng và thời gian dựng lại chỉ mục khi đổi mô hình embedding.
  • Kiểm tra sao lưu, phục hồi, nâng cấp; đo bằng dữ liệu thật đã ẩn danh.

Mã so sánh vector database nằm ở code/s7-36: common.py, bench_faiss.py, bench_pgvector.py, bench_qdrant.py, bench_filter.py, explain_filter.py, update_delete.py, footprint.py và các tệp *_output.txt. Dữ liệu tải từ ANN-Benchmarks (khoảng 217 MB, không đưa vào kho mã). Chạy lại theo thứ tự sau, trong code/s7-36:

python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt
mkdir -p data && curl -o data/fashion-mnist-784-euclidean.hdf5 http://ann-benchmarks.com/fashion-mnist-784-euclidean.hdf5
export PG_LOCAL_PASSWORD=demo_local_only   # mật khẩu mặc định của các script; đặt khác thì đặt thêm PG_DSN
export PG_DSN="postgresql://postgres:$PG_LOCAL_PASSWORD@localhost:5433/postgres"
.venv/bin/python bench_faiss.py && .venv/bin/python bench_pgvector.py && .venv/bin/python bench_qdrant.py
.venv/bin/python bench_filter.py && .venv/bin/python explain_filter.py
.venv/bin/python update_delete.py && .venv/bin/python footprint.py

Lệnh docker run ở trên cần được chạy trước. Các số trong bài đo ngày chạy thử trên Apple M4; số vá của ảnh Docker có thể khác ở lần chạy của bạn.

Bài tiếp theo

Xây RAG bằng Python: chunking, truy hồi và sinh câu trả lời có dẫn nguồn. Có vector database rồi, bước kế tiếp là nối nó với LLM: cắt tài liệu thành đoạn, truy hồi đúng đoạn và buộc mô hình trả lời có dẫn nguồn. Cần ôn cách chạy mô hình local cho phần sinh câu trả lời, xem Chạy LLM local với Ollama và llama.cpp.