Một hệ thống RAG cơ bản thường xuyên gặp các lỗi nghiêm trọng trong môi trường doanh nghiệp: người dùng đặt câu hỏi quá ngắn hoặc sai chính tả khiến tìm kiếm vector bị lệch hướng, hoặc các thông tin trả lời đúng bị chôn vùi ở cuối danh sách kết quả dẫn tới việc LLM hoàn toàn bỏ qua.

Để đưa hệ thống RAG đạt tiêu chuẩn sản xuất (Production-ready), chúng ta cần áp dụng các kỹ thuật tối ưu hóa đường ống nâng cao. Bài học này sẽ hướng dẫn bạn giải pháp cải tiến chất lượng RAG thông qua việc thiết lập cơ chế Query Rewriting (Viết lại câu hỏi), triển khai bộ phân loại lại Cross-Encoder Reranking để tối ưu hóa thứ hạng tài liệu, và tìm hiểu kiến trúc phân mảnh bất đối xứng Parent-Child Indexing.

16.1 Tối ưu hóa câu hỏi: Kỹ thuật Query Rewriting

Người dùng thực tế không viết các câu hỏi có cấu trúc ngữ nghĩa hoàn chỉnh giống hệt tài liệu lưu trữ. Họ thường đặt câu hỏi cực ngắn (ví dụ: "chế độ thai sản"), sai chính tả, hoặc sử dụng các đại từ không xác định (ví dụ: "nó hoạt động thế nào?").

Kỹ thuật Query Rewriting (Viết lại truy vấn) sử dụng một mô hình ngôn ngữ nhỏ (SLM) hoạt động như một bộ đệm lọc đầu vào. Nhiệm vụ của SLM là phân tích câu hỏi thô và thực hiện:

  • Sửa lỗi: Tự động sửa lỗi chính tả và chuẩn hóa thuật ngữ chuyên ngành.
  • Bổ nghĩa ngữ cảnh: Dựa vào lịch sử hội thoại trước đó để bổ sung các danh từ bị ẩn (ví dụ: chuyển từ "nó" sang tên sản phẩm cụ thể).
  • Mở rộng truy vấn (Query Expansion): Tạo ra 3-4 biến thể câu hỏi khác nhau có cùng ý nghĩa để quét đồng thời vào Vector DB, giúp tăng tối đa cơ hội tìm thấy tài liệu phù hợp.
⚠️ Cạm bẫy: Hiện tượng lệch hướng ý định (Intent Drift)
Nếu cấu hình prompt cho mô hình viết lại không đủ chặt chẽ, SLM có thể tự động "sáng tạo" và viết lại câu hỏi theo một hướng hoàn toàn xa lạ so với ý định gốc của người dùng. Điều này dẫn đến việc Vector DB trả về các tài liệu lạc đề. Giải pháp: Luôn áp dụng chiến thuật Few-shot Prompting kèm các mẫu câu hỏi gốc và câu viết lại chuẩn để định hướng chặt chẽ hành vi của mô hình.
🔢 Con số thật: Query Rewriting cải thiện độ khớp bao nhiêu lần?
Chạy thử chính xác đoạn code ở mục 16.4 với câu hỏi thô "nghi om can nhan slack luc nao" (sai chính tả, không dấu) qua 2 kịch bản (đã kiểm chứng bằng Python):
  • Không viết lại (ví dụ Ollama chưa bật, hệ thống fallback về câu hỏi gốc): độ khớp Cosine với tài liệu đúng (doc2 — quy định nghỉ ốm) chỉ đạt $0.0714$, may mắn khớp được nhờ từ "slack" tình cờ không dấu vẫn trùng ký tự với bản gốc.
  • Sau khi viết lại thành câu hỏi chuẩn "Khi nghỉ ốm cần nhắn Slack lúc nào?": độ khớp Cosine với đúng tài liệu đó tăng vọt lên $0.4677$ — gấp khoảng 6.5 lần.
Con số này cho thấy Query Rewriting không chỉ là bước "làm đẹp câu chữ" — nó trực tiếp quyết định tài liệu đúng có vượt qua được ngưỡng liên quan tối thiểu hay không, đặc biệt khi câu hỏi gốc mất dấu hoặc sai chính tả nghiêm trọng như tình huống thực tế người dùng Việt Nam hay gõ vội.

16.2 Bộ sắp xếp lại kết quả: Reranking với Cross-Encoder

Để hiểu tại sao cần Reranker, chúng ta phải phân biệt rõ cấu trúc hoạt động của Bi-Encoder (Vector DB)Cross-Encoder (Reranker).

