Welcome back!

Resume reading from where you left off?

Cover image for RAG-Nomic LLM using Java and Spring AI
Java

RAG-Nomic LLM using Java and Spring AI

A Retrieval-Augmented Generation (RAG) system built with Spring AI, designed to chat with local documents.

By Pankaj Sonani from aigrama.net • 9 min read
#project#java#spring-ai#rag#llm

Introduction

This application is a Retrieval-Augmented Generation (RAG) system built with Spring AI, designed to chat with local documents.

Architectural Plan

The system follows a standard RAG pattern:

Data Ingestion

  1. The DataIngestionService reads documents (.md or .java files) from a specified directory.
  2. It uses Apache Tika to extract content from the files.
  3. The content is split into smaller, manageable chunks using TokenTextSplitter.
  4. These chunks are embedded using Ollama and stored in an in-memory SimpleVectorStore.

Chat / Retrieval

  1. The ChatController utilizes a ChatClient configured with a QuestionAnswerAdvisor.
  2. When a user asks a question, the advisor searches the VectorStore for relevant document chunks.
  3. These chunks are used as context for the LLM to generate an answer.

How to Setup

Prerequisites

  • JDK 17 or higher installed.
  • Ollama installed and running locally with the necessary embedding model.
  • Ensure your application.yaml is configured to connect to your local Ollama instance.

Build

Use the Gradle wrapper in the project root:

./gradlew build

Run

Start the application using:

./gradlew bootRun

How to Use

Ingest Data

Send a GET request to ingest documents from a specific folder on your machine:

GET http://localhost:8080/ingest?path=C:/path/to/your/documents

Note: Supported file types are .md and .java.

Ask Questions

Send a GET request to ask the model a question:

GET http://localhost:8080/ask?question=Your+question+here

How to Test

Run Unit Tests

The project includes NomicApplicationTests.java. Run them using:

./gradlew test

Manual Verification

After starting the app, use the /ingest endpoint to load files, then use the /ask endpoint to verify that it answers based on the ingested content.

The Role of an Embedding Model

In a Retrieval-Augmented Generation (RAG) pipeline, the chat model (like llama3) and the embedding model (like nomic-embed-text) have completely different responsibilities.

While the chat model is responsible for reading text and writing conversational answers, the embedding model is the mathematical engine that powers the search functionality inside the application database.

What is an Embedding?

Computers cannot inherently understand the semantic meaning or context of human language; they understand math. An embedding model takes a piece of text (a word, a sentence, or an entire code block) and converts it into a long array of numbers, known as a Vector or an Embedding.

nomic-embed-text is specifically designed to output a 768-dimensional vector for every piece of text it processes. This means it translates any text chunk into a sequence of 768 precise floating-point numbers.

The magic of these numbers is that text with similar meanings will produce vectors that sit close to each other in mathematical space, even if they use completely different words.

How it Functions inside the Spring Boot RAG Pipeline

The configuration property tells Spring AI to route text through Ollama to this specific model during two critical phases:

Phase 1: The Ingestion Phase (Writing to the Database)

When the application reads documentation or source files during the ingestion process, the text chunks cannot be stored as raw strings if semantic searching is desired.

  1. The application sends a text chunk (e.g., “The Chronos Sync Engine listens on internal TCP port 9443.”) to Ollama.
  2. Ollama processes it using nomic-embed-text and returns a 768-dimensional vector.
  3. The VectorStore saves both the original text string and its corresponding vector array into the local storage layout.

Phase 2: The Query Phase (Reading from the Database)

When an endpoint receives a question like “Where does the sync engine route traffic?”, a standard keyword search might miss the answer if the word “route” or “traffic” isn’t explicitly in the documentation.

  1. The QuestionAnswerAdvisor intercepts the question and sends it to nomic-embed-text.
  2. The question is converted into a 768-dimensional vector.
  3. The application performs a mathematical comparison (typically using Cosine Similarity) between the question’s vector and all the saved vectors in the database.
  4. The database returns the text chunks whose vectors have the highest mathematical similarity score, providing the chat model with the exact context needed to formulate an accurate response.

