Build a RAG Chatbot with Spring Boot

A Complete End-to-End Guide for Java Developers

Vikram had read every article he could find about RAG.

He understood the concept. He could explain embeddings at a whiteboard. He knew what a vector store was, how chunking worked, and why retrieval quality depended so heavily on how you split your documents. In his last three interviews, he had answered every Gen AI theory question correctly.

And then the interviewer said: “Great. Can you walk us through how you would actually build this in Java?”

Vikram paused. He knew what the pieces were. He had just never assembled them. He had never written the Spring Boot service that ingested a PDF. Never configured pgvector with Spring AI. Never built the REST endpoint that took a user’s question, retrieved the relevant chunks, assembled the prompt, called the LLM, and streamed the response back to a frontend.

He knew the theory. He had no working code.

“Understanding something and having built it are two very different things. Interviewers always know which one you have done.”

This post is for every developer who is in Vikram’s position right now. We are going to build a complete, working RAG chatbot from scratch using Spring Boot 3, Spring AI, pgvector, and OpenAI. Every layer. Every decision explained. Real code that you can run.

By the end of this post you will have built something you can demo, extend, and talk about confidently in any interview room.

What we are building

We are building a document-aware chatbot that:

The tech stack is:

Want to use a free LLM instead?

Swap OpenAI for Groq (llama-3.1-70b) or Ollama (local models). Spring AI supports both. Just change the dependency and the model name in application.yml. The rest of the code stays exactly the same.

Step 1: Project setup

Maven dependencies

Create a new Spring Boot project at start.spring.io and add the following dependencies. Spring AI uses a BOM (Bill of Materials) to manage versions consistently.

<properties>
    <java.version>21</java.version>
    <spring-ai.version>1.0.0</spring-ai.version>
</properties>
 
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>${spring-ai.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
 
<dependencies>
    <!-- Spring Boot core -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
 
    <!-- Spring AI: OpenAI + pgvector -->
    <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>
 
    <!-- PDF parsing -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-pdf-document-reader</artifactId>
    </dependency>
 
    <!-- PostgreSQL driver -->
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

application.yml

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/ragdb
    username: ${DB_USER}
    password: ${DB_PASS}
  jpa:
    hibernate:
      ddl-auto: update
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o-mini
          temperature: 0.3
      embedding:
        options:
          model: text-embedding-3-small
    vectorstore:
      pgvector:
        index-type: hnsw
        distance-type: cosine_distance
        dimensions: 1536
        initialize-schema: true

Setting initialize-schema to true tells Spring AI to create the vector store table and the pgvector extension automatically on startup. In production you would manage schema changes through Flyway or Liquibase instead, but for getting started this is the fastest path.

Step 2: Database and conversation history schema

Spring AI creates the vector store table automatically. You need to create one additional table to store conversation history so the chatbot can remember what was said earlier in a session.

-- Enable pgvector (Spring AI does this automatically if initialize-schema=true)
CREATE EXTENSION IF NOT EXISTS vector;
 
-- Conversation sessions
CREATE TABLE chat_sessions (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    created_at  TIMESTAMP DEFAULT now()
);
 
-- Individual messages within a session
CREATE TABLE chat_messages (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    session_id  UUID NOT NULL REFERENCES chat_sessions(id),
    role        VARCHAR(20) NOT NULL,  -- "user" or "assistant"
    content     TEXT NOT NULL,
    created_at  TIMESTAMP DEFAULT now()
);
 
CREATE INDEX idx_messages_session ON chat_messages(session_id, created_at);

JPA entities

@Entity
@Table(name = "chat_sessions")
public class ChatSession {
    @Id @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
 
    @CreationTimestamp
    private LocalDateTime createdAt;
 
    @OneToMany(mappedBy = "session", cascade = CascadeType.ALL)
    @OrderBy("createdAt ASC")
    private List<ChatMessage> messages = new ArrayList<>();
}
 
@Entity
@Table(name = "chat_messages")
public class ChatMessage {
    @Id @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "session_id")
    private ChatSession session;
 
    private String role;    // "user" or "assistant"
 
    @Column(columnDefinition = "TEXT")
    private String content;
 
    @CreationTimestamp
    private LocalDateTime createdAt;
}

