LLM cần bằng chứng để trả lời đúng: RAG giải thích chi tiết kèm ví dụ

Bài này có bản video ngắn:YouTubeTikTok

1. Tổng quan

Một LLM dù mạnh đến đâu cũng không tự biết nội dung tài liệu nội bộ của công ty bạn — nó chỉ biết những gì được đưa vào input. Bài này giải thích cơ chế Retrieval-Augmented Generation (RAG): tại sao cần nó, retrieval hoạt động thế nào, và vì sao chất lượng câu trả lời phụ thuộc gần như hoàn toàn vào bước tìm kiếm trước khi sinh văn bản. Đọc xong, bạn sẽ hiểu rõ luồng dữ liệu trong một hệ RAG và tự viết được một pipeline retrieval đơn giản bằng Java.

2. Vấn đề: LLM không có “bộ nhớ” về dữ liệu riêng

Model được huấn luyện trên một tập dữ liệu công khai, đóng băng tại một thời điểm. Nó không biết chính sách hoàn tiền mới ban hành tuần trước của công ty bạn, càng không biết nội dung hợp đồng nội bộ nào đó.

Có hai cách đưa thông tin riêng vào:

  1. Nhét thẳng vào prompt — chỉ khả thi khi tài liệu nhỏ, vừa context window.
  2. Truy xuất (retrieval) — khi tập tài liệu lớn, ứng dụng chỉ chọn ra vài đoạn liên quan nhất rồi mới đưa vào prompt.
// Cách 1: nhồi toàn bộ tài liệu — chỉ ổn với văn bản ngắn
String prompt = """
    Dựa trên chính sách sau, trả lời câu hỏi của khách hàng.
    Chính sách: %s
    Câu hỏi: %s
    """.formatted(fullPolicyText, userQuestion);

Cách này đơn giản nhưng không scale: context window có giới hạn token, và càng nhồi nhiều văn bản không liên quan, model càng dễ bị “lạc hướng” (lost in the middle). Khi tập tài liệu có hàng nghìn trang, bạn buộc phải dùng retrieval.

3. RAG là gì và luồng hoạt động

RAG (Retrieval-Augmented Generation) tách bài toán thành hai giai đoạn độc lập:

  • Retrieval: tìm đúng đoạn văn bản liên quan đến câu hỏi.
  • Generation: LLM dùng các đoạn đó để sinh câu trả lời, kèm trích dẫn nguồn.
Câu hỏi người dùng
      │
      ▼
[Embedding model] ── chuyển câu hỏi thành vector
      │
      ▼
[Vector database] ── tìm các đoạn văn bản gần nghĩa nhất
      │
      ▼
[Passages gốc] ── đưa nguyên văn vào prompt
      │
      ▼
[LLM] ── sinh câu trả lời + trích dẫn nguồn

Điểm mấu chốt: LLM không “tra cứu” trực tiếp vào vector database. Nó chỉ nhận văn bản thuần (plain text) do ứng dụng cung cấp sau khi retrieval xong. Vector database chỉ là công cụ hỗ trợ tìm kiếm, không phải nơi lưu “kiến thức” mà model đọc được.

3.1. Embedding — biến câu hỏi thành con số

Embedding model chuyển một đoạn text thành một vector số thực (ví dụ 1536 chiều). Hai đoạn text có nghĩa gần nhau sẽ cho ra vector gần nhau trong không gian đó.

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
@Service
public class PolicyEmbeddingService {

    private final EmbeddingModel embeddingModel;

    public PolicyEmbeddingService(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }

    public float[] embed(String text) {
        EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text));
        return response.getResult().getOutput();
    }
}

embed() nhận một đoạn text (ví dụ: nội dung câu hỏi “Chính sách hủy phòng khách sạn thế nào?”) và trả về một mảng float[] — vector biểu diễn ngữ nghĩa của câu đó. Đây chính là thứ sẽ được so khớp với các vector đã lưu sẵn trong vector database.

3.2. Vector database — tìm đoạn văn bản gần nghĩa nhất

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
@Service
public class PolicyRetrievalService {

    private final VectorStore vectorStore;

    public PolicyRetrievalService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public void indexPolicy(String policyId, String content) {
        Document document = new Document(content, Map.of("policyId", policyId));
        vectorStore.add(List.of(document));
    }

