RAG từ A tới Z: lấy tài liệu rồi mới trả lời

1. Tổng quan

LLM chỉ biết những gì có trong dữ liệu huấn luyện. Nó không biết chính sách đổi trả của cửa hàng bạn hay quy trình nội bộ của công ty. Khi bị hỏi về những thứ đó, nó thường bịa ra câu trả lời. RAG (Retrieval-Augmented Generation) khắc phục điều này: hệ thống lấy tài liệu liên quan trước, rồi mới để LLM trả lời dựa trên các tài liệu đó.

Trong bài này, bạn sẽ tự xây một pipeline RAG đầy đủ bằng Spring Boot 3 và Spring AI. Pipeline gồm các bước nạp tài liệu, chia chunk, tạo embedding, tìm kiếm, ghép prompt có trích nguồn và viết test. Cuối bài có phần lỗi hay gặp và những trường hợp không nên dùng RAG.

2. RAG hoạt động thế nào

RAG có hai giai đoạn tách biệt.

Giai đoạn nạp (ingestion) chạy một lần hoặc chạy định kỳ:

  1. Đọc tài liệu (Markdown, PDF, HTML…).
  2. Chia nhỏ thành các chunk vài trăm token.
  3. Gọi embedding model để biến mỗi chunk thành một vector số.
  4. Lưu vector và nội dung chunk vào vector store.

Giai đoạn truy vấn (query) chạy mỗi khi người dùng hỏi:

  1. Embed câu hỏi thành vector.
  2. Tìm các chunk có vector gần nhất (similarity search).
  3. Ghép các chunk đó vào prompt. Đây là bước augment.
  4. Gửi prompt cho LLM để sinh câu trả lời. Đây là bước generate.

Ý tưởng cốt lõi rất đơn giản. LLM không cần “nhớ” tài liệu của bạn, nó chỉ cần đọc đúng đoạn tài liệu ngay trong prompt.

3. Chuẩn bị dự án

Bạn thêm Spring AI BOM và starter OpenAI. Starter này cung cấp cả chat model lẫn embedding model.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Tiếp theo là cấu hình model:

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.1
spring.ai.openai.embedding.options.model=text-embedding-3-small

Bạn nên đặt temperature thấp. Với RAG, mục tiêu là trả lời bám sát tài liệu, không cần sáng tạo.

Cuối cùng, bạn khai báo hai bean: một vector store và một ChatClient.

@Configuration
public class RagConfig {

    @Bean
    VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }

    @Bean
    ChatClient chatClient(ChatClient.Builder builder) {
        return builder.build();
    }
}

SimpleVectorStore lưu toàn bộ vector trong bộ nhớ. Nó phù hợp để học và demo. EmbeddingModel được Spring AI tự cấu hình từ starter OpenAI. Mỗi khi bạn thêm document, store sẽ gọi model này để tạo vector.

Lưu ý: SimpleVectorStore mất hết dữ liệu khi ứng dụng khởi động lại và tìm kiếm tuyến tính. Với môi trường production, bạn nên dùng PGvector, Qdrant, Elasticsearch… Spring AI có starter cho từng loại, và interface VectorStore giữ nguyên nên code phía dưới không phải đổi.

4. Nạp tài liệu

Giả sử bạn có file src/main/resources/docs/return-policy.md:

# Chính sách đổi trả

Khách hàng được đổi trả sản phẩm trong vòng 30 ngày kể từ ngày nhận hàng.
Sản phẩm phải còn nguyên tem, nhãn và hóa đơn.
Tiền hoàn sẽ được chuyển về tài khoản trong 5–7 ngày làm việc.
Đồ lót và sản phẩm giảm giá trên 50% không được đổi trả.

4.1. Đọc và chia chunk

@Component
public class DocumentIngestor implements ApplicationRunner {

    private final VectorStore vectorStore;
    private final ResourcePatternResolver resolver;

    public DocumentIngestor(VectorStore vectorStore, ResourcePatternResolver resolver) {
        this.vectorStore = vectorStore;
        this.resolver = resolver;
    }