Step 3: Document ingestion service

This is the indexing pipeline. It takes a PDF file, splits it into chunks, generates embeddings for each chunk, and stores them in pgvector. This runs once per document, or whenever a document is updated.

@Service
public class DocumentIngestionService {
 
    private final VectorStore vectorStore;
 
    public DocumentIngestionService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }
 
    public int ingestPdf(MultipartFile file, String category) throws IOException {
 
        // Step 1: Load the PDF
        PagePdfDocumentReader reader = new PagePdfDocumentReader(
            new InputStreamResource(file.getInputStream()),
            PdfDocumentReaderConfig.builder()
                .withPageTopMargin(0)
                .withPageBottomMargin(0)
                .build()
        );
        List<Document> rawDocs = reader.get();
 
        // Step 2: Split into chunks with overlap
        TokenTextSplitter splitter = new TokenTextSplitter(
            500,   // target chunk size in tokens
            50,    // minimum chunk size
            5,     // minimum length per document
            10000, // maximum length
            true   // keep separator
        );
        List<Document> chunks = splitter.apply(rawDocs);
 
        // Step 3: Attach metadata to each chunk
        String sourceFile = file.getOriginalFilename();
        chunks.forEach(chunk -> {
            chunk.getMetadata().put("source", sourceFile);
            chunk.getMetadata().put("category", category);
            chunk.getMetadata().put("indexed_at",
                LocalDateTime.now().toString());
        });
 
        // Step 4: Embed and store
        // Spring AI handles embedding automatically here
        vectorStore.add(chunks);
 
        return chunks.size();
    }
}

The TokenTextSplitter is important here. It splits by token count rather than character count, which means your chunks align with how the LLM actually processes text. A 500-token chunk is roughly 375 words: enough context to be meaningful, small enough to be focused.

Step 4: Document upload controller

@RestController
@RequestMapping("/api/documents")
public class DocumentController {
 
    private final DocumentIngestionService ingestionService;
 
    public DocumentController(DocumentIngestionService ingestionService) {
        this.ingestionService = ingestionService;
    }
 
    @PostMapping("/upload")
    public ResponseEntity<Map<String, Object>> uploadDocument(
            @RequestParam("file") MultipartFile file,
            @RequestParam(value = "category", defaultValue = "general") String category) {
 
        if (file.isEmpty()) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "No file provided"));
        }
 
        String filename = file.getOriginalFilename();
        if (filename == null || !filename.endsWith(".pdf")) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Only PDF files are supported"));
        }
 
        try {
            int chunkCount = ingestionService.ingestPdf(file, category);
            return ResponseEntity.ok(Map.of(
                "message", "Document indexed successfully",
                "file", filename,
                "chunks_created", chunkCount
            ));
        } catch (IOException e) {
            return ResponseEntity.internalServerError()
                .body(Map.of("error", "Failed to process document: " + e.getMessage()));
        }
    }
}

Step 5: The RAG chat service

This is the heart of the application. It takes a user’s question and a session ID, retrieves relevant chunks from pgvector, loads the conversation history, assembles the final prompt, calls the LLM, saves the exchange to the database, and returns the answer.

@Service
public class RagChatService {
 
    private final VectorStore vectorStore;
    private final ChatClient chatClient;
    private final ChatSessionRepository sessionRepo;
    private final ChatMessageRepository messageRepo;
 
    private static final String SYSTEM_PROMPT = """
        You are a helpful assistant that answers questions based on
        the provided context documents.
 
        Rules:
        - Answer ONLY using the information in the context below.
        - If the context does not contain enough information to answer
          the question, say: "I could not find that information in the
          available documents. Please try rephrasing your question."
        - Do not make up facts or use information from your training data.
        - If the user asks a follow-up question, use the chat history
          to understand what they are referring to.
        - Be concise and direct. Avoid unnecessary filler.
        """;
 
