Danh sách bài viết

Bài 13: Project 3 — RAG Chatbot trên tài liệu nội bộ

Xây dựng RAG chatbot Q&A trên collection PDF nội bộ: ingest pipeline, chunking, embedding, retrieval, generation, evaluation với Ragas, và deploy với Docker + FastAPI + Streamlit.

27/05/2026
0 lượt xem
1

Mục tiêu và tech stack

Domain: Q&A trên 50–500 trang PDF nội bộ — chính sách công ty, tài liệu kỹ thuật, quy định pháp lý, manual sản phẩm.

Goal: User nhập câu hỏi → chatbot trả lời bằng tiếng Việt hoặc tiếng Anh + trích dẫn source (tên file, số trang).

Timeline: 3–4 tuần nếu làm part-time (~2 giờ/ngày).

Tech stack

Layer Tool Version
Orchestration LangChain 0.3.x
LLM OpenAI / Anthropic / HuggingFace gpt-4o-mini / claude-3-haiku
Embedding OpenAI text-embedding-3-small
Vector DB ChromaDB 0.5.x
Backend API FastAPI 0.115+
Frontend Streamlit 1.35+
Evaluation Ragas 0.1.x
Container Docker + Compose

Sau bài này bạn có thể:

  • Build full RAG pipeline từ raw PDF đến production API
  • Đo lường chất lượng retrieval và generation bằng số (Ragas metrics)
  • Containerize ứng dụng và deploy lên cloud miễn phí
  • Trình bày project trong phỏng vấn với số liệu cụ thể
2

Vì sao project này phù hợp portfolio

RAG là pattern được hỏi thường xuyên nhất trong phỏng vấn AI Engineer 2024–2025 vì nó giải quyết bài toán thực tế: LLM không biết dữ liệu nội bộ của công ty, nhưng dữ liệu đó có thể nhạy cảm và không thể fine-tune.

Project này show được đủ các layer kỹ năng:

  • Data engineering: load PDF, clean, chunk, lưu trữ
  • ML/NLP: embedding, vector search, semantic retrieval
  • LLM integration: prompt engineering, output parsing, streaming
  • Backend: FastAPI với async, Pydantic schema, lifespan management
  • Frontend: Streamlit với chat UI, source citation
  • Evaluation: Ragas metrics — faithfulness, answer relevancy, context precision
  • DevOps: Docker Compose, env management, deploy to cloud

Use case cũng không trừu tượng: customer support bot, internal knowledge base, legal document Q&A, medical guideline assistant — recruiter hiểu ngay giá trị business.

3

Chọn data source

Chọn data có thể share công khai trên GitHub — recruiter cần test được demo. Ba hướng:

Option A — Tài liệu public có sẵn

Dễ reproduce, recruiter có thể verify:

  • Vietnamese Labor Code (Bộ luật Lao động 2019) — PDF từ website Chính phủ
  • GDPR full text (EUR-Lex PDF, ~150 trang)
  • Constitution of Vietnam 2013
  • Một cuốn sách kỹ thuật open license (vd PostgreSQL documentation)

Option B — Tài liệu tự tổng hợp

Phù hợp nếu bạn có domain expertise:

  • Crawl 20–50 bài blog kỹ thuật (Medium, Dev.to, Towards Data Science) về 1 chủ đề
  • Convert HTML sang text, lưu dưới dạng PDF hoặc Markdown

Option C — Domain crossover (leverage background cũ)

Mạnh nhất khi trình bày — bạn hiểu domain nên golden set Q&A sẽ chất lượng hơn:

  • Bác sĩ → medical guidelines (WHO PDF, clinical protocols)
  • Luật sư / sinh viên luật → văn bản pháp luật, contract templates
  • HR → company policy templates, labor law
  • Finance → báo cáo tài chính hàng năm của 1 công ty niêm yết

Lưu ý: Không dùng tài liệu có bản quyền chặt mà bạn không được phép phân phối lại. Khi deploy public, chỉ nhúng embedding chứ không expose nội dung gốc.

4

Cấu trúc thư mục

rag-chatbot/
├── app/
│   ├── __init__.py
│   ├── main.py          # FastAPI app
│   └── rag_chain.py     # RAG chain builder
├── scripts/
│   ├── ingest.py        # Ingest pipeline
│   └── evaluate.py      # Ragas evaluation
├── frontend.py          # Streamlit UI
├── data/
│   ├── raw/             # PDFs gốc (commit vào git nếu nhỏ, dùng Git LFS nếu lớn)
│   └── processed/       # ChromaDB persist directory (gitignore)
├── eval/
│   └── golden_set.json  # 30-50 Q&A ground truth
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── .env.example
└── README.md

