Every RAG tutorial I've read makes the same two assumptions: you have a GPU, and you can call a cloud API. For the environments I build for, both assumptions are wrong.
I work on health information systems in the public sector. The stack has to run inside institutional infrastructure — no data leaves the network — and the hardware I get is whatever the procurement cycle produced two years ago. In practice that means Windows Server, CPU only, and open-weight models running locally.
So I built a RAG stack that runs entirely on-premise, no GPU, no cloud, no Docker. It's open source at github.com/psychohub/rag-onpremise: ASP.NET Core 9 for orchestration, Ollama for local inference, Qdrant for vectors, Python for the ingest pipeline, Mistral 7B as the LLM, nomic-embed-text for embeddings.
Getting it into production took longer than the design did, because five things broke that no tutorial had warned me about. This is the field report.
The environment, and why it matters
Before the lessons, it's worth being precise about the constraint, because it changes what "good" looks like.
The stack has to run on a Windows Server, not a Linux workstation. Docker is not available on many of the target machines — either because it wasn't approved, because GPO policies restrict it, or because ops teams already run everything as Windows services and adding a container runtime is a new operational surface nobody wants to own. GPUs are aspirational. In the meantime, you have CPU inference and you have to make it work.
None of this is exotic. It's the default reality in a lot of public sector, healthcare, and legacy enterprise environments. It's also the reality most RAG content on the internet quietly assumes away.
The overall shape of the system:
Documents (PDF / Word / Excel)
│
▼
[ Python ingest ]
├─ Text extraction (pdfplumber, python-docx, openpyxl)
├─ Chunking (500 tokens, 50 overlap)
├─ Embeddings (nomic-embed-text via Ollama)
└─ Store (Qdrant, cosine similarity)
│
User query │
│ │
▼ │
[ ASP.NET Core 9 API ] ──────────────────────────── ┘
├─ 1. Embed the question
├─ 2. Retrieve top-K chunks from Qdrant
├─ 3. Assemble prompt with context
├─ 4. Call Mistral 7B via Ollama
└─ 5. Return answer + cited sources
Enter fullscreen mode Exit fullscreen mode
Lesson 1: The Qdrant .NET SDK uses gRPC. Use REST directly.
The first thing I tried was the official Qdrant SDK for .NET. Clean API, well documented, felt like the right choice. It also failed in a way that took me a day to diagnose, because the failure wasn't obvious: connections were being established, then dropped, with error messages that pointed at everything except the actual cause.
The cause: the SDK talks to Qdrant over gRPC, and the network path between the .NET application and the Qdrant instance was HTTP/1.1 only. gRPC needs HTTP/2. Some intermediate proxy or load balancer downgraded the connection, and the SDK didn't degrade gracefully — it just failed.
The fix was to skip the SDK entirely and talk to Qdrant's REST API directly with HttpClient:
// Not this — the SDK uses gRPC under the hood
// var client = new QdrantClient(new Uri(url));
// This — plain REST works everywhere
var response = await _httpClient.PostAsync(
$"{qdrantUrl}/collections/{collection}/points/search",
content);
Enter fullscreen mode Exit fullscreen mode
Qdrant's REST API is complete enough for RAG workloads. You lose type safety and some ergonomics; you gain the ability to deploy without arguing about HTTP/2 support at every network hop. In a corporate or public sector network, that's a good trade.
Lesson 2: The default HttpClient timeout will kill your responses.
HttpClient in .NET defaults to a 100-second timeout. That's fine for most HTTP work. It is not fine when the thing on the other end is Mistral 7B running on CPU.
On a modest server — 4 vCPU, 16 GB RAM — Mistral 7B takes between 60 and 120 seconds to produce a full response. The first time I ran an end-to-end query, it worked. The second time it worked. The third time the model happened to generate a longer answer and the client timed out, mid-stream, leaving the user staring at a generic error while the server continued generating an answer nobody would ever see.
The fix is two lines:
// Not this — 100s default, will cut you off
var client = new HttpClient();
// This — set the ceiling explicitly, above your worst-case
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(300) };
Enter fullscreen mode Exit fullscreen mode
The number itself matters less than the discipline. If you're calling a local LLM on CPU, measure the worst case on your actual hardware, and set the timeout comfortably above it. And if you're building a UI on top of this, put a progress indicator. Ninety seconds of silence looks like a broken system, even when it's working exactly as designed.
Lesson 3: Ollama only listens on localhost by default.
This one I discovered when I moved from developing on my laptop to deploying on the server, and the .NET application on a different machine couldn't reach Ollama.
Ollama, out of the box, binds to 127.0.0.1:11434. Fine for local development. Useless for any deployment where the LLM host is separate from the application host, or even where the application runs under a service account that doesn't share the loopback context with the interactive user.
The fix is an environment variable:
$env:OLLAMA_HOST = "0.0.0.0:11434"
ollama serve
Enter fullscreen mode Exit fullscreen mode
Which is simple, once you know. The trap is that Ollama's error messages when it's unreachable are generic connection errors, not "hey, I'm only listening on loopback." I spent an afternoon reading firewall rules before I checked the binding.
If you're deploying Ollama as a Windows service — which you probably should — that environment variable needs to be set at service level, not user level. Setting it in a PowerShell prompt won't affect the service. Small detail, real time cost.
Lesson 4: The Python MSI installer fails under corporate GPO. Use the embeddable package.
The ingest pipeline is Python. On a locked-down Windows Server with corporate Group Policy Objects controlling what installers can run, the standard Python MSI would not install. It failed in ways that ranged from silent "operation completed" with nothing on disk, to loud errors about elevation that the actual admin account couldn't resolve either.
The fix, which is not obvious the first time: use the embeddable Python package. It's a ZIP file, not an installer, so it sidesteps most of the GPO surface.
The setup is a little more manual than the installer:
- Download
python-3.x.x-amd64-embed.zipfrom python.org. - Extract to a folder —
C:\Python311\, wherever. - In that folder, open
python3xx._pthand uncomment theimport siteline. Without this,pipwon't work. - Download
get-pip.pyand runpython get-pip.pyfrom that folder. - From there,
pip install -r requirements.txtworks normally.
Nothing here is hard. It's just not documented as the default path, so if you don't know it exists you spend two days fighting an installer that will never succeed.
Lesson 5: The prompt is where most of the quality lives.
I spent weeks tuning chunking, embedding parameters, and retrieval top-K, and got single-digit percentage improvements each time. Then I rewrote the prompt template and got a step-change in response quality that made all the retrieval tuning look like rounding error.
The two failure modes I kept oscillating between:
Too restrictive. "Answer ONLY from the context. If the context does not contain the answer, say you don't know." The model became allergic to context. It would refuse to answer questions that were partially covered, refuse to make reasonable inferences, and pepper the user with "I don't know" for questions any human reading the same documents could answer.
Too permissive. "Use the context to help you answer the question." The model started hallucinating confidently, filling in gaps in the retrieved chunks with plausible-sounding invention. In a regulated environment, that's not a quality problem. It's a liability.
What ended up working, roughly:
Answer BASED on the provided context.
If the information is partially relevant, use it and be explicit
about what the context does and does not say.
Only if there is absolutely nothing related to the question,
say so clearly.
Do NOT invent data that is not in the context.
Enter fullscreen mode Exit fullscreen mode
The keywords that mattered were "partially relevant" (permission to reason from incomplete context) and "be explicit about what the context does and does not say" (forcing the model to distinguish what it read from what it inferred). Neither is a magic incantation. But together they moved the balance from "refuses to answer" and "makes things up" to "answers when it can, defers when it can't, and tells you which."
What CPU inference actually looks like
The other thing tutorials skip: numbers. Everything above assumes latency you can live with. Here's what I actually measured on the hardware I had:
| Hardware | Model | Response time |
|---|---|---|
| 4 vCPU / 16 GB RAM | Mistral 7B | 60–120 seconds |
| 16 vCPU / 32 GB RAM | Mistral 7B | 20–45 seconds |
| 4 vCPU / 8 GB RAM | phi3:mini | 15–30 seconds |
| GPU 8 GB+ | Mistral 7B | 3–8 seconds |
The CPU rows are sustained measurements on the servers I actually deploy on. The GPU row is from a single test on borrowed hardware, not sustained production measurements — take that one as a reference point, not a promise.
Two things worth calling out. First, phi3:mini on modest hardware is competitive with Mistral 7B on much better hardware, for latency. If your quality bar allows it, downgrade the model before you upgrade the hardware. Second, the jump from CPU to GPU is roughly 10×. If you can get one 8 GB GPU into your environment, do it — it changes what interactions are possible.
Because CPU latency is what it is, the repo includes a semantic cache in front of the LLM: cosine similarity between the incoming query and cached queries, with a threshold of 0.92. When a user asks something semantically close to a previous query, they get the cached answer in under a second. When they ask something new, they wait for the model. On a moderately busy internal system, cache hit rates got high enough that the average user experience felt reasonable, even though the worst case was still ninety seconds.
One warning I learned by breaking it: clear the cache when you change the LLM. Cached answers are pinned to whoever generated them. When you swap Mistral for a newer model, the cache is now returning answers from a model you're no longer running, and users will notice the personality change before you do.
What I'd tell someone starting today
If you're building on-premise RAG on constrained hardware, the compressed version:
- Talk to Qdrant over REST, not gRPC. Fewer surprises on corporate networks.
- Set your HTTP timeouts explicitly. The defaults were designed for web traffic, not local LLMs on CPU.
- Configure Ollama's binding for your deployment, not your laptop. And set the environment variable at service level if you're running it as a service.
- Use Python's embeddable package on locked-down Windows. The MSI is not your friend under GPO.
- Tune the prompt before the retrieval. Chunking and top-K matter, but the prompt is where the quality bar actually sits.
- Cache aggressively when your LLM is slow, and remember to invalidate when you change models.
-
Downgrade the model before upgrading the hardware.
phi3:minion 8 GB RAM beats Mistral 7B on a machine you can't afford.
None of this is exotic. It's the part of RAG that gets skipped when the tutorial assumes GPU, cloud, and a Linux dev box. When you don't have any of those, this is the reality you build against.
The next thing on my roadmap is proper embedding evaluation on Spanish clinical text — because "it works" and "it works well in your language on your corpus" are not the same thing, and I haven't measured the gap yet. That's the next article.
This article describes the design and implementation of my personal open-source project rag-onpremise. The measurements are from my own test hardware and my own project, not from any specific institutional deployment. The views expressed here are my own.
Hubert García Gordon works on health information systems in the Costa Rican public sector and teaches at UNED Costa Rica. He maintains rag-onpremise and writes about applied AI in constrained environments.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.