    public ChatResponse chat(UUID sessionId, String userQuestion) {
 
        // Step 1: Get or create the session
        ChatSession session = sessionRepo.findById(sessionId)
            .orElseGet(() -> sessionRepo.save(new ChatSession()));
 
        // Step 2: Retrieve relevant chunks from pgvector
        List<Document> relevantDocs = vectorStore.similaritySearch(
            SearchRequest.query(userQuestion)
                .withTopK(4)
                .withSimilarityThreshold(0.65)
        );
 
        // Step 3: Build the context string
        String context = relevantDocs.stream()
            .map(doc -> {
                String source = (String) doc.getMetadata()
                    .getOrDefault("source", "Unknown");
                return "[Source: " + source + "]\n" + doc.getContent();
            })
            .collect(Collectors.joining("\n\n---\n\n"));
 
        if (context.isBlank()) {
            context = "No relevant documents were found for this query.";
        }
 
        // Step 4: Build conversation history
        List<ChatMessage> history = messageRepo
            .findBySessionOrderByCreatedAtAsc(session);
 
        // Step 5: Assemble and call the LLM
        ChatClient.ChatClientRequest request = chatClient.prompt()
            .system(SYSTEM_PROMPT)
            .user(u -> u.text(
                "Context:\n{context}\n\nQuestion: {question}")
                .param("context", context)
                .param("question", userQuestion)
            );
 
        // Inject conversation history
        for (ChatMessage msg : history) {
            if ("user".equals(msg.getRole())) {
                request = request.messages(
                    new UserMessage(msg.getContent()));
            } else {
                request = request.messages(
                    new AssistantMessage(msg.getContent()));
            }
        }
 
        String answer = request.call().content();
 
        // Step 6: Persist the exchange
        saveMessage(session, "user", userQuestion);
        saveMessage(session, "assistant", answer);
 
        // Step 7: Return response with sources
        List<String> sources = relevantDocs.stream()
            .map(d -> (String) d.getMetadata()
                .getOrDefault("source", "Unknown"))
            .distinct()
            .collect(Collectors.toList());
 
        return new ChatResponse(session.getId(), answer, sources);
    }
 
    private void saveMessage(ChatSession session, String role, String content) {
        ChatMessage msg = new ChatMessage();
        msg.setSession(session);
        msg.setRole(role);
        msg.setContent(content);
        messageRepo.save(msg);
    }
}

A few decisions in this code worth explaining:

Step 6: Chat REST controller

@RestController
@RequestMapping("/api/chat")
public class ChatController {
 
    private final RagChatService chatService;
 
    public ChatController(RagChatService chatService) {
        this.chatService = chatService;
    }
 
    // Start a new session
    @PostMapping("/sessions")
    public ResponseEntity<Map<String, String>> createSession() {
        // Session is created automatically on first message,
        // but expose this endpoint for frontend convenience
        return ResponseEntity.ok(Map.of(
            "session_id", UUID.randomUUID().toString()
        ));
    }
 
    // Send a message
    @PostMapping("/sessions/{sessionId}/messages")
    public ResponseEntity<ChatResponseDto> sendMessage(
            @PathVariable UUID sessionId,
            @RequestBody ChatRequestDto request) {
 
        if (request.question() == null || request.question().isBlank()) {
            return ResponseEntity.badRequest().build();
        }
 
        ChatResponse response = chatService.chat(sessionId, request.question());
 
        return ResponseEntity.ok(new ChatResponseDto(
            response.sessionId(),
            response.answer(),
            response.sources()
        ));
    }
}
 
// Request/Response DTOs
public record ChatRequestDto(String question) {}
 
public record ChatResponseDto(
    UUID sessionId,
    String answer,
    List<String> sources
) {}

Step 7: Testing the full flow

With the application running, here is the sequence of API calls to test everything end to end:

Upload a document

curl -X POST http://localhost:8080/api/documents/upload \
  -F "file=@company_policy.pdf" \
  -F "category=hr"
 
# Response:
# {
#   "message": "Document indexed successfully",
#   "file": "company_policy.pdf",
#   "chunks_created": 47
# }

Ask a question

curl -X POST http://localhost:8080/api/chat/sessions/\
  550e8400-e29b-41d4-a716-446655440000/messages \
  -H "Content-Type: application/json" \
  -d '{"question": "What is the leave policy for new joiners?"}'