File .gitignore tối thiểu:

.env
data/processed/
__pycache__/
*.pyc

data/raw/ chứa PDF source. Nếu file nặng hơn 50 MB thì dùng Git LFS hoặc upload lên Hugging Face Datasets và ghi link trong README.

5

Bước 1 — Ingest pipeline

Ingest pipeline làm 3 việc: load PDF → chunk → embed và lưu vào vector store. Chạy offline một lần, kết quả persist trên disk.

# scripts/ingest.py
from pathlib import Path
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

def load_pdfs(pdf_dir: str) -> list:
    docs = []
    for pdf_path in Path(pdf_dir).glob("*.pdf"):
        loader = PyPDFLoader(str(pdf_path))
        docs.extend(loader.load())
    return docs

def chunk_documents(docs: list, chunk_size=500, chunk_overlap=50):
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        # Thử chia theo đoạn văn trước, rồi câu, rồi word
        separators=["\n\n", "\n", ". ", " "],
    )
    return splitter.split_documents(docs)

def main():
    docs = load_pdfs("data/raw")
    print(f"Loaded {len(docs)} pages")

    chunks = chunk_documents(docs)
    print(f"Created {len(chunks)} chunks")

    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        collection_name="internal_docs",
        persist_directory="data/processed/chroma_db",
    )
    print("Indexed!")

if __name__ == "__main__":
    main()

Về chunk size

Không có giá trị "đúng" cho mọi document. Thử nghiệm với 3 giá trị và đo Ragas sau:

  • chunk_size=200: chi tiết, nhưng mỗi chunk thiếu context xung quanh — recall giảm với câu hỏi cần đọc nhiều dòng
  • chunk_size=500: điểm cân bằng thường dùng cho văn bản tiếng Anh dạng prose
  • chunk_size=1000: nhiều context hơn, nhưng precision giảm vì LLM nhận chunk chứa nhiều thông tin không liên quan

Với văn bản tiếng Việt, các từ ngắn hơn nên chunk_size tương đương tiếng Anh sẽ chứa nhiều từ hơn — thường cần giảm nhẹ xuống 300–400 token.

Metadata quan trọng

PyPDFLoader tự động thêm source (đường dẫn file) và page (số trang 0-index) vào mỗi document. Những metadata này sẽ được dùng cho source citation ở bước sau — không xóa đi.

# Kiểm tra metadata sau khi load
for doc in docs[:3]:
    print(doc.metadata)
# {'source': 'data/raw/labor-code.pdf', 'page': 0}
# {'source': 'data/raw/labor-code.pdf', 'page': 1}
# ...
6

Bước 2 — RAG chain với LCEL

LCEL (LangChain Expression Language) dùng pipe operator | để compose các bước thành chain. Code này build chain và trả về cả retriever để dùng riêng khi cần lấy sources.

# app/rag_chain.py
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from operator import itemgetter

def build_chain():
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    vectorstore = Chroma(
        collection_name="internal_docs",
        embedding_function=embeddings,
        persist_directory="data/processed/chroma_db",
    )
    retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

    prompt = ChatPromptTemplate.from_messages([
        ("system", """Bạn là trợ lý trả lời câu hỏi dựa trên tài liệu được cung cấp.
- Chỉ dùng thông tin có trong context.
- Nếu không có thông tin → nói rõ "Không tìm thấy trong tài liệu".
- Trích dẫn source (tên file, số trang) khi trả lời."""),
        ("user", "Context:\n{context}\n\nQuestion: {question}\n\nAnswer:"),
    ])

    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

    def format_docs(docs):
        return "\n\n".join(
            f"[Source: {d.metadata.get('source', '?')}, "
            f"Page {d.metadata.get('page', '?')}]\n{d.page_content}"
            for d in docs
        )

    chain = (
        {
            "context": itemgetter("question") | retriever | format_docs,
            "question": itemgetter("question"),
        }
        | prompt
        | llm
        | StrOutputParser()
    )
    return chain, retriever

Tại sao temperature=0

RAG system dùng temperature=0 để output deterministic — cùng câu hỏi cho cùng kết quả. Điều này quan trọng khi evaluate và debug. Nếu muốn response tự nhiên hơn có thể tăng lên 0.1–0.3, nhưng faithfulness score thường giảm.

k=4 retrieval

Lấy 4 chunk gần nhất. Tăng k → nhiều context hơn nhưng LLM phải xử lý nhiều token hơn và có thể bị "distracted" bởi thông tin ít liên quan. Giảm k → nhanh hơn nhưng có thể bỏ sót thông tin. Thử 3, 4, 5 rồi đo Context Precision với Ragas.