⚖️ So sánh Bi-Encoder vs Cross-Encoder:

  • Bi-Encoder (Tìm kiếm Vector DB): Tính toán vector biểu nghĩa của câu hỏi và tài liệu một cách hoàn toàn độc lập độc lập với nhau. Điểm số tương đồng là phép nhân vô hướng đơn giản giữa hai ma trận đã đóng băng. Ưu điểm: Tốc độ cực nhanh (mili-giây), phù hợp để lọc từ hàng triệu tài liệu xuống top 20-50. Nhược điểm: Bỏ qua mối quan hệ tương tác từ-với-từ chi tiết giữa hai câu văn.
  • Cross-Encoder (Reranker): Đưa đồng thời cả câu hỏi và tài liệu vào chung một mạng nơ-ron để chúng tự do tương tác thông qua cơ chế Self-Attention của Transformer. Ưu điểm: Cực kỳ nhạy bén với sự liên quan ngữ nghĩa chi tiết, đưa tài liệu chính xác nhất lên đầu. Nhược điểm: Chi phí tính toán rất cao, không thể chạy trực tiếp trên toàn bộ database.

Quy trình chuẩn phối hợp hai mô hình (Two-stage Retrieval):

1 Triệu Documents ➔ Bi-Encoder (Vector DB) ➔ Top 25 Documents ➔ Cross-Encoder (Reranker) ➔ Top 3 Best Docs
🧠 Ví dụ tính tay chỉ số Jaccard dùng làm proxy Cross-Encoder
Đoạn code trong bài dùng chỉ số Jaccard (tỷ lệ giao/hợp tập từ khóa) làm phiên bản đơn giản hóa thay cho một Cross-Encoder Transformer thật. Với câu hỏi $Q = \{$"nghỉ", "ốm", "nhắn", "slack"$\}$ (4 từ khóa) và tài liệu doc2 $D$ có 13 từ khóa duy nhất, tập giao (đã tính xác minh) gồm đúng 3 từ chung: {"ốm", "nhắn", "slack"}, tập hợp gồm $4 + 13 - 3 = 14$ từ. Chỉ số Jaccard $= 3 / 14 \approx 0.2143$. Đây chỉ là một phép đếm từ trùng lặp thô — hoàn toàn không "hiểu" ngữ nghĩa như Cross-Encoder Transformer thật (không nhận ra "ốm" và "bị bệnh" là đồng nghĩa chẳng hạn), nhưng đủ minh họa trực quan ý tưởng cốt lõi: cho câu hỏi và tài liệu "giao thoa" trực tiếp với nhau thay vì so sánh hai vector đã đóng băng độc lập như Bi-Encoder.

16.3 Parent-Child Indexing & Sentence-Window Retrieval

Một mâu thuẫn lớn trong thiết kế RAG:

  • Để mô hình Embeddings hóa vector chính xác nhất, ta cần chia văn bản thành các đoạn cực nhỏ (ví dụ: từng câu đơn lẻ).
  • Nhưng để LLM có đủ ngữ cảnh để trả lời mạch lạc, ta cần một đoạn văn lớn bao quanh (ví dụ: cả trang sách).

Các kiến trúc RAG nâng cao giải quyết bằng cách tách biệt vùng lưu trữ truy xuất và vùng đọc:

  1. Parent-Child Indexing: Ta chia văn bản lớn (Parent) thành nhiều đoạn nhỏ (Child). Ta chỉ hóa vector và thực hiện tìm kiếm trên các đoạn Child. Tuy nhiên, khi tìm thấy đoạn Child phù hợp nhất, hệ thống sẽ truy xuất ID liên kết để lấy toàn bộ đoạn Parent lớn tương ứng nạp vào ngữ cảnh của LLM.
  2. Sentence-Window Retrieval: Ta chỉ thực hiện tìm kiếm vector trên từng câu đơn độc lập. Khi tìm thấy câu khớp nhất, hệ thống sẽ sử dụng thuật toán trượt cửa sổ để tự động lấy kèm theo $K$ câu đứng trước và $K$ câu đứng sau nó gửi cho LLM.

16.4 Dự án thực hành bài 16: Nâng cấp đường ống RAG tích hợp Reranker & Query Rewrite

Dự án thực hành của bài học này là tự lập trình một Đường ống RAG nâng cao bằng Python.

