Một mô hình ngôn ngữ trả lời trôi chảy về chính sách đổi trả của cửa hàng bạn, nhưng con số "14 ngày" nó đưa ra không có trong bất kỳ tài liệu nào. Đó là lý do RAG (Retrieval-Augmented Generation, sinh văn bản có tăng cường truy hồi) trở thành kiến trúc mặc định cho chatbot nội bộ: thay vì hy vọng mô hình nhớ đúng, ta tìm đoạn tài liệu liên quan, đưa vào prompt và buộc mô hình trả lời dựa trên đó, kèm số đoạn làm căn cứ. Bài này dựng một hệ thống RAG hoàn chỉnh bằng Python trên 11 tài liệu chính sách của một cửa hàng đồ gia dụng giả định, và đo từng quyết định bằng số thật thay vì cảm giác.
Bài nối tiếp Chọn vector database: FAISS, pgvector, Qdrant và chỉ mục HNSW (kho vector) và Embedding và semantic search: sentence-transformers và FAISS (biến chữ thành vector). Phần gọi mô hình dùng lại cách làm ở Gọi LLM API từ Python: streaming, structured output và kiểm soát chi phí. Môi trường: Apple M4, 16 GB RAM, Python 3.12, SDK openai trỏ vào Ollama qua endpoint tương thích OpenAI http://localhost:11434/v1; mô hình sinh qwen2.5:1.5b, embedding nomic-embed-text. Mã nằm trong code/s7-37.
RAG giải quyết vấn đề gì và gồm những bước nào
Bài báo gốc của Lewis và cộng sự (NeurIPS 2020) đặt tên cho ý tưởng này: kết hợp bộ nhớ tham số (kiến thức nằm trong trọng số mô hình) với bộ nhớ phi tham số (một chỉ mục vector có thể tra cứu). Hai lợi ích thực tế của RAG: kiến thức cập nhật không cần huấn luyện lại, và nguồn để người dùng kiểm tra.
Một pipeline RAG cơ bản có hai pha, và cả hai pha đều chạy được trên laptop. Pha lập chỉ mục (chạy một lần, hoặc khi tài liệu đổi): đọc tài liệu, cắt thành chunk, tính embedding, lưu. Pha trả lời (mỗi câu hỏi): embed câu hỏi, lấy top-k chunk gần nhất, ghép vào prompt, gọi mô hình, kiểm tra nguồn trích dẫn.
| Bước | Đầu vào | Đầu ra | Lỗi điển hình |
|---|---|---|---|
| Chunking | Tài liệu thô | Danh sách đoạn | Cắt ngang câu, mất ngữ cảnh |
| Embedding | Đoạn, câu hỏi | Vector chuẩn hoá | Quên tiền tố, sai mô hình giữa hai pha |
| Truy hồi | Vector câu hỏi | Top-k đoạn | Đoạn đúng nằm ngoài top-k |
| Sinh | Prompt có ngữ cảnh | Câu trả lời + nguồn | Bịa, dẫn nguồn sai, bỏ sót |
Quy tắc quan trọng nhất của việc xây RAG: đo truy hồi và sinh tách riêng. Nếu đoạn đúng không vào được prompt thì không prompt nào cứu được; nếu đoạn đúng đã có mà câu trả lời vẫn sai thì lỗi nằm ở mô hình hoặc prompt. Phần cuối bài có bảng tách hai loại lỗi này.
Dữ liệu và bộ câu hỏi thử cho RAG
Mọi hệ thống RAG đều bắt đầu từ dữ liệu và một bộ câu hỏi để đo. Thư mục data/ có 11 tệp Markdown, tổng 16.023 ký tự, do tôi tự soạn cho một cửa hàng gia dụng hư cấu: đổi trả và hoàn tiền, bảo hành, vận chuyển, thanh toán, hướng dẫn nồi chiên, máy xay, nồi cơm, tài khoản và điểm thưởng, khuyến mãi, bảo mật dữ liệu, liên hệ. Không có dữ liệu khách thật. Mỗi tệp chia mục bằng tiêu đề ## , đây là cấu trúc ta sẽ tận dụng khi chunking.
Bộ thử questions.json gồm 25 câu: 22 câu có đáp án trong kho (ghi rõ tệp, mục chứa đáp án và các "dữ kiện" như 30.000, 7 ngày mà câu trả lời đúng phải chứa) và 3 câu ngoài kho (hỏi về iPhone, giá hiện tại, tuyển dụng). Chênh một câu là 4,5 điểm phần trăm, nên bộ RAG này chỉ dùng để so sánh tương đối.
Chunking cho RAG: cắt theo cấu trúc, không cắt theo ký tự
Chunking quyết định đơn vị mà RAG tìm và trích dẫn. Cách đơn giản nhất là cắt cứ mỗi 400 ký tự. Nó nhanh, nhưng nó không biết câu nào kết thúc ở đâu. Đây là hai chunk liên tiếp mà cắt cố định tạo ra từ tệp đổi trả (output thật của chunk_stats.py):
[doi-tra-hoan-tien.md#f0] ...'ài tới 30 ngày kể từ ngày nhận hàng. Cửa hàng'
[doi-tra-hoan-tien.md#f1] 'chịu toàn bộ phí vận chuyển hai chiều trong t'...
Chủ ngữ "Cửa hàng" ở chunk này, vị ngữ "chịu toàn bộ phí vận chuyển" ở chunk kia. Embedding của mỗi nửa đều lệch nghĩa. Cách thứ hai tôn trọng cấu trúc: mỗi mục H2 là một chunk, mục nào dài quá max_chars thì tách tiếp theo đoạn văn, và gắn tiêu đề tài liệu và tiêu đề mục vào đầu chunk để chunk tự đứng được khi bị lấy ra khỏi tài liệu:
def chunk_by_section(docs, max_chars=900):
out = []
for src, text in docs.items():
title = re.match(r"# (.+)", text).group(1)
for heading, body in split_sections(text):
buf = ""
for p in (p.strip() for p in body.split("\n\n") if p.strip()):
if buf and len(buf) + len(p) > max_chars: # mục dài: tách theo đoạn văn
out.append(Chunk(f"{src}#{len(out)}", src, heading, f"{title} > {heading}\n{buf}"))
buf = ""
buf = (buf + "\n\n" + p).strip()
if buf:
out.append(Chunk(f"{src}#{len(out)}", src, heading, f"{title} > {heading}\n{buf}"))
return out
Thống kê ba chiến lược trên cùng kho tài liệu:
fixed400 48 chunk | ký tự: min 7, trung vị 399, max 400
fixed400+ov80 57 chunk | ký tự: min 7, trung vị 400, max 400
theo-muc 51 chunk | ký tự: min 221, trung vị 330, max 584
Chunk 7 ký tự ở hai cách đầu là mẩu đuôi tệp, vô nghĩa khi đứng riêng; cắt theo mục không có chunk rác.
Embedding và truy hồi: hai chi tiết dễ làm hỏng RAG
Pha truy hồi của RAG dùng nomic-embed-text qua endpoint /v1/embeddings của Ollama (tài liệu OpenAI compatibility liệt kê input dạng mảng và dimensions trong tham số hỗ trợ). Mô hình này yêu cầu tiền tố tác vụ: search_document: cho đoạn được lập chỉ mục và search_query: cho câu hỏi. Quên tiền tố không báo lỗi, chỉ làm chất lượng truy hồi tụt âm thầm, nên tôi gói vào một hàm duy nhất:
def embed(texts, prefix, batch=32):
vecs = []
for i in range(0, len(texts), batch):
r = client.embeddings.create(model=EMBED_MODEL, input=[prefix + t for t in texts[i:i + batch]])
vecs += [d.embedding for d in r.data]
v = np.asarray(vecs, dtype=np.float32)
return v / np.linalg.norm(v, axis=1, keepdims=True) # chuẩn hoá: tích vô hướng = cosine
Lớp Index trong rag_core.py chỉ lưu ma trận vector và tính vecs @ embed_query(q). Với 51 chunk, tìm kiếm vét cạn bằng một phép nhân ma trận là đủ và chính xác tuyệt đối. Chỉ khi kho lên hàng trăm nghìn chunk mới cần chỉ mục xấp xỉ như HNSW.
Bây giờ đo xem chiến lược chunking nào đưa đoạn đúng lên cao hơn. Một chunk tính là "trúng" nếu đúng tệp và đúng mục chứa đáp án (với chunk cố định, nếu chứa đủ các dữ kiện của câu hỏi). Output thật của eval_retrieval.py trên 22 câu:
chiến lược chunk hit@1 hit@3 MRR
fixed400 48 0.50 0.68 0.604
fixed400+ov80 57 0.36 0.64 0.532
theo-muc 51 0.64 0.82 0.742
Cắt theo mục đạt hit@3 = 0,82 (18 trên 22 câu có đoạn đúng trong top-3), so với 0,68 của cắt cố định. Thêm chồng lấn (overlap) 80 ký tự không cứu được mà còn làm hit@1 giảm từ 0,50 xuống 0,36. Tôi tưởng nguyên nhân là chunk gần trùng nhau chen nhau, nhưng diag_overlap.py cho thấy không phải: ranh giới cắt dịch đi, nên với nhiều câu chunk chứa đủ dữ kiện không còn đứng đầu mà nhường chỗ cho chunk lệch chủ đề (câu về đổi máy mới tụt từ hạng 1 xuống hạng 7). Chạy lại với một tiêu chí chung (đúng tệp và chứa đủ dữ kiện) cho cả ba chiến lược, bảng trên không đổi. Với 22 câu, chênh hit@1 giữa 0,64 và 0,50 chỉ là 3 câu, nên đừng kết luận quá mạnh.
Sinh câu trả lời có dẫn nguồn trong RAG
Pha sinh của RAG là chỗ mô hình nhỏ dễ lệch nhất. Thử đầu tiên, free_prompt.py, yêu cầu mô hình "ghi số đoạn như [1] sau mỗi ý" trong văn bản tự do. Với qwen2.5:1.5b, kết quả trên 6 câu của rag_demo.py (output trong free_prompt_output.txt): 3 câu không có số nguồn dạng [n], và 3 câu trả lời chỉ là một tham chiếu như [1] mà không có nội dung (ví dụ câu về hoàn tiền trả về [2] (bao-mat-du-lieu.md / Dữ liệu được thu thập)). Mô hình nhỏ tuân thủ định dạng văn bản tự do kém. Cách bền hơn là bắt mô hình trả JSON với hai trường, answer và sources, rồi kiểm tra phía mã thay vì tin:
def answer(question, index, k=3, model=None):
hits = index.search(question, k)
r = client.chat.completions.create(
model=model or CHAT_MODEL, temperature=0, seed=7, max_tokens=300,
response_format={"type": "json_object"},
messages=[{"role": "system", "content": SYSTEM},
{"role": "user", "content": build_prompt(question, hits)}])
raw = r.choices[0].message.content
try:
data = json.loads(raw)
text = str(data.get("answer", "")).strip() or NOT_FOUND
src = [int(x) for x in data.get("sources", []) if str(x).isdigit()]
except (json.JSONDecodeError, TypeError, ValueError):
return raw.strip(), hits, [] # JSON hỏng: không có nguồn
cited = sorted({n for n in src if 1 <= n <= len(hits)}) # bỏ số đoạn không tồn tại
return text, hits, cited
Prompt hệ thống ra ba lệnh: chỉ dùng các đoạn được đánh số, trả JSON đúng dạng, và nếu không có thông tin thì trả đúng câu "Tôi không tìm thấy thông tin này trong tài liệu." kèm sources rỗng; cuối prompt có một ví dụ mẫu (few-shot) vì mô hình 1,5 tỉ tham số cần thấy dạng đầu ra mong muốn (xem Prompt engineering có hệ thống). Đặt temperature=0 và seed để lặp lại được; theo tài liệu API của Ollama, muốn đầu ra tái lập thì đặt seed cùng temperature bằng 0. Output thật của rag_demo.py (rút gọn hai câu):
Q: Nồi chiên không dầu báo lỗi E03 là gì?
A: Mã lỗi E03: quá nhiệt do khay chiên đặt sai vị trí hoặc đầy dầu mỡ, tắt máy, để nguội 30 phút rồi lắp lại khay đúng khớp.
top-3: ['noi-chien-khong-dau/Mã lỗi hiển thị 0.75', 'noi-com-dien/Lỗi thường gặp 0.72', 'noi-com-dien/Vệ sinh và thay thế 0.71']
dẫn nguồn: ['noi-chien-khong-dau.md / Mã lỗi hiển thị'] | 2.0s
Q: Cửa hàng nghỉ Tết bao nhiêu ngày?
A: Tôi không tìm thấy thông tin này trong tài liệu.
top-3: ['cua-hang-lien-he/Cửa hàng trải nghiệm 0.75', 'bao-mat-du-lieu/Mục đích sử dụng và chia sẻ 0.74', 'doi-tra-hoan-tien/Phí đổi trả 0.73']
dẫn nguồn: không có | 1.1s
Câu thứ hai là một thất bại có ích: tài liệu có thông tin nghỉ Tết (mục "Giờ làm việc" của tệp liên hệ), nhưng truy hồi không đưa mục đó vào top-3, nên mô hình từ chối đúng theo chỉ dẫn. Hành vi từ chối là chính xác; lỗi nằm ở truy hồi.
Kiểm tra RAG: tách lỗi truy hồi khỏi lỗi sinh
answer_check.py chạy cả 22 câu có đáp án và ghi ba cột: đoạn đúng có trong top-3 không, câu trả lời có chứa đủ dữ kiện không, và nguồn mô hình trích có trúng mục đúng không. Kết quả tổng hợp:
truy hồi top-3 đúng : 18/22
trả lời chứa dữ kiện: 16/22
khi truy hồi đúng : 16/18
khi truy hồi trượt: 0/4
dẫn nguồn trúng mục : 17/22
Con số then chốt là dòng 3 và 4: khi truy hồi trượt, 0 trên 4 câu trả lời đúng. Giới hạn trên của cả hệ thống chính là chất lượng truy hồi (18/22 = 82%); mô hình sinh chỉ làm mất thêm 2 câu. Muốn cải thiện hệ thống RAG này, đầu tư vào truy hồi có lợi hơn đổi mô hình sinh. Bốn câu trượt truy hồi (chiên cánh gà, máy xay chạy liên tục, đổi điểm thưởng, nghỉ Tết) có điểm chung là dùng từ ít trùng với chữ trong mục chứa đáp án, đúng loại lỗi mà bài sau (hybrid search và reranking) nhắm tới.
Ba trường hợp còn lại đáng đọc kỹ (xem failures_output.txt và rag_demo_output.txt).
Câu "Đơn COD tối đa là bao nhiêu tiền?" có mục đúng ở vị trí số 1 nhưng mô hình trả lời "từ 3.000.000 đồng trở lên cần xác nhận...", tức nhặt nhầm con số trong cùng chunk: lỗi sinh thật sự. Câu "Máy bị vào nước ở động cơ thì có được bảo hành không?" cho đáp án "Không", đúng về nội dung nhưng quá cộc nên bộ so khớp dữ kiện chấm trượt; đây là hạn chế của phép chấm bằng chuỗi con, và là lý do cần LLM-as-judge ở bài đánh giá.
Câu "Hoàn tiền về thẻ ngân hàng mất bao lâu?" trả lời đúng "7 đến 10 ngày" nhưng nguồn trích lại trỏ sang chunk bảo mật dữ liệu, tức nguồn trích dẫn có thể sai dù câu trả lời đúng. Vì vậy trích dẫn của mô hình chỉ là gợi ý; sản phẩm thật nên hiển thị luôn toàn bộ top-k để người dùng đối chiếu.
Câu hỏi ngoài kho: ngưỡng điểm không đủ
Phản xạ phổ biến là đặt ngưỡng: nếu điểm cosine top-1 thấp hơn X thì từ chối, khỏi gọi mô hình. refuse_check.py đo xem ngưỡng có khả thi không:
top-1 cosine, 22 câu trong kho : min 0.730, trung vị 0.784, max 0.839
top-1 cosine, 3 câu ngoài kho: 0.762, 0.793, 0.812
từ chối đúng 3/3
Hai dải điểm chồng lên nhau: câu ngoài kho đạt tới 0,812, trong khi 15 trên 22 câu hợp lệ có điểm dưới 0,80. Ngưỡng 0,80 sẽ từ chối nhầm 15 câu hợp lệ mà vẫn để lọt một trong ba câu ngoài kho. Với embedding và kho nhỏ này, điểm tương đồng chỉ cho biết "gần chủ đề cửa hàng", không cho biết "có câu trả lời". Điều cứu hệ thống ở đây là chỉ dẫn từ chối trong prompt: cả 3 câu ngoài kho được từ chối đúng. Đây chỉ là 3 câu; đừng coi 3/3 là tỉ lệ từ chối thật, hãy có vài chục câu ngoài kho trong bộ thử.
Biến thể và giới hạn của RAG
- Kích thước chunk và k. Chunk nhỏ cho truy hồi chính xác nhưng thiếu ngữ cảnh; chunk lớn đủ ngữ cảnh nhưng embedding loãng. Tăng k tăng khả năng có đoạn đúng, nhưng prompt dài hơn tốn token, và nghiên cứu Lost in the Middle cho thấy mô hình dùng thông tin ở giữa ngữ cảnh dài kém hơn ở đầu và cuối.
- Truy hồi lai và xếp hạng lại. Với RAG, kết hợp từ khoá (BM25) với vector, rồi dùng cross-encoder chấm lại top ứng viên; phần lớn hệ thống RAG nghiêm túc đều có bước này. Chủ đề của bài kế tiếp.
- Truy hồi theo tài liệu cha và HyDE. Tìm bằng chunk nhỏ nhưng đưa cả mục hoặc tài liệu cha vào prompt; hoặc để mô hình viết một đáp án giả định rồi embed nó thay vì câu hỏi (HyDE). Bài này chưa đo hai cách đó.
Khi nào không nên dùng RAG
RAG hợp với kiến thức dạng văn bản thay đổi và cần dẫn nguồn.
RAG không hợp khi câu trả lời cần tính toán trên dữ liệu có cấu trúc (doanh thu quý này bao nhiêu thì cần SQL, không cần embedding), khi tài liệu quá ít và vừa vặn trong một prompt (đưa thẳng cả kho vào ngữ cảnh đơn giản và chính xác hơn; kho 16 nghìn ký tự của bài này chỉ khoảng vài nghìn token), hoặc khi mục tiêu là đổi phong cách, giọng điệu của mô hình (đó là việc của prompt hoặc fine-tuning).
Cũng đừng coi RAG là biện pháp bảo mật: tài liệu bị đưa vào prompt đều có thể bị lộ cho người hỏi, nên phân quyền phải làm ở bước truy hồi, và nội dung tài liệu nhúng chỉ dẫn độc hại có thể điều khiển mô hình (prompt injection gián tiếp).
Lỗi thường gặp khi xây RAG
- Quên tiền tố embedding: với các mô hình đòi tiền tố như nomic, thiếu tiền tố làm truy hồi kém đi mà không có lỗi nào.
- Chunk RAG mất ngữ cảnh: đoạn "mức phí 30.000 đồng" không nói là phí gì. Gắn tiêu đề tài liệu và mục vào đầu chunk.
- Tin điểm tương đồng làm ngưỡng từ chối mà không đo phân bố điểm của câu trong và ngoài kho như phần trên.
- Tin trích dẫn của mô hình: kiểm tra số đoạn hợp lệ ở phía mã, và hiển thị nguồn thật.
- Chỉ đánh giá câu trả lời cuối của RAG: không biết lỗi do truy hồi hay do sinh nên sửa nhầm chỗ.
Checklist RAG
- Với mọi dự án RAG, cắt chunk theo cấu trúc tài liệu, gắn tiêu đề vào chunk, đo hit@k và MRR trước khi chọn kích thước.
- Chuẩn hoá embedding, dùng đúng tiền tố, cùng mô hình cho hai pha, cache theo hash nội dung.
- Bắt mô hình trả JSON có
answervàsources, kiểm tra nguồn ở phía mã. - Luôn có câu hỏi ngoài kho trong bộ thử và quy tắc từ chối rõ ràng trong prompt.
- Báo cáo RAG tách riêng: truy hồi đúng bao nhiêu, và khi truy hồi đúng thì sinh đúng bao nhiêu.
Mã nguồn của bài RAG này gồm rag_core.py (chunking, embedding, truy hồi, sinh), chunk_stats.py, eval_retrieval.py, rag_demo.py, refuse_check.py, answer_check.py, failures.py, free_prompt.py, diag_overlap.py cùng questions.json, data/ và các tệp *_output.txt. Mọi con số trong bài là output thật trên Apple M4, 16 GB, với 25 câu hỏi tự soạn trên kho tài liệu giả định; số sẽ thay đổi nếu bạn đổi tài liệu, mô hình hoặc bộ câu hỏi.
Bài tiếp theo
RAG nâng cao: hybrid search, reranking và viết lại câu hỏi. Bốn câu trượt truy hồi hôm nay là chỗ để thử: bài sau thêm BM25, hợp nhất bằng Reciprocal Rank Fusion và cross-encoder xếp hạng lại, rồi đo xem mỗi bước đáng giá bao nhiêu.



