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.
- 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.
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) và 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):
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:
- 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.
- 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.
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
- Đạ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 liệu tham khảo
- Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks — Nghiên cứu đặt nền móng cho Bi-Encoder & Cross-Encoder (Reimers & Gurevych, 2019)
- Query Rewriting for Retrieval-Augmented Large Language Models — Khảo sát các kỹ thuật viết lại câu hỏi tối ưu hóa chất lượng RAG (Ma et al., 2023)
- Retrieve & Re-Rank Frame — Tài liệu chính thống hướng dẫn luồng tìm kiếm hai giai đoạn của Sentence-Transformers
Bình luận