Tối ưu độ chính xác cho RAG với BGE-Reranker và FlagEmbedding

Artificial Intelligence tutorial - IT technology blog
Artificial Intelligence tutorial - IT technology blog

Khi Vector Search trả về kết quả "nhìn thì khớp nhưng sai bản chất"

Hầu hết các pipeline RAG cơ bản đều đi theo một luồng quen thuộc: nhận câu hỏi → embedding query → tìm kiếm Cosine Similarity trên Vector DB → ném Top 5 chunk vào prompt cho LLM. Quy trình này chạy rất ổn trong giai đoạn demo với vài chục file PDF.

Mọi thứ thay đổi hoàn toàn khi bạn đưa hệ thống vào production. Với kho tài liệu nội bộ hàng chục nghìn trang, Hit Rate của giai đoạn Retrieval thường tụt dốc thảm hại.

Hãy xem một case thực tế:

  • Câu hỏi người dùng: "Nhân viên thử việc có được công ty đóng bảo hiểm y tế không?"
  • Kết quả Vector Search Top 3: Trả về 3 đoạn quy định chi trả bảo hiểm của nhân viên chính thức vì chứa mật độ dày đặc các từ "nhân viên", "bảo hiểm y tế", "chi trả". Trong khi đó, điều khoản loại trừ dành riêng cho nhân viên thử việc lại rớt xuống tận vị trí thứ 9.

Hậu quả? LLM nhận nhầm context và tự tin "chém gió" (hallucination) rằng nhân viên thử việc vẫn được đóng bảo hiểm đầy đủ.

Tăng top_k lên 15 hay 20 cũng không giải quyết được vấn đề. Bạn sẽ vấp phải hiệu ứng Lost in the Middle khi LLM bỏ sót thông tin quan trọng nằm giữa context. Chưa kể, chi phí token và độ trễ phản hồi đều tăng gấp bội.

Vì sao Bi-Encoder thường xuyên lấy lệch context?

Để khắc phục, chúng ta cần hiểu rõ giới hạn phần cứng và thuật toán của các model Dense Retrieval (như text-embedding-3-small hay bge-base-en-v1.5).

Những model này sử dụng kiến trúc Bi-Encoder:

  1. Query và Document được tách riêng thành hai nhánh độc lập để ép thành 2 vector cố định (768 hoặc 1536 chiều).
  2. Hệ thống đo độ liên quan thông qua tích vô hướng (Dot Product) hoặc Cosine Distance giữa 2 vector đó.

Điểm yếu chí mạng nằm ở chỗ: Query và Document không hề tương tác trực tiếp ở cấp độ token (không có cross-attention). Việc nén một đoạn văn 400–500 từ thành một vector duy nhất làm mất đi các sắc thái điều kiện hoặc phủ định tinh tế.

3 hướng tiếp cận nâng cao Retrieval Accuracy

Các kỹ sư AI hiện nay thường cân nhắc 3 giải pháp chính:

  • 1. Hybrid Search (BM25 + Dense Retrieval): Kết hợp tìm kiếm từ khóa chính xác và ngữ nghĩa vector. Phương pháp này trị tốt các truy vấn chứa mã lỗi, model máy hoặc tên riêng. Tuy nhiên, nó vẫn bất lực trước các câu hỏi đòi hỏi suy luận điều kiện phức tạp.
  • 2. LLM-based Filtering: Dùng model nhỏ (Llama-3.1-8B, GPT-4o-mini) đọc lướt Top 20 chunk để lọc. Cách này cực kỳ chính xác. Điểm trừ là độ trễ quá cao (thường thêm 1–2 giây) kèm hóa đơn API phình to nhanh chóng.
  • 3. Two-stage Retrieval với Cross-Encoder Reranker (Khuyên dùng): Chia pipeline tìm kiếm thành 2 tầng rõ rệt:
    • Giai đoạn 1 (Fast Retrieval): Dùng Bi-Encoder quét nhanh hàng triệu record trên Qdrant/Milvus để gom khoảng 25–40 ứng viên sơ bộ (latency ~10–20ms).
    • Giai đoạn 2 (Re-ranking): Dùng Cross-Encoder tính toán ma trận cross-attention giữa query và từng ứng viên, chọn ra Top 3–5 chunk chuẩn xác nhất đưa vào LLM.

Thực chiến với BGE-Reranker và FlagEmbedding

BGE-Reranker từ viện BAAI hiện là một trong những họ model Cross-Encoder mã nguồn mở hiệu quả nhất. Kết hợp với thư viện FlagEmbedding, việc setup chỉ tốn chưa đầy 10 phút.

