All posts

Swap the LLM, keep the code: one RAG app on Azure, OpenAI and local Mistral

The application code never changed when I swapped the LLM three times. The configuration nearly beat me — and the two traps are worth knowing.

The short answer

Part 2 of the RAG tutorial series makes the LLM provider (Azure OpenAI, OpenAI API, or fully offline Mistral via Ollama) and the vector store (in-memory or PostgreSQL + pgvector) swappable via Spring profiles — the RAG code itself never changes. Two traps are worth the post alone: with several provider starters on the classpath you must set every spring.ai.model.* selector to none (not just chat and embedding — the image and audio autoconfigurations default to on and crash without credentials), and switching embedding providers is a data migration, not a config change, because dimensionality and vector spaces differ (1536 vs 768) — you must re-ingest.

I swapped the LLM under a running RAG application three times last week — Azure OpenAI to the plain OpenAI API to a local Mistral running in Docker. The application code never changed. The configuration nearly beat me, and the two places where it fought back are exactly what part 2 of my RAG tutorial series is about.

Part 1 built the smallest working RAG loop: ingest documents, retrieve chunks, augment the prompt, generate. One provider (Azure OpenAI), one in-memory store, everything visible. Part 2 asks the question that decides whether an abstraction is real: can you change the expensive decisions — which model, which store — without touching the code that does the work?

The design: profiles select, code injects

The RAG service and the ingestion pipeline inject three Spring AI interfaces: ChatModel, EmbeddingModel, VectorStore. Which implementations arrive is decided at startup by Spring profiles — one axis for the provider (azure, openai, ollama), one for the store (simple in-memory, pgvector on PostgreSQL). Any combination works:

mvn spring-boot:run -Dspring-boot.run.profiles=pgvector,openai
mvn spring-boot:run -Dspring-boot.run.profiles=simple,ollama   # fully offline

The ollama variant is the one I care about most: Mistral for chat, nomic-embed-text for embeddings, both served by a dockerized Ollama. No API key, no cloud, no document leaves the machine. For anyone answering questions over internal documents under Swiss or EU data expectations, "the offline profile exists and is a first-class citizen" is not a demo feature — it's the argument.

Trap one: none means all of them

With three provider starters on the classpath, Spring AI activates every autoconfiguration whose selector property is unset. So you disable them all by default and let each profile re-enable its own:

spring.ai.model:
  chat: none
  embedding: none

That's what I wrote first. It compiles, it looks complete, and it crashes at startup under the ollama profile — with an Azure error: "Endpoint must not be empty."

The reason: chat and embedding are not the only model types. The Azure and OpenAI starters also ship image, audio and moderation autoconfigurations, each with its own selector (spring.ai.model.image, spring.ai.model.audio.transcription, …) — and each defaulting to active when unset. The image autoconfiguration dutifully tried to build an Azure client for a profile that has no Azure credentials. The fix is boring and complete: set every selector to none in the base config. The lesson is less boring: when you disable by default, enumerate the whole surface, not the part you're using. Conditional beans that default to "on" fail in the profile you didn't test.

Trap two: swapping embeddings is a data migration

The chat model is genuinely swappable: same prompt in, answer out, nothing stored. The embedding model is not, for two stacked reasons.

First, dimensionality: OpenAI/Azure text-embedding-3-small produces 1536-dimensional vectors, nomic-embed-text produces 768. pgvector bakes the dimension into its table schema, so the tutorial exposes it as a config knob (rag.vector-store.dimensions) that the ollama profile overrides.

Second — and this one bites people who got the dimensions right — vectors from different models live in different spaces. Even if the sizes matched, comparing a query embedded by model A against chunks embedded by model B produces distances that mean nothing. Retrieval doesn't error; it just quietly returns garbage.

So the honest rule: changing the embedding provider means dropping the vector table and re-ingesting everything. Treat it like a schema migration, not a config change. Practically, that means keeping your sources retrievable — files, URLs, wiki pages — so re-ingestion is a script you run, not an archaeology project. The tutorial's ingestion history exists precisely so you can see what would need to be replayed.

What pgvector buys you

The second profile axis adds PostgreSQL with the pgvector extension via docker-compose. I picked it over dedicated vector databases for a reason worth stating: it's just Postgres. Backups you already know, SQL access to your chunks (select * from vector_store is a debugging superpower), operations your team already runs. A dedicated vector DB earns its place at a scale most internal RAG systems never reach.

The takeaway

An abstraction is only real once you've swapped the implementation under it — and the swap is where you learn. Two things transfer beyond this tutorial: disable-by-default must cover the entire autoconfiguration surface, and embedding models are stateful decisions with migration costs, not interchangeable endpoints.

Part 3 connects the pipeline to where documents and questions actually live: Confluence, Microsoft Teams and Slack — as connectors that are off by default and verify every webhook signature.

Code: github.com/halviclabs/rag-tutorials — part 2 is rag-tutorial-02-llms, self-contained with docker-compose for Postgres and Ollama.

Frequently asked questions

How do I switch LLM providers in a Spring AI application?

Put the provider starters on the classpath, disable all model autoconfiguration by default by setting every spring.ai.model.* selector (chat, embedding, image, moderation, audio.*) to none, and let each Spring profile switch exactly one chat and one embedding model back on — for example spring.ai.model.chat: ollama. Your services keep injecting ChatModel, EmbeddingModel and VectorStore; which implementation arrives is decided at startup by the active profile.

Can I run a RAG application completely offline?

Yes. With Ollama serving Mistral for chat and nomic-embed-text for embeddings, plus a local Postgres with pgvector, the entire stack — ingestion, embedding, retrieval, generation — runs on your machine. No API key, no data leaving the network. Answers are slower on CPU, and smaller local models follow grounding instructions less reliably than the big cloud models, so judge quality for your use case.

Why does the embedding dimension matter for pgvector?

pgvector creates a table column with a fixed vector dimensionality, so it must know the size up front — 1536 for OpenAI/Azure text-embedding-3-small, 768 for nomic-embed-text. That is why the tutorial exposes rag.vector-store.dimensions as a config knob that provider profiles override.

Can I keep my stored vectors when switching embedding providers?

No. Embeddings from different models live in different vector spaces — even at identical dimensionality, distances between vectors from different models are meaningless. Switching the embedding provider means dropping the vector table and re-ingesting every document. Plan for it: keep sources retrievable so re-ingestion is a script, not an archaeology project.

AI code without tech debt — the checklist

Sign up: the checklist plus new posts on AI engineering. No spam, unsubscribe anytime.