Production-Grade RAG with Spring AI 1.0 in Java: Chunking, Reranking, and Hallucination Guards

Most RAG tutorials stop at “load a PDF, embed it, ask a question.” That works in a demo. In production โ€” handling 10 000 documents, diverse query types, and user expectations of factual accuracy โ€” it falls apart within weeks. This guide walks through every layer of a production-grade Retrieval-Augmented Generation (RAG) system built entirely in Java using Spring AI 1.1.0: from chunking strategy selection and vector store tuning to reranking, hallucination guards, cost tracking, and observability.

What Is RAG and Why Does It Break in Production?

RAG is the pattern of augmenting an LLM’s prompt with context retrieved from your own data. It solves the two biggest problems with raw LLMs: outdated training data and hallucination on domain-specific facts. The architecture has three phases โ€” ingest, retrieve, and generate โ€” and production failures happen in all three.

Common failure modes include:

  • Chunk boundary problems โ€” a paragraph is split mid-sentence; the retrieved chunk has no context
  • Top-K retrieval noise โ€” irrelevant chunks appear in the top 5 because cosine similarity is imperfect
  • Context window overflow โ€” too many chunks are stuffed into the prompt, increasing cost and degrading quality
  • Answer faithfulness drift โ€” the model ignores the retrieved context and hallucinates anyway
  • No observability โ€” you have no idea which retrieval steps caused a wrong answer

Spring AI 1.1.0 (released November 2025) gives Java teams a production-ready toolkit to address each of these. Let’s build it layer by layer.

Architecture Overview

The system we’ll build has two pipelines: an ingestion pipeline (run offline or on document change) and a query pipeline (run on every user request).

Architecture Diagram

The ingestion pipeline is a one-time (or incremental) process. The query pipeline runs on every user request and must complete within your latency budget โ€” typically under 2 seconds end-to-end.

Project Setup: Spring AI 1.1.0 Dependencies

<properties>
    <spring-ai.version>1.1.0</spring-ai.version>
    <java.version>21
    <spring-boot.version>3.4.5</spring-boot.version></java.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 Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring AI: OpenAI (chat + embeddings) -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    </dependency>

    <!-- Spring AI: pgvector (PostgreSQL vector store) -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
    </dependency>

    <!-- Spring AI: PDF document reader -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-pdf-document-reader</artifactId>
    </dependency>

    <!-- Micrometer for observability -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
</dependencies>

Step 1: Smart Chunking โ€” The Most Underrated Skill in RAG

The quality of your retrieval is determined more by how you chunk than by which embedding model you use. Spring AI provides a TokenTextSplitter, but production systems need more nuance. Here are the three chunking strategies and when to use each:

Strategy A: Fixed-Size Sliding Window (Baseline)

Use this for homogeneous text like customer reviews or log entries โ€” documents where structure doesn’t matter.

import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.document.Document;
import java.util.List;

/**
 * Fixed-size sliding window chunker.
 * chunkSize:    target tokens per chunk (matches your embedding model's window)
 * minChunkSize: discard chunks smaller than this (avoids noise from short fragments)
 * overlap:      token overlap between adjacent chunks to preserve context continuity
 */
public class FixedWindowChunker {

    private final TokenTextSplitter splitter;

    public FixedWindowChunker() {
        this.splitter = new TokenTextSplitter(
            512,    // chunkSize - matches text-embedding-3-small's sweet spot
            128,    // overlap   - keeps ~25% context across chunk boundaries
            5,      // minChunkCharsToKeep - discard tiny trailing fragments
            10_000, // maxNumChunks - safety limit per document
            true    // keepSeparator - preserves newlines as chunk boundaries
        );
    }

    public List<Document> chunk(List<Document> rawDocs) {
        // Adds chunk index + source metadata to each chunk automatically
        return splitter.apply(rawDocs);
    }
}

Strategy B: Recursive Character Splitter (Recommended for Most Cases)

This tries paragraph โ†’ sentence โ†’ word boundaries in order, only splitting at a finer level when needed. It produces semantically coherent chunks far more often than a naive fixed split.

