RAG de A a Z: busque o documento antes de responder

1. Visão geral

O LLM só sabe o que está nos dados de treinamento. Não sabe a política de devolução da sua loja nem os processos internos da sua empresa. Quando perguntado sobre essas coisas, ele geralmente inventa uma resposta. RAG (Retrieval-Augmented Generation) resolve isso: o sistema recupera documentos relevantes primeiro, depois deixa o LLM responder com base nesses documentos.

Neste artigo, você construirá um pipeline RAG completo do zero com Spring Boot 3 e Spring AI. O pipeline inclui as etapas de carregar documentos, dividir em chunks, criar embeddings, buscar, montar o prompt com trecho da fonte e escrever testes. No final, há uma seção sobre erros comuns e quando não usar RAG.

2. Como RAG funciona

RAG tem duas fases distintas.

A fase de ingestão roda uma vez ou periodicamente:

  1. Ler documentos (Markdown, PDF, HTML…).
  2. Dividir em chunks de algumas centenas de tokens.
  3. Chamar o modelo de embedding para converter cada chunk em um vetor numérico.
  4. Armazenar o vetor e o conteúdo do chunk no vector store.

A fase de consulta roda cada vez que o usuário faz uma pergunta:

  1. Incorporar a pergunta em um vetor.
  2. Encontrar os chunks com vetores mais próximos (busca por similaridade).
  3. Montar esses chunks no prompt. Esta é a etapa de augment.
  4. Enviar o prompt para o LLM gerar a resposta. Esta é a etapa de generate.

A ideia central é simples. O LLM não precisa “memorizar” seus documentos, precisa apenas ler o trecho correto dentro do prompt.

3. Preparando o projeto

Você adiciona o Spring AI BOM e o starter do OpenAI. Este starter fornece tanto o chat model quanto o embedding model.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

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

Em seguida, configure o modelo:

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

Você deve definir temperature baixa. Com RAG, o objetivo é seguir de perto a documentação, não ser criativo.

Finalmente, declare dois beans: um vector store e um ChatClient.

@Configuration
public class RagConfig {

    @Bean
    VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }

    @Bean
    ChatClient chatClient(ChatClient.Builder builder) {
        return builder.build();
    }
}

SimpleVectorStore armazena todos os vetores na memória. É adequado para aprender e fazer demos. EmbeddingModel é auto-configurado pelo Spring AI a partir do starter OpenAI. Cada vez que você adiciona um documento, o store chama este modelo para criar o vetor.

Nota: SimpleVectorStore perde todos os dados quando a aplicação reinicia e faz busca linear. Para produção, você deve usar PGvector, Qdrant, Elasticsearch… Spring AI tem um starter para cada um, e a interface VectorStore permanece a mesma, então o código abaixo não precisa mudar.

4. Carregando documentos

Suponha que você tenha o arquivo src/main/resources/docs/return-policy.md:

# Política de devolução

Clientes podem devolver produtos dentro de 30 dias após o recebimento.
O produto deve estar com etiqueta, rótulo e nota fiscal intactos.
O reembolso será transferido para a conta em 5–7 dias úteis.
Lingerie e produtos com desconto acima de 50% não podem ser devolvidos.

4.1. Lendo e dividindo em chunks

@Component
public class DocumentIngestor implements ApplicationRunner {

    private final VectorStore vectorStore;
    private final ResourcePatternResolver resolver;

    public DocumentIngestor(VectorStore vectorStore, ResourcePatternResolver resolver) {
        this.vectorStore = vectorStore;
        this.resolver = resolver;
    }

    @Override
    public void run(ApplicationArguments args) throws IOException {
        // chunkSize=300 token, minChunkSizeChars=100, minChunkLengthToEmbed=5,
        // maxNumChunks=10000, keepSeparator=true
        var splitter = new TokenTextSplitter(300, 100, 5, 10_000, true);

        for (Resource file : resolver.getResources("classpath:docs/*.md")) {
            var reader = new TextReader(file);
            reader.getCustomMetadata().put("source", file.getFilename());

            List<Document> chunks = splitter.apply(reader.get());
            vectorStore.add(chunks);
            System.out.printf("Carregado %s: %d chunks%n", file.getFilename(), chunks.size());
        }
    }
}

O código acima faz o seguinte:

  • ApplicationRunner executa uma vez após o context ser inicializado, então os documentos sempre são carregados antes da primeira requisição.
  • TextReader lê o arquivo como um Document. Anexamos metadados source como o nome do arquivo para usar ao citar a fonte depois.
  • TokenTextSplitter divide o texto em chunks de cerca de 300 tokens. Os metadados são copiados para cada chunk.
  • vectorStore.add(...) chama o modelo de embedding para cada chunk e armazena o vetor.

Ao iniciar, você verá:

Carregado return-policy.md: 1 chunk
Carregado shipping.md: 3 chunks

4.2. Escolhendo o tamanho do chunk

O tamanho do chunk afeta diretamente a qualidade da resposta:

  • Chunk muito grande: um chunk contém muitos tópicos, o vetor fica “diluído” dificultando a correspondência precisa. O prompt também consome mais tokens.
  • Chunk muito pequeno: a frase perde o contexto ao ser cortada. Por exemplo, “dentro de 30 dias” fica em um chunk e “devolver” fica em outro.

De 200–500 tokens é um ponto de partida razoável para documentos em prosa. Para documentos bem estruturados (FAQ, índice), você deve cortar por headings para que cada chunk seja uma ideia completa.

5. Recuperando documentos relevantes

A etapa de recuperação requer apenas uma chamada:

List<Document> docs = vectorStore.similaritySearch(
        SearchRequest.builder()
                .query("Quanto tempo posso devolver?")
                .topK(4)
                .similarityThreshold(0.5)
                .build());
  • query: a pergunta. O vector store incorpora automaticamente a pergunta usando o mesmo modelo usado no carregamento.
  • topK(4): obter no máximo 4 chunks mais próximos.
  • similarityThreshold(0.5): descartar chunks com similaridade de cosseno abaixo de 0.5. Se você não definir um limite, o store sempre retorna topK resultados, mesmo que não estejam relacionados à pergunta.

Não há um limite ótimo para todos os casos. Você deve registrar as pontuações com algumas dezenas de perguntas reais e escolher um limite que separe os resultados corretos do ruído.

6. Montando o prompt e gerando a resposta

6.1. Construtor de prompt

Separar a montagem do prompt em uma função pura facilitará o teste:

public final class PromptBuilder {

    private static final String TEMPLATE = """
            Você é um assistente de atendimento ao cliente.
            Responda apenas com base no CONTEXTO abaixo.
            Se o contexto não contiver a informação, responda exatamente com:
            "Não encontrei esta informação na documentação."
            No final da resposta, cite a fonte entre colchetes, por exemplo [return-policy.md].

            CONTEXTO:
            {context}

            PERGUNTA: {question}
            """;

    private PromptBuilder() {}

    public static String build(String question, List<Document> docs) {
        String context = docs.stream()
                .map(d -> "[" + d.getMetadata().getOrDefault("source", "unknown") + "]\n" + d.getText())
                .collect(Collectors.joining("\n---\n"));
        return TEMPLATE.replace("{context}", context)
                       .replace("{question}", question);
    }
}

Existem três pontos importantes neste template:

  1. “Responda apenas com base no CONTEXTO” limita o LLM aos documentos, reduzindo alucinações.
  2. Uma sentença de rejeição fixa dá ao LLM uma saída válida quando a documentação não tem informações.
  3. Cada chunk vem com o nome da fonte, permitindo que o LLM cite a fonte e o usuário verifique.

Aqui usamos replace em vez de String.formatted. Razão: o conteúdo do documento pode conter o caractere % (por exemplo, “desconto acima de 50%”), e formatted lançará UnknownFormatConversionException ao encontrá-lo.

6.2. RagService

@Service
public class RagService {

    static final String NOT_FOUND = "Não encontrei esta informação na documentação.";

    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    public RagService(VectorStore vectorStore, ChatClient chatClient) {
        this.vectorStore = vectorStore;
        this.chatClient = chatClient;
    }

    public String answer(String question) {
        List<Document> docs = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(question)
                        .topK(4)
                        .similarityThreshold(0.5)
                        .build());

        if (docs == null || docs.isEmpty()) {
            return NOT_FOUND;
        }

        return chatClient.prompt()
                .user(PromptBuilder.build(question, docs))
                .call()
                .content();
    }
}

Quando nenhum chunk é encontrado, o serviço retorna uma rejeição imediatamente sem chamar o LLM. Isso economiza custos e elimina a chance de o LLM inventar uma resposta com base no conhecimento geral.

Finalmente, o controller:

@RestController
public class AskController {

    private final RagService ragService;

    public AskController(RagService ragService) {
        this.ragService = ragService;
    }

    @GetMapping("/ask")
    public String ask(@RequestParam String q) {
        return ragService.answer(q);
    }
}

Agora teste o call da API:

curl -G "http://localhost:8080/ask" --data-urlencode "q=Quanto tempo posso devolver?"
curl -G "http://localhost:8080/ask" --data-urlencode "q=Vocês vendem laptops?"
Você pode devolver dentro de 30 dias após o recebimento, desde que o produto esteja com etiqueta, rótulo e nota fiscal intactos. [return-policy.md]
Não encontrei esta informação na documentação.

A primeira resposta segue de perto a documentação e cita a fonte. A segunda pergunta não tem documentação correspondente, então o sistema rejeita em vez de inventar.

7. Escrevendo testes

