Bỏ qua điều hướng
CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ HQG
Giai đoạn 7 · RAGTrung cấp

RAG trên tài liệu nội bộ tiếng Việt: trích xuất, chunking, embedding, vector DB, rerank và phân quyền

Dựng pipeline hỏi đáp trên tài liệu nội bộ: trích xuất PDF/Word tiếng Việt (kể cả bản scan), chia đoạn, embedding đa ngôn ngữ, lưu pgvector hoặc Qdrant, rerank, đánh giá chất lượng và lọc theo quyền truy cập.

Khoảng 8 phút đọcCập nhật: 09/20267 bước
Mục tiêu

Nhân viên hỏi bằng tiếng Việt, hệ thống trả lời dựa trên đúng tài liệu họ được phép xem, kèm trích dẫn nguồn để kiểm chứng.

Dành cho ai
  • Kỹ sư AI/phần mềm xây chatbot tài liệu
  • Chủ doanh nghiệp muốn tận dụng kho quy trình, hợp đồng mẫu, tài liệu kỹ thuật
Yêu cầu
  • Máy phục vụ LLM theo bài triển khai suy luận
  • GPU (có thể dùng chung) cho embedding và rerank; CPU chạy được nhưng chậm khi nạp kho lớn
  • Ổ lưu trữ cho tài liệu gốc, văn bản trích xuất và cơ sở dữ liệu vector
  • Môi trường Python theo bài Python & PyTorch
  • Docker để chạy PostgreSQL/pgvector hoặc Qdrant
Mục lục bài

Pipeline RAG gồm những gì

  1. Nạp: trích xuất văn bản sạch từ tài liệu, gắn metadata (nguồn, phòng ban, quyền xem, ngày hiệu lực).
  2. Chia đoạn (chunking) theo cấu trúc tài liệu.
  3. Embedding: biến mỗi đoạn thành vector, lưu vào vector DB cùng metadata.
  4. Truy vấn: embedding câu hỏi → lọc theo quyền → tìm đoạn gần nhất → rerank.
  5. Sinh câu trả lời: LLM đọc các đoạn đã chọn, trả lời kèm trích dẫn.
  6. Đánh giá và cập nhật liên tục khi tài liệu thay đổi.

Lưu ý: Phần lớn lỗi RAG nằm ở khâu nạp và chia đoạn, không phải ở LLM. Dành thời gian cho bước 1–2.

Các bước thực hiện

Tiến độ của bạn
0/7