    public List<Document> search(String question) {
        SearchRequest request = SearchRequest.query(question).withTopK(3);
        return vectorStore.similaritySearch(request);
    }
}
  • indexPolicy() lưu một đoạn chính sách vào vector database, kèm metadata (policyId) để sau này biết đoạn văn bản này thuộc tài liệu nào.
  • search() nhận câu hỏi người dùng, tự động embedding nội bộ rồi tìm 3 đoạn gần nghĩa nhất (topK(3)).
Kết quả search("Chính sách hủy phòng khách sạn thế nào?"):
1. "Khách có thể hủy phòng miễn phí trước 24 giờ..."
2. "Trường hợp đặt phòng không hoàn tiền (non-refundable)..."
3. "Phí hủy phòng áp dụng với đặt phòng nhóm từ 5 người..."

3.3. Generation — LLM trả lời dựa trên bằng chứng

@Service
public class PolicyAnswerService {

    private final ChatClient chatClient;
    private final PolicyRetrievalService retrievalService;

    public PolicyAnswerService(ChatClient.Builder builder,
                                PolicyRetrievalService retrievalService) {
        this.chatClient = builder.build();
        this.retrievalService = retrievalService;
    }

    public String answer(String question) {
        List<Document> passages = retrievalService.search(question);

        String context = passages.stream()
            .map(Document::getContent)
            .collect(Collectors.joining("\n---\n"));

        String prompt = """
            Chỉ dựa vào các đoạn văn bản sau để trả lời. Nếu không đủ thông tin, nói rõ là không biết.
            Đoạn văn bản:
            %s

            Câu hỏi: %s
            """.formatted(context, question);

        return chatClient.prompt(prompt).call().content();
    }
}

Lưu ý câu lệnh “Chỉ dựa vào các đoạn văn bản sau” — đây là ràng buộc quan trọng để giảm hallucination: model được yêu cầu bám sát bằng chứng (evidence) thay vì bịa thêm từ kiến thức huấn luyện sẵn có.

Lưu ý: Nếu không ràng buộc rõ “chỉ dựa vào context”, model vẫn có thể trộn lẫn kiến thức nền của nó với nội dung retrieval được, dẫn đến câu trả lời nghe hợp lý nhưng sai lệch so với tài liệu thực tế.

4. Vì sao retrieval tệ thì câu trả lời cũng tệ

Đây là phần dễ bị bỏ qua: LLM chỉ giỏi diễn giải những gì nó nhận được, nó không có cách nào tự kiểm tra xem thông tin đó có đúng hay đủ hay không.

Hai tình huống thường gặp:

Tình huống 1 — Retrieval lấy tài liệu lỗi thời. Nếu vector database vẫn còn index phiên bản chính sách cũ (chưa được cập nhật/xoá), hệ thống sẽ trả về đoạn văn bản cũ. LLM đọc đoạn đó và trả lời hoàn toàn tự tin — nhưng là tự tin sai.

Tình huống 2 — Retrieval thiếu ngữ cảnh quan trọng. Giả sử tài liệu gốc có cấu trúc:

Điều 5. Chính sách hủy phòng
Khách có thể hủy phòng miễn phí trước 24 giờ so với giờ nhận phòng.

Điều 5.1. Ngoại lệ
Đối với đặt phòng vào dịp lễ Tết, không áp dụng hủy miễn phí;
phí hủy cố định là 50% giá trị đặt phòng.

Nếu bước chia nhỏ tài liệu (chunking) tách “Điều 5” và “Điều 5.1” thành hai chunk độc lập, và retrieval chỉ lấy được chunk “Điều 5” (vì câu hỏi không chứa từ “Tết” hay “ngoại lệ”), LLM sẽ trả lời “có thể hủy miễn phí trước 24 giờ” — nghe rất hợp lý, nhưng bỏ sót điều kiện ngoại lệ quan trọng.

@Test
void khiThieuChunkNgoaiLe_LLMSeBoSotDieuKien() {
    // Giả lập retrieval chỉ trả về 1 chunk, thiếu phần ngoại lệ
    List<Document> incompletePassages = List.of(
        new Document("Khách có thể hủy phòng miễn phí trước 24 giờ so với giờ nhận phòng.")
    );

    String answer = fakeAnswerService.answerFromPassages(
        "Tôi đặt phòng dịp Tết, hủy có mất phí không?",
        incompletePassages
    );

    // Câu trả lời sẽ sai vì thiếu bằng chứng về ngoại lệ dịp lễ
    assertThat(answer).doesNotContain("50%");
}

Test này không nhằm bắt lỗi LLM — nó minh hoạ rằng vấn đề nằm ở retrieval, không phải ở khả năng suy luận của model. Nếu bạn cung cấp đủ bằng chứng (cả hai chunk), câu trả lời sẽ đúng ngay cả với một model tầm trung.

5. Chunking: chìa khoá để retrieval “đúng mức độ chi tiết”

Vì ví dụ trên cho thấy chunking sai cách gây hậu quả, đây là nơi cần đầu tư nhiều nhất khi xây RAG.

@Service
public class PolicyChunkingService {

    public List<String> splitByHeading(String document) {
        // Tách theo pattern "Điều X" hoặc "Điều X.Y" để giữ nguyên ngữ cảnh cha-con
        String[] rawChunks = document.split("(?=Điều \\d+)");
        return Arrays.stream(rawChunks)
            .map(String::trim)
            .filter(chunk -> !chunk.isBlank())
            .toList();
    }
}

Thay vì chia theo số ký tự cố định (ví dụ cứ 500 ký tự cắt một lần — dễ cắt ngang giữa điều khoản và ngoại lệ), hàm này chia theo ranh giới ngữ nghĩa (“Điều X”). Cách này giúp mỗi chunk giữ được trọn vẹn một đơn vị thông tin, giảm nguy cơ tách rời quy tắc chính và ngoại lệ của nó.

@Test
void splitByHeading_PhaiGomCaDieuChinhVaNgoaiLe() {
    String document = """
        Điều 5. Chính sách hủy phòng
        Khách có thể hủy phòng miễn phí trước 24 giờ.

        Điều 5.1. Ngoại lệ
        Dịp lễ Tết, phí hủy cố định là 50%.
        """;

    List<String> chunks = chunkingService.splitByHeading(document);

    assertThat(chunks).hasSize(2);
    assertThat(chunks.get(1)).contains("Ngoại lệ", "50%");
}

Lưu ý: Chunking theo heading chỉ hiệu quả khi tài liệu có cấu trúc rõ ràng (hợp đồng, quy chế, tài liệu kỹ thuật). Với văn bản tự do (email, chat log), bạn cần chiến lược khác — ví dụ chia theo đoạn văn kèm overlap, hoặc dùng semantic chunking dựa trên độ tương đồng giữa các câu liền kề.

6. Khi nào KHÔNG nên dùng RAG

RAG không phải giải pháp vạn năng:

  • Tập dữ liệu nhỏ, cố định: nếu toàn bộ tài liệu chỉ vài nghìn token, nhét thẳng vào prompt đơn giản và ít lỗi hơn so với việc dựng cả pipeline retrieval.
  • Câu hỏi cần suy luận đa bước phức tạp mà câu trả lời không nằm trực tiếp trong một đoạn văn bản nào — RAG tìm “đoạn giống câu hỏi”, không tìm “đoạn chứa đáp án suy luận ra”.
  • Dữ liệu thay đổi liên tục theo giây (giá cổ phiếu, tồn kho realtime): nên gọi API trực tiếp (function calling) thay vì tái index vector database liên tục.

7. Kết luận

LLM không hề “biết” dữ liệu riêng của bạn — nó chỉ giỏi diễn giải bằng chứng được cung cấp. RAG giải quyết bài toán này bằng cách tách retrieval (tìm đúng đoạn văn bản) ra khỏi generation (sinh câu trả lời), với embedding model và vector database làm cầu nối. Chất lượng câu trả lời cuối cùng phụ thuộc gần như hoàn toàn vào việc bạn index và chunk dữ liệu tốt đến đâu — một LLM mạnh cũng không cứu được một bước retrieval tồi. Nếu bạn đang build RAG, hãy dành thời gian kiểm tra chunking và tỉ lệ retrieval đúng/sai trước khi nghĩ đến việc đổi sang model lớn hơn.

ragllmvector-databasespring-aijava