Why nomic-embed-text is Chosen for Local Setups

  • Large Context Window: It supports a context window of up to 8,192 tokens. This allows large paragraphs of text or long Java classes to be converted into a single vector without truncating critical details.
  • Hardware Efficiency: It is a lightweight, open-source model. Running it via Ollama utilizes the processing capabilities of hardware like the RX 9070 XT efficiently, generating thousands of vectors in milliseconds without consuming massive amounts of VRAM.

To visualize how text strings are transformed into mathematical arrays and compared for semantic closeness, you can test various phrases in the system to observe how the embedding engine calculates similarity scores.

Service Classe

package com.aigrama.nomic.service;

import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.ollama.OllamaEmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;

import java.io.File;
import java.util.List;

@Service
public class DataIngestionService {

    private final SimpleVectorStore vectorStore;

    public DataIngestionService(@Lazy SimpleVectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public void ingestMyFiles(String directoryPath) {
        File folder = new File(directoryPath);
        File[] listOfFiles = folder.listFiles();

        if (listOfFiles != null) {
            for (File file : listOfFiles) {
                if (file.isFile() && (file.getName().endsWith(".md") || file.getName().endsWith(".java"))) {
                    // 1. Read the file
                    TikaDocumentReader documentReader = new TikaDocumentReader("file:" + file.getAbsolutePath());
                    List<Document> documents = documentReader.get();

                    // 2. Split into smaller chunks using the new Builder API
                    TokenTextSplitter textSplitter = TokenTextSplitter.builder()
                            // Optional: configure custom chunk sizes and overlap here
                            // .withChunkSize(800)
                            .build();

                    List<Document> splitDocuments = textSplitter.apply(documents);

                    // 3. Save to Vector Store
                    vectorStore.add(splitDocuments);
                }
            }
        }
        // 4. Write the memory out to the hard drive so it survives a restart
        File vectorStoreFile = new File("C:/Pan-temp/rag-nomic-llm/data/my-local-vectors.json");
        // Create the 'data' directory if it does not exist yet
        File parentDir = vectorStoreFile.getParentFile();
        if (parentDir != null && !parentDir.exists()) {
            // Check the boolean result to satisfy the IDE lint check
            boolean dirCreated = parentDir.mkdirs();
            if (!dirCreated) {
                throw new RuntimeException("Failed to create storage directory: " + parentDir.getAbsolutePath());
            }
        }
        vectorStore.save(vectorStoreFile);
    }

    // Configuration bean for the local vector store
    @Bean
    public SimpleVectorStore vectorStore(OllamaEmbeddingModel embeddingModel) {
        File vectorStoreFile = new File("C:/Pan-temp/rag-nomic-llm/data/my-local-vectors.json");
        SimpleVectorStore store = SimpleVectorStore.builder(embeddingModel).build();
        if (vectorStoreFile.exists()) {
            store.load(vectorStoreFile);
        }
        return store;
    }
}

Controller Class

package com.aigrama.nomic.controller;

import com.aigrama.nomic.service.DataIngestionService;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Lazy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ChatController {

    private final ChatClient chatClient;
    private final DataIngestionService ingestionService;

    public ChatController(ChatClient.Builder chatClientBuilder, VectorStore vectorStore, @Lazy DataIngestionService ingestionService) {
        this.ingestionService = ingestionService;

        // Explicitly tune the search request parameters for 2.0.0
        SearchRequest finedTunedSearch = SearchRequest.builder()
                .topK(4) // Retrieve top 4 matching document chunks
                .similarityThreshold(0.5) // Ensure it isn't overly restrictive on match confidence
                .build();

        QuestionAnswerAdvisor ragAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder().build())
                .build();
        this.chatClient = chatClientBuilder
                .defaultAdvisors(ragAdvisor)
                .build();
    }

    @GetMapping("/ingest")
    public String ingestData(@RequestParam String path) {
        ingestionService.ingestMyFiles(path);
        return "Successfully ingested files from: " + path;
    }