1. Cài đặt môi trường

pip install FlagEmbedding torch

2. Test nhanh độ nhạy ngữ cảnh với FlagReranker

Đoạn script dưới đây minh họa khả năng phân biệt ngữ cảnh chi tiết của model:

from FlagEmbedding import FlagReranker

# bge-reranker-v2-m3 hỗ trợ đa ngôn ngữ cực tốt, bao gồm cả tiếng Việt
# use_fp16=True giúp giảm một nửa VRAM và tăng tốc độ suy luận
reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)

query = "Nhân viên thử việc có được hưởng bảo hiểm y tế doanh nghiệp chi trả không?"
passages = [
    "Tất cả nhân viên chính thức ký hợp đồng từ 1 năm trở lên được công ty mua gói bảo hiểm sức khỏe cao cấp và đóng bảo hiểm y tế bắt buộc theo luật.",
    "Trong thời gian thử việc 2 tháng, người lao động chưa thuộc đối tượng tham gia bảo hiểm y tế bắt buộc của công ty, lương thử việc đã bao gồm các khoản hỗ trợ trực tiếp.",
    "Quy trình thanh toán chi phí y tế: nhân viên gửi hóa đơn đỏ về phòng Kế toán trong vòng 7 ngày làm việc kể từ ngày xuất viện."
]

pairs = [[query, p] for p in passages]
scores = reranker.compute_score(pairs)

results = sorted(zip(passages, scores), key=lambda x: x[1], reverse=True)

print("=== KẾT QUẢ RERANKING ===")
for doc, score in results:
    print(f"Score: {score:.4f} | Nội dung: {doc[:80]}...")

Kết quả thực tế cho thấy đoạn văn thứ hai (chứa chính xác quy định cho nhân viên thử việc) nhận điểm số cao vượt trội (~5.82 so với -2.14 của đoạn một), dù đoạn một có số lượng keyword trùng khớp nhiều hơn.

3. Tích hợp Reranker vào Retriever Class

Dưới đây là boilerplate code cho tầng backend RAG:

from typing import List, Dict, Any
from FlagEmbedding import FlagReranker

class TwoStageRetriever:
    def __init__(self, vector_store: Any, model_name: str = 'BAAI/bge-reranker-v2-m3'):
        self.vector_store = vector_store
        self.reranker = FlagReranker(model_name, use_fp16=True)

    def retrieve_and_rerank(
        self, 
        query: str, 
        initial_top_k: int = 30, 
        final_top_k: int = 4
    ) -> List[Dict[str, Any]]:
        # Bước 1: Quét nhanh ứng viên qua Vector DB
        initial_docs = self.vector_store.similarity_search(query, k=initial_top_k)
        if not initial_docs:
            return []

        # Bước 2: Ghép cặp query - document
        doc_texts = [doc.page_content for doc in initial_docs]
        pairs = [[query, text] for text in doc_texts]
        
        # Bước 3: Chấm điểm tương quan chéo
        scores = self.reranker.compute_score(pairs)
        
        # Bước 4: Gán điểm và sort lấy Top-K
        scored_docs = []
        for doc, score in zip(initial_docs, scores):
            doc.metadata["rerank_score"] = float(score)
            scored_docs.append(doc)
            
        scored_docs.sort(key=lambda x: x.metadata["rerank_score"], reverse=True)
        return scored_docs[:final_top_k]

4. Kinh nghiệm triển khai trên Production

  • Chọn đúng kích thước model:
    • bge-reranker-base (~280M params, ăn khoảng 600MB VRAM): Phù hợp khi cần latency cực thấp dưới 25ms hoặc chạy trên cụm CPU.
    • bge-reranker-v2-m3 (560M params, ăn khoảng 1.2GB VRAM ở FP16): Lựa chọn tốt nhất cho tiếng Việt và các ngữ cảnh đa ngôn ngữ.
  • Đo lường Latency: Trên GPU T4 phổ thông, việc rerank 30 chunk bằng bge-reranker-v2-m3 (FP16) chỉ tốn khoảng 45–60ms. Đây là mức đánh đổi hoàn toàn xứng đáng để tăng Hit Rate@3 từ ~68% lên hơn 91%.
  • Ngưỡng batch size hợp lý: Đặt initial_top_k trong khoảng 25–40. Nếu đẩy lên trên 80 chunk, độ trễ sẽ tăng tuyến tính mà không đem lại nhiều cải thiện rõ rệt cho độ chính xác.
Share: