- Cơ sở dữ liệu vector lưu embedding cùng dữ liệu đi kèm, và tìm nhanh các vector gần câu hỏi nhất bằng chỉ mục gần đúng.
- Chỉ mục gần đúng nhanh hơn rất nhiều nhưng có thể bỏ sót kết quả đúng. Trong ví dụ của bài, xét 6% số vector tìm lại được 88% kết quả đúng; xét 21% tìm lại được 97%.
- Với vài chục nghìn vector, so từng vector bằng numpy thường đã đủ nhanh và luôn chính xác.
- Nếu đã dùng Postgres, tiện ích pgvector cho tìm vector ngay trong cơ sở dữ liệu quen thuộc trước khi nghĩ tới hệ thống riêng.
Bài này dựa trên README của pgvector và FAISS, cùng bài báo giới thiệu chỉ mục HNSW của Malkov và Yashunin (2016), đọc ngày 11/11/2026. Nếu chưa rõ embedding là gì, đọc trước bài embedding là gì.
Bài toán: tìm vector gần nhất
Trong một hệ thống RAG, mỗi đoạn tài liệu được biến thành một embedding và lưu lại. Khi có câu hỏi, ta biến câu hỏi thành vector rồi tìm k vector gần nhất, thường là 5 đến 20. Cách đơn giản nhất là so câu hỏi với từng vector đã lưu, tính độ tương đồng, sắp xếp lấy k cái cao nhất. Cách này luôn đúng, và tốn thời gian tỷ lệ với số vector.
Với 20.000 vector 1024 chiều, mỗi lần tìm là khoảng 20 triệu phép nhân, numpy làm trong chớp mắt. Với 50 triệu vector, mỗi lần tìm thành 50 tỷ phép nhân, và nếu có nhiều người hỏi cùng lúc thì không chịu nổi. Đó là lúc cần chỉ mục.
Chỉ mục gần đúng
Chỉ mục gần đúng tìm một tập ứng viên nhỏ có khả năng chứa kết quả, rồi chỉ so câu hỏi với tập đó. Hai họ phổ biến:
IVF (chia cụm). Trước khi tìm, chia các vector thành nhiều cụm bằng thuật toán gom cụm như k-means, mỗi cụm có một tâm. Khi tìm, so câu hỏi với các tâm, chọn vài cụm gần nhất, rồi chỉ so với vector trong các cụm đó. Xét càng nhiều cụm càng chính xác và càng chậm.
HNSW (đồ thị nhiều tầng). Mỗi vector là một đỉnh, nối với vài đỉnh gần nó. Các tầng trên thưa, giúp nhảy nhanh tới vùng gần câu hỏi; tầng dưới dày, giúp tinh chỉnh. Bài báo gốc mô tả cách đi từ tầng thưa xuống tầng dày này giúp số bước tìm tăng chậm, gần như theo log của số vector.
Độ tốt của chỉ mục gần đúng đo bằng recall@k: trong k kết quả đúng (do cách so từng vector cho ra), chỉ mục tìm lại được bao nhiêu phần.
Đo thử bằng Python
Đoạn code dưới đây tạo 20.000 vector ngẫu nhiên có cụm để giả lập embedding, chuẩn hóa về độ dài 1. Nó cài cách so từng vector và một chỉ mục IVF đơn giản 100 cụm, rồi với 50 câu hỏi, đo số phép so sánh trung bình và recall@10 khi xét 1, 5 và 20 cụm.
"""Tìm láng giềng gần nhất: so từng vector (chính xác) và chỉ mục chia cụm kiểu IVF (gần đúng).
Dữ liệu là vector ngẫu nhiên có cụm (giả lập embedding), đã chuẩn hóa độ dài 1, nên tích vô hướng = cosin.
IVF: chia vector thành `so_cum` cụm bằng k-means; khi tìm, chỉ xét `so_cum_xet` cụm có tâm gần câu hỏi nhất.
Đo: số phép so sánh (càng ít càng nhanh) và recall@10 (tỷ lệ 10 kết quả đúng tìm lại được).
Cần: numpy.
"""
import numpy as np
def tao_du_lieu(n=20_000, chieu=64, so_nhom=50, seed=0):
rng = np.random.default_rng(seed)
tam = rng.normal(size=(so_nhom, chieu))
x = tam[rng.integers(0, so_nhom, n)] + 1.5 * rng.normal(size=(n, chieu))
return x / np.linalg.norm(x, axis=1, keepdims=True)
def tim_chinh_xac(x, q, k=10):
diem = x @ q
return set(np.argsort(-diem)[:k]), len(x)
def kmeans(x, so_cum, so_vong=10, seed=1):
rng = np.random.default_rng(seed)
tam = x[rng.choice(len(x), so_cum, replace=False)]
for _ in range(so_vong):
nhan = np.argmax(x @ tam.T, axis=1)
for c in range(so_cum):
thanh_vien = x[nhan == c]
if len(thanh_vien):
v = thanh_vien.mean(axis=0)
tam[c] = v / np.linalg.norm(v)
return tam, np.argmax(x @ tam.T, axis=1)
class ChiMucIVF:
def __init__(self, x, so_cum=100):
self.x = x
self.tam, nhan = kmeans(x, so_cum)
self.danh_sach = [np.flatnonzero(nhan == c) for c in range(so_cum)]
def tim(self, q, k=10, so_cum_xet=1):
cum = np.argsort(-(self.tam @ q))[:so_cum_xet]
ung_vien = np.concatenate([self.danh_sach[c] for c in cum])
diem = self.x[ung_vien] @ q
so_sanh = len(self.tam) + len(ung_vien)
return set(ung_vien[np.argsort(-diem)[:k]]), so_sanh
def danh_gia(so_cau_hoi=50):
x = tao_du_lieu()
rng = np.random.default_rng(2)
cau_hoi = x[rng.choice(len(x), so_cau_hoi, replace=False)] + 0.1 * rng.normal(size=(so_cau_hoi, x.shape[1]))
cau_hoi /= np.linalg.norm(cau_hoi, axis=1, keepdims=True)
chi_muc = ChiMucIVF(x)
kq = {}
for so_cum_xet in (1, 5, 20):
recall, so_sanh = [], []
for q in cau_hoi:
dung, _ = tim_chinh_xac(x, q)
tim_duoc, s = chi_muc.tim(q, so_cum_xet=so_cum_xet)
recall.append(len(dung & tim_duoc) / 10)
so_sanh.append(s)
kq[so_cum_xet] = (float(np.mean(recall)), float(np.mean(so_sanh)))
return len(x), kq
def chay_thu():
n, kq = danh_gia()
dong = [f"So từng vector: {n} phép so sánh, recall@10 = 1.00"]
for so_cum_xet, (r, s) in kq.items():
dong.append(f"IVF 100 cụm, xét {so_cum_xet:>2} cụm: khoảng {s:.0f} phép so sánh ({s / n:.0%}), recall@10 = {r:.2f}")
print("\n".join(dong))
return dong
if __name__ == "__main__":
chay_thu()
Kết quả: xét 1 cụm chỉ tốn khoảng 367 phép so sánh (2% so với 20.000) nhưng recall@10 chỉ 0,62, tức trung bình bỏ sót gần 4 trong 10 kết quả đúng. Xét 5 cụm tốn khoảng 1.147 phép (6%), recall 0,88. Xét 20 cụm tốn khoảng 4.129 phép (21%), recall 0,97. Số phép so sánh đã tính cả việc so với 100 tâm cụm.
Ba điều rút ra. Một, luôn có đánh đổi giữa tốc độ và độ đầy đủ, và bạn chọn điểm đánh đổi bằng tham số. Hai, con số cụ thể phụ thuộc dữ liệu: dữ liệu thật có cụm rõ thì recall cao hơn, dữ liệu lộn xộn thì thấp hơn. Ba, cần đo trên chính dữ liệu của mình, so với kết quả của cách so từng vector, trước khi chọn tham số.
pgvector: tìm vector trong Postgres
pgvector là tiện ích mã nguồn mở thêm kiểu dữ liệu vector vào Postgres. Bạn lưu vector cùng các cột dữ liệu khác (tiêu đề, ngày, quyền xem) và truy vấn bằng SQL. Theo README, mặc định pgvector tìm chính xác, tức so từng vector, nên recall hoàn hảo. Muốn nhanh hơn thì tạo chỉ mục gần đúng, chấp nhận giảm recall.
README so sánh hai loại chỉ mục. HNSW xây chậm hơn và tốn bộ nhớ hơn, nhưng đánh đổi tốc độ và recall tốt hơn, và không cần dữ liệu sẵn trước khi tạo. IVFFlat xây nhanh hơn, tốn ít bộ nhớ hơn, nhưng nên tạo sau khi đã có dữ liệu; README gợi ý số danh sách bằng số dòng chia 1.000 khi tới 1 triệu dòng, và căn bậc hai số dòng khi nhiều hơn, còn số danh sách cần xét khởi điểm là căn bậc hai số danh sách. README liệt kê sáu toán tử khoảng cách; ba cái hay gặp nhất với embedding là khoảng cách Euclid, tích vô hướng (dạng âm) và khoảng cách cosin, ba cái còn lại là L1, Hamming và Jaccard. Kiểu vector đánh chỉ mục được tới 2.000 chiều; nhiều chiều hơn thì dùng kiểu nửa độ chính xác.
Ưu điểm lớn của cách này là đơn giản: một hệ thống, một chỗ sao lưu, lọc theo điều kiện thường bằng SQL ngay trong cùng truy vấn.
Lọc, cập nhật và xóa
Tìm vector hiếm khi đứng một mình. Người dùng thường hỏi kèm điều kiện: chỉ tài liệu của môn Toán, chỉ bài viết sau năm 2025, chỉ đoạn văn mà tài khoản này được phép đọc. Có hai cách lọc. Lọc trước rồi mới tìm vector thì chắc chắn đúng điều kiện, nhưng chỉ mục gần đúng có thể chạy kém khi tập còn lại quá nhỏ. Tìm vector trước rồi mới lọc thì nhanh, nhưng có thể trả về ít hơn k kết quả vì nhiều kết quả bị loại. Khi chọn công cụ, hãy thử đúng kiểu câu hỏi có điều kiện mà ứng dụng của bạn sẽ gặp.
Dữ liệu cũng thay đổi. Tài liệu được sửa thì phải nhúng lại và thay vector cũ; tài liệu bị xóa thì vector phải xóa theo, nếu không chatbot vẫn trích đoạn văn đã gỡ. Chỉ mục kiểu chia cụm có thể kém dần khi thêm nhiều dữ liệu mới khác hẳn dữ liệu lúc xây, nên định kỳ xây lại. Ghi lại phiên bản mô hình embedding cạnh mỗi vector để biết vector nào cần nhúng lại khi đổi mô hình.
Khi nào cần một hệ thống riêng
Có nhiều hệ thống chuyên cho vector, cả mã nguồn mở lẫn dịch vụ đám mây, và thư viện FAISS của Meta cho phép xây chỉ mục ngay trong chương trình Python. Một cách nghĩ thực tế:
- Vài nghìn đến vài chục nghìn vector, ít người dùng: numpy so từng vector. Đơn giản, chính xác, không thêm hệ thống nào. Ví dụ trong bài RAG là gì làm đúng như vậy.
- Đã dùng Postgres, tới vài triệu vector: pgvector, bắt đầu không chỉ mục, thêm HNSW khi thấy chậm.
- Cần chỉ mục trong chương trình, không cần máy chủ: FAISS.
- Hàng chục triệu vector trở lên, nhiều người truy vấn đồng thời, cần mở rộng nhiều máy: cân nhắc hệ thống chuyên dụng, và đo kỹ chi phí vận hành.
Đừng chọn hệ thống trước khi có số đo. Nhiều dự án sinh viên dựng một cơ sở dữ liệu vector cho vài trăm đoạn văn, trong khi một mảng numpy là đủ.
- Dựng hệ thống vector riêng cho vài nghìn đoạn văn.
- Dùng chỉ mục gần đúng mà không đo recall so với cách so từng vector.
- Chọn phép đo khoảng cách khác với phép đo mà mô hình embedding được thiết kế cho.
- Quên lọc theo quyền xem: người dùng tìm thấy đoạn văn họ không được phép đọc.
- Đổi mô hình embedding mà không xây lại chỉ mục với vector mới.
Câu hỏi thường gặp
Cơ sở dữ liệu vector có thay được cơ sở dữ liệu thường không?
Không. Nó giỏi tìm theo độ gần, còn lưu đơn hàng, điểm số, giao dịch vẫn cần cơ sở dữ liệu thường. Nhiều hệ thống dùng cả hai, hoặc dùng pgvector để gộp chung.
Recall bao nhiêu là đủ?
Tùy ứng dụng. Với RAG, lấy dư vài kết quả rồi để mô hình hoặc bộ xếp hạng lại lọc thường bù được recall chưa tuyệt đối. Đo bằng bộ câu hỏi có đáp án.
HNSW hay IVF?
HNSW thường cho đánh đổi tốc độ và recall tốt hơn nhưng tốn bộ nhớ; IVF xây nhanh và nhẹ hơn. Thử cả hai trên dữ liệu của bạn.
Có cần GPU không?
Không bắt buộc. GPU giúp khi dữ liệu rất lớn hoặc cần xây chỉ mục nhanh; phần lớn dự án học tập chạy tốt trên CPU.
Học tiếp
- Vector đến từ đâu: embedding là gì.
- Dùng tìm kiếm vector để trả lời từ tài liệu: RAG là gì.
- Ôn numpy để tự làm phép so từng vector: Python cho AI.
Nếu bạn muốn học Python và AI bài bản cùng giảng viên, tham khảo khóa học AI của Big-O Coding.
Khóa AI – Introduction to Machine Learning: nền tảng Máy học từ thuật toán cơ bản đến Neural Networks và LLM, học bằng Python. Học thử buổi 1 miễn phí.


