Bỏ qua để đến nội dung
Search & RAG

7.8 Filterable HNSW và query planner

Tầng 5 đã gọi tên cái bẫy: filter + vector search. Mục này là bản đầy đủ, và nó là thứ Qdrant làm khác nhất so với một thư viện ANN.

Đây cũng là mục có nhiều hệ RAG đang âm thầm mất recall nhất mà không ai biết, vì hỏng kiểu này không sinh ra lỗi — nó chỉ trả về ít kết quả hơn, hoặc kết quả tệ hơn.

Bạn muốn: “10 chunk giống câu hỏi nhất, trong phòng ban kế toán.”

Hai cách hiển nhiên, cả hai đều sai:

HNSW lấy top-100 trên toàn corpus → lọc phòng ban = kế toán → còn lại 2 kết quả

Nếu kế toán chỉ chiếm 1% corpus thì kỳ vọng chỉ có 1 điểm trong 100 điểm đầu thoả filter. Bạn xin 10, nhận về 1. Và không có lỗi nào được báo.

Đây là chế độ hỏng nguy hiểm nhất vì nó im lặng và phụ thuộc dữ liệu: hôm nay phòng kế toán chiếm 30% corpus thì mọi thứ ổn; tháng sau bạn nạp thêm tài liệu phòng khác, tỷ lệ tụt xuống 2%, và chất lượng sập mà không ai đổi một dòng code nào.

Pre-filter ngây thơ — lọc trước, quét sau

Phần tiêu đề “Pre-filter ngây thơ — lọc trước, quét sau”
lọc phòng ban = kế toán → được 200.000 điểm → brute-force cosine trên 200.000 điểm

Đúng về kết quả, nhưng bạn vừa vứt bỏ toàn bộ lý do dùng ANN.

Và cách thứ ba, cũng sai: đi HNSW rồi bỏ qua node không khớp

Phần tiêu đề “Và cách thứ ba, cũng sai: đi HNSW rồi bỏ qua node không khớp”

Nghe hợp lý: đi đồ thị như bình thường, gặp node không thoả filter thì không nhận nó vào kết quả nhưng vẫn đi tiếp qua nó.

Nó hỏng vì một lý do đồ thị: filter chặt làm đồ thị đứt thành nhiều mảnh. HNSW đi được là nhờ các cạnh nối; nếu 99% node bị loại, các node còn lại có thể không còn đường nối với nhau. Greedy descent mắc kẹt trong một mảnh nhỏ và không bao giờ tới được mảnh chứa đáp án.

Cách Qdrant giải: thêm cạnh vào đồ thị

Phần tiêu đề “Cách Qdrant giải: thêm cạnh vào đồ thị”

Ý tưởng của Qdrant, một dòng:

Nếu filter làm đứt đồ thị, hãy dựng sẵn thêm những cạnh nối các điểm cùng giá trị payload lại với nhau.

Đồ thị HNSW gốc Đồ thị có thêm cạnh theo payload
(● = kế toán, ○ = khác)
○───○───● ○───○───●
│ │ │ │ │ │╲
● ○ ○ lọc ● ⇒ ● ○ ○ ╲
│ │ │ ba mảnh │╲__│___│__╲
○───●───○ rời rạc ○───●───○ (cạnh phụ nối ● với ●)
⇒ các ● vẫn thông nhau

Qdrant dựng các đồ thị con theo từng giá trị payload rồi gộp chúng trở lại đồ thị đầy đủ. Tham số điều khiển số cạnh phụ này là payload_m (mặc định null = dùng m).

Hệ quả cực kỳ quan trọng, và là lỗi triển khai phổ biến nhất trong cả Phần 7:

⚠️ Cạnh phụ chỉ được tạo cho những trường đã có payload index tại thời điểm xây đồ thị HNSW. Tạo payload index sau khi đã nạp dữ liệu ⇒ đồ thị hiện có không có cạnh phụ, và toàn bộ cơ chế này không hoạt động — dù GET /collections/{name} vẫn báo là index đã tồn tại.

Cách đúng: tạo payload index trước khi nạp dữ liệu. Cách chữa nếu lỡ: buộc dựng lại HNSW bằng cách tăng ef_construct lên 1 (7.7).

Query planner: ba đường, chọn theo cardinality

Phần tiêu đề “Query planner: ba đường, chọn theo cardinality”

