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:

  1. 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.
  2. 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...".
⚠️ Cạm bẫy: Hiện tượng Lost in the Middle (Bị lạc ở giữa prompt)
Một nghiên cứu khoa học nổi tiếng chỉ ra rằng LLM có xu hướng chú ý rất tốt vào thông tin ở đầu promptcuối prompt, nhưng lại bỏ quên hoặc bỏ qua dữ liệu nằm ở giữa prompt. Nếu bạn truy xuất quá nhiều tài liệu không liên quan rồi nhồi nhét tất cả vào prompt, bạn vừa làm tăng chi phí token, vừa làm giảm chất lượng câu trả lời của AI do thông tin quan trọng bị trôi vào vùng mù ở giữa.

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. 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. 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. 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. 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. 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).

📐 Công thức toán học tính toán chỉ số TF-IDF
Chỉ số TF-IDF đánh giá tầm quan trọng của một từ $t$ trong một đoạn tài liệu $d$ thuộc tập hợp tài liệu $D$:
  1. 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).
  2. 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.
  3. Điểm số tích hợp TF-IDF: $$\text{TF-IDF}(t, d, D) = \text{TF}(t, d) \times \text{IDF}(t, D)$$
Sau khi có vector TF-IDF đại diện cho câu hỏi $A$ và đoạn văn $B$, ta tính độ tương đồng hướng bằng công thức Cosine Similarity: $$\text{Cosine Similarity}(A, B) = \frac{A \cdot B}{\|A\|_2 \|B\|_2} = \frac{\sum A_i B_i}{\sqrt{\sum A_i^2} \sqrt{\sum B_i^2}}$$
🔢 Ví dụ tính tay TF-IDF trên một kho tài liệu nhỏ (đã kiểm chứng)
Giả sử kho tài liệu $D$ chỉ có 3 đoạn: đoạn 0 nói về "nghỉ phép nhân viên", đoạn 1 nói về "nghỉ ốm bác sĩ", đoạn 2 nói về "giám đốc phê duyệt". Tính TF-IDF của hai từ trong đoạn 0 (đã chạy Python xác minh):
  • 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$.
Hai từ có cùng tần suất xuất hiện trong đoạn (TF giống hệt nhau), nhưng điểm TF-IDF cuối cùng khác biệt hoàn toàn — minh chứng trực quan cho việc IDF mới là yếu tố quyết định từ nào thực sự "đặc trưng" cho một đoạn văn.

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.

simple_rag.py
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)
⚠️ Cạm bẫy phát hiện khi chạy thử thật: Retrieval không có ngưỡng, luôn "tự tin" trả lời
Chạy thử chính xác đoạn code trên với 2 câu hỏi ở kịch bản cuối cho ra một kết quả đáng chú ý (đã kiểm chứng bằng Python): câu hỏi 1 (có trong tài liệu, "ai duyệt đơn nghỉ phép?") đạt độ khớp Cosine $0.2414$, còn câu hỏi 2 (hoàn toàn ngoài tài liệu, "công ty thành lập năm nào?") lại đạt độ khớp $0.2501$ — CAO HƠN cả câu hỏi đúng chủ đề, chỉ vì nó vô tình trùng từ "công ty" với đoạn mở đầu tài liệu.

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

🔑 Bài học đạt đượ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 về simple_rag.py

📖 Tài liệu tham khảo

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

Bài 13: Cục bộ hóa AI với Ollama Bài 15: Chiến thuật Chunking & Vector DB Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận