Grep trước, embedding tính sau
Chúng tôi làm retrieval theo hai hướng trái ngược: file thuần với grep cho bộ nhớ của agent, còn embedding lai cho tìm kiếm phía người dùng. Cả hai lựa chọn đều đến từ đo đạc, chứ không phải chạy theo trào lưu.
4 phút đọcViết từ Seoul và Nha Trang
Retrieval xuất hiện hai lần trong stack của chúng tôi, và hai trường hợp lại chọn hai kiến trúc trái ngược. Bộ nhớ của agent, vài trăm ghi chú được chọn lọc kỹ, chạy bằng file và grep. Tìm kiếm phía người dùng, hàng chục nghìn bản ghi đa ngôn ngữ, chạy bằng embedding lai. Ranh giới giữa hai bên đáng để nói cho rõ, vì mặc định của cả ngành là tậu luôn một vector database cho cả hai.
Chỗ grep thắng
Bộ nhớ của agent thì nhỏ, được chọn lọc, và thứ đọc nó lại là thứ vốn đã biết suy luận. Mỗi ghi chú có một dòng mô tả trong file index; agent đọc cái index 40 dòng, tự quyết đâu là thứ liên quan, rồi mở hai ba file. Debug là mở đúng cái file mà agent đã đọc. Sửa là chỉnh lại một đoạn văn. Khi thử dựng chính bộ dữ liệu đó phía sau embedding, retrieval biến thành một phân phối xác suất mà chúng tôi phải ngồi tra hỏi, muốn cập nhật thì phải embedding lại, và không ai trả lời nổi câu duy nhất đáng quan tâm lúc có sự cố: vì sao nó lại đọc cái đó?
Chỗ embedding chứng minh được giá trị
Tìm kiếm trên 40,000 bản ghi lẫn lộn tiếng Hàn, tiếng Anh và tiếng Việt lại là chuyện hoàn toàn khác. Người dùng gõ "spa giá rẻ gần sông" còn bản ghi thì ghi "massage ven sông, bình dân". Keyword thì trượt; vector thì bắt được. Nhưng dense retrieval thuần lại thua hàng lai ngay trên bộ eval của chính chúng tôi, nên thứ đang chạy là hàng lai:
const kw = bm25(query, 40); // exact terms, names, numbers
const dn = cosine(embed(query), 40); // paraphrase, cross-lingual
const pool = dedupe([...kw, ...dn]);
const ranked = await llmRerank(query, pool.slice(0, 30)); // one cheap model call
return ranked.slice(0, 8); // what actually enters the context
Trên một bộ 400 truy vấn có nhãn dựng từ log tìm kiếm thật, recall@20 đạt 0.71 nếu chỉ dùng keyword, 0.76 nếu chỉ dùng dense, và 0.90 với hàng lai có rerank. Lệnh gọi rerank tốn chưa tới một xu mà lại xử lý đoạn cuối tốt hơn mọi phép đổi embedding model chúng tôi từng thử.
Chất lượng thật ra nằm ở khâu chunking
Cửa sổ cố định 512 token chặt lìa bảng khỏi tiêu đề của nó và tách câu trả lời khỏi câu hỏi. Chúng tôi chia chunk theo cấu trúc tài liệu, theo heading và ranh giới của list, và mỗi chunk mang theo đường dẫn nguồn cùng byte offset, nhờ vậy bất cứ thứ gì model trích dẫn cũng chỉ cách câu gốc đúng một cú click. Một hệ thống retrieval không dẫn được nguồn thì chẳng qua là cái máy tung tin đồn với latency đẹp.