7

Bước 3 — FastAPI backend

FastAPI backend expose 2 endpoint: /ask trả về JSON đầy đủ (answer + sources), /ask-stream stream token để frontend hiển thị real-time.

# app/main.py
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.rag_chain import build_chain
from contextlib import asynccontextmanager

chain_state = {}

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Load chain một lần khi khởi động server
    chain, retriever = build_chain()
    chain_state["chain"] = chain
    chain_state["retriever"] = retriever
    yield
    chain_state.clear()

app = FastAPI(lifespan=lifespan)

class QueryRequest(BaseModel):
    question: str

@app.post("/ask")
async def ask(req: QueryRequest):
    chain = chain_state["chain"]
    retriever = chain_state["retriever"]

    docs = retriever.invoke(req.question)
    answer = await chain.ainvoke({"question": req.question})

    return {
        "answer": answer,
        "sources": [
            {"text": d.page_content[:200], "metadata": d.metadata}
            for d in docs
        ],
    }

@app.post("/ask-stream")
async def ask_stream(req: QueryRequest):
    chain = chain_state["chain"]

    async def event_generator():
        async for chunk in chain.astream({"question": req.question}):
            yield chunk

    return StreamingResponse(event_generator(), media_type="text/plain")

Lưu ý về lifespan

lifespan là cách FastAPI 0.93+ khuyến nghị để chạy startup/shutdown logic — thay thế @app.on_event("startup") đã deprecated. Chain được load vào memory một lần, dùng lại cho mọi request, tránh re-load vector store mỗi lần gọi.

Kiểm tra API

uvicorn app.main:app --reload --port 8000

# Test với curl
curl -X POST http://localhost:8000/ask \
  -H "Content-Type: application/json" \
  -d '{"question": "What is the overtime compensation rate?"}'

FastAPI tự generate Swagger UI tại http://localhost:8000/docs — tiện để test manually mà không cần curl.

8

Bước 4 — Streamlit frontend

Streamlit có st.chat_messagest.chat_input built-in từ version 1.23 — không cần custom CSS cho chat UI cơ bản.

# frontend.py
import streamlit as st
import requests

API_URL = "http://localhost:8000"

st.title("RAG Chatbot — Tài liệu nội bộ")

if "messages" not in st.session_state:
    st.session_state.messages = []

# Render lịch sử chat
for msg in st.session_state.messages:
    with st.chat_message(msg["role"]):
        st.write(msg["content"])
        if msg.get("sources"):
            with st.expander("Sources"):
                for src in msg["sources"]:
                    st.caption(
                        f"{src['metadata'].get('source', '')} "
                        f"— page {src['metadata'].get('page', '?')}"
                    )
                    st.text(src["text"])

# Chat input
if prompt := st.chat_input("Nhập câu hỏi..."):
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.write(prompt)

    with st.chat_message("assistant"):
        with st.spinner("Đang tìm kiếm..."):
            response = requests.post(
                f"{API_URL}/ask",
                json={"question": prompt},
                timeout=30,
            ).json()

        st.write(response["answer"])
        if response.get("sources"):
            with st.expander("Sources"):
                for src in response["sources"]:
                    st.caption(
                        f"{src['metadata'].get('source', '')} "
                        f"— page {src['metadata'].get('page', '?')}"
                    )
                    st.text(src["text"])

        st.session_state.messages.append({
            "role": "assistant",
            "content": response["answer"],
            "sources": response.get("sources", []),
        })

Chạy frontend:

streamlit run frontend.py --server.port 8501
9

Bước 5 — Evaluation với Ragas

Đây là phần phân biệt portfolio chuyên nghiệp với "toy project". Không có số đo → recruiter không có cơ sở trust kết quả.

Ba metric cốt lõi của Ragas

  • Faithfulness: Câu trả lời có chỉ dùng thông tin trong context không? (chống hallucination)
  • Answer Relevancy: Câu trả lời có thực sự trả lời câu hỏi không?
  • Context Precision: Các chunk được retrieve có liên quan đến câu hỏi không?

Tạo golden set

Tạo thủ công 30–50 cặp câu hỏi–đáp án từ tài liệu. Đây là công việc cần hiểu domain — quality của golden set quyết định quality của evaluation.

// eval/golden_set.json
[
  {
    "question": "Mức phụ cấp làm thêm giờ ngày thường theo Bộ luật lao động là bao nhiêu?",
    "ground_truth": "Ít nhất 150% mức lương theo hợp đồng.",
    "answer": "",
    "contexts": []
  }
]

Điền answercontexts bằng cách chạy RAG chain qua từng câu hỏi:

