Vấn đề mình gặp phải: LLM không biết gì về dữ liệu nội bộ
Khoảng 8 tháng trước, mình được giao task xây dựng chatbot hỗ trợ nhân viên tra cứu tài liệu nội bộ — hơn 400 file PDF hướng dẫn quy trình, policy công ty, tài liệu kỹ thuật. Ban đầu mình nghĩ đơn giản: dùng ChatGPT API, nhét câu hỏi vào, lấy câu trả lời ra. Thực tế không đơn giản vậy.
Vấn đề đầu tiên xuất hiện ngay tuần đầu: model trả lời sai hoàn toàn vì nó không biết gì về quy trình nội bộ của công ty. Hỏi “Quy trình xin nghỉ phép là gì?” — nó bịa ra một quy trình nghe có vẻ hợp lý nhưng sai hết. Hallucination kinh điển.
Mình thử nhét toàn bộ nội dung tài liệu vào prompt — ngay lập tức đụng giới hạn context window. 300 trang PDF không thể nhét hết vào 128k token được, chưa kể chi phí API tính theo token sẽ bùng nổ.
Nguyên nhân gốc rễ: LLM là “thư viện đóng”
LLM được training trên dữ liệu công khai đến một thời điểm nhất định (training cutoff). Sau đó model bị “đóng băng” — nó không tự học thêm, không có cách nào biết về tài liệu nội bộ của công ty bạn, database riêng, hay bất kỳ thông tin private nào.
Khi bạn hỏi về thứ model không biết, nó có hai lựa chọn: nói “tôi không biết” (ít phổ biến) hoặc tự suy diễn và bịa ra câu trả lời nghe có lý (hallucination — cực kỳ phổ biến). Cả hai đều khiến chatbot trở nên không đáng tin. Với tài liệu quy trình hay nội quy công ty, nhân viên làm theo thông tin sai còn tệ hơn không có chatbot.
Ba hướng giải quyết — và tại sao 2 cái đầu không dùng được
Hướng 1: Fine-tuning model
Fine-tuning là train lại model với dữ liệu của bạn. Về lý thuyết nghe hay, thực tế có vấn đề:
- Chi phí compute rất cao, cần GPU mạnh
- Tốn thời gian: pipeline chuẩn bị data → train → evaluate → deploy
- Khi tài liệu cập nhật (policy thay đổi, version mới), phải fine-tune lại từ đầu
- Không giải quyết được vấn đề hallucination — model vẫn có thể “nhớ sai”
Fine-tuning phù hợp khi muốn thay đổi cách model trả lời — giọng điệu, format, ngôn ngữ. Còn muốn đưa kiến thức từ tài liệu nội bộ vào? Sai công cụ rồi.
Hướng 2: Nhét toàn bộ context vào prompt
Cách trực tiếp nhất: nhét hết tài liệu vào system prompt. Với kho tài liệu nhỏ (dưới 50 trang) thì tạm dùng được, nhưng:
- Context window có giới hạn (dù GPT-4 có 128k, Claude có 200k thì vẫn không đủ cho kho tài liệu lớn)
- Chi phí token tăng tuyến tính với mỗi request
- Model xử lý kém hơn khi context quá dài (“lost in the middle” problem)
Hướng 3: RAG — cái duy nhất hoạt động tốt
RAG (Retrieval-Augmented Generation) tiếp cận theo hướng khác hẳn: thay vì nhét tất cả vào prompt, chỉ tìm và nhét phần tài liệu liên quan nhất đến câu hỏi. Nghe có vẻ đơn giản — và đúng là vậy. Nhưng đây chính là thứ giải quyết được cả ba vấn đề nêu trên.
Flow cơ bản:
- Chuyển toàn bộ tài liệu thành vector embeddings, lưu vào vector database
- Khi user đặt câu hỏi, chuyển câu hỏi thành vector
- Tìm những đoạn tài liệu có vector gần nhất (semantic similarity)
- Nhét những đoạn đó vào prompt cùng câu hỏi, gửi cho LLM
- LLM trả lời dựa trên ngữ cảnh vừa cung cấp
Xây dựng RAG với LangChain — code thực tế mình đang dùng
Cài đặt dependencies
pip install langchain langchain-community langchain-openai
pip install chromadb # vector database
pip install pypdf # đọc PDF
pip install python-dotenv
Mình dùng ChromaDB vì chạy local không cần server, phù hợp prototype. Khi lên production scale lớn hơn có thể chuyển sang Qdrant hoặc Pinecone.
Bước 1: Load và chunk tài liệu
from langchain_community.document_loaders import PyPDFDirectoryLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
# Load toàn bộ PDF trong thư mục
loader = PyPDFDirectoryLoader("./docs/")
documents = loader.load()
# Chia nhỏ thành chunks — đây là bước quan trọng nhất
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # ~1000 ký tự mỗi chunk
chunk_overlap=200, # overlap để không mất ngữ cảnh giữa chunks
separators=["\n\n", "\n", ".", " "]
)
chunks = splitter.split_documents(documents)
print(f"Tổng số chunks: {len(chunks)}")
Chunk size là thứ mình mất nhiều thời gian tune nhất. Quá nhỏ (200-300 ký tự) thì mất ngữ cảnh, quá lớn (2000+) thì mang vào prompt nhiều noise. 800-1200 ký tự là sweet spot cho tài liệu kỹ thuật.
Bước 2: Tạo embeddings và lưu vào vector store
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
import os
from dotenv import load_dotenv
load_dotenv()
# Tạo embeddings
embeddings = OpenAIEmbeddings(
model="text-embedding-3-small", # rẻ hơn large, đủ tốt cho tiếng Anh/Việt
openai_api_key=os.getenv("OPENAI_API_KEY")
)
# Lưu vào ChromaDB (persist_directory để không phải embed lại mỗi lần)
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db"
)
print("Vector store đã được tạo và lưu.")
Bước 3: Xây dựng retrieval chain
from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
# Load vector store đã có (không cần embed lại)
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings
)
# Retriever — lấy top 4 chunks liên quan nhất
retriever = vectorstore.as_retriever(
search_type="mmr", # Maximum Marginal Relevance — giảm redundancy
search_kwargs={"k": 4, "fetch_k": 10}
)
# Custom prompt — quan trọng để model không hallucinate
prompt_template = """Dựa vào các tài liệu được cung cấp dưới đây, hãy trả lời câu hỏi.
Nếu thông tin không có trong tài liệu, hãy nói rõ "Tôi không tìm thấy thông tin này trong tài liệu."
Tài liệu tham khảo:
{context}
Câu hỏi: {question}
Trả lời:"""
PROMPT = PromptTemplate(
template=prompt_template,
input_variables=["context", "question"]
)
# Build chain
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
chain_type_kwargs={"prompt": PROMPT},
return_source_documents=True # để debug và cite nguồn
)
# Test
result = qa_chain.invoke({"query": "Quy trình xin nghỉ phép là gì?"})
print(result["result"])
print("\nNguồn:", [doc.metadata for doc in result["source_documents"]])
Bước 4: Script hoàn chỉnh — index và query tách riêng
# index.py — chạy 1 lần khi có tài liệu mới
python index.py --docs-dir ./docs/
# query.py — chạy hàng ngày
python query.py --question "Quy trình onboarding nhân viên mới"
Tách index và query là điều mình học được sau khi nhận complain từ team: mỗi lần restart app lại phải chờ 5 phút để embed lại toàn bộ tài liệu. Persist vectorstore ra disk, chỉ re-index khi tài liệu thay đổi.
Những điểm mình vấp ngã — để bạn không phải vấp lại
1. Chunk overlap bị bỏ qua
Nhiều tutorial đặt chunk_overlap=0 cho gọn. Thực tế, câu trả lời quan trọng thường nằm ngay ranh giới giữa hai chunks. Overlap 15-20% chunk_size là con số mình đang dùng.
2. Không validate retrieved documents
Retriever không tự biết khi nào nó đang trả về kết quả không liên quan — nó cứ lấy top-k dù score thấp đến đâu. Nên set threshold để lọc trước:
retriever = vectorstore.as_retriever(
search_type="similarity_score_threshold",
search_kwargs={"score_threshold": 0.7, "k": 4}
)
3. Metadata thiếu
Khi user hỏi “tài liệu nào nói về X”, bạn cần biết chunk đó từ file nào, trang mấy. LangChain PDF loader tự động add source và page vào metadata — đừng xóa chúng.
4. Tiếng Việt và embedding
Về tiếng Việt: text-embedding-3-small của OpenAI xử lý khá ổn — khoảng $0.02/1M tokens, gần như không đáng kể với quy mô doanh nghiệp nhỏ. Budget thực sự eo hẹp thì thử paraphrase-multilingual-mpnet-base-v2 từ HuggingFace — chạy local, miễn phí, chất lượng chấp nhận được.
Kết quả sau 6 tháng production
Hệ thống RAG mình xây đang xử lý khoảng 200-300 câu hỏi/ngày từ nhân viên. Accuracy (câu trả lời đúng, có nguồn dẫn từ tài liệu thật) đạt khoảng 85-90% — so với 0% khi dùng LLM thuần túy không có context.
Chi phí API cũng giảm rõ rệt. Nếu nhét hết 400 file vào prompt thì mỗi query có thể tốn 50k-100k tokens. Giờ trung bình chỉ khoảng 1500-2000 tokens — tức là 30-50 lần rẻ hơn.
Bắt đầu nhỏ thôi: ChromaDB local, 50-100 trang tài liệu mẫu, test với 20-30 câu hỏi thật của team bạn. Một tuần là đủ để biết RAG có phù hợp với bài toán cụ thể của mình hay không — nhanh và rẻ hơn nhiều so với đợi fine-tuning pipeline chạy xong.