import org.springframework.ai.document.Document;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Recursive character splitter that respects natural text boundaries.
 * Tries separators in order: paragraph break โ†’ sentence โ†’ word โ†’ character.
 * Produces semantically coherent chunks for prose, technical docs, and FAQs.
 */
public class RecursiveCharacterChunker {

    // Ordered from coarsest to finest โ€” we prefer large, natural units
    private static final List<String> SEPARATORS = List.of(
        "nn",   // paragraph break (ideal)
        "n",     // line break
        ". ",     // sentence boundary
        " ",      // word boundary (last resort)
        ""        // character split (emergency fallback)
    );

    private final int targetChunkSize;   // characters, not tokens
    private final int overlapSize;

    public RecursiveCharacterChunker(int targetChunkSize, int overlapSize) {
        this.targetChunkSize = targetChunkSize;
        this.overlapSize = overlapSize;
    }

    public List<Document> chunk(Document sourceDoc) {
        List<String> chunks = splitText(sourceDoc.getText(), SEPARATORS);
        List<Document> result = new ArrayList<>();

        for (int i = 0; i < chunks.size(); i++) {
            // Carry forward source metadata + add chunk-specific metadata
            Map<String, Object> metadata = new java.util.HashMap<>(sourceDoc.getMetadata());
            metadata.put("chunk_index", i);
            metadata.put("chunk_total", chunks.size());
            metadata.put("source_id", sourceDoc.getId());
            result.add(new Document(chunks.get(i), metadata));
        }
        return result;
    }

    private List<String> splitText(String text, List<String> separators) {
        List<String> finalChunks = new ArrayList<>();
        String separator = separators.get(separators.size() - 1); // fallback

        // Find the first separator that actually exists in the text
        for (String sep : separators) {
            if (sep.isEmpty() || text.contains(sep)) {
                separator = sep;
                break;
            }
        }

        // Split on separator, merge small pieces back up to targetChunkSize
        String[] splits = separator.isEmpty()
            ? text.chars().mapToObj(c -> String.valueOf((char) c)).toArray(String[]::new)
            : text.split(java.util.regex.Pattern.quote(separator), -1);

        StringBuilder currentChunk = new StringBuilder();
        for (String split : splits) {
            if (currentChunk.length() + split.length() <= targetChunkSize) {
                if (!currentChunk.isEmpty()) currentChunk.append(separator);
                currentChunk.append(split);
            } else {
                if (!currentChunk.isEmpty()) {
                    finalChunks.add(currentChunk.toString());
                    // Keep overlap from the end of the previous chunk
                    String overlap = currentChunk.length() > overlapSize
                        ? currentChunk.substring(currentChunk.length() - overlapSize)
                        : currentChunk.toString();
                    currentChunk = new StringBuilder(overlap).append(separator).append(split);
                } else {
                    // Single split is larger than target โ€” recurse with finer separators
                    List<String> remainingSeps = separators.subList(
                        separators.indexOf(separator) + 1, separators.size()
                    );
                    if (!remainingSeps.isEmpty()) {
                        finalChunks.addAll(splitText(split, remainingSeps));
                    } else {
                        finalChunks.add(split); // cannot split further
                    }
                }
            }
        }
        if (!currentChunk.isEmpty()) finalChunks.add(currentChunk.toString());
        return finalChunks;
    }
}

Strategy C: Semantic Chunking (Highest Quality, Higher Cost)

Semantic chunking embeds individual sentences, then splits at points where cosine similarity between adjacent sentences drops sharply. Use this when document quality matters most (legal docs, medical records, technical specifications).

import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.document.Document;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Semantic chunker: splits text at points of low semantic similarity.
 * Uses the embedding model to detect topic shifts between sentences.
 * Cost: 1 embedding call per sentence โ†’ use for high-value documents only.
 *
 * breakpointThreshold: cosine distance above which a split is inserted.
 *   0.3 = aggressive splitting (more, smaller chunks)
 *   0.6 = conservative splitting (fewer, larger chunks)
 */