# scripts/build_eval_dataset.py
import json
from app.rag_chain import build_chain

chain, retriever = build_chain()

with open("eval/golden_set.json") as f:
    items = json.load(f)

for item in items:
    docs = retriever.invoke(item["question"])
    answer = chain.invoke({"question": item["question"]})
    item["answer"] = answer
    item["contexts"] = [d.page_content for d in docs]

with open("eval/eval_dataset.json", "w") as f:
    json.dump(items, f, ensure_ascii=False, indent=2)

Chạy Ragas

# scripts/evaluate.py
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision
from datasets import Dataset
import json

with open("eval/eval_dataset.json") as f:
    items = json.load(f)

ds = Dataset.from_list(items)

result = evaluate(
    ds,
    metrics=[faithfulness, answer_relevancy, context_precision],
)
print(result)
# Faithfulness:      0.87
# Answer Relevancy:  0.91
# Context Precision: 0.83

Ghi kết quả vào README. Nếu score thấp hơn target:

  • Faithfulness thấp → prompt system quá lỏng, thêm "chỉ dùng thông tin trong context, không bịa"
  • Context Precision thấp → chunk_size quá lớn hoặc k quá cao, thử giảm cả hai
  • Answer Relevancy thấp → prompt không đủ hướng dẫn format câu trả lời
10

Bước 6 — Dockerize

Dockerfile cho FastAPI backend:

FROM python:3.11-slim
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ ./app/
# processed ChromaDB được copy vào image tại build time
# hoặc mount qua volume tại runtime
COPY data/processed/ ./data/processed/

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

docker-compose.yml:

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}

  frontend:
    build:
      context: .
      dockerfile: Dockerfile.frontend
    ports:
      - "8501:8501"
    environment:
      - API_URL=http://api:8000
    depends_on:
      - api

Dockerfile.frontend:

FROM python:3.11-slim
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir streamlit requests

COPY frontend.py .

EXPOSE 8501
CMD ["streamlit", "run", "frontend.py", "--server.port=8501", "--server.address=0.0.0.0"]

Chạy toàn bộ stack:

cp .env.example .env
# Điền OPENAI_API_KEY vào .env

docker compose up --build
# API: http://localhost:8000
# Frontend: http://localhost:8501

Về ChromaDB persist

Có 2 cách quản lý ChromaDB trong container:

  • Bake vào image: đơn giản, không cần mount, nhưng image nặng hơn. Phù hợp khi corpus ổn định.
  • Volume mount: - ./data/processed:/app/data/processed trong docker-compose. Phù hợp khi corpus cần update thường xuyên.
11

Bước 7 — Deploy

Target: có URL public cho cả frontend và backend. Recruiter cần vào được demo mà không cần clone repo hay chạy local.

Phương án miễn phí

Component Platform Giới hạn free tier
FastAPI backend Render Web Service Sleep sau 15 phút idle, 512 MB RAM
FastAPI backend (alt) Hugging Face Spaces (Docker) 2 vCPU, 16 GB RAM trên T4 nếu dùng GPU Space
Streamlit frontend Streamlit Community Cloud Public repo, không giới hạn thời gian
Vector DB Qdrant Cloud 1 cluster, 1 GB storage miễn phí

Cấu hình env trên Render

Trong Render dashboard → Environment → thêm:

OPENAI_API_KEY=sk-...

Không commit .env vào git. File .env.example chỉ chứa key names, không có values:

OPENAI_API_KEY=
ANTHROPIC_API_KEY=

Lưu ý về cold start

Render free tier sleep container sau 15 phút không có request. Lần đầu gọi API sau khi sleep sẽ mất 20–30 giây để wake up. Ghi chú này vào README để recruiter không nhầm là bug.

12

Bước 8 — README

README là trang đầu tiên recruiter đọc. Cấu trúc tối thiểu:

# RAG Chatbot — [Domain name]

**Demo:** [URL Streamlit]

## Architecture
[Sơ đồ: PDF → ingest → ChromaDB → FastAPI ← Streamlit]
Tự vẽ với draw.io hoặc Excalidraw, export PNG, nhúng vào README.

## Dataset
- 5 PDF, tổng 312 trang
- 2,847 chunks (chunk_size=500, overlap=50)
- Embedding dimension: 1536 (text-embedding-3-small)

## Evaluation (Ragas)
| Metric | Score |
|---|---|
| Faithfulness | 0.87 |
| Answer Relevancy | 0.91 |
| Context Precision | 0.83 |
Evaluated on 40 Q&A pairs (eval/golden_set.json)