Tiến độ chỉ lưu trên trình duyệt này.

  1. Bước 1: Trích xuất văn bản tiếng Việt, kể cả bản scan

    Bash
    sudo apt install -y tesseract-ocr tesseract-ocr-vie ocrmypdf poppler-utils
    # PDF scan → PDF có lớp chữ tiếng Việt
    ocrmypdf -l vie --skip-text input-scan.pdf output-ocr.pdf
    # PDF có lớp chữ → văn bản
    pdftotext -layout output-ocr.pdf output.txt
    
    uv pip install pymupdf python-docx
    extract.py
    import unicodedata
    from pathlib import Path
    
    import docx
    import fitz  # PyMuPDF
    
    def extract(path: Path) -> str:
        if path.suffix.lower() == ".pdf":
            with fitz.open(path) as pdf:
                text = "\n".join(page.get_text() for page in pdf)
        elif path.suffix.lower() == ".docx":
            text = "\n".join(p.text for p in docx.Document(path).paragraphs)
        else:
            text = path.read_text(encoding="utf-8")
        return unicodedata.normalize("NFC", text)
    
    for f in Path("docs").rglob("*"):
        if f.suffix.lower() in {".pdf", ".docx", ".txt", ".md"}:
            out = Path("extracted") / (f.stem + ".txt")
            out.parent.mkdir(exist_ok=True)
            out.write_text(extract(f), encoding="utf-8")
    • Chuẩn hoá Unicode NFC — văn bản tiếng Việt từ nhiều nguồn hay lẫn kiểu dấu tổ hợp.
    • Kiểm tra ngẫu nhiên văn bản OCR: bảng biểu, số tiền, mã hiệu hay bị nhận sai.
    • Tài liệu nhiều bảng/bố cục phức tạp: cân nhắc công cụ chuyên phân tích bố cục (ví dụ Docling, Unstructured) và so sánh kết quả.
  2. Bước 2: Chia đoạn theo cấu trúc tài liệu

    • Ưu tiên cắt theo tiêu đề, điều khoản, mục — mỗi đoạn trọn một ý.
    • Gắn tiêu đề mục cha vào đầu đoạn (ví dụ "Quy trình bảo hành > Bước 3") để đoạn tự đứng được.
    • Quy tắc thô để bắt đầu: vài trăm token mỗi đoạn, chồng lấp khoảng 10–15%; điều chỉnh theo kết quả đánh giá ở bước 6.
    • Lưu metadata: doc_id, tên file, trang/mục, phòng ban, mức quyền, ngày hiệu lực.
    chunk.py
    from transformers import AutoTokenizer
    
    tok = AutoTokenizer.from_pretrained("BAAI/bge-m3")
    
    def chunk_section(title: str, body: str, max_tokens: int = 400, overlap: int = 50):
        ids = tok.encode(body, add_special_tokens=False)
        step = max_tokens - overlap
        for start in range(0, max(len(ids), 1), step):
            piece = tok.decode(ids[start:start + max_tokens])
            yield f"{title}\n{piece}".strip()
            if start + max_tokens >= len(ids):
                break
  3. Bước 3: Dựng vector DB: pgvector (hoặc Qdrant)

    Bash
    sudo docker run -d --name pgvector --restart unless-stopped \
      -e POSTGRES_USER=rag -e POSTGRES_PASSWORD="$(openssl rand -hex 16)" -e POSTGRES_DB=rag \
      -p 127.0.0.1:5432:5432 -v pgdata:/var/lib/postgresql/data \
      pgvector/pgvector:pg17
    sudo docker inspect pgvector --format '{{range .Config.Env}}{{println .}}{{end}}' | grep POSTGRES_PASSWORD
    schema.sql
    CREATE EXTENSION IF NOT EXISTS vector;
    
    CREATE TABLE chunks (
      id          bigserial PRIMARY KEY,
      doc_id      text NOT NULL,
      source      text NOT NULL,
      department  text NOT NULL,
      content     text NOT NULL,
      embedding   vector(1024) NOT NULL
    );
    
    CREATE INDEX chunks_embedding_hnsw ON chunks USING hnsw (embedding vector_cosine_ops);
    CREATE INDEX chunks_department ON chunks (department);

    Số chiều 1024 khớp với mô hình embedding BAAI/bge-m3 dùng ở bước 4 — đổi mô hình thì đổi số chiều. Muốn dùng Qdrant: sudo docker run -d -p 127.0.0.1:6333:6333 -v qdrant_data:/qdrant/storage qdrant/qdrant, lọc quyền bằng payload filter.

  4. Bước 4: Embedding đa ngôn ngữ và nạp dữ liệu

    Bash
    uv pip install sentence-transformers "psycopg[binary]" pgvector
    ingest.py
    import os
    import psycopg
    from pgvector.psycopg import register_vector
    from sentence_transformers import SentenceTransformer
    
    embedder = SentenceTransformer("BAAI/bge-m3", device="cuda")
    conn = psycopg.connect(os.environ["RAG_DATABASE_URL"])  # postgresql://rag:...@127.0.0.1:5432/rag
    register_vector(conn)
    
    def ingest(doc_id: str, source: str, department: str, chunks: list[str]) -> None:
        vectors = embedder.encode(chunks, normalize_embeddings=True, batch_size=32)
        with conn.transaction():
            conn.execute("DELETE FROM chunks WHERE doc_id = %s", (doc_id,))  # nạp lại khi tài liệu đổi
            for text, vec in zip(chunks, vectors):
                conn.execute(
                    "INSERT INTO chunks (doc_id, source, department, content, embedding) VALUES (%s, %s, %s, %s, %s)",
                    (doc_id, source, department, text, vec),
                )

    Mẹo: Mô hình embedding khác (ví dụ intfloat/multilingual-e5-large) có quy ước riêng như tiền tố "query: "/"passage: ". Đọc model card và kiểm thử trên câu hỏi tiếng Việt của bạn trước khi chốt.

  5. Bước 5: Truy vấn có lọc quyền, rerank và sinh câu trả lời

    ask.py
    import os
    from openai import OpenAI
    import psycopg
    from pgvector.psycopg import register_vector
    from sentence_transformers import CrossEncoder, SentenceTransformer
    
    embedder = SentenceTransformer("BAAI/bge-m3", device="cuda")
    reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", device="cuda")
    conn = psycopg.connect(os.environ["RAG_DATABASE_URL"])
    register_vector(conn)
    llm = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key=os.environ["VLLM_API_KEY"])
    
    def retrieve(question: str, allowed_departments: list[str], k: int = 5):
        q = embedder.encode(question, normalize_embeddings=True)
        rows = conn.execute(
            "SELECT source, content FROM chunks WHERE department = ANY(%s) "
            "ORDER BY embedding <=> %s LIMIT 30",
            (allowed_departments, q),
        ).fetchall()
        if not rows:
            return []
        scores = reranker.predict([(question, content) for _, content in rows])
        ranked = sorted(zip(rows, scores), key=lambda x: x[1], reverse=True)
        return [row for row, _ in ranked[:k]]
    
    def ask(question: str, allowed_departments: list[str]) -> str:
        hits = retrieve(question, allowed_departments)
        if not hits:
            return "Không tìm thấy tài liệu phù hợp trong phạm vi bạn được xem."
        context = "\n\n".join(f"[{i + 1}] ({src})\n{text}" for i, (src, text) in enumerate(hits))
        resp = llm.chat.completions.create(
            model="Qwen/Qwen2.5-7B-Instruct",
            temperature=0.1,
            messages=[
                {"role": "system", "content": "Chỉ trả lời dựa trên tài liệu được cung cấp. Ghi số trích dẫn [n] sau mỗi ý. Nếu tài liệu không có thông tin, nói rõ là không có."},
                {"role": "user", "content": f"Tài liệu:\n{context}\n\nCâu hỏi: {question}"},
            ],
        )
        return resp.choices[0].message.content
    
    print(ask("Quy trình đổi trả thiết bị lỗi trong thời gian bảo hành?", ["kinh-doanh", "chung"]))

    Cảnh báo: Phân quyền phải lọc ở câu truy vấn cơ sở dữ liệu (như department = ANY(...) ở trên), dựa trên danh tính người dùng lấy từ hệ thống đăng nhập. Không bao giờ dựa vào câu lệnh trong prompt kiểu "đừng tiết lộ tài liệu X".

  6. Bước 6: Đánh giá chất lượng

    • Lập bộ câu hỏi thật từ các phòng ban, mỗi câu kèm tài liệu/đoạn đúng và đáp án mong đợi.
    • Đo khâu tìm kiếm: đoạn đúng có nằm trong top-k không (recall@k) — trước và sau rerank.
    • Đo khâu trả lời: có bám tài liệu không, có trích dẫn đúng không, có bịa thêm không. Người có chuyên môn chấm, có thể hỗ trợ bằng công cụ như Ragas.
    • Mỗi lần đổi chunking, mô hình embedding, prompt: chạy lại toàn bộ bộ câu hỏi và so sánh.
    • Thu thập phản hồi người dùng (đúng/sai) trên giao diện để bổ sung bộ câu hỏi.
  7. Bước 7: Đồng bộ tài liệu và quyền truy cập

    • Đồng bộ định kỳ từ nguồn tài liệu (file server, SharePoint, Google Drive…): tài liệu sửa → nạp lại theo doc_id; tài liệu xoá → xoá chunk.
    • Đồng bộ quyền cùng tài liệu; thay đổi quyền phải có hiệu lực ở lần truy vấn kế tiếp.
    • Loại tài liệu hết hiệu lực hoặc đánh dấu ngày hiệu lực để LLM ưu tiên bản mới.
    • Ghi nhật ký: ai hỏi gì, những tài liệu nào được truy xuất — lưu có kiểm soát (xem bài bảo mật dữ liệu AI).

