Bài 5: RAG Pipeline¶
Tổng quan¶
RAG (Retrieval-Augmented Generation) giải quyết giới hạn context window của LLM bằng cách tìm kiếm thông tin liên quan rồi đưa vào prompt - thay vì nhồi toàn bộ tài liệu. Kết quả: model trả lời dựa trên tài liệu thực tế, không "hallucinate".
1. Kiến trúc Tổng quan RAG¶
graph LR
subgraph Ingestion Pipeline [Ingestion - Chạy 1 lần]
D[Documents<br/>PDF/DOCX/HTML] --> L[Loader]
L --> C[Chunking]
C --> E[Embedding Model]
E --> V[(Vector DB)]
end
subgraph Retrieval & Generation [Inference - Mỗi query]
Q[User Query] --> QE[Embed Query]
QE --> S[Semantic Search]
V --> S
S --> R[Ranked Chunks]
R --> P[Augmented Prompt]
P --> LLM[LLM]
LLM --> A[Answer + Citations]
end
2. Ingestion Pipeline¶
Document Loading¶
from langchain_community.document_loaders import (
PyPDFLoader,
Docx2txtLoader,
UnstructuredHTMLLoader,
DirectoryLoader,
)
# PDF
loader = PyPDFLoader("luat-doanh-nghiep-2020.pdf")
pages = loader.load() # List[Document], mỗi Document là 1 trang
# DOCX
loader = Docx2txtLoader("hop-dong.docx")
docs = loader.load()
# Toàn bộ thư mục
loader = DirectoryLoader(
"./legal_docs/",
glob="**/*.pdf",
loader_cls=PyPDFLoader,
show_progress=True,
)
all_docs = loader.load()
Document object:
# Document có 2 thuộc tính:
doc.page_content # Text content
doc.metadata # {"source": "path/to/file.pdf", "page": 5, ...}
Chunking Strategies¶
Chunking chia tài liệu thành các đoạn nhỏ có thể fit vào context window.
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # Số characters mỗi chunk
chunk_overlap=64, # Overlap để tránh mất ngữ cảnh ở boundaries
separators=["\n\n", "\n", ".", " ", ""], # Thử tách theo thứ tự này
)
chunks = splitter.split_documents(docs)
Ưu điểm: Đơn giản, dự đoán được Nhược điểm: Có thể cắt giữa câu/đoạn
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
# Tách dựa trên semantic boundaries thay vì fixed size
splitter = SemanticChunker(
embeddings=OpenAIEmbeddings(),
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=95,
)
chunks = splitter.split_documents(docs)
Ưu điểm: Chunks có nghĩa hoàn chỉnh hơn Nhược điểm: Chậm hơn, chi phí embedding cao hơn
from langchain_text_splitters import MarkdownHeaderTextSplitter
headers_to_split_on = [
("#", "H1"),
("##", "H2"),
("###", "H3"),
]
splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on
)
# Tách theo headers → chunks giữ nguyên context của section
chunks = splitter.split_text(markdown_text)
Metadata Enrichment¶
Metadata giúp filter và re-rank sau này:
from datetime import datetime
def enrich_metadata(chunks, source_file):
for chunk in chunks:
chunk.metadata.update({
"source": source_file,
"ingested_at": datetime.now().isoformat(),
"chunk_size": len(chunk.page_content),
# Với tài liệu pháp lý:
"doc_type": "legal",
"year": 2020,
"law_id": "68/2020/QH14",
})
return chunks
Indexing¶
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings
embedding_model = HuggingFaceEmbeddings(
model_name="intfloat/multilingual-e5-large",
model_kwargs={"device": "cuda"},
encode_kwargs={"normalize_embeddings": True},
)
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embedding_model,
persist_directory="./legal_vectorstore",
collection_name="legal_docs",
)
print(f"Indexed {vectorstore._collection.count()} chunks")
3. Retrieval¶
Basic Semantic Search¶
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 5},
)
results = retriever.invoke("Điều kiện thành lập công ty TNHH là gì?")
Query Rewriting¶
Câu hỏi người dùng thường không phải query tốt nhất cho vector search. Query rewriting cải thiện recall.
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
rewrite_prompt = ChatPromptTemplate.from_template("""
Bạn là chuyên gia tìm kiếm thông tin pháp lý.
Hãy viết lại câu hỏi sau thành query tìm kiếm tối ưu hơn,
sử dụng thuật ngữ pháp lý chính xác.
Câu hỏi gốc: {question}
Query tối ưu:""")
rewriter = rewrite_prompt | llm
rewritten = rewriter.invoke({"question": "Cần những gì để mở công ty?"})
# → "Điều kiện và thủ tục thành lập doanh nghiệp theo Luật Doanh nghiệp 2020"
Hybrid Search¶
Kết hợp dense retrieval (semantic) + sparse retrieval (BM25/keyword):
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
# Dense retriever (semantic)
dense_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
# Sparse retriever (keyword/BM25)
bm25_retriever = BM25Retriever.from_documents(chunks, k=5)
# Ensemble: kết hợp cả hai
hybrid_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, dense_retriever],
weights=[0.4, 0.6], # BM25 40%, semantic 60%
)
results = hybrid_retriever.invoke("thành lập công ty TNHH")
Khi nào dùng Hybrid Search:
- Documents chứa nhiều proper nouns, số liệu, mã điều luật
- Queries ngắn, keyword-based
- Cần cả precision (keyword) và recall (semantic)
Re-ranking¶
Sau retrieval, re-ranker đánh giá lại relevance để cải thiện precision:
from sentence_transformers import CrossEncoder
# Cross-encoder: đánh giá query-document pair trực tiếp
# (chậm hơn bi-encoder nhưng chính xác hơn)
cross_encoder = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def rerank(query, docs, top_k=3):
pairs = [(query, doc.page_content) for doc in docs]
scores = cross_encoder.predict(pairs)
ranked = sorted(
zip(docs, scores),
key=lambda x: x[1],
reverse=True
)
return [doc for doc, score in ranked[:top_k]]
# Pipeline
initial_results = retriever.invoke(query) # Lấy top-10
reranked = rerank(query, initial_results, top_k=3) # Lọc còn top-3
4. Generation¶
Grounding - Bám sát tài liệu¶
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
rag_prompt = ChatPromptTemplate.from_template("""
Bạn là trợ lý pháp lý chuyên về luật doanh nghiệp Việt Nam.
Chỉ trả lời dựa trên TÀI LIỆU được cung cấp bên dưới.
Nếu thông tin không có trong tài liệu, hãy nói rõ "Thông tin này không có trong tài liệu".
TÀI LIỆU:
{context}
CÂU HỎI: {question}
Trả lời bằng tiếng Việt, trích dẫn điều khoản cụ thể khi có thể:""")
def format_context(docs):
return "\n\n---\n\n".join([
f"[Nguồn: {doc.metadata.get('source', 'unknown')}, "
f"Trang {doc.metadata.get('page', '?')}]\n{doc.page_content}"
for doc in docs
])
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.1)
chain = rag_prompt | llm
Citations¶
from pydantic import BaseModel
class AnswerWithCitations(BaseModel):
answer: str
citations: list[dict] # [{"text": "...", "source": "...", "page": 5}]
confidence: str # "high" | "medium" | "low"
citation_prompt = ChatPromptTemplate.from_template("""
Trả lời câu hỏi dựa trên tài liệu.
Với mỗi phần thông tin quan trọng, trích dẫn nguồn cụ thể.
Tài liệu:
{context}
Câu hỏi: {question}
Trả về JSON với format:
{{
"answer": "câu trả lời đầy đủ",
"citations": [
{{"text": "đoạn trích dẫn", "source": "tên file", "page": số_trang}}
],
"confidence": "high/medium/low"
}}""")
structured_llm = llm.with_structured_output(AnswerWithCitations)
5. Full RAG Pipeline - Chat với Vietnamese PDFs¶
import os
from langchain_community.document_loaders import PyPDFDirectoryLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
# ====== INGESTION ======
loader = PyPDFDirectoryLoader("./legal_pdfs/")
docs = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=64)
chunks = splitter.split_documents(docs)
embedding = HuggingFaceEmbeddings(
model_name="intfloat/multilingual-e5-large",
encode_kwargs={"normalize_embeddings": True},
)
vectorstore = Chroma.from_documents(chunks, embedding, persist_directory="./db")
retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
# ====== RETRIEVAL & GENERATION ======
prompt = ChatPromptTemplate.from_template("""
Trả lời câu hỏi dựa trên tài liệu pháp lý sau.
Nếu không có thông tin, nói rõ "Không tìm thấy thông tin".
Tài liệu: {context}
Câu hỏi: {question}""")
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
rag_chain = (
{"context": retriever | (lambda docs: "\n\n".join(d.page_content for d in docs)),
"question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
# ====== USAGE ======
answer = rag_chain.invoke("Điều kiện thành lập công ty TNHH theo luật 2020?")
print(answer)
Tóm tắt: Các quyết định quan trọng¶
| Bước | Lựa chọn | Khuyến nghị |
|---|---|---|
| Loader | PyPDF, Unstructured, LlamaParse | PyPDF cho PDF đơn giản; LlamaParse cho PDF phức tạp |
| Chunk size | 256–1024 chars | 512 chars + 64 overlap là điểm khởi đầu tốt |
| Chunking strategy | Fixed / Semantic / Structure-aware | Fixed cho bắt đầu; Semantic khi quality không đủ |
| Embedding | OpenAI / BGE-M3 / multilingual-e5 | BGE-M3 cho tiếng Việt self-hosted |
| Retrieval | Semantic / Hybrid | Hybrid khi có proper nouns / mã số |
| Re-ranking | Cross-encoder | Thêm vào khi precision không đủ |
| top_k | 3–10 | Bắt đầu với 5; tăng nếu context cần nhiều hơn |
Debugging RAG
Khi RAG không hoạt động tốt, debug theo thứ tự:
- Retrieval - retrieved chunks có relevant không? (
retriever.invoke(query)) - Chunking - chunk có đủ context không? Thử tăng
chunk_size - Embedding model - thử model khác, check similarity scores
- Prompt - model có bị "lạc lối" không? Thêm constraint rõ hơn