So sánh các phương pháp tiếp cận Vector Search
Các công cụ tìm kiếm toàn văn (full-text search) như Elasticsearch dựa vào sự trùng khớp từ khóa chính xác thông qua thuật toán tính điểm BM25. Mô hình này gặp hạn chế khi người dùng tìm kiếm bằng các từ đồng nghĩa. Một truy vấn như “budget laptop for software engineering” sẽ không thể hiển thị sản phẩm có tiêu đề “cheap developer notebook” vì các token không trùng khớp với nhau.
Dense vector embedding giải quyết triệt để sự bất tương thích về mặt từ vựng này. Các mô hình sẽ ánh xạ văn bản thành các điểm trong không gian nhiều chiều, nơi sự tương đồng về mặt khái niệm tương quan trực tiếp với khoảng cách hình học. Tuy nhiên, một khi bạn chuyển đổi các câu văn thành mảng chứa 384 hoặc 1.536 số thực (float), việc chọn nơi lưu trữ và cách thức triển khai hệ thống Semantic Search quy mô lớn sẽ quyết định liệu hệ thống của bạn có thể mở rộng quy mô hay bị tắc nghẽn khi lưu lượng truy cập tăng cao:
- PostgreSQL với pgvector: Một lựa chọn thực dụng nếu hệ thống của bạn đã sử dụng sẵn Postgres. Bạn có thể truy vấn các bảng quan hệ và tính toán độ tương đồng cosine ngay trong các câu lệnh SQL chuẩn. Điểm hạn chế? Việc xây dựng chỉ mục HNSW trên 5 triệu hàng đòi hỏi hàng gigabyte bộ nhớ bảo trì chuyên dụng (maintenance_work_mem), đồng thời độ trễ sẽ tăng vọt khi các phép JOIN phức tạp chạy song song với phép tính khoảng cách vector.
- Hậu lọc hai giai đoạn (Two-Stage Post-Filtering): Kiến trúc này truy vấn chỉ mục láng giềng gần nhất xấp xỉ (ANN) trước, sau đó loại bỏ bất kỳ kết quả nào không vượt qua bước kiểm tra metadata. Cách này nhanh chóng bộc lộ điểm yếu khi gặp điều kiện lọc có tính chọn lọc cao (high selectivity). Nếu bạn yêu cầu lấy ra 10 mục hàng đầu có
category = "electronics", nhưng 95% láng giềng gần nhất lại thuộc"clothing", truy vấn sẽ gần như không trả về kết quả nào trừ khi bạn phải nới rộng phạm vi lấy dư (over-fetch) hàng trăm hàng ứng viên. - Qdrant (Lọc một giai đoạn – Single-Stage Filtering): Được viết bằng Rust, Qdrant tích hợp trực tiếp metadata của payload vào quá trình duyệt đồ thị HNSW. Thay vì lọc bớt các ứng viên sau khi quá trình tìm kiếm vector hoàn tất, nó kiểm tra các bộ lọc boolean ở từng bước di chuyển qua các node trên đồ thị. Những vector không khớp điều kiện sẽ không bao giờ được đưa vào hàng đợi tính khoảng cách.
Tổng quan về sự đánh đổi trong kiến trúc
Mỗi chiến lược vector đều là sự đánh đổi có chủ đích giữa độ trễ, mức tiêu thụ bộ nhớ và độ phức tạp của cơ sở hạ tầng, đặc biệt khi cân nhắc mở rộng quy mô tìm kiếm vector trên PostgreSQL với pgvector.
1. PostgreSQL + pgvector
- Ưu điểm: Không cần bổ sung hạ tầng mới. Bạn vẫn giữ được các đảm bảo ACID tiêu chuẩn, khóa cấp dòng (row-level lock) và các phép JOIN quan hệ quen thuộc với các bảng nghiệp vụ hiện có.
- Nhược điểm: Mức tiêu thụ bộ nhớ tăng rất nhanh trong quá trình dựng chỉ mục. Trên các collection multi-tenant quy mô lớn, các truy vấn kết hợp giữa bộ lọc và vector thường bị rơi vào tình trạng quét tuần tự (sequential scan) chậm chạp nếu bộ tối ưu hóa truy vấn (planner) đánh giá sai tính chọn lọc của bộ lọc.
2. Hậu lọc đơn giản (Naive Post-Filtering)
- Ưu điểm: Dễ dàng triển khai trên các thư viện ANN độc lập như FAISS hoặc Annoy.
- Nhược điểm: Độ trễ trong trường hợp xấu nhất (worst-case latency) rất tệ. Các bộ lọc phạm vi hẹp buộc bạn phải quét một số lượng láng giềng khó lường trước—thường từ 500 đến 1.000 điểm—chỉ để trả về 10 kết quả hợp lệ.
3. Qdrant Single-Stage Engine
- Ưu điểm: Độ trễ tìm kiếm dưới 10ms trên các collection có hàng chục triệu bản ghi. Hỗ trợ memory-mapped files (mmap) trên ổ đĩa và cung cấp tính năng lượng tử hóa vô hướng 8-bit (scalar quantization) nguyên bản, giúp giảm tới 75% dung lượng RAM với độ suy giảm recall dưới 1%.
- Nhược điểm: Thêm một dịch vụ stateful độc lập cần phải giám sát, sao lưu và mở rộng quy mô song song với cơ sở dữ liệu chính của bạn.
Stack công nghệ khuyến nghị: Qdrant + FastEmbed
Kết hợp Qdrant cùng FastEmbed trong Python tạo nên một pipeline truy xuất tinh gọn, sẵn sàng cho môi trường production. Đối với tập dữ liệu tài liệu dưới 500.000 mục, một mô hình 384 chiều như BAAI/bge-small-en-v1.5 hoặc all-MiniLM-L6-v2 mang lại độ chính xác ngữ nghĩa vững chắc trong khi chi phí vận hành vẫn rất thấp. Trong các thử nghiệm benchmark của chúng tôi trên một instance AWS c6i.xlarge (4 vCPU, 8 GB RAM), cấu hình này duy trì độ trễ truy vấn p95 dưới 8 mili giây với tải 120 yêu cầu đồng thời mỗi giây.
Hệ thống được chia thành ba thành phần riêng biệt:
- Qdrant Daemon: Chạy bên trong Docker hoặc Kubernetes, lưu trữ đồ thị vector, các chỉ mục payload đảo ngược (inverted index) và các tệp phân đoạn memory-mapped.
- Inference Layer: Python đảm nhiệm việc tách từ (tokenization) nhẹ nhàng và tạo embedding qua ONNX runtime bằng FastEmbed, giúp giảm thiểu tối đa tải cho CPU.
- Tuned HNSW Index: Các tham số đồ thị được tinh chỉnh nhằm cân bằng giữa tốc độ dựng chỉ mục và khả năng tìm kiếm truy vấn thời gian thực nhanh chóng.
Hướng dẫn triển khai chi tiết
Hãy cùng đi qua các bước cấu hình daemon, khởi tạo schema cho collection, lập chỉ mục các trường quan trọng và truy vấn với các bộ lọc phức tạp.
Bước 1: Khởi chạy Qdrant trong Docker
Khởi động container Qdrant với volume lưu trữ dữ liệu liên tục (persistent storage). Mở cổng 6333 cho các thao tác HTTP và cổng 6334 cho giao tiếp gRPC thông lượng cao:
docker run -d --name qdrant-server \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage:z \
qdrant/qdrant:v1.12.1
Bước 2: Cài đặt các thư viện Python
Cài đặt client chính thức cùng với FastEmbed—thư viện đi kèm sẵn ONNX Runtime giúp tránh việc phải cài đặt các dependency cồng kềnh từ PyTorch:
pip install qdrant-client fastembed
Bước 3: Định nghĩa Schema cho Collection và thiết lập HNSW
Các vector dense định dạng FP32 ngốn bộ nhớ rất nhanh. Việc lưu trữ 1 triệu vector 384 chiều ở dạng số thực 32-bit thô đòi hỏi khoảng 1,5 GB RAM chỉ tính riêng tọa độ thô, chưa kể chi phí phụ trội của đồ thị. Bằng cách kích hoạt lượng tử hóa vô hướng INT8, Qdrant nén mỗi số thực 4-byte thành một số nguyên 1-byte duy nhất, giảm dung lượng bộ nhớ xuống còn ~400 MB. Thiết lập on_disk=True cho các vector đảm bảo chỉ các vector thường xuyên truy cập (hot vector) và biểu diễn lượng tử hóa mới được ghim lại trong RAM.
from qdrant_client import QdrantClient
from qdrant_client.http import models
client = QdrantClient(host="localhost", port=6333)
COLLECTION_NAME = "tech_kb_articles"
VECTOR_SIZE = 384 # Khớp với số chiều đầu ra của BAAI/bge-small-en-v1.5
client.recreate_collection(
collection_name=COLLECTION_NAME,
vectors_config=models.VectorParams(
size=VECTOR_SIZE,
distance=models.Distance.COSINE,
on_disk=True # Lưu vector FP32 gốc trên đĩa, phân trang qua mmap
),
hnsw_config=models.HnswConfigDiff(
m=16, # 16 liên kết mỗi node; giúp duyệt đồ thị nhanh và nhẹ
ef_construct=100, # Đánh giá 100 láng giềng ứng viên cho mỗi điểm khi chèn
on_disk=False # Giữ đồ thị điều hướng HNSW trong RAM để nhảy nhanh
),
quantization_config=models.ScalarQuantization(
scalar=models.ScalarQuantizationConfig(
type=models.ScalarType.INT8,
quantile=0.99, # Loại bỏ các giá trị ngoại lai cực đoan để bảo toàn dải động
always_ram=True # Giữ biểu diễn 1-byte nhỏ gọn trong bộ nhớ
)
)
)
print(f"Đã cấu hình collection '{COLLECTION_NAME}'.")
Bước 4: Đánh chỉ mục cho Payload Metadata
Việc lọc mà không có chỉ mục payload sẽ buộc Qdrant phải quét các khối JSON metadata thô trên đĩa trong quá trình tìm kiếm. Việc tạo chỉ mục trường rõ ràng sẽ xây dựng các danh sách đảo ngược (inverted list) và cây số học (numeric tree), đảm bảo việc kiểm tra bộ lọc diễn ra tức thì trong quá trình duyệt đồ thị:
# Keyword index để khớp chuỗi chính xác trên danh mục
client.create_payload_index(
collection_name=COLLECTION_NAME,
field_name="category",
field_schema=models.PayloadSchemaType.KEYWORD,
)
# Numeric index để đánh giá phạm vi b-tree nhanh chóng
client.create_payload_index(
collection_name=COLLECTION_NAME,
field_name="view_count",
field_schema=models.PayloadSchemaType.INTEGER,
)
print("Đã đăng ký payload index.")
Bước 5: Tạo Embedding và chèn dữ liệu theo Batch
Tạo embedding cho tài liệu bằng FastEmbed và tải chúng lên Qdrant cùng với payload metadata tương ứng:
from fastembed import TextEmbedding
from uuid import uuid4
embedding_model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5")
documents = [
{
"text": "Khắc phục sự cố connection timeout và lỗi DNS trong mạng Docker bridge.",
"category": "devops",
"status": "published",
"view_count": 1250
},
{
"text": "Cấu hình rate limiting, token bucket và proxy buffering trong Nginx.",
"category": "devops",
"status": "published",
"view_count": 3400
},
{
"text": "Viết truy vấn cơ sở dữ liệu bất đồng bộ chuẩn mực với Python AsyncIO và SQLAlchemy 2.0.",
"category": "python",
"status": "published",
"view_count": 5100
},
{
"text": "Bản nháp nội bộ chi tiết quy trình failover replication PostgreSQL cũ.",
"category": "database",
"status": "draft",
"view_count": 40
}
]
texts = [doc["text"] for doc in documents]
embeddings = list(embedding_model.embed(texts))
points = [
models.PointStruct(
id=str(uuid4()),
vector=emb.tolist(),
payload=doc
)
for doc, emb in zip(documents, embeddings)
]
client.upsert(
collection_name=COLLECTION_NAME,
points=points
)
print(f"Đã upsert {len(points)} bản ghi vào '{COLLECTION_NAME}'.")
Bước 6: Truy vấn với bộ lọc Payload một giai đoạn
Thực thi tìm kiếm có kèm bộ lọc trên collection. Qdrant sẽ đánh giá các tiêu chí lọc một cách linh hoạt trong khi duyệt đồ thị HNSW:
query_text = "cách khắc phục rớt gói tin và timeout mạng trong container"
query_vector = list(embedding_model.embed([query_text]))[0].tolist()
search_filter = models.Filter(
must=[
models.FieldCondition(
key="category",
match=models.MatchValue(value="devops")
),
models.FieldCondition(
key="status",
match=models.MatchValue(value="published")
)
],
should=[
models.FieldCondition(
key="view_count",
range=models.Range(gte=1000)
)
]
)
results = client.search(
collection_name=COLLECTION_NAME,
query_vector=query_vector,
query_filter=search_filter,
limit=3,
search_params=models.SearchParams(
hnsw_ef=64, # Độ sâu tìm kiếm; giá trị cao hơn cho recall tốt hơn nhưng tăng nhẹ latency
quantization=models.QuantizationSearchParams(
rescore=True # Xếp hạng lại các ứng viên hàng đầu dựa trên vector FP32 gốc trên đĩa
)
)
)
for hit in results:
print(f"Điểm số: {hit.score:.4f} | Danh mục: {hit.payload['category']} | Văn bản: {hit.payload['text']}")
Thiết lập rescore=True mang lại lợi ích toàn diện nhất. Qdrant duyệt qua đồ thị bằng các vector lượng tử hóa 8-bit siêu nhẹ trong RAM để định vị các mục ứng viên chỉ trong tích tắc. Khi đã thu hẹp phạm vi tìm kiếm xuống danh sách các ứng viên hàng đầu, nó sẽ đọc các vector FP32 gốc từ đĩa để tính điểm lại cho danh sách cuối cùng. Nhờ đó, bạn vẫn bảo toàn được độ chính xác số thực dấu phẩy động hoàn chỉnh mà không phải trả giá bằng việc tốn bộ nhớ RAM để lưu trữ các vector không nén.