# Response:
# {
#   "session_id": "550e8400-e29b-41d4-a716-446655440000",
#   "answer": "New joiners are entitled to 12 days of earned leave
#    after completing 6 months of employment. 
#    Leave cannot be carried forward in the first year...", 
#   "sources": ["company_policy.pdf"]
# }

Follow-up question (tests conversation memory)

curl -X POST http://localhost:8080/api/chat/sessions/\
  550e8400-e29b-41d4-a716-446655440000/messages \
  -H "Content-Type: application/json" \
  -d '{"question": "What about sick leave?"}'
 
# The model uses conversation history to understand
# "What about sick leave" refers to the leave policy topic,
# not a completely new question.

Common issues and how to fix them

The model says it cannot find the information, but the answer is in the document

This is almost always a retrieval problem, not a generation problem. The right chunk is not making it into the context. Debug by logging the retrieved documents before they are passed to the LLM. Common causes: the similarity threshold is too high and filtering out relevant chunks, the chunk size is too large so the relevant sentence is buried in noise, or the question and the document use different terminology for the same concept (semantic mismatch).

Fix: lower the similarity threshold to 0.55 and increase topK to 6. If that helps, your indexing is fine but your threshold was too aggressive. If it does not help, look at your chunking strategy.

The model is mixing up information from different documents

Your context is too noisy. The model is receiving chunks from multiple documents and blending them together. Fix: add metadata filters so that queries only search within relevant document categories. If the user is asking an HR question, filter by category=hr. We covered this in detail in Week 4.

Responses are slow

Two common causes. First: the vector search is slow because your index is not in memory. Make sure you have created an HNSW index on your pgvector table, not just a flat index. Second: the prompt is too large. If you are passing 4 large chunks plus a long conversation history, your prompt might be 6,000 tokens, which slows down the LLM response. Reduce topK to 3 and trim old conversation history to the last 5 exchanges.

The application runs out of memory under load

If you are running pgvector on the same machine as your Spring Boot application, the HNSW index competes for RAM with the JVM. Move the database to a separate instance, or increase your server memory. As a rough guide, plan for 1 GB of RAM per million 1,536-dimensional vectors in the HNSW index.

What to build next on top of this

You now have a working RAG chatbot. Here are the most valuable things to add once you have the core working:

  1. Streaming responses: instead of waiting for the full answer, stream tokens to the frontend as they are generated. Spring AI supports streaming via Flux<String>. This makes the application feel dramatically faster to users.
  2. Document management: add endpoints to list indexed documents, delete a document and its chunks, and re-index an updated version. Without this, your index will grow stale over time.
  3. Multi-format support: extend the ingestion pipeline to handle Word documents, plain text files, and web URLs, not just PDFs.
  4. Authentication: add Spring Security with JWT so that different users have their own sessions and potentially their own document namespaces.
  5. Evaluation: build a small test suite of question-answer pairs and run them against your RAG pipeline automatically. Measure how often the correct answer appears in the retrieved context and how often the model’s answer matches the expected answer.

Each of these is a meaningful addition that moves the application from a prototype to something production-worthy. Pick one, build it, and you will learn more from that single implementation than from reading ten more articles.

What is coming in Week 7?

You have now built a RAG system. But at some point you will face a question that every team building with Gen AI eventually faces: should we use RAG, or should we fine-tune the model on our data?

These are not the same thing, and choosing the wrong one is an expensive mistake. In Week 7, we break down the difference between fine-tuning and RAG, when each approach makes sense, what fine-tuning actually costs, and why most production applications are better served by RAG than by fine-tuning, at least at the scale most teams are operating at.

Key Takeaway

Theory is how you understand a problem. Working code is how you solve it. You now have a complete, running RAG chatbot in Spring Boot that you can extend, demo, and talk about in any interview room. The architecture is production-ready. The patterns are the same ones used in real enterprise AI applications. What you do with it from here is up to you.

Follow the full series at codekerdos.in

New post every week. The most practical Gen-AI content for Java and Spring Boot developers. Join our WhatsApp community for live code walkthroughs, Q&A, and early access to every post.

Scroll to Top