Qdrant không luôn dùng filterable HNSW. Trước mỗi truy vấn có filter, nó ước lượng cardinality — có bao nhiêu điểm thoả filter — rồi chọn một trong ba đường:

ước lượng số điểm thoả filter
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
RẤT ÍT VỪA PHẢI RẤT NHIỀU
(< full_scan_threshold) (gần như cả corpus)
│ │ │
▼ ▼ ▼
Bỏ HNSW hoàn toàn. Filterable HNSW: HNSW thường,
Dùng payload index đi đồ thị có cạnh phụ, bỏ qua node
lấy tập điểm rồi chỉ nhận node thoả không khớp
chấm điểm trực tiếp filter khi duyệt
│ │ │
recall = 1.0 recall cao recall cao
và nhanh hơn HNSW (filter loại ít)

Ba nhận xét về sơ đồ này:

  1. Nhánh trái cho recall hoàn hảo. Filter chặt không phải là tin xấu — nó là tin tốt, miễn là bạn có payload index. Không có index thì Qdrant không ước lượng được cardinality và không đi được nhánh này.
  2. Nhánh phải rẻ vì filter loại ít nên đồ thị gần như không đứt.
  3. Nhánh giữa là chỗ khó, và là lý do filterable HNSW tồn tại.

Ngưỡng chuyển giữa nhánh trái và giữa là full_scan_threshold_kb, mặc định 10000 KB (≈ 10.000 vector 256 chiều, hoặc ≈ 1.700 vector 1536 chiều — xem 7.4).

Từ v1.16, Qdrant có thêm chiến lược ACORN, dựa trên paper cùng tên (SIGMOD 2024).

Cạnh phụ theo payload hoạt động tốt khi filter là một điều kiện trên một trường đã index. Nó đuối trong hai tình huống:

  • Nhiều filter chặt kết hợpphòng ban = kế toán AND ngôn ngữ = vi AND năm = 2026. Không có đồ thị con nào được dựng sẵn cho giao của ba điều kiện.
  • Nhiều điểm đã xoá mềm — đồ thị đầy lỗ thủng mà vacuum chưa kịp dọn (7.6).

ACORN xử lý bằng cách nhìn xa hơn một bước: khi các láng giềng trực tiếp đều bị filter loại, nó xét láng giềng của láng giềng để bắc cầu qua vùng chết. Đổi lại là latency cao hơn. Đây là đánh đổi có ý thức, không phải bữa trưa miễn phí.

Bài kiểm tra, làm được trong nửa giờ, và nên làm trước khi bạn nghi ngờ bất cứ thứ gì khác:

Với cùng một bộ truy vấn, đo bốn số:
(a) recall@k không filter, exact: true ← trần tuyệt đối
(b) recall@k không filter, HNSW ← ANN lấy đi bao nhiêu
(c) recall@k CÓ filter, exact: true ← trần khi có filter
(d) recall@k CÓ filter, HNSW ← số thật của production

Đọc kết quả:

Quan sátChẩn đoán
(a) ≈ (b) và (c) ≈ (d)Lành mạnh. Vấn đề của bạn nằm chỗ khác
(a) ≈ (b) nhưng (c) ≫ (d)Đúng bệnh của mục này. Filter đang phá HNSW
(a) ≫ (b)Bệnh HNSW thuần — tăng hnsw_ef (7.7)
(a) thấpBệnh không nằm ở Qdrant. Xem 5.4

Điểm mấu chốt: hầu hết người ta chỉ đo (b). Đo recall khi không filter rồi triển khai một hệ luôn luôn có filter là cách đo sai phổ biến nhất khi dùng vector database — và nó khớp đúng với tinh thần “bảy lỗi phương pháp” ở Phần 2 — 7.

Theo thứ tự, từ rẻ tới đắt:

  1. Kiểm payload index có tồn tại không. Không có index thì không có gì trong mục này hoạt động cả.
  2. Kiểm index được tạo trước hay sau khi nạp dữ liệu. Nếu sau: buộc dựng lại HNSW (ef_construct +1).
  3. Tăng hnsw_ef cho các truy vấn có filter. Rẻ, hiệu lực ngay.
  4. Tăng full_scan_threshold_kb để filter chặt rơi vào nhánh quét thẳng — recall 1.0.
  5. Bật ACORN nếu bạn kết hợp nhiều filter chặt.
  6. Xem lại payload_m nếu trường lọc chính có ít giá trị phân biệt.
Phần 7 — Qdrant