Dù mô hình LLM có thông minh đến đâu, tri thức của nó vẫn luôn bị giới hạn tại thời điểm đóng băng dữ liệu huấn luyện. Nếu bạn hỏi AI về thông tin nội bộ của công ty hoặc một sự kiện thời sự vừa xảy ra ngày hôm qua, mô hình chắc chắn sẽ trả lời sai hoặc tự bịa ra thông tin giả.
Để khắc phục triệt để điểm yếu này, thế giới sử dụng kiến trúc RAG (Retrieval-Augmented Generation — Sinh dữ liệu tăng cường truy xuất). Thay vì huấn luyện lại mô hình tốn kém, chúng ta cung cấp trực tiếp các đoạn văn bản chứa câu trả lời vào trong Prompt đầu vào để LLM đọc và tổng hợp. Bài học này sẽ hướng dẫn bạn giải phẫu bản đồ quy trình RAG 5 bước, làm chủ kỹ thuật phân đoạn văn bản (Chunking), tìm hiểu toán học của bộ lọc TF-IDF & Cosine Similarity, và tự tay lập trình hệ thống hỏi đáp tài liệu hoàn toàn offline.
14.1 Vì sao LLM cần RAG? Giải quyết ảo tưởng (Hallucination)
Hiện tượng ảo tưởng (Hallucination) là việc mô hình nơ-ron tự tin đưa ra các câu trả lời sai sự thật một cách cực kỳ trôi chảy, thuyết phục. Nguyên nhân là do LLM hoạt động dựa trên xác suất đoán từ tiếp theo, chứ không có cơ chế đối chiếu sự thật khách quan (Fact-checking).
Kiến trúc RAG giải quyết bài toán này bằng cách biến mô hình LLM từ một kỳ thi "học vẹt nhớ kiến thức" sang một kỳ thi "đọc hiểu đề mở". Khi người dùng gửi câu hỏi, hệ thống sẽ thực hiện hai pha hành động:
- Pha 1 (Retrieval - Truy xuất): Đi lục tìm trong kho tài liệu nội bộ để lấy ra các đoạn văn bản có chứa từ khóa hoặc ngữ nghĩa liên quan nhất đến câu hỏi.
- Pha 2 (Generation - Sinh chữ): Lắp các đoạn tài liệu tìm được vào làm ngữ cảnh tham chiếu (Context), gửi kèm câu hỏi cho LLM và ra lệnh: "Chỉ dựa vào ngữ cảnh sau đây để trả lời câu hỏi...".
14.2 Bản đồ quy trình RAG chuẩn (RAG Pipeline)
Một hệ thống RAG tiêu chuẩn công nghiệp gồm có 5 giai đoạn liên tiếp:
🗺️ 5 Giai đoạn của RAG Pipeline:
- 1. Ingestion (Nạp tài liệu): Đọc dữ liệu thô từ các định dạng file khác nhau (PDF, DOCX, TXT, HTML) và làm sạch định dạng.
- 2. Chunking (Phân đoạn): Cắt nhỏ văn bản dài thành các khối nhỏ (chunks) để vừa với giới hạn bộ nhớ token của LLM và giữ tính tập trung ngữ nghĩa.
- 3. Embedding (Hóa vector): Biến đổi mỗi đoạn văn bản thô thành một vector số học đại diện cho đặc trưng ngữ nghĩa của nó. (Trong bài học này ta sẽ sử dụng bộ vector TF-IDF đặc trưng tần suất từ).
- 4. Retrieval (Truy xuất tương đồng): Khi có câu hỏi, chuyển câu hỏi thành vector, so sánh khoảng cách góc (Cosine Similarity) với toàn bộ vector tài liệu để lấy ra các đoạn khớp nhất.
- 5. Generation (Tổng hợp phản hồi): Nạp các đoạn tài liệu tìm được vào prompt gửi cho LLM (ví dụ: Ollama local) để sinh câu trả lời hoàn chỉnh.
14.3 Kỹ thuật phân tách văn bản (Chunking)
Nếu ta nạp nguyên một cuốn sách 500 trang vào prompt, mô hình sẽ bị quá tải bộ nhớ. Vì vậy, ta phải chia nhỏ cuốn sách thành các đoạn văn ngắn. Hai tham số cốt lõi của kỹ thuật Chunking bao gồm:
- Chunk Size (Độ dài đoạn): Số lượng ký tự hoặc số từ tối đa trong một đoạn văn (ví dụ: 500 ký tự).
- Chunk Overlap (Độ chồng gối): Số ký tự được lặp lại ở ranh giới giữa hai đoạn liền kề (ví dụ: 50 ký tự).
Tại sao bắt buộc phải có Chunk Overlap? Nếu ta cắt văn bản một cách cơ học đúng vị trí ký tự thứ 500, một câu văn quan trọng có thể bị chẻ làm đôi (nửa câu ở đoạn 1, nửa câu ở đoạn 2). Sự đứt gãy thông tin ở ranh giới này khiến vector ngữ nghĩa bị sai lệch hoàn toàn. Phép chồng gối giữ cho ngữ cảnh chuyển giao giữa các đoạn liền mạch, không bị mất thông tin ranh giới.
14.4 Toán học đằng sau bộ lọc TF-IDF & Cosine Similarity
Để máy tính biết đoạn văn nào giống câu hỏi nhất mà không cần gọi mô hình học sâu khổng lồ, ta sử dụng thuật toán kinh điển TF-IDF (Term Frequency - Inverse Document Frequency).
- Tần suất xuất hiện của từ (Term Frequency - $\text{TF}$): Đo tần suất xuất hiện của từ trong đoạn: $$\text{TF}(t, d) = \frac{f_{t,d}}{\sum_{t'} f_{t',d}}$$ (Số lần từ xuất hiện chia cho tổng số từ của đoạn).
- Tần suất nghịch đảo của tài liệu (Inverse Document Frequency - $\text{IDF}$): Đo mức độ hiếm của từ trên toàn bộ kho tài liệu: $$\text{IDF}(t, D) = \log\left(\frac{|D|}{1 + |\{d \in D : t \in d\}|}\right)$$ Nếu từ xuất hiện ở quá nhiều đoạn văn (như các từ nối "và", "là", "thì"), chỉ số IDF của nó sẽ tiến dần về 0. Ngược lại, các từ khóa độc đáo mang nhiều thông tin đặc trưng sẽ có IDF rất cao.
- Điểm số tích hợp TF-IDF: $$\text{TF-IDF}(t, d, D) = \text{TF}(t, d) \times \text{IDF}(t, D)$$
- Từ "nghỉ" (xuất hiện ở cả đoạn 0 và đoạn 1, tức 2/3 tài liệu): $\text{TF} = 0.25$, nhưng $\text{IDF} = \log(3/3) = 0$ → $\text{TF-IDF} = 0$. Từ này bị triệt tiêu hoàn toàn vì xuất hiện ở quá nhiều đoạn so với kích thước kho tài liệu.
- Từ "phép" (chỉ xuất hiện ở đoạn 0, tức 1/3 tài liệu): $\text{TF} = 0.25$ (bằng hệt từ "nghỉ"), nhưng $\text{IDF} = \log(3/2) \approx 0.405$ → $\text{TF-IDF} \approx 0.1014$.
14.5 Dự án thực hành bài 14: Lập trình hệ thống RAG offline bằng Python thuần túy
Dự án thực hành của bài học này là tự xây dựng một Đường ống RAG hoàn chỉnh bằng Python.
Chương trình thực hiện cắt nhỏ một tệp văn bản cục bộ, tự lập trình lớp tính toán vector đặc trưng TF-IDF và phép đo Cosine Similarity bằng Python thuần túy (không phụ thuộc bất kỳ thư viện bên thứ ba nào như Scikit-Learn để người học nắm rõ bản chất toán học). Sau đó, đoạn văn bản giống nhất sẽ được chèn vào prompt để gọi API Ollama local trả về câu trả lời.
import urllib.request
import json
import math
import re
# 1. Cơ sở dữ liệu tài liệu tri thức nội bộ giả lập (Thực tế đọc từ file .txt)
KNOWLEDGE_BASE = """
Quy trình xin nghỉ phép của công ty JS-Tools:
Nhân viên cần gửi đơn xin nghỉ phép trước tối thiểu 3 ngày làm việc đối với nghỉ phép năm thông thường.
Trong trường hợp nghỉ ốm đột xuất, nhân viên phải thông báo cho quản lý trực tiếp qua Slack trước 9h00 sáng của ngày nghỉ và nộp giấy xác nhận của bác sĩ khi quay trở lại làm việc.
Nếu nghỉ phép dài hạn trên 5 ngày, đơn nghỉ phép bắt buộc phải được ký phê duyệt bởi Giám đốc điều hành (CEO).
Mọi đơn từ xin nghỉ phép đều phải được nhập dữ liệu chính thức lên hệ thống HR-Portal trực tuyến của công ty để bộ phận nhân sự chấm công cuối tháng.
"""
# 2. Xây dựng bộ máy TF-IDF & Cosine Similarity bằng Python thuần túy
class SimpleTFIDF:
def __init__(self, documents):
self.documents = [self._tokenize(doc) for doc in documents]
self.vocab = list(set([word for doc in self.documents for word in doc]))
self.idf = self._calculate_idf()
def _tokenize(self, text):
# Làm sạch và tách từ cơ bản bằng Regular Expression
text = text.lower()
words = re.findall(r'\b\w+\b', text)
return words
def _calculate_idf(self):
idf = {}
total_docs = len(self.documents)
for term in self.vocab:
# Đếm số lượng tài liệu chứa từ term
containing_docs = sum(1 for doc in self.documents if term in doc)
# Công thức IDF
idf[term] = math.log(total_docs / (1 + containing_docs))
return idf
def transform(self, doc_text):
tokens = self._tokenize(doc_text)
vector = []
doc_len = len(tokens)
for term in self.vocab:
if doc_len == 0:
vector.append(0.0)
continue
# Tính TF
tf = tokens.count(term) / doc_len
# Tính TF-IDF
tfidf = tf * self.idf.get(term, 0.0)
vector.append(tfidf)
return vector
def cosine_similarity(v1, v2):
# Tính tích vô hướng của hai vector
dot_product = sum(a * b for a, b in zip(v1, v2))
# Tính độ dài vector L2 Norm
norm_v1 = math.sqrt(sum(a * a for a in v1))
norm_v2 = math.sqrt(sum(b * b for b in v2))
if norm_v1 == 0 or norm_v2 == 0:
return 0.0
return dot_product / (norm_v1 * norm_v2)
# 3. Kỹ thuật chia nhỏ tài liệu (Chunking)
def chunk_text(text, chunk_size=150, overlap=30):
chunks = []
start = 0
text_len = len(text)
while start < text_len:
end = start + chunk_size
chunk = text[start:end]
chunks.append(chunk.strip())
# Trượt bước nhảy trừ đi khoảng overlap chồng gối
start += (chunk_size - overlap)
return chunks
# 4. Giao tiếp với API Ollama cục bộ
def query_ollama(prompt, context, model_name="llama3"):
url = "http://localhost:11434/api/chat"
# Lắp ghép prompt RAG hoàn chỉnh nhúng Context tìm được
enriched_prompt = f"""Hãy trả lời câu hỏi dựa duy nhất vào phần Ngữ cảnh được cung cấp dưới đây. Nếu thông tin không có trong ngữ cảnh, hãy trả lời 'Tôi không tìm thấy thông tin này trong tài liệu'.
Ngữ cảnh:
{context}
Câu hỏi: {prompt}
Câu trả lời của bạn:"""
payload = {
"model": model_name,
"messages": [
{"role": "user", "content": enriched_prompt}
],
"stream": False
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req) as response:
res_data = json.loads(response.read().decode("utf-8"))
return res_data["message"]["content"]
except Exception as e:
return f"[Lỗi gọi Ollama]: {str(e)}. Hãy chắc chắn bạn đã khởi động Ollama."
# 5. Đường ống RAG tích hợp
def run_rag_pipeline(query):
print(f"\n🙋 [Câu hỏi của người dùng]: '{query}'")
# Bước 1: Chia nhỏ tài liệu tri thức thành các chunks
chunks = chunk_text(KNOWLEDGE_BASE, chunk_size=150, overlap=30)
print(f"-> Đã phân tách tài liệu thành {len(chunks)} chunks ngữ cảnh.")
# Bước 2: Khởi tạo mô hình TF-IDF trên các chunks
search_engine = SimpleTFIDF(chunks)
# Bước 3: Biến đổi câu hỏi sang dạng vector
query_vector = search_engine.transform(query)
# Bước 4: So khớp tương đồng Cosine Similarity để tìm chunk tốt nhất
best_similarity = -1.0
best_chunk = ""
for chunk in chunks:
chunk_vector = search_engine.transform(chunk)
sim = cosine_similarity(query_vector, chunk_vector)
if sim > best_similarity:
best_similarity = sim
best_chunk = chunk
print(f"🎯 [Tìm kiếm ngữ cảnh tương đồng nhất] (Độ khớp: {best_similarity:.4f}):")
print(f" Context: \"{best_chunk[:100]}...\"")
# Bước 5: Gọi Ollama cục bộ tổng hợp câu trả lời dựa trên ngữ cảnh trích xuất
print("🤖 Đang gọi Ollama local để sinh câu trả lời phản hồi...")
answer = query_ollama(prompt=query, context=best_chunk)
print(f"👉 [Phản hồi từ AI]: {answer}\n")
if __name__ == "__main__":
# Kịch bản 1: Câu hỏi có trong tài liệu
query_1 = "Tôi muốn nghỉ 10 ngày thì ai duyệt đơn nghỉ phép?"
run_rag_pipeline(query_1)
# Kịch bản 2: Câu hỏi hoàn toàn nằm ngoài tài liệu
query_2 = "Công ty thành lập vào năm nào?"
run_rag_pipeline(query_2)
Nguyên nhân: hàm
run_rag_pipeline không có bất kỳ ngưỡng độ khớp tối thiểu nào — nó luôn
trả về chunk có điểm cao nhất trong số các chunk hiện có, bất kể điểm đó cao hay thấp tuyệt
đối. Với kho tài liệu nhỏ và cùng chủ đề (chunking ra chỉ 5 đoạn), TF-IDF thô không đủ khả năng phân
biệt rạch ròi "liên quan" và "không liên quan". Đây là lý do câu lệnh dặn dò LLM trong prompt ("nếu
không có trong ngữ cảnh thì trả lời không tìm thấy") lại quan trọng đến vậy: chính pha Generation, chứ
không phải pha Retrieval, mới là tuyến phòng thủ cuối cùng chống lại các câu hỏi ngoài phạm vi tài liệu.
Trong hệ thống RAG thực chiến, người ta thường bổ sung một ngưỡng độ khớp tối thiểu (ví dụ từ chối truy
xuất nếu điểm dưới 0.5) VÀ thay TF-IDF bằng vector nhúng ngữ nghĩa thật (như
nn.Embedding đã học ở Bài 8) để tăng độ phân giải giữa các chủ đề khác nhau.
Tóm tắt bài học & Cầu nối kiến thức
- Đạt được: Hiểu kiến trúc một hệ thống RAG cơ bản: Chia nhỏ tài liệu -> Embedding -> Lưu Vector DB -> Truy xuất ngữ nghĩa -> Nhồi Context vào Prompt sinh câu trả lời.
- Đạt được: Tự lập trình thành công hệ thống hỏi đáp tài liệu PDF offline sử dụng mô hình local.
Cầu nối bài tiếp theo: Trong thực tế, việc cắt đoạn thô sơ sẽ làm mất mát ngữ cảnh. Ở Bài số 15, chúng ta sẽ nghiên cứu chuyên sâu các chiến thuật cắt đoạn (Chunking) nâng cao và cấu trúc tìm kiếm nhanh của Vector DB.
Tải file code thực hành minh họa bài học
File Python simple_rag.py — mã nguồn xây dựng quy trình trích xuất tài liệu, tính toán
TF-IDF & Cosine Similarity thô và truy vấn thông tin qua Ollama local (chạy
python simple_rag.py):
📖 Tài liệu tham khảo
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks — Bài báo khoa học lịch sử đề xuất kiến trúc RAG của nhóm nghiên cứu Facebook AI Research (Lewis et al., 2020)
- Lost in the Middle: How Language Models Use Long Contexts — Nghiên cứu khoa học mổ xẻ hành vi mất tập trung ở giữa prompt của LLM (Liu et al., 2023)
- Introduction to Information Retrieval: TF-IDF Weighting — Giáo trình lý thuyết tìm kiếm thông tin và cơ cấu toán học TF-IDF (Stanford IR Group)
Bình luận