Có một câu pitch quen thuộc: “Chỉ cần đưa tài liệu vào RAG, LLM sẽ trả lời câu hỏi dựa trên đó”. Mọi POC đầu tiên ai cũng thấy RAG hoạt động. Đến khi push lên production, retrieval miss câu hỏi quan trọng, LLM hallucinate, latency tăng, người dùng mất niềm tin.
Lý do thường không phải LLM ngu mà là pipeline RAG có 5 bước, mỗi bước có hàng chục lỗi có thể xảy ra. Bài này mở từng bước một, chỉ ra điểm chết người, kèm code thật chạy với Chroma.
Dành cho: dev đã hiểu embedding và LLM nói chung, đang build hoặc maintain một RAG system, hoặc đang chuẩn bị làm.
Mental model: RAG là gì, không là gì
RAG = Retrieval-Augmented Generation. Đơn giản: trước khi LLM trả lời, kéo các đoạn tài liệu liên quan vào prompt, để LLM “đọc” rồi trả lời dựa trên đó.
Pipeline 5 bước:
[ User question ]
|
v
1. Embed question -> vector
|
v
2. Tìm top-K chunks gần nhất trong vector DB
|
v
3. Lắp chunks vào template prompt (system + context + question)
|
v
4. Gửi prompt to LLM
|
v
5. Nhận answer, có thể post-process (citation, rerank)
RAG không là magic. Nó chỉ là cách “tiêm context” vào prompt thay vì fine-tune. Nếu thông tin không nằm trong chunks được retrieve, LLM không có cách nào biết.
Câu hỏi sống còn của mọi RAG system: retrieval có lấy đúng chunks không? Nếu không, mọi thứ khác vô nghĩa.
Phần 1: Indexing pipeline, một lần duy nhất
Trước khi user hỏi, phải build index từ tài liệu. Bước này chạy offline.
Documents -> Load -> Chunk -> Embed -> Lưu vào Vector DB
Bước 1.1 Load: đọc tài liệu (PDF, DOCX, HTML, Markdown, code). Mỗi format cần parser riêng. Mất 30% công sức của một RAG project tốt.
Bước 1.2 Chunk: chia tài liệu thành các đoạn nhỏ (chunk). Vì sao chia?
- LLM có context limit (8k, 32k, 128k tokens)
- Embedding model dùng để encode tốt hơn với input ngắn (512-1024 token)
- Retrieval cần đơn vị nhỏ để score chính xác
Chunk size mặc định 500-1000 tokens, overlap 50-100 tokens. Nhưng đây là phần làm sai nhiều nhất.
Bước 1.3 Embed: đưa mỗi chunk qua embedding model, ra một vector ~768 hoặc 1024 chiều.
Bước 1.4 Lưu vector DB: Chroma, Qdrant, Weaviate, Pinecone, pgvector. Lưu kèm metadata để filter sau (source file, date, author, vv).
Code Chroma minimal:
import chromadb
from chromadb.utils import embedding_functions
client = chromadb.PersistentClient(path="./chroma_db")
# Dùng sentence-transformers local, free
embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="BAAI/bge-m3"
)
collection = client.get_or_create_collection(
name="docs",
embedding_function=embed_fn,
metadata={"hnsw:space": "cosine"}
)
# Index documents
docs = [
"Astro là static site generator dùng để build blog nhanh.",
"Astro hỗ trợ Markdown, MDX, React, Vue, Svelte components.",
"Build blog với Astro: npm create astro@latest, chọn template."
]
collection.add(
ids=[f"doc_{i}" for i in range(len(docs))],
documents=docs,
metadatas=[{"source": "astro-guide"} for _ in docs]
)
Phần 2: Chunking, chỗ chết người đầu tiên
Chunking sai = retrieval sai = RAG vô dụng. Đây là khu vực mọi RAG project cần chú ý nhất.
Chiến lược 1: Fixed-size chunking. Chia theo độ dài cố định (500 tokens, overlap 50).
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=["\n\n", "\n", " ", ""]
)
chunks = splitter.split_text(document)
Ưu: đơn giản. Nhược: cắt giữa câu, mất context.
Chiến lược 2: Semantic chunking. Chia theo độ thay đổi ngữ nghĩa. Tính embedding của từng câu, gộp các câu liên tiếp có embedding gần nhau.
Ưu: chunks có ngữ nghĩa nguyên vẹn. Nhược: chậm, đắt (cần embed tất cả câu).
Chiến lược 3: Structural chunking. Chia theo cấu trúc tài liệu (heading, section, paragraph).
from langchain.text_splitter import MarkdownHeaderTextSplitter
headers = [("#", "h1"), ("##", "h2"), ("###", "h3")]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers)
Ưu: respects logical units. Nhược: chunk size thay đổi nhiều.
Chiến lược 4: Parent-child / small-to-big. Index chunk nhỏ (300 token), nhưng lúc retrieve trả về parent (1500 token) hoặc cả document. Embedding chính xác nhờ chunk nhỏ, context đầy đủ nhờ parent.
Đây là pattern advanced nhưng gần như luôn cho kết quả tốt hơn.
Quy tắc thực tế:
- Tài liệu cấu trúc tốt (Markdown, docs): structural + parent-child
- Tài liệu free-form (PDF, transcripts): semantic hoặc fixed với overlap lớn
- Code: chunk theo function / class
Phần 3: Embedding model, không có model tốt cho mọi việc
Embedding model quyết định “gần nhau” nghĩa là gì. Chọn sai model = retrieval ra kết quả vô lý.
Hai loại model:
Dense embedding (semantic): vector số thực, cosine similarity. Tốt cho semantic match ("how to install docker" match "docker installation guide"). Vd: BAAI/bge-m3, OpenAI text-embedding-3-large, nomic-embed-text.
Sparse / lexical (BM25): TF-IDF, exact match. Tốt cho keyword exact ("error code E0247"). Không cần model.
Best practice: hybrid search = dense + BM25. Score combined cho kết quả ổn định.
Câu hỏi quan trọng khi chọn embedding model:
| Câu hỏi | Lưu ý |
|---|---|
| Tiếng Việt? | bge-m3 (multilingual) > OpenAI > MiniLM (English-only) |
| Latency cần thấp? | small model (~100M params) > large model |
| Privacy / on-prem? | Local model (sentence-transformers, ollama embed) > API |
| Domain đặc thù (medical, legal)? | Fine-tune embedding model với data domain |
Pitfall: dùng embedding model khác nhau cho index và query. Vector từ model A không tương thích với model B. Phải dùng cùng một model end-to-end. Khi đổi model, phải re-index toàn bộ.
Phần 4: Retrieval, hơn cả nearest neighbors
Đơn giản: embed query, tìm top-K chunks gần nhất theo cosine similarity. Đủ cho POC.
Production cần thêm:
Hybrid retrieval: dense + sparse như đã nói.
Re-ranking: sau top-K (K thường 20-50) từ vector search, dùng cross-encoder để re-score và lấy top-N (N=3-5) cho prompt. Cross-encoder chính xác hơn nhưng chậm, nên chỉ chạy trên top-K chứ không trên toàn bộ corpus.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
initial_results = collection.query(query_texts=[query], n_results=20)
chunks = initial_results['documents'][0]
# Re-rank
pairs = [[query, chunk] for chunk in chunks]
scores = reranker.predict(pairs)
ranked = sorted(zip(chunks, scores), key=lambda x: -x[1])
top_5 = [c for c, s in ranked[:5]]
Query rewriting: câu hỏi user thường không trực tiếp khớp với tài liệu. Trước khi retrieve, dùng LLM viết lại query thành dạng phù hợp hơn.
Vd: user hỏi “cách đặt phòng giảm giá”. LLM rewrite thành “đặt phòng khuyến mãi”, “booking discount”, “đặt phòng giá rẻ”. Embed cả bộ, query parallel, merge results.
Metadata filtering: lọc theo source, date, author trước khi vector search. Nếu user hỏi “tin tức tháng trước”, filter date range trước, vector search trong tập đã lọc.
Phần 5: Prompt construction, nơi LLM gặp context
Sau khi có top-K chunks, lắp vào prompt:
def build_prompt(question, chunks, system_prompt=None):
system = system_prompt or (
"Bạn là trợ lý AI trả lời câu hỏi dựa vào tài liệu được cung cấp. "
"Nếu câu trả lời không có trong tài liệu, hãy nói 'tôi không biết'. "
"Trích nguồn bằng số [1], [2] theo thứ tự chunks."
)
context = "\n\n".join([
f"[{i+1}] {chunk}" for i, chunk in enumerate(chunks)
])
return [
{"role": "system", "content": system},
{"role": "user", "content": f"Tài liệu:\n{context}\n\nCâu hỏi: {question}"}
]
Các điểm cần chú ý:
System prompt rõ ràng về nguyên tắc trả lời. “Chỉ dựa vào tài liệu” / “Không bịa” / “Nói ‘không biết’ nếu không có trong tài liệu”. Đừng tin LLM tự suy ra điều này.
Cite nguồn. Yêu cầu LLM ghi [1], [2] tương ứng với chunk được dùng. Sau đó UI hiển thị nguồn để user verify.
Order chunks đúng cách. Một số nghiên cứu cho thấy LLM chú ý chunks đầu và cuối context nhiều hơn giữa (lost in the middle). Đặt chunk quan trọng nhất ở đầu hoặc cuối.
Context length quản lý cẩn thận. Top-5 chunks × 500 tokens = 2500 token. Cộng system prompt, question, output: ~3500 token. Phù hợp với context 8k. Đừng nhồi top-20 chunks vào, vừa chậm vừa giảm chất lượng (lost in middle).
Phần 6: End-to-end RAG với Chroma + Llama
Code chạy được, từ index đến answer:
import chromadb
from chromadb.utils import embedding_functions
from openai import OpenAI
# Setup
chroma = chromadb.PersistentClient(path="./chroma_db")
embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="BAAI/bge-m3"
)
collection = chroma.get_or_create_collection(
name="kb", embedding_function=embed_fn
)
# vLLM serving Llama-3-8B, OpenAI-compatible API
llm = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
# Indexing (chạy 1 lần)
def index_docs(docs_with_meta):
collection.add(
ids=[d['id'] for d in docs_with_meta],
documents=[d['text'] for d in docs_with_meta],
metadatas=[d.get('meta', {}) for d in docs_with_meta]
)
# Query
def rag_answer(question, top_k=5):
# 1. Retrieve
results = collection.query(
query_texts=[question],
n_results=top_k
)
chunks = results['documents'][0]
sources = [m.get('source', 'unknown') for m in results['metadatas'][0]]
# 2. Build prompt
context_str = "\n\n".join([
f"[{i+1}] (nguồn: {sources[i]})\n{chunks[i]}"
for i in range(len(chunks))
])
messages = [
{"role": "system", "content":
"Trả lời câu hỏi dựa CHỈ vào tài liệu. Nếu không có thông tin, nói 'không biết'. Trích [1], [2]."},
{"role": "user", "content": f"Tài liệu:\n{context_str}\n\nCâu hỏi: {question}"}
]
# 3. LLM
response = llm.chat.completions.create(
model="llama-3-8b-instruct",
messages=messages,
temperature=0.1
)
return response.choices[0].message.content, sources
# Sử dụng
docs = [
{"id": "1", "text": "Astro 4.0 ra mắt với cải tiến view transitions.", "meta": {"source": "astro-blog"}},
{"id": "2", "text": "Astro hỗ trợ MDX cho phép nhúng React component vào Markdown.", "meta": {"source": "astro-docs"}},
{"id": "3", "text": "Để build Astro site: npm install, npm run build.", "meta": {"source": "astro-docs"}},
]
index_docs(docs)
answer, srcs = rag_answer("Astro có hỗ trợ MDX không?")
print(answer)
print("Nguồn:", srcs)
Đây là RAG đủ dùng cho POC. Production cần thêm: chunking đúng cho format thật, reranker, query rewriting, evaluation.
Phần 7: Pitfall thực tế
Pitfall 1: Đánh giá RAG bằng cảm tính.
Một dev tôi quen build RAG cho knowledge base. Hỏi 10 câu thấy ổn, deploy. Tuần sau, user phàn nàn 30% câu trả lời sai. Anh ấy không có evaluation set chuẩn.
Fix: build test set 50-100 (câu hỏi, expected answer, expected source chunks). Mỗi lần đổi chunking / embedding / prompt, đo:
- Retrieval recall@5 (top-5 có chứa expected chunks không)
- Answer faithfulness (LLM có dựa vào chunks không, hay bịa)
- Answer relevance (LLM có trả lời đúng câu hỏi không)
Không có metrics = không biết đang tốt hay tệ.
Pitfall 2: Chunk size cùng một số cho mọi loại tài liệu.
500 tokens là số mặc định nhưng không phải số đúng. Tài liệu code thì chunk theo function (50-200 tokens). Tài liệu legal thì chunk theo article (1000-3000 tokens). Tài liệu chat thì chunk theo conversation turn.
Test 3-4 chunk size khác nhau trên evaluation set, chọn số tốt nhất cho domain của bạn.
Pitfall 3: Confuse “retrieval miss” với “LLM ngu”.
User hỏi “X là gì?”, LLM trả lời sai. Nhiều dev đổ lỗi LLM, đổi sang model mạnh hơn. Thực ra retrieval không lấy chunk chứa định nghĩa X.
Debug RAG luôn check retrieval trước. Log top-K chunks cho mỗi query. Nếu chunks không có thông tin đúng -> sửa retrieval (chunking, embedding, reranker). Nếu chunks có nhưng LLM không dùng -> sửa prompt hoặc model.
Pitfall 4: Không cập nhật index khi tài liệu đổi.
RAG index là snapshot tại thời điểm build. Tài liệu thay đổi -> index phải update. Setup pipeline: detect doc change -> re-chunk -> re-embed -> upsert vào vector DB.
Nếu hệ thống thay đổi nhiều: schedule reindex mỗi đêm. Nếu thay đổi ít: trigger manual.
Pitfall 5: System prompt mâu thuẫn.
System nói “chỉ dựa vào tài liệu”. Nhưng nếu tài liệu không có, model vẫn trả lời theo training data của nó (LLM rất khó nói “không biết”). Hậu quả: hallucinate trông như fact.
Fix: prompt rõ ràng + ví dụ:
Nếu không có thông tin trong tài liệu, trả lời CHÍNH XÁC: "Tôi không tìm thấy thông tin về câu hỏi này trong tài liệu".
Ví dụ:
Tài liệu: "Astro hỗ trợ MDX."
Câu hỏi: "Astro version mới nhất?"
Trả lời: "Tôi không tìm thấy thông tin về câu hỏi này trong tài liệu."
Few-shot example giúp model học pattern, không hallucinate.
Cheatsheet
| Câu hỏi | Trả lời nhanh |
|---|---|
| Vector DB nào? | Chroma (POC), Qdrant / pgvector (production) |
| Embedding model? | bge-m3 cho multilingual, OpenAI text-embedding-3-large nếu OK với API |
| Chunk size? | 500 token default, test 200/500/1000 trên data của bạn |
| Top K? | K=5 cho LLM context, K=20 nếu có rerank xuống 5 |
| Hybrid search? | Có nếu corpus đủ lớn (>10k docs) |
| Reranker? | Có cho production. cross-encoder bge-reranker-v2-m3 |
| Evaluation? | Bắt buộc. recall@K + faithfulness + relevance |
| Update index? | Detect change + re-embed, hoặc reindex schedule |
Quy tắc 80/20:
- 80% chất lượng RAG phụ thuộc chunking + embedding + retrieval
- 20% phụ thuộc LLM model
- Đừng vội đổi sang GPT-4 khi RAG bad, fix retrieval trước
Lời kết
RAG là pattern phổ biến nhất để LLM “biết” về tài liệu cụ thể của bạn, nhưng nó không phải “plug-and-play”. Mỗi bước trong pipeline có lỗ hổng, và lỗ hổng phổ biến nhất là chunking + embedding, không phải LLM.
Hiểu pipeline giúp bạn debug đúng chỗ, đo đúng metric, optimize đúng layer.
Hands-on cho bạn:
- Setup Chroma local, index 20-30 chunks từ một tài liệu bạn quen (docs, blog post, code repo).
- Build hàm
rag_answer()theo code Phần 6. Hỏi 10 câu, log top-5 chunks cho mỗi câu. - Đánh giá thủ công: chunks có liên quan không? Answer có dùng chunks không? Có hallucinate không?
- Thử đổi chunk size (200, 500, 1000), embedding model (MiniLM vs bge-m3), top-K (3, 5, 10). Đo lại.
- Thêm reranker (
bge-reranker-v2-m3). So sánh trước/sau.
Bài tiếp theo: LLM Agents: ReAct, tool use, planning, multi-step reasoning. RAG là retrieve một lần rồi trả lời. Agent thì lặp lại retrieve / tool call / think nhiều lần, mạnh hơn nhưng cũng phức tạp hơn nhiều. Hiểu agent giúp bạn build được hệ thống tự động làm task end-to-end.