Kiểm tra thành công

  • Với bộ câu hỏi đánh giá, đa số câu tìm được đoạn đúng trong top-k và câu trả lời có trích dẫn khớp tài liệu.
  • Người dùng phòng A hỏi về tài liệu chỉ phòng B được xem → hệ thống trả lời không tìm thấy.
  • Sửa một tài liệu nguồn, chạy đồng bộ, câu trả lời phản ánh nội dung mới.
  • Câu hỏi ngoài phạm vi tài liệu → hệ thống nói không có thông tin thay vì bịa.

Lỗi thường gặp & cách sửa

Tìm kiếm trả về đoạn không liên quan

Nguyên nhân thường gặp: Văn bản trích xuất bẩn (OCR sai, header/footer lặp), đoạn quá dài hoặc quá ngắn, lẫn NFC/NFD.

Kiểm tra mẫu văn bản trong extracted/, loại header/footer lặp, chuẩn hoá NFC, thử kích thước đoạn khác và bật rerank.

LLM trả lời đúng ý nhưng bịa thêm chi tiết

Nguyên nhân thường gặp: Prompt không ràng buộc, nhiệt độ cao, ngữ cảnh thiếu thông tin nên mô hình tự lấp.

Ràng buộc chỉ dùng tài liệu, yêu cầu trích dẫn, giảm temperature, trả lời "không có thông tin" khi điểm rerank thấp dưới ngưỡng bạn tự hiệu chỉnh.

expected 1024 dimensions, not ...

Nguyên nhân thường gặp: Số chiều cột vector khác số chiều mô hình embedding.

Đổi khai báo vector(n) theo mô hình, tạo lại bảng và nạp lại toàn bộ — không trộn vector từ hai mô hình embedding khác nhau.

Nạp kho tài liệu rất chậm

Nguyên nhân thường gặp: Embedding trên CPU, insert từng dòng không gom.

Chạy embedding trên GPU với batch_size lớn hơn, gom insert (COPY hoặc executemany), tạo chỉ mục HNSW sau khi nạp xong lượt đầu.

Bước tiếp theo

Nguồn chính chủ

Lệnh, tên gói và tham số thay đổi theo phiên bản. Trước khi chạy trên máy thật, hãy kiểm tra phiên bản mới nhất tại trang chính chủ:

Giới hạn của bài

  • Bài không khẳng định mô hình embedding nào tốt nhất cho tiếng Việt — hãy đánh giá trên bộ câu hỏi của chính doanh nghiệp.
  • Kích thước đoạn và số k trong ví dụ là điểm khởi đầu, không phải giá trị tối ưu.
  • Mã mẫu lược bỏ xử lý lỗi, hàng đợi nạp và xác thực người dùng — cần bổ sung trước khi đưa vào sản xuất.
Cần người dựng hệ thống cùng?

Kỹ sư HQG khảo sát, lên cấu hình, lắp đặt và bàn giao hạ tầng AI chạy được thật.