Bài trước dừng ở một con số khó chịu: trong 22 câu hỏi, 4 câu RAG không đưa được đoạn đúng vào top-3. Khi đoạn đúng không có trong prompt thì mô hình bịa hoặc từ chối, và không prompt nào cứu được. Bài này đi tiếp phần truy hồi với ba kỹ thuật được nhắc nhiều nhất của "RAG nâng cao": tìm kiếm lai (BM25 kết hợp vector), reranking bằng cross-encoder và viết lại câu hỏi. Điểm khác của bài là cả ba đều được đo trên cùng bộ 48 câu, và kết quả không đẹp như tài liệu quảng cáo: có kỹ thuật giúp, có kỹ thuật làm tệ đi, và lý do nằm ở chỗ bạn đang dùng embedding nào.
Bài dùng lại kho tài liệu và hàm chunking của Xây RAG bằng Python: chunking, truy hồi và sinh câu trả lời có dẫn nguồn, kho vector và embedding từ Chọn vector database: FAISS, pgvector, Qdrant và chỉ mục HNSW và Embedding và semantic search: sentence-transformers và FAISS. Môi trường: Apple M4, 16 GB RAM, Python 3.12, chạy hoàn toàn trên CPU, mỗi tiến trình dưới 1,5 GB RAM; LLM nhỏ qwen2.5:1.5b và embedding nomic-embed-text chạy qua Ollama (endpoint tương thích OpenAI http://localhost:11434/v1, không cần khoá thật).
Vì sao top-k vector chưa đủ: hai kiểu trượt khác nhau
Truy hồi theo vector gần nghĩa nhưng không bắt buộc đúng từ; truy hồi theo từ khoá thì ngược lại. Vì vậy người ta ghép chúng lại, rồi thêm reranking phía sau để chọn lại trong nhóm ứng viên. Cách phân vai khớp với tài liệu Retrieve & Re-Rank của Sentence Transformers: bi-encoder (embedding) tạo vector cho đoạn và câu hỏi độc lập nên tính trước được và nhanh trên kho lớn; cross-encoder đưa cả câu hỏi lẫn đoạn vào mô hình cùng lúc nên chính xác hơn nhưng chậm, nên chỉ dùng cho nhóm ứng viên nhỏ.
Bộ đo: 48 câu, ba nhóm độ khó
Kho vẫn là 11 tệp Markdown về một cửa hàng gia dụng hư cấu, chunking theo mục H2 cho 51 chunk. Bộ câu hỏi mở rộng từ 22 lên 48 câu, chia ba nhóm: A (22 câu gốc của bài RAG, gần từ ngữ tài liệu), B (14 câu đời thường còn từ khoá của tài liệu), C (12 câu cố tình tránh từ của tài liệu, như "ship vắng nhà ghé lại mấy bận" thay vì "giao thất bại").
Cần nói thẳng: nhóm B và C do chính tôi viết sau khi đọc tài liệu, 48 câu là bộ nhỏ (mỗi câu chiếm 2,1 điểm phần trăm) và kho chỉ 51 chunk nên "top 10" đã là 20% toàn kho. Chênh lệch dưới khoảng 4 điểm không đủ để kết luận; số liệu dùng để so sánh tương đối, không để hứa một mức chính xác. Chỉ số cho reranking: hit@k (đoạn đúng nằm trong k kết quả đầu) và MRR (trung bình nghịch đảo hạng đoạn đúng).
Bước 1: BM25 và tìm kiếm lai bằng Reciprocal Rank Fusion
BM25 cần một bộ tách từ. Tiếng Việt viết có khoảng trắng giữa các âm tiết nên cách đơn giản là hạ chữ thường, giữ dấu và tách theo ký tự chữ. Thư viện rank-bm25 làm phần chấm điểm:
def tokenize(text):
"""Tách theo âm tiết: hạ chữ thường, giữ dấu tiếng Việt, bỏ dấu câu."""
return re.findall(r"\w+", unicodedata.normalize("NFC", text).lower())
self.bm25 = BM25Okapi([tokenize(c.text) for c in self.chunks])
def sparse(self, q):
return list(np.argsort(-self.bm25.get_scores(tokenize(q))))Hai danh sách xếp hạng (BM25 và vector) có thang điểm không so được với nhau: điểm BM25 có thể là 9,2, cosine thì chỉ quanh 0,8. Reciprocal Rank Fusion bỏ qua điểm số và chỉ dùng thứ hạng. Công thức của Cormack, Clarke và Büttcher (SIGIR 2009): điểm của một đoạn là tổng của 1/(k + hạng) trên mọi danh sách mà nó xuất hiện. Hằng số k làm giảm sức nặng của những hạng đầu; giá trị 60 là mặc định thường gặp.
def rrf(rankings, k=60):
score = {}
for r in rankings:
for pos, idx in enumerate(r, 1):
score[idx] = score.get(idx, 0.0) + 1.0 / (k + pos)
return sorted(score, key=lambda i: -score[i])Ưu điểm lớn của RRF là không có tham số trọng số để chỉnh: thêm một nguồn xếp hạng chỉ là thêm một danh sách vào rankings. Nhược điểm: nó coi hai nguồn ngang quyền, nên khi một nguồn yếu hơn hẳn, nó kéo kết quả xuống. Phần đo ở dưới sẽ cho thấy điều đó.
Bước 2: reranking bằng cross-encoder
Cross-encoder nhận một cặp (câu hỏi, đoạn) và trả ra một điểm liên quan. Vì phải chạy mô hình cho từng cặp, ta chỉ chấm lại n_cand ứng viên đầu của bước truy hồi, phần còn lại giữ nguyên thứ tự cũ.
Mô hình dùng ở đây là cross-encoder/mmarco-mMiniLMv2-L12-H384-v1: nền MiniLMv2 đa ngữ, huấn luyện trên mMARCO (giấy phép Apache-2.0, khoảng 470 MB tệp trọng số). mMARCO là bản dịch máy của MS MARCO sang nhiều ngôn ngữ, và bài báo mMARCO của Bonifacio và cộng sự cùng trang dữ liệu liệt kê tiếng Việt trong danh sách ngôn ngữ. Lưu ý: dữ liệu dịch máy từ truy vấn web tiếng Anh, không phải câu hỏi về chính sách cửa hàng, nên phải đo trên dữ liệu của bạn.
def rerank(self, q, order, n_cand=10):
cand, rest = list(order[:n_cand]), list(order[n_cand:])
scores = self.ce.predict([(q, self.chunks[i].text) for i in cand], batch_size=16)
return [cand[j] for j in np.argsort(-scores)] + restĐiểm của cross-encoder ở đây là điểm thô (chưa qua sigmoid), chỉ có nghĩa khi so các đoạn của cùng một câu hỏi; đừng đặt ngưỡng cố định rồi so giữa các câu hỏi khác nhau mà không hiệu chuẩn.
Kết quả đo: embedding quyết định, reranking cứu thêm
Chạy eval_all.py (đã đặt torch.set_num_threads(2) và device="cpu"). Đây là output thật:
48 câu: A = 22 câu gốc của bài RAG, B = 14 câu đời thường (còn từ của tài liệu), C = 12 câu cố tình tránh từ của tài liệu
cách xếp hạng hit@1 hit@3 hit@5 MRR | A@3 B@3 C@3 | ms/câu
BM25 (từ khoá) 0.75 0.88 0.90 0.814 | 1.00 0.93 0.58 | 0
dense nomic-embed-text 0.33 0.46 0.65 0.465 | 0.82 0.14 0.17 | 31
dense multilingual-e5-small 0.88 0.98 0.98 0.922 | 1.00 1.00 0.92 | 26
hybrid RRF: nomic + BM25 0.52 0.79 0.83 0.663 | 1.00 0.86 0.33 | 34
hybrid RRF: e5 + BM25 0.81 0.94 0.94 0.878 | 1.00 1.00 0.75 | 16
rerank top10 của nomic+BM25 0.83 0.88 0.88 0.857 | 1.00 0.93 0.58 | 236
rerank top10 của e5+BM25 0.88 0.96 0.98 0.916 | 1.00 1.00 0.83 | 231Đọc bảng này theo từng lớp. Thứ nhất, nomic-embed-text chỉ đạt hit@3 0,46, và gần như sụp ở nhóm B và C (0,14 và 0,17). Thẻ mô hình nomic-embed-text-v1 ghi ngôn ngữ là tiếng Anh; đây là lời giải thích hợp lý cho kết quả, dù tôi không chứng minh nó bằng một thí nghiệm riêng. Ở bài RAG trước, con số 18/22 câu đúng (nhóm A, 0,82) trông ổn vì câu hỏi dùng đúng từ của tài liệu. Chỉ khi câu hỏi nói khác đi, điểm yếu mới lộ ra.
Thứ hai, BM25 một mình, không cần mô hình nào, đã đạt hit@3 0,88, tốt hơn hẳn embedding tiếng Anh. Thứ ba, ghép nomic với BM25 bằng RRF (0,79) tệ hơn BM25 một mình: nguồn yếu làm nhiễu nguồn mạnh. Hybrid không phải phép thuật, và reranking cũng vậy; chúng chỉ tốt khi cả hai nhánh đều có giá trị. Thứ tư, reranking cứu được rất nhiều: từ hybrid nomic (hit@1 0,52) lên 0,83, tức là vào mức của BM25 nhưng với MRR cao hơn (0,857 so với 0,814).
Khi embedding đã tốt, hybrid và reranking còn đáng giá không
Thử thay nomic bằng multilingual-e5-small (báo cáo của Wang và cộng sự: đa ngữ, ba cỡ; bản small có 12 tầng, vector 384 chiều, khoảng 470 MB). Bắt buộc dùng tiền tố query: cho câu hỏi và passage: cho đoạn. Kết quả thật của dense_multilingual.py:
multilingual-e5-small: hit@1 0.88 hit@3 0.98 hit@5 0.98 MRR 0.922 | A 1.00 B 1.00 C 0.92Một mô hình nhỏ chạy trên CPU, 26 ms mỗi câu, đạt hit@3 0,98 và MRR 0,922. Hybrid với BM25 không cải thiện: hit@3 giảm xuống 0,94, MRR 0,878, vì BM25 kém ở nhóm C kéo xuống. Reranking top 10 đưa lại hit@3 0,96 và MRR 0,916 (gần bằng e5 một mình, 0,922), thậm chí không vượt. Hit@1 là 0,88 ở cả hai.
Nghĩa là với reranking trên kho nhỏ này, với embedding đa ngữ tốt, thêm BM25 và reranking không đem lại cải thiện đo được; về hit@3, 0,98 so với 0,96 chỉ là một câu hỏi trên 48 câu, còn MRR 0,922 so với 0,916 nằm trong sai số. Điều tôi rút ra, khác với nhiều bài giới thiệu: trước khi thêm tầng, hãy đo bộ truy hồi đơn giản với embedding phù hợp ngôn ngữ. Hybrid và reranking là bảo hiểm cho nhóm câu hỏi mà một nhánh trượt, không phải nâng cấp mặc định. Chúng đáng giá hơn khi kho lớn hơn, câu hỏi chứa mã sản phẩm và tên riêng, hoặc khi bạn không thể đổi embedding.
Một câu hỏi đi qua từng tầng
trace_one.py in top-4 từng tầng cho câu "Đồ gia dụng của mình dùng được bao lâu thì hết cam kết của hãng?" (đáp án: mục "Thời gian bảo hành theo nhóm sản phẩm" của bao-hanh.md). Output thật, rút gọn còn tầng e5, RRF và reranking:
e5 top-4: doi-tra-hoan-tien / Thời hạn đổi trả; bao-hanh / Quy trình và thời gian xử lý; bao-hanh / Thời gian bảo hành theo nhóm sản phẩm; ...
RRF top-4: van-chuyen / Giao thất bại; doi-tra-hoan-tien / Sản phẩm không được đổi trả; khuyen-mai / Huỷ đơn và voucher; ...
rerank top-4 (điểm cross-encoder, chưa qua sigmoid):
-3.98 doi-tra-hoan-tien / Thời hạn đổi trả
-4.57 bao-hanh / Thời gian bảo hành theo nhóm sản phẩmBM25 bị đánh lừa bởi các từ "được", "bao", "lâu" xuất hiện khắp nơi (đoạn đúng ở hạng 23). e5 đặt đoạn đúng ở hạng 3, nhưng RRF đẩy nó xuống hạng 7 vì BM25 bỏ phiếu cho đoạn khác. Reranking đưa nó lên hạng 2, sau đoạn "Thời hạn đổi trả" cũng nói về thời hạn; hai đoạn đầu cách nhau 0,59 điểm thô, một khác biệt mỏng chứ không phải phán quyết chắc chắn.
Reranking bao nhiêu ứng viên là đủ
n_cand là núm chỉnh giữa chất lượng và độ trễ. Output thật của sweep_ncand.py trên hybrid e5 + BM25 (độ trễ dao động vì máy đang chạy việc khác):
hybrid, không rerank: hit@1 0.81 hit@3 0.94 MRR 0.878
n_cand hit@1 hit@3 MRR ms/câu đáp án nằm trong n_cand đầu của hybrid
3 0.92 0.94 0.930 53 45/48
5 0.88 0.94 0.909 78 45/48
10 0.88 0.96 0.916 153 47/48
20 0.88 0.96 0.910 296 47/48Độ trễ tăng gần tuyến tính theo n_cand, còn chất lượng phẳng sau khoảng 3 đến 10: đáp án đã nằm trong 3 và cả 5 ứng viên đầu ở 45/48 câu, lên 47/48 khi n_cand từ 10. Con số 0,92 hit@1 ở n_cand=3 không có nghĩa "3 là tốt nhất", chênh một hai câu nằm trong sai số. Quy tắc thực dụng cho reranking: chọn n_cand sao cho tỉ lệ "đáp án nằm trong nhóm" gần tối đa (ở đây 10), vì reranking không cứu được đoạn không có trong nhóm.
Thí nghiệm fusion_k.py xác nhận hai điều: đổi k của RRF từ 1 đến 200 gần như không đổi kết quả (hit@3 từ 0,94 xuống 0,92 ở k=200), nên đừng tốn công chỉnh k ở quy mô này; và reranking top 10 của riêng BM25 chỉ có hit@10 0,92 vì 4 câu mà BM25 không đưa đáp án vào top 10, cross-encoder không làm gì được với chúng.
Bước 3: viết lại câu hỏi, đối thủ của reranking
Có hai ý tưởng phổ biến. Viết lại truy vấn (rewrite-retrieve-read), như trong Ma và cộng sự, "Query Rewriting for Retrieval-Augmented Large Language Models", thêm một bước LLM biến câu hỏi thô thành truy vấn tốt hơn. HyDE, trong Gao, Ma, Lin và Callan, bảo LLM viết một đoạn trả lời giả định (có thể sai chi tiết) rồi dùng đoạn đó để tìm.
Ở đây cả hai dùng qwen2.5:1.5b qua Ollama, temperature=0; mỗi lần gọi khoảng 1,1 giây, gấp 7 lần reranking top 10. Output thật của rewrite.py, chỉ các dòng hybrid e5 + BM25 và reranking:
cách truy hồi hit@1 hit@3 MRR | A@3 B@3 C@3
hybrid e5+BM25, câu gốc 0.81 0.94 0.878 | 1.00 1.00 0.75
hybrid e5+BM25, câu viết lại 0.75 0.85 0.814 | 0.91 0.93 0.67
hybrid e5+BM25, câu gốc + viết lại 0.77 0.92 0.852 | 0.95 1.00 0.75
hybrid e5+BM25, HyDE 0.83 0.92 0.887 | 1.00 0.93 0.75
hybrid e5+BM25, gốc + viết lại + HyDE 0.88 0.96 0.916 | 1.00 1.00 0.83
rerank top10 (hỏi bằng câu gốc), gốc 0.88 0.96 0.916 | 1.00 1.00 0.83Câu viết lại làm tệ đi khi thay câu gốc (hit@3 từ 0,94 xuống 0,85), kể cả khi trộn với câu gốc (0,92). Chỉ tổ hợp gốc + viết lại + HyDE đạt 0,96, bằng reranking nhưng tốn hai lần gọi LLM (khoảng 2 giây) so với 0,15 giây.
Lý do hiện ra khi đọc câu viết lại của nhóm C: với "Tôi đặt đồ lúc chiều muộn thì khi nào bên bạn bắt đầu chuẩn bị?", mô hình trả "Chúng tôi bắt đầu chuẩn bị hàng hóa từ sáng sớm..." (trả lời bịa, không viết lại); với câu về shipper, nó chép lại nguyên câu kèm tiền tố "Câu hỏi viết lại:". Mô hình 1,5 tỉ tham số quá nhỏ cho việc này.
Tôi chưa lọc tiền tố trong mã đo nên đây là cận dưới; prompt có ví dụ mẫu có thể cải thiện nhưng không đổi kết luận về chi phí. Viết lại câu hỏi đáng thử khi có hội thoại nhiều lượt ("còn cái kia thì sao?") và có mô hình đủ mạnh; với câu hỏi một lượt trên kho nhỏ, nó chưa đáng giá.
Câu vẫn trượt: giới hạn thật của reranking
failures.py liệt kê câu mà hybrid trượt top-3 hoặc reranking không đưa lên hạng 1 (cột: BM25, e5, hybrid, reranking). Output thật, ba dòng tiêu biểu:
Mua rồi chán thì còn trả lại được không? 6 1 2 3
Trả tiền mặt cho shipper có bị tính thêm khoản nào không? 41 13 22 22
Cái cối của máy bị rỉ nước phía dưới thì xử lý thế nào? 1 1 1 2Ở câu đầu và câu cuối, e5 đã đúng hạng 1 nhưng reranking hạ xuống hạng 3 và 2: reranking cũng có thể làm hỏng kết quả đang đúng. Câu về "shipper" tệ nhất: tài liệu viết "COD" và "phụ phí", người hỏi viết "tiền mặt" và "shipper", không nhánh nào nối được. Cách sửa không nằm ở xếp hạng mà ở dữ liệu: thêm từ đồng nghĩa vào tài liệu ("COD, thanh toán tiền mặt cho nhân viên giao hàng") hoặc đọc nhật ký câu hỏi thật.
Khi nào nên và không nên thêm tầng
- Bắt đầu bằng đo: embedding đúng ngôn ngữ trước, rồi BM25, rồi reranking; mỗi tầng chỉ giữ nếu bộ đo cho thấy nó giúp.
- Hybrid hợp khi nội dung có mã lỗi, mã sản phẩm, tên riêng (như "Mã E05") mà embedding hay làm mờ. BM25 cũng là bộ xếp hạng mặc định của Elasticsearch (xem Elasticsearch trên Kubernetes nếu đã có cụm).
- Reranking hợp khi nhóm ứng viên có đáp án nhưng thứ tự lộn xộn, kho lớn hơn, và chấp nhận thêm 50 đến 300 ms mỗi câu trên CPU.
- Viết lại câu hỏi chỉ khi có hội thoại nhiều lượt hoặc câu dài, mơ hồ, với mô hình đủ mạnh; luôn giữ câu gốc làm một nguồn.
Lỗi thường gặp
- Quên tiền tố
query:vàpassage:của họ E5: chất lượng giảm lặng lẽ, không có lỗi báo. - Reranking toàn bộ kho: chi phí tăng tuyến tính theo số cặp.
- Đặt ngưỡng "không có đáp án" trên điểm cross-encoder thô mà không hiệu chuẩn: điểm không có thang chung giữa các câu hỏi.
- Dùng embedding tiếng Anh cho tài liệu tiếng Việt rồi coi hybrid là cách chữa: như bảng cho thấy, nó chỉ đưa kết quả về mức BM25.
Checklist reranking và truy hồi lai
- Đo BM25, dense, hybrid riêng trước khi ghép; ghi hit@k và MRR theo nhóm câu dễ và khó.
- Chọn embedding đa ngữ cho tài liệu tiếng Việt, kiểm tra bằng câu hỏi tránh từ của tài liệu.
- Dùng RRF với k = 60 làm điểm khởi đầu, rồi thêm reranking sau cùng với
n_candsao cho đáp án gần như luôn nằm trong nhóm. - Chỉ thêm viết lại câu hỏi khi có lý do cụ thể; sửa ở tầng dữ liệu những câu xếp hạng không cứu được.
Mã nguồn gồm rag_core.py (dùng lại từ bài RAG), hybrid.py, eval_all.py, dense_multilingual.py, sweep_ncand.py, fusion_k.py, failures.py, trace_one.py, rewrite.py, questions.json, data/, requirements.txt và các tệp *_output.txt. Các phiên bản thư viện trong requirements.txt là bản đã dùng khi chạy lại và kiểm tra bài, có thể mới hơn thời điểm đăng. Số liệu là output thật trên Apple M4, CPU; độ trễ có thể lệch khi máy đang chạy việc khác.
Bài tiếp theo
Đánh giá RAG: bộ câu hỏi chuẩn, độ chính xác truy hồi và độ trung thực. Bài này đã chạm đến giới hạn của bộ 48 câu tự viết; bài sau xây bộ đo đáng tin hơn và tách riêng độ chính xác truy hồi khỏi độ trung thực của câu trả lời.