public class SemanticChunker {

    private final EmbeddingModel embeddingModel;
    private final double breakpointThreshold;

    public SemanticChunker(EmbeddingModel embeddingModel, double breakpointThreshold) {
        this.embeddingModel = embeddingModel;
        this.breakpointThreshold = breakpointThreshold;
    }

    public List<Document> chunk(Document sourceDoc) {
        // 1. Split into sentences (simple regex; replace with NLP tokenizer for production)
        String[] sentences = sourceDoc.getText().split("(?<=[.!?])s+");

        // 2. Embed each sentence individually
        List<float[]> embeddings = new ArrayList<>();
        for (String sentence : sentences) {
            embeddings.add(toFloatArray(embeddingModel.embed(sentence)));
        }

        // 3. Find breakpoints where similarity drops below threshold
        List<Integer> breakpoints = new ArrayList<>();
        for (int i = 0; i < embeddings.size() - 1; i++) {
            double similarity = cosineSimilarity(embeddings.get(i), embeddings.get(i + 1));
            if (1.0 - similarity > breakpointThreshold) {
                breakpoints.add(i + 1); // insert split AFTER this sentence
            }
        }

        // 4. Merge sentences into chunks at breakpoint boundaries
        List<Document> chunks = new ArrayList<>();
        int start = 0;
        for (int bp : breakpoints) {
            String chunkText = joinSentences(sentences, start, bp);
            Map<String, Object> meta = new java.util.HashMap<>(sourceDoc.getMetadata());
            meta.put("chunk_index", chunks.size());
            chunks.add(new Document(chunkText, meta));
            start = bp;
        }
        // Final chunk from last breakpoint to end
        if (start < sentences.length) {
            chunks.add(new Document(joinSentences(sentences, start, sentences.length),
                new java.util.HashMap<>(sourceDoc.getMetadata())));
        }
        return chunks;
    }

    private String joinSentences(String[] sentences, int from, int to) {
        return String.join(" ", java.util.Arrays.copyOfRange(sentences, from, to));
    }

    private float[] toFloatArray(List<Double> doubles) {
        float[] arr = new float[doubles.size()];
        for (int i = 0; i < doubles.size(); i++) arr[i] = doubles.get(i).floatValue();
        return arr;
    }

    private double cosineSimilarity(float[] a, float[] b) {
        double dot = 0, normA = 0, normB = 0;
        for (int i = 0; i < a.length; i++) {
            dot += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-10);
    }
}

Step 2: Ingestion Pipeline with Spring AI ETL

Spring AI 1.1’s ETL framework connects document readers, transformers, and vector store writers into a declarative pipeline. Here’s the complete ingestion service for a document knowledge base:

import org.springframework.ai.document.Document;
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.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.util.List;
import java.util.Map;

/**
 * Ingestion service: reads documents, chunks them, and stores embeddings.
 * Supports incremental updates via source hash tracking to avoid re-indexing.
 */
@Service
public class DocumentIngestionService {

    private final VectorStore vectorStore;
    private final MeterRegistry meterRegistry;
    private final IngestionTracker ingestionTracker; // tracks source โ†’ hash โ†’ ingested timestamp

    public DocumentIngestionService(VectorStore vectorStore,
                                    MeterRegistry meterRegistry,
                                    IngestionTracker ingestionTracker) {
        this.vectorStore = vectorStore;
        this.meterRegistry = meterRegistry;
        this.ingestionTracker = ingestionTracker;
    }