    @GetMapping("/ask")
    public String askMyModel(@RequestParam String question) {
        String structuredPrompt = question +
                "\n\nFormat your response using clear Markdown: " +
                "Use a bold title, short bullet points for details, " +
                "and a bold highlight for the final recommendation.";
        return chatClient.prompt()
                .user(structuredPrompt)
                .call()
                .content();
    }
}

properties

spring:
  application:
    name: nomic
  ai:
    ollama:
      base-url: http://localhost:11434
      chat:
        model: llama3
        temperature: 0.0
      embedding:
        model: nomic-embed-text

Suggested RAG Verification Prompts

Once you hit your /ingest endpoint with the directory containing this file:

GET http://localhost:8080/ingest?path=C:/Pan-temp/rag-nomic-llm/src/main/resources/ingest

You can run the following test queries against your /ask endpoint to make sure your embedding model and advisor are mapping data correctly:

Test Query 1 (Specific Asset Match)

GET http://localhost:8080/ask?question=Which+hotel+is+associated+with+the+Emerald+City+Luxury+Waterfront+Escape+package
  • Expected Answer: The Edgewater Hotel.

Test Query 2 (Value / Numeric Verification)

GET http://localhost:8080/ask?question=How+much+is+the+Digital+Costco+Shop+Card+value+for+the+family+adventure+package
  • Expected Answer: $250.

Test Query 3 (Context Synthesis)

GET http://localhost:8080/ask?question=If+a+member+does+not+want+a+rental+car,+which+Seattle+package+should+they+choose
  • Expected Answer: The Seattle Sounders Express Weekend package (since it provides Link Light Rail transit passes instead of a vehicle).

Test Query 4 (Architecture Integration)

GET http://localhost:8080/ask?question=What+is+the+purpose+of+Project+Aethelgard+and+what+port+does+Chronos+use
RAG ProjectRAG implementation steps

I’ve analyzed the provided AI stack and created this comprehensive, step-by-step tutorial to guide you through building a Production-Ready RAG (Retrieval-Augmented Generation) application using Java and Spring Boot.

This guide will walk you through each layer of the modern AI ecosystem shown in the diagram, from data ingestion to advanced evaluation, all within the Java environment.


Mastering RAG with Java & Spring AI: A Step-by-Step Tutorial

1. Prerequisites and Setup

To build this stack, we will use Spring AI, a new framework that brings the familiar Spring ecosystem to AI application development, eliminating the need to use Python for orchestration.

Your Stack:

  • Java 17+ and Maven.
  • Spring Boot 3.2.x+.
  • Docker (to run a local Vector Database).

Step 1: Initialize your Spring Boot Project

Create a new Spring Boot project via Spring Initializr or your IDE. Add the required dependencies in your pom.xml. This configuration aligns with the Framework (Layer 6) from the stack diagram.

<dependencies>
    <!-- Web Support for API endpoints -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring AI Starter - provides core abstractions -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    </dependency>

    <!-- Vector DB Starter (e.g., PgVector, Qdrant, Pinecone) -->
    <!-- We'll use PgVector, which runs on PostgreSQL -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
    </dependency>

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

<!-- Required for Spring AI Milestones -->
<repositories>
    <repository>
        <id>spring-milestones</id>
        <name>Spring Milestones</name>
        <url>https://repo.spring.io/milestone</url>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
</repositories>

2. Ingestion: Storing Your Private Data

Ingestion is the foundational stage. It involves taking your unique, private data and preparing it for the AI model to access. It covers Layers 4, 3, and 2 of the AI stack.

graph TD
    A[Raw Private Data: PDFs, Docs, Websites] -->|Layer 4: Data Extraction| B(Text Extraction & Chunking)
    B -->|Layer 3: Text Embeddings| C[Text -> Vector Conversion]
    C -->|Layer 2: Vector Database| D[(Knowledge Base Storage)]

Step 2: Layer 4 — Data Extraction

To allow an LLM to search your data, you must first extract the text from its source format.

  • Market Examples (from diagram): Docling, LlamaParse v2, Firecrawl, Crawl4AI.
  • Java Implementation: For local files, we can use an abstraction in Spring AI that uses Apache Tika to read PDFs, Word docs, and more.

