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 vừa xảy ra hôm qua, mô hình chắc chắn sẽ trả lời sai hoặc tự bịa ra thông tin.
Kiến trúc RAG (Retrieval-Augmented Generation — Sinh dữ liệu tăng cường truy xuất) khắc phục điểm yếu đó: thay vì huấn luyện lại mô hình, ta đi tìm sẵn đoạn văn bản chứa câu trả lời rồi đưa thẳng vào prompt. Bài học này dựng đường ống RAG 5 bước hoàn chỉnh, tự cài đặt TF-IDF và Cosine Similarity bằng Python thuần, rồi đo hai chỗ mà đường ống đó vỡ — và cả hai lỗi đều là thứ bạn sẽ gặp lại trong hệ thống thật.
ollama pull bge-m3, khoảng 1.2 GB) thì mục 14.5 chạy được phần đối chứng quan trọng nhất của bài; thiếu nó chương trình
vẫn chạy và chỉ bỏ qua phần đó. Thư viện Python: không cần cài gì. TF-IDF và Cosine Similarity được viết tay chứ không gọi Scikit-Learn, để phần toán không bị thư viện che mất.
Kiến thức cần có: Bài 13 cho cách gọi Ollama; Bài 11 cho khái niệm token và vì sao không thể nhồi cả cuốn tài liệu vào prompt; Bài 8 cho ý tưởng biểu diễn văn bản bằng vector — mục 14.4 dùng một cách làm thô sơ hơn nhiều, và mục 14.5 sẽ cho thấy cái giá của sự thô sơ đó.
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ó.
- 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 (ở đây là Ollama cục bộ của Bài 13) để sinh câu trả lời hoàn chỉnh.
Hai giai đoạn nghe có vẻ tầm thường nhất — bước 2 và bước 3 — lại chính là hai chỗ hệ thống hỏng. Mục 14.3 và 14.4 giải thích chúng, rồi mục 14.5 đo mức thiệt hại thật.
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ẽ vượt cửa sổ ngữ cảnh (và bạn trả tiền cho toàn bộ số token đó ở mỗi lượt hỏi — Bài 11). Vì vậy phải chia nhỏ. Hai tham số cốt lõi:
- 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ụ: 150 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ụ: 30 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ứ 150, một câu văn quan trọng có thể bị chẻ làm đôi. Phép chồng gối giữ cho ngữ cảnh chuyển giao giữa các đoạn liền mạch.
Nghe rất thuyết phục — và mục 14.5 sẽ cho thấy nó không đủ. Trên chính kho tài liệu của dự án, chồng gối 30 ký tự vẫn cắt lìa một câu quy định khỏi điều kiện áp dụng của nó, khiến câu trả lời cuối cùng thiếu mất vế "nếu". Hãy giữ câu hỏi đó trong đầu khi đọc tiếp.
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, ta bắt đầu bằng thuật toán kinh điển TF-IDF (Term Frequency - Inverse Document Frequency). Ý tưởng gốc rất đời thường: một từ đặc trưng cho đoạn văn khi nó xuất hiện nhiều trong đoạn đó nhưng hiếm trên toàn kho.
- 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$.
Trước khi vào dự án, đây là một sân chơi tương tác để bạn cảm nhận không gian vector: nhập câu hỏi, xem các đoạn tài liệu nằm ở đâu so với nó, và độ tương đồng thay đổi ra sao khi bạn đổi chữ. Nếu phần toán ở trên còn trừu tượng, nghịch ở đây vài phút sẽ giúp ích hơn đọc lại công thức.
14.5 Dự án thực hành bài 14: Đường ống RAG, và hai chỗ nó vỡ
Dự án dựng trọn 5 giai đoạn ở mục 14.2 trên một kho tài liệu nhỏ: quy định nghỉ phép của công ty. Nó chạy hai câu hỏi — một câu có trong tài liệu ("nghỉ 10 ngày thì ai duyệt?") và một câu hoàn toàn không có ("công ty thành lập năm nào?") — rồi đo xem hệ thống xử lý ra sao.
"""Lesson 14 project: a complete RAG pipeline, and a measurement of its weak spot.
Run: python3 simple_rag.py
Needs: Ollama running (Lesson 13) for the generation step.
Optional: an embedding model (`ollama pull bge-m3`) for the comparison in part 4.
TF-IDF and cosine similarity are written out by hand rather than imported from
scikit-learn, so the maths stays visible.
"""
import json
import math
import re
import urllib.error
import urllib.request
OLLAMA = "http://localhost:11434"
CHAT_PREFERRED = ["qwen2.5:7b", "qwen2.5-coder:7b", "llama3.2", "llama3.1", "gemma2"]
EMBED_PREFERRED = ["bge-m3", "nomic-embed-text", "mxbai-embed-large"]
# The internal knowledge base. In a real system this is read from .txt/.pdf files.
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.
"""
IN_SCOPE = "Tôi muốn nghỉ 10 ngày thì ai duyệt đơn nghỉ phép?"
OUT_OF_SCOPE = "Công ty thành lập vào năm nào?"
# ---------------------------------------------------------------------------
# Part 1 - chunking
# ---------------------------------------------------------------------------
def chunk_text(text, chunk_size=150, overlap=30):
"""Cut text into overlapping windows of characters."""
if overlap >= chunk_size:
# Without this guard the stride below is <= 0 and the loop never ends.
raise ValueError("overlap must be smaller than chunk_size")
chunks, start = [], 0
while start < len(text):
chunk = text[start:start + chunk_size].strip()
if chunk:
chunks.append(chunk)
start += chunk_size - overlap
return chunks
def chunk_by_sentence(text, max_chars=250):
"""Cut on sentence boundaries instead of on a fixed character grid.
A rule like "if the leave is longer than 5 days, the CEO must sign it" only
works when the condition and the consequence stay in the same chunk.
"""
parts = [p.strip() for p in
re.split(r"(?<=[.:!?])\s*\n|(?<=[.!?])\s+", text) if p.strip()]
chunks, current = [], ""
for part in parts:
if current and len(current) + 1 + len(part) > max_chars:
chunks.append(current)
current = part
else:
current = f"{current} {part}".strip()
if current:
chunks.append(current)
return chunks
# ---------------------------------------------------------------------------
# Part 2 - TF-IDF and cosine similarity, by hand
# ---------------------------------------------------------------------------
class SimpleTFIDF:
"""A minimal TF-IDF vectoriser fitted on one list of documents."""
def __init__(self, documents):
self.documents = [self._tokenize(doc) for doc in documents]
self.vocab = sorted({word for doc in self.documents for word in doc})
self.idf = self._calculate_idf()
def _tokenize(self, text):
return re.findall(r"\b\w+\b", text.lower())
def _calculate_idf(self):
idf = {}
total_docs = len(self.documents)
for term in self.vocab:
containing = sum(1 for doc in self.documents if term in doc)
# A term in every document scores 0 or below: it separates nothing.
idf[term] = math.log(total_docs / (1 + containing))
return idf
def transform(self, text):
tokens = self._tokenize(text)
if not tokens:
return [0.0] * len(self.vocab)
return [tokens.count(term) / len(tokens) * self.idf.get(term, 0.0)
for term in self.vocab]
def cosine_similarity(v1, v2):
"""The angle between two vectors, ignoring their lengths."""
dot = sum(a * b for a, b in zip(v1, v2))
norm1 = math.sqrt(sum(a * a for a in v1))
norm2 = math.sqrt(sum(b * b for b in v2))
if norm1 == 0 or norm2 == 0:
return 0.0
return dot / (norm1 * norm2)
def rank(query_vector, chunk_vectors):
"""Score every chunk and return (score, index) sorted best first."""
scored = [(cosine_similarity(query_vector, vector), index)
for index, vector in enumerate(chunk_vectors)]
return sorted(scored, reverse=True)
# ---------------------------------------------------------------------------
# Part 3 - talking to Ollama (the pattern from Lesson 13)
# ---------------------------------------------------------------------------
def installed_models():
"""Names of the models this Ollama has, or [] if it is not reachable."""
try:
with urllib.request.urlopen(f"{OLLAMA}/api/tags", timeout=5) as response:
return [m["name"] for m in json.loads(response.read())["models"]]
except urllib.error.URLError:
return []
def pick(names, preferred):
for wanted in preferred:
for name in names:
if name == wanted or name.startswith(wanted + ":"):
return name
return None
def post(path, payload):
"""POST JSON, and keep the two failure modes distinguishable."""
request = urllib.request.Request(
f"{OLLAMA}{path}", data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request) as response:
return json.loads(response.read())
except urllib.error.HTTPError as exc:
detail = json.loads(exc.read() or b"{}").get("error", "no detail")
raise RuntimeError(f"Ollama answered HTTP {exc.code}: {detail}") from None
except urllib.error.URLError as exc:
raise RuntimeError(f"cannot reach Ollama at {OLLAMA} - {exc.reason}") from None
def embed(text, model):
"""One embedding vector from a real embedding model."""
return post("/api/embeddings", {"model": model, "prompt": text})["embedding"]
def generate_answer(question, context, model):
"""Step 5: hand the retrieved context to the model and forbid guessing."""
prompt = (
"Hãy trả lời câu hỏi dựa duy nhất vào phần Ngữ cảnh dưới đây. "
"Nếu thông tin không có trong ngữ cảnh, hãy trả lời đúng câu "
"'Tôi không tìm thấy thông tin này trong tài liệu'.\n\n"
f"Ngữ cảnh:\n{context}\n\nCâu hỏi: {question}\nCâu trả lời của bạn:"
)
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
}
return post("/api/chat", payload)["message"]["content"].strip()
# ---------------------------------------------------------------------------
# Part 4 - the pipeline, and the measurement that exposes its weak spot
# ---------------------------------------------------------------------------
def show_ranking(label, question, scores, chunks):
print(f" {label} - {question}")
for score, index in scores[:2]:
print(f" {score:.4f} chunk {index}: {chunks[index][:52]!r}")
def compare_retrievers(chunks, embed_model):
"""Score both questions with TF-IDF, then with real embeddings.
Returns the top score of each question under each retriever, so the claim
at the end is checked rather than asserted in prose.
"""
engine = SimpleTFIDF(chunks)
tfidf_vectors = [engine.transform(c) for c in chunks]
print("=== Retriever 1: TF-IDF (keyword overlap) ===")
tfidf_top = {}
for question in (IN_SCOPE, OUT_OF_SCOPE):
scores = rank(engine.transform(question), tfidf_vectors)
tfidf_top[question] = scores[0][0]
show_ranking("tf-idf", question, scores, chunks)
print()
if embed_model is None:
print("=== Retriever 2: skipped - no embedding model installed ===")
print(" Install one with `ollama pull bge-m3` to run the comparison.\n")
return tfidf_top, None
print(f"=== Retriever 2: {embed_model} (real semantic embeddings) ===")
chunk_vectors = [embed(c, embed_model) for c in chunks]
print(f" vector dimension: {len(chunk_vectors[0])}")
embed_top = {}
for question in (IN_SCOPE, OUT_OF_SCOPE):
scores = rank(embed(question, embed_model), chunk_vectors)
embed_top[question] = scores[0][0]
show_ranking("embed ", question, scores, chunks)
print()
return tfidf_top, embed_top
def report_separation(tfidf_top, embed_top):
"""Can a similarity threshold tell the two questions apart?"""
print("=== Can a threshold reject the out-of-scope question? ===")
for label, top in (("tf-idf", tfidf_top), ("embeddings", embed_top)):
if top is None:
continue
good, bad = top[IN_SCOPE], top[OUT_OF_SCOPE]
gap = good - bad
verdict = "YES" if gap > 0 else "NO - the wrong question scores higher"
print(f" {label:<11} in-scope {good:.4f} out-of-scope {bad:.4f}"
f" gap {gap:+.4f} -> {verdict}")
print()
def run_pipeline(question, chunks, chat_model, embed_model, threshold=0.5):
"""The full five stages, with a retrieval threshold this time."""
if embed_model:
vectors = [embed(c, embed_model) for c in chunks]
scores = rank(embed(question, embed_model), vectors)
scorer = embed_model
else:
engine = SimpleTFIDF(chunks)
scores = rank(engine.transform(question), [engine.transform(c) for c in chunks])
scorer = "tf-idf"
best_score, best_index = scores[0]
print(f" retrieved with {scorer}: {best_score:.4f}")
if best_score < threshold:
print(f" below the {threshold} threshold - refusing to answer, and no")
print(" tokens are spent calling the model at all.")
return None
print(f" context: {chunks[best_index][:64]!r}")
answer = generate_answer(question, chunks[best_index], chat_model)
print(f" answer : {answer}")
return answer
def compare_chunking(chat_model, embed_model):
"""Same question, same model, same retriever - only the chunking changes."""
print("=== Does the chunking change the answer? ===")
grid = chunk_text(KNOWLEDGE_BASE, chunk_size=150, overlap=30)
sentences = chunk_by_sentence(KNOWLEDGE_BASE)
# The condition and its consequence are one sentence in the source. Does
# each chunker keep them together? This part is deterministic.
condition = "trên 5 ngày"
grid_hit = next((c for c in grid if "Giám đốc điều hành" in c), "")
sentence_hit = next((c for c in sentences if "Giám đốc điều hành" in c), "")
print(f" character grid -> {len(grid)} chunks; the chunk naming the CEO "
f"starts {grid_hit[:26]!r}")
print(f" does it also contain '{condition}'? "
f"{condition in grid_hit}")
print(f" sentence-aware -> {len(sentences)} chunks; the chunk naming the CEO "
f"starts {sentence_hit[:26]!r}")
print(f" does it also contain '{condition}'? "
f"{condition in sentence_hit}")
print()
answers = {}
for label, chunks in (("character grid", grid), ("sentence-aware", sentences)):
print(f" --- {label} ---")
answers[label] = run_pipeline(IN_SCOPE, chunks, chat_model, embed_model)
print()
# Structural, so it holds on every run regardless of what the model says.
assert condition not in grid_hit, \
"the grid chunk was expected to have lost the condition"
assert condition in sentence_hit, \
"the sentence chunk was expected to keep the condition"
print(" PASS - the grid chunk states who approves but not WHEN it applies;")
print(" the sentence chunk keeps the condition attached to the rule.")
print(" The model's wording varies between runs; the missing condition does not.")
return answers
def main():
chunks = chunk_text(KNOWLEDGE_BASE, chunk_size=150, overlap=30)
print(f"=== Chunking ===\n {len(chunks)} chunks of at most 150 characters,"
f" overlapping by 30\n")
names = installed_models()
if not names:
print("Ollama is not reachable. Start it, then run this again.")
return
chat_model = pick(names, CHAT_PREFERRED) or names[0]
embed_model = pick(names, EMBED_PREFERRED)
tfidf_top, embed_top = compare_retrievers(chunks, embed_model)
report_separation(tfidf_top, embed_top)
print("=== The out-of-scope question, end to end ===")
run_pipeline(OUT_OF_SCOPE, chunks, chat_model, embed_model)
print()
answers = compare_chunking(chat_model, embed_model)
# The two claims this lesson makes, checked instead of asserted.
assert tfidf_top[IN_SCOPE] < tfidf_top[OUT_OF_SCOPE], \
"TF-IDF was expected to rank the out-of-scope question higher here"
if embed_top:
assert embed_top[IN_SCOPE] > embed_top[OUT_OF_SCOPE], \
"embeddings were expected to rank the in-scope question higher"
print("PASS - on this corpus TF-IDF ranks the wrong question higher,")
print(" and semantic embeddings put it back in the right order.")
if __name__ == "__main__":
main()
Chỗ vỡ thứ nhất: TF-IDF chấm câu hỏi sai điểm cao hơn câu hỏi đúng
Đây là kết quả chạy thật, và nó không hề như mong đợi:
=== Retriever 1: TF-IDF (keyword overlap) ===
tf-idf - Tôi muốn nghỉ 10 ngày thì ai duyệt đơn nghỉ phép?
0.2414 chunk 3: 'ngày, đơn nghỉ phép bắt buộc phải được ký phê duyệt '
0.0149 chunk 0: 'Quy trình xin nghỉ phép của công ty JS-Tools:\nNhân v'
tf-idf - Công ty thành lập vào năm nào?
0.2501 chunk 0: 'Quy trình xin nghỉ phép của công ty JS-Tools:\nNhân v'
0.2357 chunk 4: 'c nhập dữ liệu chính thức lên hệ thống HR-Portal trự'
=== Retriever 2: bge-m3:latest (real semantic embeddings) ===
vector dimension: 1024
embed - Tôi muốn nghỉ 10 ngày thì ai duyệt đơn nghỉ phép?
0.6958 chunk 3: 'ngày, đơn nghỉ phép bắt buộc phải được ký phê duyệt '
0.6849 chunk 2: 'ck trước 9h00 sáng của ngày nghỉ và nộp giấy xác nhậ'
embed - Công ty thành lập vào năm nào?
0.3466 chunk 4: 'c nhập dữ liệu chính thức lên hệ thống HR-Portal trự'
0.3364 chunk 3: 'ngày, đơn nghỉ phép bắt buộc phải được ký phê duyệt '
=== Can a threshold reject the out-of-scope question? ===
tf-idf in-scope 0.2414 out-of-scope 0.2501 gap -0.0087 -> NO - the wrong question scores higher
embeddings in-scope 0.6958 out-of-scope 0.3466 gap +0.3492 -> YES
Đọc kỹ hai dòng cuối. Với TF-IDF, câu hỏi nằm ngoài tài liệu ("công ty thành lập năm nào?") đạt $0.2501$, cao hơn câu hỏi đúng chủ đề ($0.2414$) — chỉ vì nó tình cờ trùng chữ "công ty" với đoạn mở đầu. Hệ quả rất nghiêm trọng: không tồn tại một ngưỡng nào vừa nhận câu đúng vừa loại câu sai, vì thứ tự đã bị đảo. Bất kể bạn đặt ngưỡng ở đâu, bạn hoặc mất câu đúng, hoặc nhận câu sai.
Với vector nhúng ngữ nghĩa thật (mô hình bge-m3, 1024 chiều, chạy ngay trên
Ollama của Bài 13), khoảng cách giãn ra thành $0.6958$ so với $0.3466$ — một khe hở
$0.3492$. Bây giờ một ngưỡng ở $0.5$ nhận câu đúng và từ chối câu sai một cách sạch sẽ.
Đó là toàn bộ lý do các hệ thống RAG thật không dùng TF-IDF làm bộ truy xuất chính.
=== The out-of-scope question, end to end ===
retrieved with bge-m3:latest: 0.3466
below the 0.5 threshold - refusing to answer, and no
tokens are spent calling the model at all.
Hai cái lợi cùng lúc: người dùng nhận một câu "tôi không có thông tin này" trung thực thay vì một câu
bịa trôi chảy, và bạn không tốn một token nào — vì hệ thống chặn trước khi gọi mô hình. Trong
một sản phẩm thật, phần lớn câu hỏi rác bị loại ngay ở đây.
Chỗ vỡ thứ hai: cắt đúng ký tự thứ 150 làm rơi mất vế "nếu"
Chỗ vỡ này tinh vi hơn nhiều, và nó là lý do mục 14.3 dặn bạn giữ câu hỏi trong đầu. Kho tài liệu có một câu quy định trọn vẹn: "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)." Cắt theo lưới 150 ký tự với chồng gối 30, câu đó rơi vào ranh giới và bị chẻ đôi:
=== Does the chunking change the answer? ===
character grid -> 5 chunks; the chunk naming the CEO starts 'ngày, đơn nghỉ phép bắt bu'
does it also contain 'trên 5 ngày'? False
sentence-aware -> 4 chunks; the chunk naming the CEO starts 'Nếu nghỉ phép dài hạn trên'
does it also contain 'trên 5 ngày'? True
--- character grid ---
retrieved with bge-m3:latest: 0.6958
context: 'ngày, đơn nghỉ phép bắt buộc phải được ký phê duyệt bởi Giám đốc'
answer : Theo ngữ cảnh, đơ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).
--- sentence-aware ---
retrieved with bge-m3:latest: 0.7185
context: 'Nếu nghỉ phép dài hạn trên 5 ngày, đơn nghỉ phép bắt buộc phải đ'
answer : Theo ngữ cảnh cung cấp, nếu bạn muốn nghỉ phép dài hạn trên 5 ngày (trong trường hợp này là 10 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).
Nhìn qua thì câu trả lời của bản cắt theo lưới có vẻ đúng — nó nêu đúng CEO. Nhưng nó đã đánh rơi vế điều kiện: đọc câu đó, người dùng sẽ hiểu rằng mọi đơn nghỉ phép đều cần CEO ký, kể cả nghỉ một ngày. Quy định thật chỉ áp dụng khi nghỉ trên 5 ngày. Bản cắt theo câu giữ nguyên vế "nếu" và áp dụng đúng cho trường hợp 10 ngày.
Vì vậy phép kiểm ở cuối dự án không kiểm câu chữ của mô hình (câu chữ đổi mỗi lần chạy), mà kiểm một điều kiện tất định: đoạn chứa tên người duyệt có chứa luôn điều kiện áp dụng hay không. Với lưới ký tự là
False, với cắt theo câu là True, ở mọi lần chạy.
Cách chạy dự án này trên máy bạn
-
Bật Ollama và có sẵn một mô hình chat (Bài 13). Muốn chạy phần đối chứng quan trọng nhất thì tải thêm mô
hình nhúng:
ollama pull bge-m3(khoảng 1.2 GB). Thiếu nó, chương trình tự bỏ qua mục so sánh và nói rõ đã bỏ qua. -
Chạy:
python3 simple_rag.py. Các con số TF-IDF sẽ giống hệt trong bài (thuật toán tất định); các con số embedding giống nếu bạn dùng cùng mô hình; câu chữ do LLM sinh thì sẽ khác. -
Rồi thử phá nó theo ba cách:
-
Đổi
chunk_size=150thành400. Câu quy định gọn lại trong một đoạn và lỗi rơi mất vế "nếu" biến mất — nhưng mỗi đoạn giờ tốn nhiều token hơn. Đây chính là sự đánh đổi mà Bài 15 sẽ đo một cách hệ thống. -
Hạ
thresholdtừ0.5xuống0.3. Câu hỏi ngoài phạm vi lọt lưới và được gửi lên mô hình. Xem mô hình trả lời gì — và nhớ rằng lúc này tuyến phòng thủ duy nhất còn lại là câu dặn trong prompt. -
Thêm vào
KNOWLEDGE_BASEmột đoạn nói về chủ đề khác hẳn (chẳng hạn quy định gửi xe). Chạy lại và xem điểm TF-IDF của câu hỏi ngoài phạm vi thay đổi ra sao — IDF phụ thuộc vào toàn bộ kho, nên thêm tài liệu là đổi điểm của mọi câu hỏi.
-
Đổi
Tóm tắt bài học & Cầu nối kiến thức
- Đạt được: Dựng trọn đường ống RAG 5 bước, với TF-IDF và Cosine Similarity tự viết tay bằng Python thuần.
- Đạt được: Đo được giới hạn của truy xuất theo từ khóa: trên chính kho tài liệu này, TF-IDF chấm câu hỏi ngoài phạm vi $0.2501$ cao hơn câu hỏi đúng $0.2414$, nên không ngưỡng nào cứu được.
- Đạt được: Thấy vector nhúng ngữ nghĩa thật mở khe hở ra $0.3492$, đủ để một ngưỡng $0.5$ từ chối câu hỏi ngoài phạm vi mà không tốn một token nào.
- Đạt được: Nhận diện dạng lỗi RAG nguy hiểm nhất — chunking cắt lìa điều kiện khỏi quy định, cho ra câu trả lời trích đúng chữ nhưng sai phạm vi áp dụng.
Cầu nối bài tiếp theo: Bài này cho thấy cách cắt văn bản quyết định câu trả lời đúng hay sai, và cách so sánh hai chiến thuật cắt bằng mắt. Bài 15 làm việc đó một cách hệ thống: các chiến thuật chunking, và cấu trúc bên trong của Vector Database khi kho tài liệu không còn là 5 đoạn mà là 5 triệu đoạn.
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