    /**
     * Ingests a PDF resource into the vector store.
     * Skips ingestion if the document hash hasn't changed since last run (idempotent).
     */
    public IngestionResult ingestPdf(Resource pdfResource, Map<String, Object> metadata) {
        String sourceHash = computeHash(pdfResource);

        // Idempotency check: skip re-ingestion of unchanged documents
        if (ingestionTracker.isAlreadyIngested(pdfResource.getFilename(), sourceHash)) {
            return IngestionResult.skipped(pdfResource.getFilename());
        }

        Timer.Sample sample = Timer.start(meterRegistry);

        try {
            // 1. Read PDF โ€” one Document per page preserves page-level metadata
            PdfDocumentReaderConfig readerConfig = PdfDocumentReaderConfig.builder()
                .withPageExtractedTextFormatter(text -> text.replaceAll("s{3,}", "  "))
                .withPagesPerDocument(1)  // one Document object per PDF page
                .build();

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

            // 2. Enrich metadata: source filename, ingest timestamp, custom fields
            List<Document> enriched = pages.stream()
                .map(doc -> {
                    Map<String, Object> meta = new java.util.HashMap<>(doc.getMetadata());
                    meta.put("source_file", pdfResource.getFilename());
                    meta.put("source_hash", sourceHash);
                    meta.put("ingested_at", System.currentTimeMillis());
                    meta.putAll(metadata); // caller-supplied metadata (e.g., tenant_id, doc_type)
                    return new Document(doc.getText(), meta);
                })
                .toList();

            // 3. Chunk with token splitter (swap for SemanticChunker for high-value docs)
            TokenTextSplitter splitter = new TokenTextSplitter(512, 128, 5, 10_000, true);
            List<Document> chunks = splitter.apply(enriched);

            // 4. Embed + store in one batch call (Spring AI handles batching internally)
            vectorStore.add(chunks);

            // 5. Record successful ingestion for idempotency
            ingestionTracker.markIngested(pdfResource.getFilename(), sourceHash, chunks.size());

            sample.stop(meterRegistry.timer("rag.ingestion.duration",
                "source", pdfResource.getFilename()));

            meterRegistry.counter("rag.chunks.ingested").increment(chunks.size());

            return IngestionResult.success(pdfResource.getFilename(), chunks.size());

        } catch (Exception ex) {
            meterRegistry.counter("rag.ingestion.errors").increment();
            throw new IngestionException("Failed to ingest " + pdfResource.getFilename(), ex);
        }
    }

    private String computeHash(Resource resource) {
        try {
            byte[] bytes = resource.getInputStream().readAllBytes();
            var digest = java.security.MessageDigest.getInstance("SHA-256");
            return java.util.HexFormat.of().formatHex(digest.digest(bytes));
        } catch (Exception ex) {
            throw new RuntimeException("Cannot hash resource", ex);
        }
    }
}

Step 3: Query Pipeline with Reranking

The naive query pipeline: embed query โ†’ top-K cosine similarity search โ†’ inject all K chunks into prompt. The production query pipeline adds a reranker step that scores retrieved chunks by relevance to the query and prunes the noisy ones before they reach the LLM.

Why reranking matters: embedding models are optimized for semantic similarity, not for direct relevance to a specific question. A chunk about “interest rates” might score highly for the query “how does the Fed control inflation?” even if it’s from a history article about the 1970s and is completely unhelpful. Cross-encoder rerankers are trained specifically on question-document relevance and dramatically outperform raw cosine similarity at this task.

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.Comparator;
import java.util.List;
import java.util.Map;

/**
 * Production RAG query pipeline:
 *   1. Embed query and retrieve top-K candidates from vector store
 *   2. Rerank candidates using a cross-encoder model
 *   3. Build a context window from the top-N reranked chunks
 *   4. Call the LLM with the augmented prompt
 *   5. Run a hallucination guard (faithfulness check) on the response
 */
@Service
public class RagQueryService {

    // Conservative top-K: retrieve more candidates than you'll use, then rerank
    private static final int INITIAL_TOP_K = 20;
    // After reranking, only the top-N chunks enter the prompt
    private static final int RERANKED_TOP_N = 5;

    private final VectorStore vectorStore;
    private final ChatClient chatClient;
    private final CrossEncoderReranker reranker;
    private final HallucinationGuard hallucinationGuard;

    public RagQueryService(VectorStore vectorStore,
                           ChatClient.Builder chatClientBuilder,
                           CrossEncoderReranker reranker,
                           HallucinationGuard hallucinationGuard) {
        this.vectorStore = vectorStore;
        this.chatClient = chatClientBuilder.build();
        this.reranker = reranker;
        this.hallucinationGuard = hallucinationGuard;
    }

    public RagResponse query(String userQuestion, RagQueryOptions options) {
        // Step 1: Vector store retrieval โ€” cast a wide net
        SearchRequest searchRequest = SearchRequest.builder()
            .query(userQuestion)
            .topK(INITIAL_TOP_K)
            .similarityThreshold(0.6) // reject low-quality matches early
            .filterExpression(options.metadataFilter()) // optional tenant/doc-type filter
            .build();

        List<Document> candidates = vectorStore.similaritySearch(searchRequest);

        if (candidates.isEmpty()) {
            return RagResponse.noContext("No relevant documents found for your question.");
        }

        // Step 2: Rerank candidates by relevance to the query
        List<ScoredDocument> reranked = reranker.rerank(userQuestion, candidates);
        List<Document> topChunks = reranked.stream()
            .sorted(Comparator.comparingDouble(ScoredDocument::score).reversed())
            .limit(RERANKED_TOP_N)
            .map(ScoredDocument::document)
            .toList();

        // Step 3: Build context string (preserve source attribution)
        StringBuilder contextBuilder = new StringBuilder();
        for (int i = 0; i < topChunks.size(); i++) {
            Document chunk = topChunks.get(i);
            contextBuilder.append("--- Source ").append(i + 1).append(" ---n");
            contextBuilder.append("File: ").append(chunk.getMetadata().get("source_file")).append("n");
            contextBuilder.append("Page: ").append(chunk.getMetadata().get("page_number")).append("n");
            contextBuilder.append(chunk.getText()).append("nn");
        }
        String context = contextBuilder.toString();

        // Step 4: Prompt the LLM with retrieved context
        // The prompt template is strict: answer ONLY from the context, or say "I don't know"
        String promptText = """
            You are a precise assistant that answers questions using ONLY the provided context.
            If the answer is not explicitly stated in the context, respond with exactly:
            "I don't have enough information in the provided documents to answer this question."
            Do NOT speculate, extrapolate, or use knowledge outside the context.

            CONTEXT:
            {context}

            QUESTION:
            {question}

            ANSWER:
            """;

        String rawAnswer = chatClient.prompt()
            .user(spec -> spec.text(promptText)
                .param("context", context)
                .param("question", userQuestion))
            .call()
            .content();

        // Step 5: Hallucination guard โ€” verify answer is grounded in retrieved context
        FaithfulnessResult faithfulness = hallucinationGuard.check(rawAnswer, topChunks);

        return RagResponse.builder()
            .answer(rawAnswer)
            .sourceChunks(topChunks)
            .faithfulnessScore(faithfulness.score())
            .isGrounded(faithfulness.isGrounded())
            .build();
    }
}

Cross-Encoder Reranker Implementation

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.stereotype.Component;
import java.util.List;

/**
 * LLM-based cross-encoder reranker.
 * Uses a fast, cheap model (e.g., GPT-4o-mini) to score each chunk's relevance
 * to the query on a 0-10 scale.
 *
 * Alternative: use a local cross-encoder model via ONNX Runtime for zero latency cost.
 * See: https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2
 */
@Component
public class CrossEncoderReranker {

    private final ChatClient rerankerClient;

    // Reranking prompt is kept minimal for speed and cost efficiency
    private static final String RERANK_PROMPT = """
        Rate the relevance of this passage to the query on a scale of 0-10.
        Respond with ONLY a single integer (0-10). No explanation.

        Query: {query}
        Passage: {passage}
        Score:""";

    public CrossEncoderReranker(ChatClient.Builder builder) {
        // Use a fast, cheap model for reranking โ€” not your best model
        this.rerankerClient = builder.build();
    }