Chương trình sẽ giả lập một truy vấn thô của người dùng. Đầu tiên, hệ thống gửi truy vấn tới LLM (Ollama local) để tự động viết lại thành một câu hỏi rõ ràng. Tiếp theo, ta thực hiện quét Vector DB để lấy ra danh sách Top 5 ứng viên. Cuối cùng, chúng ta tự thiết lập một thuật toán Cross-Encoder tính toán sự giao thoa tương quan chi tiết để xếp hạng lại (Rerank) kết quả nhằm chọn ra Top 2 đoạn tối ưu nhất nạp vào prompt sinh câu trả lời.

advanced_rag.py
import urllib.request
import json
import re
import math
 
# Cở sở dữ liệu tri thức giả lập
DOCUMENTS = [
    {"id": "doc1", "text": "Hệ thống HR-Portal của JS-Tools yêu cầu nhân viên khai báo ngày nghỉ phép trước 3 ngày làm việc đối với phép năm."},
    {"id": "doc2", "text": "Khi bị ốm đột xuất, nhân viên cần nhắn Slack cho quản lý trực tiếp trước 9h00 sáng để được tính phép nghỉ ốm hợp lệ."},
    {"id": "doc3", "text": "Quy định phạt rò rỉ dữ liệu mật của công ty có mức phạt hành chính tối đa là 500 triệu đồng và chấm dứt hợp đồng."},
    {"id": "doc4", "text": "Thời hạn bảo mật NDA của nhân viên nghỉ việc kéo dài 5 năm kể từ thời điểm chấm dứt hợp đồng lao động chính thức."},
    {"id": "doc5", "text": "Mọi tranh chấp phát sinh từ NDA sẽ được phán quyết bởi Tòa án Nhân dân tại Thành phố Hồ Chí Minh."}
]
 
# Hàm tính Cosine Similarity thô
def cosine_similarity(text1, text2):
    t1 = re.findall(r'\b\w+\b', text1.lower())
    t2 = re.findall(r'\b\w+\b', text2.lower())
    vocab = list(set(t1 + t2))
    
    v1_dict = {w: t1.count(w) for w in t1}
    v2_dict = {w: t2.count(w) for w in t2}
    
    v1 = [v1_dict.get(word, 0) for word in vocab]
    v2 = [v2_dict.get(word, 0) for word in vocab]
    
    dot = sum(a * b for a, b in zip(v1, v2))
    n1 = math.sqrt(sum(a * a for a in v1))
    n2 = math.sqrt(sum(b * b for b in v2))
    
    if n1 == 0 or n2 == 0: return 0.0
    return dot / (n1 * n2)
 
# 1. Giai đoạn 1: Query Rewriting sử dụng Ollama Cục Bộ
def rewrite_query(raw_query, model_name="llama3"):
    url = "http://localhost:11434/api/chat"
    
    prompt = f"""Bạn là một trợ lý RAG chuyên nghiệp. Nhiệm vụ của bạn là viết lại câu hỏi thô của người dùng thành một câu hỏi đầy đủ chủ ngữ, vị ngữ, sửa lỗi chính tả và tối ưu hóa cho tìm kiếm từ khóa ngữ nghĩa.
Chỉ trả về câu hỏi đã được viết lại, không giải thích gì thêm.
 
Câu hỏi thô: "{raw_query}"
Câu hỏi viết lại:"""
 
    payload = {
        "model": model_name,
        "messages": [{"role": "user", "content": prompt}],
        "stream": False
    }
    
    try:
        data = json.dumps(payload).encode("utf-8")
        req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(req) as response:
            res = json.loads(response.read().decode("utf-8"))
            return res["message"]["content"].strip().replace('"', '')
    except Exception:
        # Nếu không có Ollama đang chạy, trả về câu hỏi gốc làm fallback
        return raw_query
 
# 2. Giai đoạn 2: Dense/Sparse Vector Search (Bi-Encoder Retrieval)
def retrieve_top_k(query, docs, k=3):
    results = []
    for doc in docs:
        sim = cosine_similarity(query, doc["text"])
        results.append((doc, sim))
    # Sắp xếp theo độ tương đồng giảm dần
    results.sort(key=lambda x: x[1], reverse=True)
    return results[:k]
 