    @Override
    public void run(ApplicationArguments args) throws IOException {
        // chunkSize=300 token, minChunkSizeChars=100, minChunkLengthToEmbed=5,
        // maxNumChunks=10000, keepSeparator=true
        var splitter = new TokenTextSplitter(300, 100, 5, 10_000, true);

        for (Resource file : resolver.getResources("classpath:docs/*.md")) {
            var reader = new TextReader(file);
            reader.getCustomMetadata().put("source", file.getFilename());

            List<Document> chunks = splitter.apply(reader.get());
            vectorStore.add(chunks);
            System.out.printf("Đã nạp %s: %d chunk%n", file.getFilename(), chunks.size());
        }
    }
}

Đoạn code trên làm các việc sau:

  • ApplicationRunner chạy một lần sau khi context khởi động xong, nên tài liệu luôn được nạp trước khi có request đầu tiên.
  • TextReader đọc file thành một Document. Ta gắn metadata source là tên file để dùng khi trích nguồn ở bước sau.
  • TokenTextSplitter cắt văn bản thành các chunk khoảng 300 token. Metadata được sao chép sang từng chunk.
  • vectorStore.add(...) gọi embedding model cho từng chunk rồi lưu vector.

Khi khởi động, bạn sẽ thấy:

Đã nạp return-policy.md: 1 chunk
Đã nạp shipping.md: 3 chunk

4.2. Chọn kích thước chunk

Kích thước chunk ảnh hưởng trực tiếp tới chất lượng câu trả lời:

  • Chunk quá lớn: một chunk chứa nhiều chủ đề, vector bị “loãng” nên khó khớp chính xác. Prompt cũng tốn nhiều token hơn.
  • Chunk quá nhỏ: câu bị cắt mất ngữ cảnh. Ví dụ “trong vòng 30 ngày” nằm một chunk, còn “đổi trả” nằm ở chunk khác.

Khoảng 200–500 token là điểm khởi đầu hợp lý cho tài liệu văn xuôi. Với tài liệu có cấu trúc rõ (FAQ, mục lục), bạn nên cắt theo heading để mỗi chunk là một ý trọn vẹn.

5. Truy xuất tài liệu liên quan

Bước truy xuất chỉ cần một lệnh gọi:

List<Document> docs = vectorStore.similaritySearch(
        SearchRequest.builder()
                .query("Tôi được đổi trả trong bao lâu?")
                .topK(4)
                .similarityThreshold(0.5)
                .build());
  • query: câu hỏi. Vector store tự embed câu hỏi bằng đúng model đã dùng lúc nạp.
  • topK(4): lấy tối đa 4 chunk gần nhất.
  • similarityThreshold(0.5): loại các chunk có độ tương đồng cosine dưới 0.5. Nếu không đặt ngưỡng, store luôn trả về đủ topK kết quả, kể cả khi chúng chẳng liên quan gì tới câu hỏi.

Không có ngưỡng tối ưu cho mọi trường hợp. Bạn nên log điểm số với vài chục câu hỏi thật, rồi chọn ngưỡng tách được kết quả đúng khỏi kết quả nhiễu.

6. Ghép prompt và sinh câu trả lời

6.1. Prompt builder

Tách phần ghép prompt thành một hàm thuần sẽ giúp bạn test dễ hơn:

public final class PromptBuilder {

    private static final String TEMPLATE = """
            Bạn là trợ lý chăm sóc khách hàng.
            Chỉ trả lời dựa trên NGỮ CẢNH bên dưới.
            Nếu ngữ cảnh không chứa thông tin, trả lời đúng câu:
            "Tôi không tìm thấy thông tin này trong tài liệu."
            Cuối câu trả lời, ghi nguồn trong ngoặc vuông, ví dụ [return-policy.md].

            NGỮ CẢNH:
            {context}

            CÂU HỎI: {question}
            """;

    private PromptBuilder() {}

    public static String build(String question, List<Document> docs) {
        String context = docs.stream()
                .map(d -> "[" + d.getMetadata().getOrDefault("source", "unknown") + "]\n" + d.getText())
                .collect(Collectors.joining("\n---\n"));
        return TEMPLATE.replace("{context}", context)
                       .replace("{question}", question);
    }
}