    public List<ScoredDocument> rerank(String query, List<Document> candidates) {
        return candidates.parallelStream() // parallel scoring for lower wall-clock latency
            .map(doc -> {
                try {
                    String response = rerankerClient.prompt()
                        .user(spec -> spec.text(RERANK_PROMPT)
                            .param("query", query)
                            .param("passage", truncate(doc.getText(), 512)))
                        .call()
                        .content()
                        .trim();

                    double score = Double.parseDouble(response);
                    return new ScoredDocument(doc, score);
                } catch (NumberFormatException ex) {
                    // Fallback to 0 score if parsing fails โ€” safer than throwing
                    return new ScoredDocument(doc, 0.0);
                }
            })
            .toList();
    }

    // Truncate passage to avoid exceeding reranker context window
    private String truncate(String text, int maxChars) {
        return text.length() > maxChars ? text.substring(0, maxChars) + "โ€ฆ" : text;
    }
}

// Simple record to carry a document and its reranking score together
record ScoredDocument(Document document, double score) {}

Step 4: Hallucination Guard

Even with a strict prompt, LLMs occasionally generate answers that aren’t grounded in the retrieved context. A hallucination guard runs a second LLM call that checks whether every factual claim in the answer is supported by at least one source chunk. This is called a faithfulness check.

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.stereotype.Component;
import java.util.List;

/**
 * Faithfulness-based hallucination guard.
 * Uses an LLM judge to verify that the generated answer is grounded in the context.
 *
 * Returns a FaithfulnessResult with:
 *   - score: 0.0 (fully hallucinated) to 1.0 (fully grounded)
 *   - isGrounded: true if score >= threshold (default 0.7)
 */
@Component
public class HallucinationGuard {

    private static final double GROUNDING_THRESHOLD = 0.7;

    private final ChatClient judgeClient;

    private static final String JUDGE_PROMPT = """
        You are a fact-checking assistant. Your job is to determine if an ANSWER is
        supported by the provided CONTEXT documents.

        Rate the answer's faithfulness to the context as a decimal between 0.0 and 1.0:
          1.0 = every claim in the answer is explicitly supported by the context
          0.5 = some claims are supported, some are not
          0.0 = the answer contains claims not found in or contradicted by the context

        Respond with ONLY a decimal number (e.g., 0.85). No explanation.

        CONTEXT:
        {context}

        ANSWER:
        {answer}

        FAITHFULNESS SCORE:""";

    public HallucinationGuard(ChatClient.Builder builder) {
        this.judgeClient = builder.build();
    }

    public FaithfulnessResult check(String answer, List<Document> contextChunks) {
        // Collapse chunks into a single context string for the judge
        String context = contextChunks.stream()
            .map(Document::getText)
            .reduce("", (a, b) -> a + "n---n" + b);

        try {
            String rawScore = judgeClient.prompt()
                .user(spec -> spec.text(JUDGE_PROMPT)
                    .param("context", context)
                    .param("answer", answer))
                .call()
                .content()
                .trim();

            double score = Double.parseDouble(rawScore);
            score = Math.max(0.0, Math.min(1.0, score)); // clamp to [0, 1]
            return new FaithfulnessResult(score, score >= GROUNDING_THRESHOLD);

        } catch (Exception ex) {
            // On judge failure: conservative โ€” mark as potentially ungrounded
            return new FaithfulnessResult(0.0, false);
        }
    }
}

record FaithfulnessResult(double score, boolean isGrounded) {}

Step 5: Observability and Cost Tracking

In production, you need to know: which queries triggered reranking, what the average faithfulness score is, and how much each query costs. Spring AI’s observability integration with Micrometer makes this straightforward.

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.stereotype.Component;

/**
 * RAG observability: tracks latency, token usage, faithfulness scores, and cache hits.
 * Exposes Prometheus metrics consumed by Grafana dashboards.
 *
 * Key metrics:
 *   rag.query.duration          - end-to-end query latency (histogram)
 *   rag.retrieval.candidates    - number of candidates before reranking (histogram)
 *   rag.faithfulness.score      - LLM judge score distribution (histogram)
 *   rag.tokens.prompt           - prompt tokens per query (counter)
 *   rag.tokens.completion       - completion tokens per query (counter)
 *   rag.cache.hits              - semantic cache hit rate (counter)
 */