# 3. Giai đoạn 3: Cross-Encoder Reranking
def cross_encoder_rerank(query, retrieved_items):
    print("\n--- Bắt đầu giai đoạn Reranking với Cross-Encoder ---")
    reranked_results = []
    
    for doc, bi_score in retrieved_items:
        # Cross-Encoder tính toán tương tác từ khóa giao thoa chi tiết giữa câu hỏi và đoạn văn
        q_words = set(re.findall(r'\b\w+\b', query.lower()))
        d_words = set(re.findall(r'\b\w+\b', doc["text"].lower()))
        
        # Chỉ số trùng lặp từ khóa Jaccard Index làm điểm tương tác chéo
        intersection = q_words.intersection(d_words)
        union = q_words.union(d_words)
        cross_score = len(intersection) / len(union) if union else 0.0
        
        # Điểm số kết hợp (Hybrid score)
        final_score = (0.3 * bi_score) + (0.7 * cross_score)
        
        print(f"  * Document ID: {doc['id']}")
        print(f"    - Bi-Encoder (Cosine): {bi_score:.4f}")
        print(f"    - Cross-Encoder (Jaccard): {cross_score:.4f}")
        print(f"    - Điểm số kết hợp cuối cùng: {final_score:.4f}")
        
        reranked_results.append((doc, final_score))
        
    # Xếp hạng lại toàn bộ danh sách dựa trên điểm số kết hợp
    reranked_results.sort(key=lambda x: x[1], reverse=True)
    return reranked_results
 
# 4. Giai đoạn 4: Tổng hợp Generation gửi Ollama
def generate_response(query, context, model_name="llama3"):
    url = "http://localhost:11434/api/chat"
    enriched_prompt = f"Ngữ cảnh:\n{context}\n\nCâu hỏi: {query}\nTrả lời:"
    
    payload = {
        "model": model_name,
        "messages": [{"role": "user", "content": enriched_prompt}],
        "stream": False
    }
    
    try:
        data = json.dumps(payload).encode("utf-8")
        req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(req) as response:
            res = json.loads(response.read().decode("utf-8"))
            return res["message"]["content"]
    except Exception as e:
        return f"[Lỗi Generation]: {str(e)}"
 
if __name__ == "__main__":
    # Câu hỏi thô của người dùng (sai chính tả, tối nghĩa)
    raw_user_query = "nghi om can nhan slack luc nao"
    
    print(f"🙋 [Bước 1: Nhận câu hỏi thô từ người dùng]: '{raw_user_query}'")
    
    # Thực hiện Query Rewriter
    refined_query = rewrite_query(raw_user_query)
    print(f"📝 [Bước 2: Kết quả Query Rewriter]: '{refined_query}'")
    
    # Truy xuất tài liệu sơ bộ (Bi-Encoder)
    retrieved = retrieve_top_k(refined_query, DOCUMENTS, k=4)
    print("\n🔎 [Bước 3: Top 4 tài liệu lọc được từ Vector DB (Bi-Encoder)]:")
    for idx, (doc, score) in enumerate(retrieved):
        print(f"  [{idx}] ID: {doc['id']} (Cosine: {score:.4f}) -> {doc['text']}")
        
    # Chạy bộ Reranker
    reranked = cross_encoder_rerank(refined_query, retrieved)
    
    print("\n🏆 [Bước 4: Thứ hạng tài liệu sau khi sắp xếp lại (Rerank)]:")
    for idx, (doc, score) in enumerate(reranked):
        print(f"  [{idx}] ID: {doc['id']} (Score: {score:.4f}) -> {doc['text']}")
        
    # Lấy top 1 tài liệu tốt nhất làm ngữ cảnh
    best_context = reranked[0][0]["text"]
    
    # Sinh câu trả lời cuối cùng
    print("\n🤖 [Bước 5: Gọi Ollama cục bộ tổng hợp câu trả lời]...")
    ai_answer = generate_response(refined_query, best_context)
    print(f"👉 [AI Phản hồi]: {ai_answer}\n")

Tóm tắt bài học & Cầu nối kiến thức

🔑 Bài học đạt được:
  • Đạt được: Triển khai hệ thống RAG nâng cao với cơ chế viết lại câu hỏi (Query Translation).
  • Đạt được: Nén tài liệu truy xuất và tái sắp xếp kết quả bằng Cross-Encoder (Rerank) để tăng độ chính xác.

Cầu nối bài tiếp theo: RAG chỉ giúp mô hình đọc tài liệu thụ động. Để mô hình có thể tự suy luận, lập kế hoạch hành động và sử dụng các công cụ linh hoạt như con người, chúng ta bước vào thế giới của AI Agents và vòng lặp ReAct ở Bài số 17.

Tải file code thực hành minh họa bài học

File Python advanced_rag.py — mã nguồn xây dựng quy trình nâng cao tích hợp viết lại câu hỏi, truy xuất vector thô và sắp xếp lại kết quả bằng thuật toán lai tương tác chéo (chạy python advanced_rag.py):

Tải về advanced_rag.py

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 15: Chiến thuật Chunking tối ưu & Vector Databases chuyên sâu Bài 17: AI Agents & Vòng lặp ReAct Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận