
Backend AI hỏng im lặng: RAG trả 0 dòng, job chạy hai lần
Index HNSW lọc sau nên LIMIT 10 có thể trả về 0 dòng, còn retry của hàng đợi không phải idempotency. Hai lỗi không sinh log, và cách bắt chúng kêu.
Khách mở ticket đúng một dòng: "Trợ lý của bên bạn nói tài liệu không đề cập chuyện hoàn tiền. Chúng tôi có hẳn một trang tên Refunds." Trang đó có thật, đã được chunk, đã embed, nằm trong Postgres với đúng tenant_id. Chạy lại đúng câu truy vấn mà bot chạy, bằng tay, trong psql: 0 dòng. Không phải dòng sai, không phải dòng kém. Là không có dòng nào — từ một query có LIMIT 10.
Đây là kiểu hỏng tệ nhất trong backend AI: không lỗi, không timeout, không cảnh báo slow query. Hai bài viết trên dev.to tuần này mổ xẻ hai chỗ nó hay xảy ra nhất — tầng retrieval và tầng hàng đợi. Cả hai đều có cách phát hiện rẻ hơn nhiều so với cái giá phải trả khi khách hàng phát hiện hộ bạn.
Lỗi 1: HNSW lọc sau, nên LIMIT 10 trả về 0
Nguyên nhân là thứ tự thực thi. Index HNSW của pgvector tìm hnsw.ef_search vector gần nhất — mặc định 40 — trên toàn bộ bảng trước, rồi Postgres mới áp mệnh đề WHERE của bạn lên đúng 40 ứng viên đó. Index không hề biết tenant_id tồn tại.
Tác giả mô tả bảng có khoảng 200.000 chunk, một tenant lớn chiếm phần lớn, còn tenant 42 chỉ có 4.000 chunk — tức 2%. Phép tính đi theo công thức: số dòng kỳ vọng ≈ ef_search × độ chọn lọc.
Số khớp kỳ vọng = 40 × 0.02 = 0.8 dòng
Xác suất không có dòng nào = 0.98^40 ≈ 45%
Nghĩa là khoảng một nửa câu hỏi của tenant 42 không lấy về được gì cả, và gần như không câu nào lấy đủ 10. Dấu vết trong EXPLAIN ANALYZE chỉ có một dòng đáng đọc: Rows Removed by Filter: 40.
Ngưỡng để nhớ: bạn bắt đầu mất dòng ngay khi bộ lọc khớp dưới LIMIT / ef_search số dòng của bảng. Với LIMIT 10 và ef_search = 40 mặc định, ngưỡng đó là 25%.
| Bộ lọc khớp | Dòng kỳ vọng từ 40 ứng viên | Thực nhận với LIMIT 10 |
|---|---|---|
| 50% số dòng | 20 | 10 |
| 25% số dòng | 10 | khoảng 10, đôi khi ít hơn |
| 10% số dòng | 4 | khoảng 4 |
| 2% số dòng | 0.8 | 0 hoặc 1 |
Dòng 10% không phải tác giả tự nghĩ ra — bài viết cho biết README của pgvector nêu đúng ví dụ này: điều kiện khớp 10% số dòng với ef_search mặc định trả về trung bình khoảng 4 dòng.
Thực tế còn tệ hơn phép tính. Tenant lớn trong câu chuyện bán sản phẩm tương tự, nên các chunk chính sách hoàn tiền của họ nằm sát ngay cạnh chunk hoàn tiền của tenant 42 trong không gian embedding. 40 hàng xóm gần nhất của câu "how do refunds work" đều thuộc tenant lớn. Độ tương đồng ngữ nghĩa quay ra chống lại bạn.
Lý do bug này trốn giỏi: tenant test của bạn thường là tenant lớn nhất, hoặc là tenant duy nhất. Bộ lọc chỉ chọn lọc cao đối với những khách ít kêu ca nhất.
Tăng ef_search chỉ là băng dán
Đặt SET hnsw.ef_search = 400 cho bạn 400 ứng viên thay vì 40, và độ chọn lọc 2% cho khoảng 8 dòng kỳ vọng — vẫn dưới 10. Chi phí tìm kiếm tăng theo ef_search trên mọi query, kể cả những query chưa bao giờ cần, và trần là 1000. Ở độ chọn lọc 0,1% thì 1000 cũng không đủ. Bạn đang vặn một núm toàn cục để sửa một vấn đề theo từng bộ lọc.
Ba cách sửa thật sự, và chúng cộng dồn được
- Iterative index scan (pgvector 0.8.0 trở lên).
SET hnsw.iterative_scan = strict_orderhoặcrelaxed_orderđổi hành vi từ "tìm 40, lọc, xong" thành "tìm 40, lọc, còn thiếu thì đi tiếp", dừng khi đủLIMIThoặc chạmhnsw.max_scan_tuples(mặc định 20000).strict_ordertrả về đúng thứ tự khoảng cách;relaxed_ordernhanh hơn nhưng phải tự sort lại bằng CTEMATERIALIZED. Tác giả đặt nó ở mứcSET LOCALtrong transaction retrieval chứ không toàn cục, để các vector query khác giữ nguyên hành vi cũ cho tới khi kiểm tra xong. - Partial index hoặc partition. Nếu bộ lọc có ít giá trị phân biệt, tạo một index HNSW riêng cho mỗi tenant với mệnh đề
WHERE; mọi ứng viên đã đúng tenant nên bước lọc không bỏ đi gì. Cách này không scale tới 10.000 tenant nhưng rất hợp với một nhúm category lớn. Nhiều giá trị thì dùngPARTITION BY LISThoặc hash partition, mỗi partition một index HNSW. - Quét chính xác cho bộ lọc nhỏ. Với 4.000 vector, quét exact vừa rẻ vừa cho recall 100%. Ép nó bằng cách materialize tập đã lọc trước để index HNSW không được dùng cho bước sắp xếp.
Cấu hình tác giả cho biết đã ship: bật iterative scan cho retrieval, cộng thêm nhánh exact search cho mọi tenant dưới một ngưỡng số dòng. Tenant lớn ở lại với index xấp xỉ, tenant nhỏ được recall hoàn hảo mà vẫn trả lời nhanh.
Phát hiện trước khi người dùng phát hiện hộ
Thứ rẻ nhất trong cả bài là mười dòng log: mỗi lần retrieval trả về ít dòng hơn LIMIT, ghi một cảnh báo kèm tenant, số muốn lấy và số thực nhận. Kết quả ngắn là bình thường nếu tenant đó thật sự có ít hơn k chunk; nó là tín hiệu bug nếu tenant có hàng nghìn.
Mỗi tuần chạy thêm một bài kiểm tra recall: lấy mẫu các truy vấn thật, chạy một lần qua index và một lần qua CTE exact, rồi so hai tập ID. Nếu độ trùng ở nhóm truy vấn có lọc thấp hơn hẳn nhóm không lọc, bạn vừa tìm đúng con bug này.
Một bình luận dưới bài bổ sung một biến thể đáng sợ hơn về mặt bảo mật: cùng hình dạng bug nhưng bộ lọc là cột phân quyền theo role — HNSW vui vẻ trả về 40 dòng mà người dùng không có quyền xem, rồi mới lọc.
Lỗi 2: retry không phải là idempotency
Đẩy việc AI ra khỏi request HTTP giúp đường request nhẹ đi. Nhưng theo bài phân tích thứ hai, đưa việc vào hàng đợi không làm nó đúng. Tác giả mổ chính codebase AI Support Assistant của mình — và nói rõ đây là rủi ro tính đúng đắn lộ ra từ code, không phải sự cố production đã xảy ra, còn các bản sửa đề xuất thì chưa được triển khai.
Ba cửa sổ hỏng, không cái nào ném exception:
Database commit nhưng queue thì không. Message insert thành công, cập nhật timestamp của ticket thành công, rồi bước publish job sang Redis lỗi — hoặc process chết trước khi publish xong. Database giờ chứa thay đổi hội thoại mà không có việc nền tương ứng. Retry worker không cứu được một job chưa bao giờ được publish. Tệ hơn, lỗi publish có thể để lại kết quả nhập nhằng: Redis có thể đã nhận job trước khi caller mất acknowledgement.
Retry lặp lại toàn bộ công việc AI. Giả sử sinh summary thành công, sinh sentiment lỗi. BullMQ retry processor, và nó bắt đầu lại từ đầu: load message, sinh summary lại. Không có checkpoint nào tái dùng summary trước đó. Đẩy điểm lỗi xuống muộn hơn: summary, sentiment, priority đều xong, ticket đã update, nhưng insert bản ghi interaction lỗi — ticket đã mang metadata AI trong khi job bị tính là failed, và lần retry sẽ chạy lại cả chuỗi AI rồi ghi tiếp, với kết quả không nhất thiết trùng lần trước.
Thứ tự ghi là ràng buộc nghiệp vụ. Hai message tới sát nhau trên cùng ticket: job A đọc hội thoại có A, job B đọc cả A và B, B xong trước và ghi metadata đúng, A xong sau và ghi đè bằng context cũ hơn. Câu update chỉ khớp theo id: ticketId, không có gì chặn bước bốn. Worker chạy concurrency: 5, nên năm job có thể chạy song song kể cả khi cùng một ticket.
Khoá idempotency nên đặt ở đâu
Bài viết phân biệt ba câu hỏi khác nhau mà nhiều team gộp làm một: retry hỏi "có nên thử lại sau khi lỗi không", idempotency hỏi "nếu làm lại thì trạng thái còn đúng không", deduplication hỏi "việc tương đương có nên được nhận hoặc chạy lại không".
Job ID tĩnh chỉ giải quyết được phần nhận việc, và chỉ trong lúc job còn tồn tại — tài liệu BullMQ nêu rõ một custom ID đang tồn tại sẽ chặn job khác cùng ID được thêm vào, nhưng khi job bị xoá thì ID đó hết tác dụng. Điều này liên quan trực tiếp tới cấu hình removeOnComplete: true trong chính codebase đó.
Đề xuất của tác giả là đặt khoá ở tầng tác động: định nghĩa một logical analysis key từ ticket, revision của hội thoại và phiên bản prompt, lưu nó trong một bản ghi bền có ràng buộc unique, rồi commit cùng lúc ba thứ — update ticket, insert interaction, và marker đánh dấu đã phân tích xong. Một bước đọc "đã làm chưa?" riêng lẻ là không đủ, vì hai worker có thể cùng đi qua được nó.
Với trường hợp DB đã commit mà queue chưa nhận, hướng xử lý là transactional outbox: ghi message, ghi ticket và ghi outbox event trong cùng một transaction Postgres, rồi để một dispatcher đẩy sang Redis và đánh dấu đã publish. Lưu ý tác giả nhấn mạnh: outbox vẫn có thể publish hai lần nếu dispatcher chết trước khi đánh dấu, nên nó cần event identity ổn định và tiêu thụ an toàn với bản sao — nó không mang lại exactly-once.
Làm gì trước
Phân tích của chúng tôi về thứ tự: cả hai lỗi trên đều câm, nên việc đầu tiên không phải là sửa mà là làm cho chúng kêu.
- Thêm log shortfall cho mọi truy vấn retrieval. Mười dòng, không cần đổi schema, và nó bắt được bug từ ngày đầu.
- Chạy
EXPLAIN ANALYZEtrên truy vấn RAG của tenant nhỏ nhất, tìm dòngRows Removed by Filter. - Kiểm tra phiên bản pgvector. Dưới 0.8.0 thì iterative scan không có, bạn chỉ còn partial index, partition hoặc exact scan.
- Liệt kê các tác động của worker — gọi provider, ghi metadata, ghi bản ghi interaction — và hỏi với từng cái: chạy lại lần hai thì trạng thái còn đúng không?
- Kiểm tra concurrency của worker so với ràng buộc thứ tự theo từng thực thể. Hạ concurrency xuống 1 sẽ giảm chồng lấn nhưng cũng serialize cả những ticket không liên quan, nên đó là đánh đổi chứ không phải bản sửa.
Không spam, hủy đăng ký bất kỳ lúc nào.
Bài viết liên quan

Hóa đơn LLM tăng vọt: tìm cache miss trước khi hạ model
04 thg 10, 2026
Trả lời tin nhắn tự động trên Android bằng Gemini và API gốc
03 thg 10, 2026