Có ba điểm quan trọng trong template:

  1. “Chỉ trả lời dựa trên NGỮ CẢNH” giới hạn LLM vào tài liệu, giảm tình trạng bịa đặt.
  2. Câu từ chối cố định cho LLM một lối thoát hợp lệ khi tài liệu không có thông tin.
  3. Mỗi chunk có tên nguồn đi kèm, nhờ đó LLM trích được nguồn và người dùng kiểm chứng được.

Ở đây ta dùng replace thay cho String.formatted. Lý do: nội dung tài liệu có thể chứa ký tự % (ví dụ “giảm giá trên 50%”), và formatted sẽ ném UnknownFormatConversionException khi gặp ký tự này.

6.2. RagService

@Service
public class RagService {

    static final String NOT_FOUND = "Tôi không tìm thấy thông tin này trong tài liệu.";

    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    public RagService(VectorStore vectorStore, ChatClient chatClient) {
        this.vectorStore = vectorStore;
        this.chatClient = chatClient;
    }

    public String answer(String question) {
        List<Document> docs = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(question)
                        .topK(4)
                        .similarityThreshold(0.5)
                        .build());

        if (docs == null || docs.isEmpty()) {
            return NOT_FOUND;
        }

        return chatClient.prompt()
                .user(PromptBuilder.build(question, docs))
                .call()
                .content();
    }
}

Khi không tìm thấy chunk nào, service trả lời từ chối ngay mà không gọi LLM. Cách này vừa tiết kiệm chi phí, vừa loại hẳn khả năng LLM tự bịa câu trả lời từ kiến thức chung.

Cuối cùng là controller:

@RestController
public class AskController {

    private final RagService ragService;

    public AskController(RagService ragService) {
        this.ragService = ragService;
    }

    @GetMapping("/ask")
    public String ask(@RequestParam String q) {
        return ragService.answer(q);
    }
}

Giờ bạn thử gọi API:

curl -G "http://localhost:8080/ask" --data-urlencode "q=Tôi được đổi trả trong bao lâu?"
curl -G "http://localhost:8080/ask" --data-urlencode "q=Cửa hàng có bán laptop không?"
Bạn được đổi trả trong vòng 30 ngày kể từ ngày nhận hàng, với điều kiện sản phẩm còn nguyên tem, nhãn và hóa đơn. [return-policy.md]
Tôi không tìm thấy thông tin này trong tài liệu.

Câu trả lời đầu bám sát tài liệu và có trích nguồn. Câu thứ hai không có tài liệu nào khớp, nên hệ thống từ chối thay vì bịa.

7. Viết test

Bạn không nên gọi LLM thật trong unit test vì chậm, tốn tiền và kết quả không ổn định. Thay vào đó, hãy test hai phần có tính xác định: prompt builder và nhánh từ chối khi không có tài liệu.

class RagServiceTest {

    @Test
    void promptContainsContextSourceAndQuestion() {
        var doc = new Document("Đổi trả trong vòng 30 ngày.", Map.of("source", "return-policy.md"));

        String prompt = PromptBuilder.build("Đổi trả bao lâu?", List.of(doc));

        assertThat(prompt)
                .contains("[return-policy.md]")
                .contains("Đổi trả trong vòng 30 ngày.")
                .contains("CÂU HỎI: Đổi trả bao lâu?")
                .doesNotContain("{context}", "{question}");
    }

    @Test
    void percentSignInDocumentDoesNotBreakPrompt() {
        var doc = new Document("Giảm giá trên 50% không được đổi trả.", Map.of("source", "return-policy.md"));

        assertThat(PromptBuilder.build("Hàng sale?", List.of(doc)))
                .contains("trên 50% không");
    }

    @Test
    void returnsNotFoundWithoutCallingLlmWhenNoDocuments() {
        VectorStore vectorStore = mock(VectorStore.class);
        ChatClient chatClient = mock(ChatClient.class);
        when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());

        String answer = new RagService(vectorStore, chatClient).answer("Có bán laptop không?");

        assertThat(answer).isEqualTo(RagService.NOT_FOUND);
        verifyNoInteractions(chatClient);
    }
}

