RAG-Nomic LLM using Java and Spring AI
A Retrieval-Augmented Generation (RAG) system built with Spring AI, designed to chat with local documents.
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
- The
DataIngestionServicereads documents (.mdor.javafiles) from a specified directory. - It uses Apache Tika to extract content from the files.
- The content is split into smaller, manageable chunks using
TokenTextSplitter. - These chunks are embedded using Ollama and stored in an in-memory
SimpleVectorStore.
Chat / Retrieval
- The
ChatControllerutilizes aChatClientconfigured with aQuestionAnswerAdvisor. - When a user asks a question, the advisor searches the
VectorStorefor relevant document chunks. - 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.yamlis 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.
- The application sends a text chunk (e.g., “The Chronos Sync Engine listens on internal TCP port 9443.”) to Ollama.
- Ollama processes it using
nomic-embed-textand returns a 768-dimensional vector. - The
VectorStoresaves 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.
- The
QuestionAnswerAdvisorintercepts the question and sends it tonomic-embed-text. - The question is converted into a 768-dimensional vector.
- The application performs a mathematical comparison (typically using Cosine Similarity) between the question’s vector and all the saved vectors in the database.
- 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
EmbeddingClientinterface, 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:
- Context Precision: Did the Vector DB retrieve the exact information needed for the question?
- 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.
Subscribe & Follow
Get notified of new technical articles on AI/ML, Java, Python, and system architecture.