@Component
public class RagObservability {

    private final MeterRegistry meterRegistry;

    public RagObservability(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    public void recordQuery(RagQueryMetrics metrics) {
        // Latency breakdown by pipeline stage
        meterRegistry.timer("rag.retrieval.duration")
            .record(metrics.retrievalDuration());
        meterRegistry.timer("rag.reranking.duration")
            .record(metrics.rerankingDuration());
        meterRegistry.timer("rag.generation.duration")
            .record(metrics.generationDuration());

        // Retrieval quality metrics
        meterRegistry.summary("rag.retrieval.candidates")
            .record(metrics.candidateCount());
        meterRegistry.summary("rag.faithfulness.score")
            .record(metrics.faithfulnessScore());

        // Token cost tracking (map to dollar cost using your model's pricing)
        meterRegistry.counter("rag.tokens.prompt").increment(metrics.promptTokens());
        meterRegistry.counter("rag.tokens.completion").increment(metrics.completionTokens());

        // Tag with grounding status for alert rules
        meterRegistry.counter("rag.queries.total",
            "grounded", String.valueOf(metrics.isGrounded()),
            "model", metrics.modelName()
        ).increment();

        // Alert if faithfulness drops below threshold โ†’ potential prompt injection or model regression
        if (metrics.faithfulnessScore() < 0.5) {
            meterRegistry.counter("rag.faithfulness.alerts").increment();
        }
    }
}

Performance Comparison: Chunking Strategies

Performance Comparison Chart

The benchmark above was run against a 500-document corpus of technical PDFs, using text-embedding-3-small for embeddings and GPT-4o-mini for the reranker and judge. Key takeaways:

  1. Fixed-size chunking is fast and cheap but yields the worst retrieval precision (71%). Acceptable for homogeneous, low-stakes corpora.
  2. Recursive character splitting is the best default for most production workloads โ€” good precision (82%), low cost overhead, and fast ingestion.
  3. Semantic chunking achieves the best precision (91%) but costs 8ร— more at ingestion time due to per-sentence embedding calls. Use it for your most important documents.

Anti-Patterns to Avoid

Anti-Pattern 1: Re-Ingesting Unchanged Documents

Every time your application starts, don’t re-index your entire document corpus. Track a SHA-256 hash of each source document and skip re-ingestion if the hash hasn’t changed. Not doing this will burn embedding API budget and slow deployments.

Anti-Pattern 2: Injecting All K Retrieved Chunks into the Prompt

More context is not always better. Injecting 20 chunks into a 128K context window costs money and, counterintuitively, degrades answer quality โ€” the model’s attention is diluted across irrelevant chunks. Always rerank and prune to 3-7 high-relevance chunks before generation.

Anti-Pattern 3: Ignoring Metadata Filters

If your vector store contains documents from multiple tenants, product lines, or time periods, a pure semantic search will mix them together. Always use metadata filters to scope searches to the relevant subset. Spring AI’s filter expression API supports compound boolean filters.

Anti-Pattern 4: No Semantic Cache

The most expensive operation in a RAG pipeline is the LLM generation call. For enterprise knowledge bases, a significant fraction of queries are near-duplicates (e.g., “What is the refund policy?” asked by hundreds of users). A semantic cache intercepts queries whose embedding is close to a previously answered query and serves the cached response. Spring AI supports this via SemanticSearchCacheAdvisor.

application.yml โ€” Complete Production Configuration

# application.yml โ€” Spring AI 1.1 production RAG configuration
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}   # Always inject from environment, never hardcode
      chat:
        options:
          model: gpt-4o             # Primary generation model
          temperature: 0.1          # Low temperature = more deterministic, better for factual RAG
          max-tokens: 1024
      embedding:
        options:
          model: text-embedding-3-small  # Cost-efficient; upgrade to large only if precision < 80%
    vectorstore:
      pgvector:
        index-type: HNSW            # HNSW outperforms IVFFlat for <10M vectors
        distance-type: COSINE_DISTANCE
        dimensions: 1536            # Must match embedding model output dimensions
        initialize-schema: false    # Always false in production โ€” manage schema with Flyway
        schema-name: rag
        table-name: document_chunks

