Java SpringAI

Build a RAG Chatbot with Spring Boot — Full Working Example with PDF Documents

Build a RAG Chatbot with Spring Boot — Full Working Example with PDF Documents

This tutorial builds a complete RAG (Retrieval Augmented Generation) chatbot that loads PDF documents, stores their content in a vector database, and answers questions based on that content. This is the full end-to-end implementation used in production AI systems: document ingestion, semantic search, and grounded answer generation.

Project Structure

src/main/java/com/java9r/rag/
├── RagChatbotApp.java
├── config/
│   └── RagConfig.java
├── ingestion/
│   └── DocumentIngestionService.java
├── chat/
│   ├── RagChatService.java
│   └── RagChatController.java
src/main/resources/
├── application.properties
└── docs/
    └── spring-boot-guide.pdf    ← your documents go here

Maven Dependencies

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-pdf-document-reader</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
</dependencies>

application.properties

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

spring.datasource.url=jdbc:postgresql://localhost:5432/vectordb
spring.datasource.username=postgres
spring.datasource.password=postgres

spring.ai.vectorstore.pgvector.initialize-schema=true
spring.ai.vectorstore.pgvector.dimensions=1536
spring.ai.vectorstore.pgvector.distance-type=COSINE_DISTANCE

DocumentIngestionService.java — Load and Index PDF

import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;

@Service
public class DocumentIngestionService {

    private final VectorStore       vectorStore;
    private final TokenTextSplitter splitter;

    public DocumentIngestionService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
        this.splitter    = new TokenTextSplitter(600, 120, 5, 10000, true);
    }

    public void ingestPdf(Resource pdfResource) {
        // Configure PDF reader — one document per page
        PdfDocumentReaderConfig config = PdfDocumentReaderConfig.builder()
                .withPageTopMargin(0)
                .withPageBottomMargin(0)
                .build();

        PagePdfDocumentReader reader = new PagePdfDocumentReader(pdfResource, config);
        List<Document> pages = reader.get();

        // Add source metadata
        pages.forEach(doc -> {
            doc.getMetadata().put("source", pdfResource.getFilename());
            doc.getMetadata().put("ingested_at", LocalDateTime.now().toString());
        });

        // Split into chunks and embed
        List<Document> chunks = splitter.apply(pages);
        vectorStore.add(chunks);

        System.out.printf("Ingested PDF: %s → %d pages → %d chunks%n",
                pdfResource.getFilename(), pages.size(), chunks.size());
    }

    public void ingestText(String text, String sourceName) {
        Document doc = new Document(text, Map.of("source", sourceName));
        List<Document> chunks = splitter.apply(List.of(doc));
        vectorStore.add(chunks);
        System.out.printf("Ingested: %s → %d chunks%n", sourceName, chunks.size());
    }
}

RagChatService.java — Answer Questions from Documents

import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.vectorstore.SearchRequest;

@Service
public class RagChatService {

    private final ChatClient chatClient;

    public RagChatService(ChatClient.Builder builder, VectorStore vectorStore) {
        InMemoryChatMemory memory = new InMemoryChatMemory();

        this.chatClient = builder
                .defaultSystem("""
                    You are a knowledgeable assistant. Answer questions based ONLY
                    on the provided context documents. If the answer is not in the
                    context, say "I don't have that information in my documents."
                    Be concise and cite the source when possible.
                    """)
                .defaultAdvisors(
                    new MessageChatMemoryAdvisor(memory),
                    new QuestionAnswerAdvisor(
                        vectorStore,
                        SearchRequest.defaults()
                                .withTopK(4)
                                .withSimilarityThreshold(0.6)
                    )
                )
                .build();
    }

    public String chat(String sessionId, String question) {
        return chatClient.prompt()
                .user(question)
                .advisors(a -> a.param(
                        MessageChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, sessionId))
                .call()
                .content();
    }
}

RagChatController.java

@RestController
@RequestMapping("/rag")
public class RagChatController {

    private final RagChatService         chatService;
    private final DocumentIngestionService ingestionService;

    public RagChatController(RagChatService chatService,
                             DocumentIngestionService ingestionService) {
        this.chatService      = chatService;
        this.ingestionService = ingestionService;
    }

    @PostMapping("/ingest/text")
    public String ingestText(@RequestParam String source,
                             @RequestBody String text) {
        ingestionService.ingestText(text, source);
        return "Ingested successfully";
    }

    @PostMapping("/ingest/pdf")
    public String ingestPdf(@RequestParam("file") MultipartFile file) throws IOException {
        Resource resource = file.getResource();
        ingestionService.ingestPdf(resource);
        return "PDF ingested: " + file.getOriginalFilename();
    }

    @PostMapping("/chat/{sessionId}")
    public String chat(@PathVariable String sessionId,
                       @RequestBody String question) {
        return chatService.chat(sessionId, question);
    }
}

Test the Chatbot

# 1. Ingest some text
POST /rag/ingest/text?source=java-guide
Body: Spring Boot 3.x requires Java 17. @SpringBootApplication enables auto-configuration.
      Spring Data JPA creates CRUD operations automatically for @Repository interfaces.
      Actuator exposes /actuator/health for health checks.

# 2. Ask questions
POST /rag/chat/session1
Body: What Java version is required for Spring Boot 3?

POST /rag/chat/session1
Body: Tell me more about Spring Data JPA

POST /rag/chat/session1
Body: How can I check if my app is healthy?

Output

Q: What Java version is required for Spring Boot 3?
A: Spring Boot 3.x requires Java 17 or later.

Q: Tell me more about Spring Data JPA
A: Spring Data JPA automatically creates CRUD operations for interfaces annotated
   with @Repository, so you don't need to write boilerplate SQL or JPA code.

Q: How can I check if my app is healthy?
A: You can use Spring Boot Actuator which exposes the /actuator/health endpoint
   to check the health status of your application.

Q: What is Kubernetes?   ← not in ingested docs
A: I don't have that information in my documents.

Key Points

  • Stack MessageChatMemoryAdvisor before QuestionAnswerAdvisor — memory is applied first, then RAG context is injected
  • PagePdfDocumentReader reads one page per document — adjust chunk size to match your PDF's content density
  • The system prompt "answer ONLY from context" prevents hallucination on topics outside your documents
  • A similarity threshold of 0.6 is a good starting point — increase it to reduce noise, decrease it if relevant chunks are being missed
  • For production, replace InMemoryChatMemory with Redis-backed memory so sessions survive restarts
Topics: Java SpringAI
← Newer Post Older Post →