Você não deve chamar o LLM real em testes unitários porque é lento, caro e os resultados são instáveis. Em vez disso, teste as duas partes determinísticas: o construtor de prompt e a rejeição quando não há documentação.

class RagServiceTest {

    @Test
    void promptContainsContextSourceAndQuestion() {
        var doc = new Document("Devolução em 30 dias.", Map.of("source", "return-policy.md"));

        String prompt = PromptBuilder.build("Quanto tempo para devolver?", List.of(doc));

        assertThat(prompt)
                .contains("[return-policy.md]")
                .contains("Devolução em 30 dias.")
                .contains("PERGUNTA: Quanto tempo para devolver?")
                .doesNotContain("{context}", "{question}");
    }

    @Test
    void percentSignInDocumentDoesNotBreakPrompt() {
        var doc = new Document("Desconto acima de 50% não pode ser devolvido.", Map.of("source", "return-policy.md"));

        assertThat(PromptBuilder.build("Itens em promoção?", List.of(doc)))
                .contains("acima de 50% não");
    }

    @Test
    void returnsNotFoundWithoutCallingLlmWhenNoDocuments() {
        VectorStore vectorStore = mock(VectorStore.class);
        ChatClient chatClient = mock(ChatClient.class);
        when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());

        String answer = new RagService(vectorStore, chatClient).answer("Vocês vendem laptops?");

        assertThat(answer).isEqualTo(RagService.NOT_FOUND);
        verifyNoInteractions(chatClient);
    }
}

O terceiro teste verifica duas coisas. Primeiro, a resposta correta é a rejeição. Segundo, ChatClient não é chamado. Se no futuro alguém acidentalmente deletar o branch isEmpty(), o teste falhará imediatamente.

Para avaliar a qualidade da recuperação, você pode escrever um teste de integração separado. Prepare um conjunto de perguntas com o nome do arquivo de origem esperado e verifique se esse arquivo está nos resultados de topK. Esse suite de testes deve ser executado periodicamente, não em cada build.

8. Simplificando com QuestionAnswerAdvisor

Depois de entender cada etapa, você pode usar o advisor fornecido pelo Spring AI. Este advisor encapsula a recuperação e montagem do prompt. Você precisa adicionar a dependência:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
String answer = chatClient.prompt()
        .advisors(QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder().topK(4).similarityThreshold(0.5).build())
                .build())
        .user("Quanto tempo posso devolver?")
        .call()
        .content();

O advisor incorpora automaticamente a pergunta, encontra chunks e os insere no prompt usando um template padrão. Essa abordagem é conveniente, mas dificulta o controle do formato de origem e da rejeição antecipada. Portanto, a abordagem manual da seção 6 ainda vale a pena quando você precisa desses requisitos.

9. Erros comuns e quando não usar RAG

Mudar o modelo de embedding sem recarregar os dados. Vetores de diferentes modelos não são comparáveis. Cada vez que você muda o modelo, deve re-incorporar todos os documentos.

Não definir similarityThreshold. O vector store sempre retorna topK resultados. O LLM então recebe contexto inútil e responde incorretamente com confiança.

Recarregar documentos a cada inicialização com um store persistente. Com PGvector, o ApplicationRunner acima criará duplicatas após cada deploy. Você deve atribuir um ID fixo a cada chunk, por exemplo hash do conteúdo e nome do arquivo, ou excluir por source antes de recarregar.

Colocar muitos chunks no prompt. Aumentar topK para 20 não melhora a resposta. O LLM facilmente perde informações no meio de um contexto longo, e o custo também aumenta com o número de tokens.

Confiar absolutamente no conteúdo do documento. Se os documentos vêm de usuários, podem conter comandos como “ignore todas as instruções anteriores” (prompt injection). Você precisa controlar quais fontes de dados podem ser carregadas no store.

Nota: RAG não é adequado para todos os problemas. Perguntas que precisam sintetizar todos os dados, como “quantos pedidos foram reembolsados em setembro”, devem ser convertidas em consultas SQL. A busca por similaridade retorna apenas alguns chunks e não pode contar ou sintetizar. Se toda a sua documentação tem apenas algumas páginas, colocá-la diretamente no prompt é geralmente mais simples e preciso do que um pipeline RAG inteiro.

10. Conclusão

RAG separa o conhecimento do modelo. Os documentos ficam no vector store e o LLM lê apenas a parte certa para cada pergunta. Neste artigo, você construiu ambas as fases: ingestão de documentos (ler, dividir em chunks, incorporar, armazenar) e consulta (busca com limite, montar prompt com fonte, chamar LLM). Você também escreveu testes para o prompt e a rejeição.

A partir dessa base, você pode seguir nessas direções: substituir SimpleVectorStore por PGvector, cortar chunks por headings, filtrar por metadados (por exemplo, procurar apenas em documentos de um departamento), ou adicionar uma etapa de reranking para reordenar resultados antes de colocá-los no prompt.

ragspring-aillmvector-storespring-boot