# Observability โ€” expose RAG metrics on /actuator/prometheus
management:
  endpoints:
    web:
      exposure:
        include: health, info, prometheus, metrics
  metrics:
    tags:
      application: ${spring.application.name}
      environment: ${APP_ENV:local}

# Logging โ€” structured JSON for log aggregation (Loki, Splunk, etc.)
logging:
  structured:
    format:
      console: ecs    # Elastic Common Schema JSON output

Decision Tree: Which RAG Configuration Should You Use?

RAG Configuration Decision Tree

Key Takeaways

Key Takeaways

  • Chunking strategy is the biggest lever for retrieval quality. Start with recursive character splitting and upgrade to semantic chunking for high-value documents.
  • Always rerank. Retrieve K=20 candidates, rerank with a cross-encoder, inject only the top 3-7 into the prompt.
  • A hallucination guard adds one LLM call but catches response quality regressions before they reach users โ€” essential for production.
  • Spring AI 1.0’s ETL framework handles batching, retries, and metadata enrichment out of the box. Use it instead of rolling your own ingestion loops.
  • Track faithfulness score as a Prometheus metric and alert when it drops below 0.7 โ€” it’s your canary for prompt injection attempts and model regressions.
  • Add a semantic cache for enterprise deployments with repetitive queries. It can cut LLM costs by 40-60% on knowledge base workloads.

Frequently Asked Questions

What is the best vector store for Spring AI in production?

For most teams already using PostgreSQL, pgvector with HNSW indexing is the easiest production choice โ€” it requires no new infrastructure and supports up to ~5 million vectors with sub-100ms queries. For vector-first workloads at scale (>10 million vectors, multi-tenancy, or complex filters), Qdrant or Weaviate are better fits. Spring AI supports both with auto-configuration.

How do I handle multi-tenant RAG in Spring AI?

Store a tenant_id field in every chunk’s metadata during ingestion. At query time, pass a filter expression to SearchRequest: SearchRequest.builder().filterExpression("tenant_id == '" + tenantId + "'").build(). This ensures each tenant can only retrieve their own documents. Use Spring Security’s tenant resolution to inject the tenant ID at the service layer.

How do I handle documents that update frequently?

Implement an incremental update strategy: on each document change event, delete all chunks with the old source hash and re-ingest the new version. Use the IngestionTracker pattern shown above to track source hash โ†’ chunk IDs for efficient targeted deletion. Do not re-index unchanged documents to save embedding costs.

What chunk size should I use for code documentation?

For code documentation, use a chunk size of 256-384 tokens (smaller than prose) with a function-level split strategy โ€” treat each function/method as a natural chunk boundary. This preserves semantic coherence because a partial function signature without its body is rarely useful in retrieval. Combine with metadata tagging: doc_type=api_reference, language=java.

Is Spring AI production-ready in 2026?

Spring AI 1.1.0 (November 2025) is production-ready..1.0 was released in May 2025 after an extensive beta period. It is backed by VMware/Broadcom (Spring team) and is used in production at Microsoft (hundreds of enterprise customers) and at major financial institutions via the LangChain4j integration path. The API is stable with semantic versioning guarantees from 1.0 onward.

See Also

Conclusion

Building a production-grade RAG system in Java is no longer a Python-vs-Java trade-off. With Spring AI 1.1.0, you get enterprise-grade document ingestion, pluggable vector stores, reranking, and observability โ€” all in the familiar Spring programming model. The key is to move beyond the Hello World tutorial and invest in the three layers that separate demos from production systems: smart chunking, reranking, and faithfulness guards. Instrument everything from day one, and you will have the telemetry to continuously improve retrieval quality as your document corpus grows.


๐Ÿš€ Ready to run it? The complete project with docker-compose, full directory structure, all source files, build instructions, and an end-to-end PDF ingestion + query walkthrough is in the companion post: Spring AI RAG โ€” Complete Runnable Code โ†’

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.