Create a service to load a directory of documents:

import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.stereotype.Service;
import java.nio.file.Path;
import java.util.List;

@Service
public class IngestionService {

    public List<Document> extractTextFromFiles(Path directoryPath) {
        // Look for all PDFs in the specified directory
        TikaDocumentReader tikaReader = new TikaDocumentReader("file:" + directoryPath.toString() + "/*.pdf");
        
        // This extracts the text and creates Spring AI Document objects
        List<Document> extractedDocs = tikaReader.get();
        System.out.println("Extracted " + extractedDocs.size() + " documents from PDFs.");
        return extractedDocs;
    }
}

Step 3: Layer 3 — Text Embeddings

This is the mathematical core of RAG. A Text Embedding model takes text and converts it into a long string of numbers (a “vector”). This vector captures the meaning of the text. Sentences with similar meanings will have vectors close to each other in mathematical space.

  • Market Examples (from diagram): OpenAI, Nomic, Google, Cohere.
  • Java Implementation: We will use Spring AI’s EmbeddingClient interface, which connects to providers like OpenAI or local models.

No extensive coding is needed here; the VectorStore uses the EmbeddingClient automatically when we save the data.

Step 4: Layer 2 — Vector Database

Unlike a standard SQL database, a Vector Database is specialized to store these vectors and perform extremely fast “similarity searches.” When you ask a question, the database doesn’t look for matching keywords; it looks for vectors mathematically closest to the vector of your question.

  • Market Examples (from diagram): Pinecone, PostgreSQL, Qdrant, Milvus, weaviate.
  • Java Implementation: We’ll use PgVector, which adds vector capabilities to PostgreSQL.

Start PgVector locally using Docker:

docker run -p 5432:5432 -e POSTGRES_USER=myuser -e POSTGRES_PASSWORD=mypassword -e POSTGRES_DB=mydb ankane/pgvector

Configure your Spring application to connect to PgVector and OpenAI in application.properties:

# Vector DB Config
spring.ai.vectorstore.pgvector.schema-name=public
spring.ai.vectorstore.pgvector.table-name=vector_store
spring.ai.vectorstore.pgvector.dimension=1536 # For OpenAI embeddings
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=myuser
spring.datasource.password=mypassword

# LLM & Embedding Config
spring.ai.openai.api-key=${OPENAI_API_KEY} # Set this in your environment

Step 5: Complete the Ingestion Pipeline

Now, update the IngestionService to combine extraction, embedding, and storage.

import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.nio.file.Path;
import java.util.List;

@Service
public class IngestionService {

    private final VectorStore vectorStore;

    // The vectorStore is auto-configured to use PgVector and OpenAI Embeddings
    public IngestionService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public void ingestDirectory(Path directoryPath) {
        // 1. Data Extraction (Layer 4)
        TikaDocumentReader tikaReader = new TikaDocumentReader("file:" + directoryPath.toString() + "/*.pdf");
        List<Document> documents = tikaReader.get();

        // 2. Chunking (Critical Step for advanced RAG)
        // Models can't process massive PDFs at once. We split them into smaller, meaningful pieces.
        TokenTextSplitter splitter = new TokenTextSplitter();
        List<Document> splitDocuments = splitter.apply(documents);

        // 3. Text Embeddings & 4. Vector DB Storage (Layers 3 & 2)
        // This single line converts the text chunks into vectors AND stores them in PgVector.
        this.vectorStore.accept(splitDocuments);
        System.out.println("Stored " + splitDocuments.size() + " text chunks in the Vector DB.");
    }
}

3. Retrieval and Generation: The RAG Flow

With your knowledge base established, you can now build the application that answers questions. This involves Layers 6, 1, and 5 of the stack.

graph TD
    A[User Question] -->|Layer 3: Embed Query| B[Question Vector]
    B -->|Similarity Search| C[(Layer 2: Vector DB)]
    C -->|Retrieve Top Matches| D[Context Documents]
    D & A -->|Layer 6: Framework orchestrates| E[Constructed Prompt]
    E -->|Layer 1: LLM| F[Answer grounded in context]