## Tech stack
LangChain 0.3 · ChromaDB 0.5 · FastAPI 0.115 · Streamlit 1.35
OpenAI gpt-4o-mini · text-embedding-3-small · Docker Compose

## Quick start
```bash
cp .env.example .env  # điền OPENAI_API_KEY
docker compose up --build
# Frontend: http://localhost:8501
```

## Cost estimate
- Ingest 312 trang: ~$0.02 (text-embedding-3-small)
- 1000 câu hỏi: ~$0.50 (gpt-4o-mini, avg 500 input + 200 output token/query)

Architecture diagram không cần đẹp — chỉ cần đủ rõ luồng dữ liệu. Một PNG 800px là đủ.

13

Nâng cấp thêm

Hoàn thành version cơ bản trước. Sau đó chọn 1–2 trong các phần dưới nếu còn thời gian:

Hybrid search (BM25 + vector)

Kết hợp TF-IDF keyword search với semantic search. Hữu ích cho tài liệu có nhiều từ kỹ thuật, mã điều khoản, số liệu cụ thể mà vector search dễ bỏ sót.

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 4

ensemble = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.4, 0.6],
)

Reranking với Cohere Rerank

Sau khi retrieve 10 chunks, dùng cross-encoder để rerank và lấy top 4. Thường tăng Context Precision 5–10 điểm.

from langchain.retrievers.contextual_compression import ContextualCompressionRetriever
from langchain_cohere import CohereRerank

reranker = CohereRerank(model="rerank-multilingual-v3.0", top_n=4)
compression_retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=vectorstore.as_retriever(search_kwargs={"k": 10}),
)

Conversation memory (multi-turn)

Thêm chat history để user có thể hỏi follow-up mà không cần lặp lại context:

from langchain.chains import create_history_aware_retriever
from langchain_core.prompts import MessagesPlaceholder

contextualize_prompt = ChatPromptTemplate.from_messages([
    ("system", "Dựa trên lịch sử hội thoại và câu hỏi mới nhất, "
               "tái cấu trúc câu hỏi thành standalone question."),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
])

history_aware_retriever = create_history_aware_retriever(
    llm, retriever, contextualize_prompt
)
14

Result targets

Checklist để coi project là "complete" trước khi link vào CV:

  • Faithfulness ≥ 0.85 trên golden set
  • Answer Relevancy ≥ 0.88 trên golden set
  • Context Precision ≥ 0.80 trên golden set
  • Golden set ≥ 30 cặp Q&A, commit vào eval/golden_set.json
  • Demo URL public, accessible không cần login
  • README có architecture diagram, dataset stats, eval table, quick start, cost estimate
  • docker compose up --build chạy thành công từ cold start trên máy mới

Nếu score Ragas thấp hơn target sau 2–3 lần tune, ghi rõ điều đó trong README kèm lý do và giải pháp đề xuất. Recruiter đánh giá cao việc candidate hiểu limitation của system hơn là claim số đẹp không có cơ sở.

15

Common pitfalls

Embedding model mismatch

Query được embed bằng model A, documents được embed bằng model B — cosine similarity sẽ cho kết quả vô nghĩa. Embed query và documents bằng cùng một model, cùng version. Khi migrate model, phải re-index toàn bộ corpus.

Chunk size không phù hợp với tài liệu

Văn bản pháp lý có điều khoản dài nhiều đoạn: chunk nhỏ (200 token) sẽ cắt giữa chừng. Văn bản kỹ thuật có bảng biểu: PDF loader có thể extract bảng thành text không có cấu trúc — cần test thủ công output của loader trước khi chunk.

Source citation thiếu

Nếu chatbot trả lời không kèm source, recruiter không thể verify — và trong production, user cũng không thể verify. Luôn hiển thị ít nhất tên file và số trang. Nếu LLM không tự cite, ép buộc qua format_docs function (đã có trong code trên) và nhắc lại trong system prompt.

Không có evaluation

Claim "chatbot cho kết quả tốt" mà không có số đo là không đủ. Ragas chạy mất khoảng 15–30 phút cho 40 câu hỏi (tùy LLM). Chi phí evaluation với gpt-4o-mini thường dưới $1. Không có lý do để bỏ qua.

Demo chỉ chạy local

Nếu recruiter không test được online, trust giảm đáng kể. Render free tier đủ để serve traffic thấp từ recruiter review — setup deploy ngay khi code chạy local, đừng để sau.

System prompt quá lỏng

LLM mặc định muốn helpful → sẽ "bổ sung" thông tin ngoài context khi context không đủ. System prompt phải rõ ràng: "Chỉ dùng thông tin trong context. Nếu không có → nói không có." Sau đó measure Faithfulness để xác nhận.