Hybrid search và rerank: dựng pipeline RAG tìm đúng số hợp đồng, mã sản phẩm
Embedding hiểu nghĩa câu hỏi rất tốt, nhưng lại dễ nhầm giữa HD-2024-0153 và HD-2024-0135. Bài hướng dẫn này đi qua sáu bước, từ bộ đo, tokenizer đến rerank, để sửa lỗi đó và đo được kết quả sau khi sửa.
- 1Truy vấn chứa mãVí dụ: điều khoản phạt của hợp đồng HD-2024-0153
- 2Hai nhánh chạy song songNhánh BM25 khớp đúng chuỗi mã; nhánh vector khớp nghĩa. Cả hai chạy cùng lúc
- 3Gộp bằng RRFCộng 1/(k+rank) qua từng danh sách, khử trùng lặp, không cần dò trọng số
- 4RerankDữ liệu có cấu trúc định dạng YAML, chỉ giữ các chunk liên quan nhất
- 5Chuyển cho LLMChỉ những chunk đã qua rerank được đưa vào ngữ cảnh của mô hình
BM25 và vector chạy song song, RRF gộp theo thứ hạng, rerank lọc lại rồi mới chuyển chunk cho mô hình; đo tỷ lệ thất bại top-20 trước và sau mỗi thay đổi.
Đồ hoạ: FDE Times
Tóm tắt nhanh
- Vector search khớp theo nghĩa nên hay bỏ lỡ mã chính xác. BM25 khớp theo chuỗi ký tự nên bắt được mã, vì vậy cần dùng cả hai.
- RRF gộp hai danh sách dựa trên thứ hạng nên không phải dò trọng số. Rerank sau đó lọc lại để chỉ những chunk liên quan nhất đến được mô hình.
- Trên dữ liệu của Anthropic, hybrid giảm 49% số lần truy xuất thất bại, thêm rerank thì giảm 67%. Với khách hàng của bạn, hãy tự đo lại trên bộ eval riêng.
Thử hình dung một nhân viên pháp chế gõ vào chatbot nội bộ: “Điều khoản phạt của hợp đồng HD-2024-0153 là gì?”. Hệ thống RAG trả lời rất trôi chảy, nhưng câu trả lời lại lấy từ hợp đồng HD-2024-0135. Hai chuỗi này gần như trùng nhau về mặt ngữ nghĩa, và embedding không có lý do gì để phân biệt chúng.
Lỗi kiểu này dễ xuất hiện ở những kho tài liệu dày đặc mã định danh, và Anthropic đưa ra một ví dụ cùng loại: người dùng tìm “Error code TS-999” trong cơ sở dữ liệu hỗ trợ kỹ thuật.
Theo Anthropic, BM25 đặc biệt hiệu quả với những truy vấn chứa mã định danh duy nhất hoặc thuật ngữ kỹ thuật, vì nó tìm đúng chuỗi văn bản đó trong tài liệu.
Trên bộ dữ liệu của Anthropic, kết hợp embedding với BM25 giảm 49% số lần truy xuất thất bại ở top-20 chunk, thêm rerank thì giảm 67%. Bài này hướng dẫn bạn tự dựng pipeline đó và tự đo con số tương ứng trên dữ liệu của chính bạn.
Bạn sẽ dựng gì, và cần chuẩn bị gì?
Pipeline gồm năm khâu: truy vấn chạy song song qua bộ tìm từ khóa và bộ tìm vector, hai danh sách được gộp bằng reciprocal rank fusion (RRF), qua rerank, rồi mới đến LLM. Bạn cần Python 3, một tập chunk dạng {chunk_id: text}, và quyền truy cập một dịch vụ embedding và rerank. Các ví dụ dưới đây nhắc đến Cohere.
Phần code tìm từ khóa trong bài đã được giản lược cho mục đích học. Khi lên production, bạn nên dùng BM25 có sẵn của Elasticsearch hoặc Weaviate.
Bước 1: Dựng bộ đo trước khi viết dòng code nào
Chưa có số đo thì bạn không thể chứng minh với khách hàng rằng mình đã cải thiện được gì. Hãy gom những câu hỏi thật có chứa mã, mỗi câu gắn với chunk đúng, rồi đo tỷ lệ chunk đúng không lọt vào top-20. Đây cũng là thước đo mà Anthropic dùng.
def failure_rate(evalset, retrieve, top_n=20):
miss = sum(1 for q, gold in evalset
if gold not in retrieve(q)[:top_n])
return miss / len(evalset)
Kiểm tra: chạy hàm này với pipeline vector hiện tại và ghi lại con số. Đó là baseline của bạn.
Bước 2: Tokenizer phải giữ nguyên mã
Khi tìm theo từ khóa mà trượt mã định danh, một nguyên nhân hay gặp nằm ở tokenizer chứ không phải ở thuật toán. Nếu “HD-2024-0153” bị tách thành “hd”, “2024”, “0153”, thì chuỗi “2024” sẽ khớp với hàng trăm hợp đồng khác và mất hết tác dụng.
import re
TOKEN = re.compile(r"\w+(?:[-/]\w+)*")
def tokenize(text):
return [t.lower() for t in TOKEN.findall(text)]
tokenize("Hợp đồng HD-2024-0153 ký ngày")
# ['hợp', 'đồng', 'hd-2024-0153', 'ký', 'ngày']
Kiểm tra: in kết quả tokenize của 20 mã thật lấy từ dữ liệu khách hàng. Mỗi mã phải ra đúng một token.
Bước 3: Bộ tìm từ khóa (bản giản lược)
Hàm dưới đây chỉ đếm số lần token của truy vấn xuất hiện trong chunk. Nó không có IDF hay chuẩn hóa độ dài như BM25 thật, nhưng đủ để bạn thấy cách khớp chuỗi bắt được mã.
from collections import Counter
def keyword_search(query, chunks, top_n=20):
q = set(tokenize(query))
scored = []
for cid, text in chunks.items():
tf = Counter(tokenize(text))
score = sum(tf[t] for t in q)
if score:
scored.append((score, cid))
scored.sort(reverse=True)
return [cid for _, cid in scored[:top_n]]
Kiểm tra: với truy vấn chứa HD-2024-0153, chunk của hợp đồng đó phải đứng trên chunk của HD-2024-0135. Chunk HD-2024-0135 vẫn có thể xuất hiện trong danh sách, vì hàm giản lược này cũng cộng điểm cho các từ chung như “điều”, “khoản”, “phạt”, “hợp”, “đồng”. Nếu hai chunk ngang điểm, hãy quay lại kiểm tra tokenizer ở bước 2.
Bước 4: Bộ tìm vector, và một tham số hay bị bỏ quên
Elastic phân biệt hai cách tìm: keyword search khớp theo từ, còn semantic search khớp theo nghĩa của câu hỏi. Vector search vẫn cần thiết cho những câu kiểu “hợp đồng nào phạt chậm giao hàng nặng nhất”.
Lỗi hay gặp nằm ở khâu embedding: tài liệu của Cohere yêu cầu truy vấn phải được embed với input_type="search_query", và tài liệu phải dùng một giá trị input_type khác dành riêng cho tài liệu.
# giản lược: embed() là wrapper bạn tự viết quanh SDK
q_vec = embed([query], input_type="search_query")
Kiểm tra: grep toàn bộ codebase để chắc rằng khâu index và khâu truy vấn không dùng chung một giá trị input_type.
Bước 5: Gộp bằng RRF, không cần dò trọng số
Bước của Anthropic là gộp kết quả embedding với BM25 bằng rank fusion và khử trùng lặp. Tài liệu Elasticsearch đưa công thức RRF như sau: với mỗi danh sách, điểm của tài liệu được cộng thêm 1/(k + rank). Ưu điểm là bạn không phải tìm trọng số kết hợp tuyến tính giữa hai bộ tìm.
def rrf(result_lists, k):
scores = {}
for results in result_lists:
for rank, cid in enumerate(results, start=1):
scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
Dictionary scores tự lo việc khử trùng lặp. Bạn có thể đặt k theo giá trị mà tài liệu của engine bạn dùng gợi ý.
Một ví dụ tính tay, chọn k = 10 chỉ để số cho dễ nhìn. Chunk A đứng hạng 1 bên từ khóa và hạng 8 bên vector: 1/11 + 1/18 ≈ 0,146. Chunk B chỉ đứng hạng 1 bên vector: 1/11 ≈ 0,091. Chunk C đứng hạng 2 ở cả hai bên: 1/12 + 1/12 ≈ 0,167.
C thắng dù không đứng đầu danh sách nào. Đó là bản chất của RRF: nó thưởng cho chunk được cả hai bộ tìm đồng ý.
Nếu khách hàng dùng Weaviate, bạn không cần tự viết hàm này. Weaviate có hai thuật toán fusion, trong đó rankedFusion chỉ giữ vị trí của kết quả trong mỗi danh sách và bỏ điểm số. Tham số alpha điều chỉnh tỷ trọng giữa dense và sparse: 0.5 là cân bằng, mặc định là 0.75.
Với tập truy vấn nặng về mã, hãy thử vài giá trị alpha trên bộ eval ở bước 1 thay vì giữ nguyên mặc định.
Bước 6: Rerank, và định dạng YAML cho dữ liệu có cấu trúc
Theo Anthropic, rerank là kỹ thuật lọc dùng để bảo đảm chỉ những chunk liên quan nhất được chuyển cho mô hình. Hãy lấy các chunk đầu danh sách sau RRF, gửi qua Cohere Rerank cùng truy vấn, rồi chỉ giữ vài chunk đầu. Tài liệu Cohere khuyến nghị: nếu tài liệu chứa dữ liệu có cấu trúc, nên định dạng thành chuỗi YAML để đạt hiệu quả tốt nhất.
# giản lược: không xử lý escape ký tự đặc biệt
def to_yaml(rec):
return "\n".join(f"{k}: {v}" for k, v in rec.items())
to_yaml({"so_hop_dong": "HD-2024-0153",
"ben_ban": "...", "dieu_khoan_phat": "..."})
Vì thế, với bản ghi hợp đồng lấy ra từ ERP, hãy làm theo khuyến nghị đó: chuyển sang từng dòng khóa: giá trị trước khi rerank, đừng gửi nguyên JSON thô.
Kiểm tra: chạy lại failure_rate cho ba cấu hình (vector, hybrid, hybrid + rerank) và đặt ba con số cạnh nhau trong một bảng.
Những lỗi khiến pipeline trông đúng nhưng chạy sai
Lỗi đầu tiên là tokenizer cắt mã, như đã nói ở bước 2. Lỗi này không báo exception nào, chỉ làm kết quả tệ dần. Lỗi thứ hai là index tài liệu bằng input_type dành cho truy vấn, thường do copy code từ notebook thử nghiệm sang. Lỗi thứ ba là gửi bản ghi có cấu trúc đi rerank mà bỏ qua khuyến nghị định dạng YAML.
Lỗi thứ tư nằm ở cách báo cáo. Con số 49% và 67% là kết quả của Anthropic trên dữ liệu của họ, không phải lời hứa cho dữ liệu của khách hàng. Đừng đưa chúng vào slide như thể đó là kết quả của bạn. Hãy trình bày số đo từ bước 1.
Kỹ năng này xuất hiện thế nào ở site khách hàng?
Tuần đầu tại khách hàng, việc nên hỏi trước tiên là: người dùng có hay gõ mã sản phẩm, số hợp đồng, mã lỗi không, và các mã đó có định dạng gì. Câu trả lời quyết định cách bạn viết tokenizer trước cả khi bàn đến chuyện chọn mô hình embedding.
Khi đọc JD của các vị trí FDE hay AI engineer, những cụm như “retrieval quality”, “hybrid search” hay “evaluation” chính là kỹ năng này. Trên CV, đừng viết chung chung kiểu “xây dựng RAG”.
Hãy viết theo dạng: “giảm tỷ lệ truy xuất thất bại top-20 từ X% xuống Y% trên bộ eval 200 câu hỏi chứa mã hợp đồng bằng hybrid search và rerank”, với X, Y là số bạn tự đo được.
Khách hàng sẽ không nhớ bạn dùng RRF hay alpha bao nhiêu. Họ chỉ nhớ lần đầu tiên chatbot trả lời đúng hợp đồng HD-2024-0153.