Step 6: Layer 6 — Framework Orchestration (Retrieval)

The framework (Spring AI) handles the flow. It takes the user’s question, uses the VectorStore to find relevant documents, and builds the “grounded” prompt.

Step 7: Layer 1 — LLMs & Step 8: Layer 5 — Open LLM Access (Generation)

This layer provides the intelligence to generate the answer. You have options here:

  • Closed Models (Layer 1): OpenAI (GPT-4), Claude, Gemini. These are accessed via API and are typically the most capable.
  • Open Models (Layer 5): Llama 4, Qwen, Gemma 3, etc. These can be run locally using a tool like Ollama or accessed via high-speed providers like Groq.

Create a RagService to orchestrate this flow:

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
public class RagService {

    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    // ChatClient abstraction works for OpenAI, Llama (via Ollama), or any Layer 1/5 model.
    public RagService(VectorStore vectorStore, ChatClient.Builder chatClientBuilder) {
        this.vectorStore = vectorStore;
        this.chatClient = chatClientBuilder.build();
    }

    public String generateAnswerWithContext(String userQuestion) {
        // 1. Retrieval: Perform similarity search against the Vector DB
        // By default, this converts the user's question into a vector first.
        List<Document> similarities = this.vectorStore.similaritySearch(
                SearchRequest.query(userQuestion).withTopK(3) // Get top 3 most relevant chunks
        );

        // 2. Concatenate the retrieved text chunks to form our context
        String contextInformation = similarities.stream()
                .map(Document::getContent)
                .collect(Collectors.joining("\n\n"));

        // 3. Prompt Construction: Ground the LLM with this context
        String promptTemplate = """
                You are a precise corporate assistant. Use the provided Context section to answer the User Question.
                If the answer is not contained within the context, politely state that you do not know the answer.
                Do not make up facts.
                
                [Context Information]
                %s
                
                [User Question]
                %s
                """;

        String finalPrompt = promptTemplate.formatted(contextInformation, userQuestion);

        // 4. Generation: Call the model (Layer 1: e.g., OpenAI, Claude, Gemini)
        return chatClient.prompt().user(finalPrompt).call().content();
    }
}

Step 9: Expose the API

Create a controller to handle user interaction.

import org.springframework.web.bind.annotation.*;
import java.nio.file.Paths;

@RestController
@RequestMapping("/api/ai")
public class AiController {

    private final IngestionService ingestionService;
    private final RagService ragService;

    public AiController(IngestionService ingestionService, RagService ragService) {
        this.ingestionService = ingestionService;
        this.ragService = ragService;
    }

    // Step 1: Upload and ingest data from a directory
    @PostMapping("/ingest")
    public String ingestData(@RequestParam String path) {
        ingestionService.ingestDirectory(Paths.get(path));
        return "Ingestion complete.";
    }

    // Step 2: Ask a question based on your ingested data
    @GetMapping("/ask")
    public String ask(@RequestParam String question) {
        return ragService.generateAnswerWithContext(question);
    }
}

4. Advanced: Evaluation (Layer 7)

A basic RAG system is easy to build but hard to make reliable. Evaluation is the critical final layer for enterprise systems.

  • Market Examples (from diagram): Ragas, TruLens, Giskard.

Why is this needed? A “naive” RAG system might retrieve the wrong information (bad Retrieval) or the LLM might ignore the context and make something up (Hallucination/bad Generation).

Advanced RAG requires setting up an Evaluation Pipeline where you use an “LLM-as-a-judge” to verify the outputs. For example, using a library like Ragas to generate metrics:

  1. Context Precision: Did the Vector DB retrieve the exact information needed for the question?
  2. Faithfulness: Is the generated answer derived only from the retrieved context?

To implement this advanced layer, you would typically integrate evaluation tools as part of your CI/CD pipeline or run continuous sampling in production to ensure answer quality is maintaining enterprise standards.

Enjoyed this article?

Subscribe & Follow

Get notified of new technical articles on AI/ML, Java, Python, and system architecture.