Test thứ ba kiểm tra hai điều. Thứ nhất, câu trả lời đúng là câu từ chối. Thứ hai, ChatClient hoàn toàn không bị gọi. Nếu sau này có ai vô tình xóa nhánh isEmpty(), test sẽ báo đỏ ngay.

Để đánh giá chất lượng truy xuất, bạn có thể viết thêm integration test riêng. Hãy chuẩn bị một bộ câu hỏi kèm tên file nguồn mong đợi, rồi kiểm tra file đó có nằm trong topK kết quả hay không. Bộ test này nên chạy định kỳ, không chạy trong mỗi lần build.

8. Rút gọn với QuestionAnswerAdvisor

Khi đã hiểu từng bước, bạn có thể dùng advisor có sẵn của Spring AI. Advisor này gói gọn phần truy xuất và ghép prompt. Bạn cần thêm dependency:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
String answer = chatClient.prompt()
        .advisors(QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder().topK(4).similarityThreshold(0.5).build())
                .build())
        .user("Tôi được đổi trả trong bao lâu?")
        .call()
        .content();

Advisor tự embed câu hỏi, tìm chunk và chèn vào prompt theo template mặc định. Cách này tiện, nhưng bạn khó kiểm soát định dạng nguồn và nhánh từ chối sớm. Vì vậy, cách tự viết ở mục 6 vẫn đáng giữ khi bạn cần các yêu cầu đó.

9. Lỗi hay gặp và khi nào không nên dùng RAG

Đổi embedding model nhưng không nạp lại dữ liệu. Vector của các model khác nhau không so sánh được với nhau. Mỗi lần đổi model, bạn phải embed lại toàn bộ tài liệu.

Không đặt similarityThreshold. Vector store luôn trả đủ topK kết quả. Khi đó LLM nhận được ngữ cảnh rác và trả lời sai một cách rất tự tin.

Nạp lại tài liệu mỗi lần khởi động với store bền vững. Với PGvector, đoạn ApplicationRunner ở trên sẽ tạo bản trùng sau mỗi lần deploy. Bạn nên gán ID cố định cho từng chunk, ví dụ hash từ nội dung và tên file, hoặc xóa theo source trước khi nạp lại.

Nhồi quá nhiều chunk vào prompt. Tăng topK lên 20 không làm câu trả lời tốt hơn. LLM dễ bỏ sót thông tin nằm giữa ngữ cảnh dài, chi phí cũng tăng theo số token.

Tin tưởng tuyệt đối vào nội dung tài liệu. Nếu tài liệu đến từ người dùng, chúng có thể chứa câu lệnh kiểu “bỏ qua mọi chỉ dẫn trước đó” (prompt injection). Bạn cần kiểm soát nguồn dữ liệu được phép nạp vào store.

Lưu ý: RAG không phù hợp cho mọi bài toán. Câu hỏi cần tổng hợp toàn bộ dữ liệu, chẳng hạn “có bao nhiêu đơn hàng bị hoàn trong tháng 9”, nên chuyển thành truy vấn SQL. Similarity search chỉ trả về vài chunk, không thể đếm hay tổng hợp. Nếu toàn bộ tài liệu của bạn chỉ vài trang, đưa thẳng vào prompt thường đơn giản và chính xác hơn cả một pipeline RAG.

10. Kết luận

RAG tách kiến thức ra khỏi model. Tài liệu nằm trong vector store, và LLM chỉ đọc đúng phần cần thiết ở mỗi câu hỏi. Trong bài này, bạn đã xây đủ hai giai đoạn: nạp tài liệu (đọc, chia chunk, embed, lưu) và truy vấn (tìm kiếm có ngưỡng, ghép prompt có nguồn, gọi LLM). Bạn cũng đã viết test cho phần prompt và nhánh từ chối.

Từ nền tảng này, bạn có thể đi tiếp theo các hướng sau: thay SimpleVectorStore bằng PGvector, cắt chunk theo heading, lọc theo metadata (ví dụ chỉ tìm trong tài liệu của một phòng ban), hoặc thêm bước rerank để sắp xếp lại kết quả trước khi đưa vào prompt.

ragspring-aillmvector-storespring-boot