
Build a Private AI Assistant With Local Models + RAG
Cloud RAG sends your documents to someone else's servers as embeddings and as context. Here's the same pipeline built entirely on your own machine instead.
A RAG assistant built the usual way sends two things off your machine for every single question: the document chunks it embeds, and the retrieved context it hands to a model for the final answer. That's a reasonable trade for most use cases, and a nonstarter for others, HR files, contracts, medical notes, anything with a compliance requirement that the document never left a specific boundary in the first place. "Ask an AI assistant about these documents" and "these documents can never leave this machine" aren't in conflict. They just require the entire pipeline, embedding, storage, retrieval, and generation, to actually run locally, not just the last step.
The one sentence to remember
"Private" is a claim about every component in the pipeline, not just the one that's easiest to run locally. A local LLM answering questions built from context assembled by a cloud embedding API hasn't built a private assistant, it's built a private-looking front end on a pipeline that still sends every document out the door.
RAG From Scratch built the retrieval pipeline itself, by hand, using cloud embeddings and a cloud model, specifically to make the mechanics visible. This is the same shape of pipeline, chunk, embed, store, retrieve, generate, rebuilt so that no step in it ever makes a network call at all.
The Architecture
One cloud call anywhere in this chain breaks the entire premise
A local vector store and a local LLM don't make the assistant private if the embedding step still calls out to a hosted API. The privacy property has to hold for every component, because the weakest link is the one that decides what actually leaves the machine.
What "Local" Actually Has to Mean Here
The broader question of when local models make sense at all, hardware, cost, and capability tradeoffs, is covered in full in AI Offline vs Online Models, and getting Ollama installed and running a first local model is covered in How to Build Your Own Offline AI Application. This article assumes both of those are already true and focuses specifically on what changes when the goal is a document-aware assistant instead of a plain chat window.
| Component | What "local" requires here |
|---|---|
| Embedding model | Runs on this machine, through Ollama, not a hosted embeddings API |
| Vector store | A file on disk, not a managed or hosted vector database |
| Generation model | Runs on this machine, through Ollama, not a hosted chat completion API |
| The documents themselves | Never uploaded, never chunked by a remote service, never cached anywhere but here |
Choosing Local Models for This Specific Job
A model that's fine for casual chat isn't automatically the right choice for grounded question-answering over private documents, and embedding and generation are two separate decisions.
The embedding model doesn't need to be large, it needs to be good at retrieval
nomic-embed-text, available through Ollama, is small enough to run comfortably on modest hardware and built specifically for embedding tasks, not general chat. A bigger general-purpose model isn't a better embedder just because it's bigger, embedding quality and instruction-following quality are different skills.
For generation, the real constraint is instruction-following discipline, staying inside the retrieved context instead of filling gaps from training data, more than raw capability. A mid-sized instruction-tuned model, llama3.1:8b or similar, run through Ollama, is usually enough for this specific job. The honest tradeoff: smaller local models are measurably weaker than frontier cloud models at complex reasoning, which matters more for some document sets than others, covered in the decision framework in AI Offline vs Online Models.
Chunking and Embedding, Entirely Local
import requests
from pathlib import Path
OLLAMA_URL = "http://localhost:11434"
EMBED_MODEL = "nomic-embed-text"
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
words = text.split()
chunks = []
start = 0
while start < len(words):
end = start + chunk_size
chunks.append(" ".join(words[start:end]))
start = end - overlap
return chunks
def embed(text: str) -> list[float]:
response = requests.post(
f"{OLLAMA_URL}/api/embeddings",
json={"model": EMBED_MODEL, "prompt": text},
)
return response.json()["embedding"]
def load_documents(folder: str) -> list[dict]:
records = []
for path in Path(folder).glob("*.txt"):
for chunk in chunk_text(path.read_text()):
records.append({"source": path.name, "chunk": chunk, "embedding": embed(chunk)})
return recordsThis is the entire network surface of the indexing step
requests.post here targets localhost. Nothing about this function is capable of reaching an external host unless OLLAMA_URL is changed to point somewhere else, which is worth treating as a configuration value someone could get wrong, not something to assume will always stay local by default.
A Persistent Local Vector Store
The educational version of this step keeps vectors in memory for the length of one script. A real assistant needs to index a document set once and answer questions against it across many separate runs, which means the store has to survive the process exiting.
import sqlite3
import numpy as np
import json
def init_store(db_path: str = "assistant.db") -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY,
source TEXT,
chunk TEXT,
embedding TEXT
)
""")
return conn
def save_records(conn: sqlite3.Connection, records: list[dict]) -> None:
conn.executemany(
"INSERT INTO chunks (source, chunk, embedding) VALUES (?, ?, ?)",
[(r["source"], r["chunk"], json.dumps(r["embedding"])) for r in records],
)
conn.commit()
def search(conn: sqlite3.Connection, query_embedding: list[float], top_k: int = 4) -> list[dict]:
rows = conn.execute("SELECT source, chunk, embedding FROM chunks").fetchall()
query_vec = np.array(query_embedding)
scored = []
for source, chunk, embedding_json in rows:
vec = np.array(json.loads(embedding_json))
score = np.dot(query_vec, vec) / (np.linalg.norm(query_vec) * np.linalg.norm(vec))
scored.append({"source": source, "chunk": chunk, "score": float(score)})
scored.sort(key=lambda r: r["score"], reverse=True)
return scored[:top_k]Index once, ask questions many times
Indexing a folder of documents can take a while, every chunk needs its own embedding call. Storing the result in assistant.db means that cost is paid once, not on every question, the same way a cloud-backed vector database avoids re-embedding a document set for every query, just running entirely as a file on this machine instead of a hosted service.
Retrieval and Grounded Generation, Fully Local
GENERATE_MODEL = "llama3.1:8b"
def build_prompt(query: str, retrieved: list[dict]) -> str:
if not retrieved:
context = "No relevant documents were found for this question."
else:
context = "\n\n".join(f"[Source: {r['source']}]\n{r['chunk']}" for r in retrieved)
return (
f"Answer the question using only the context below. "
f"If the context doesn't contain enough information, say so explicitly.\n\n"
f"Context:\n{context}\n\nQuestion: {query}"
)
def ask(conn: sqlite3.Connection, query: str) -> str:
query_embedding = embed(query)
retrieved = search(conn, query_embedding)
prompt = build_prompt(query, retrieved)
response = requests.post(
f"{OLLAMA_URL}/api/generate",
json={"model": GENERATE_MODEL, "prompt": prompt, "stream": False},
)
return response.json()["response"]The grounding instruction matters as much here as it does with a cloud model
"Answer only from the context, and say so if it isn't enough" is the same discipline covered in RAG From Scratch, and it doesn't become optional just because the model is running locally. A smaller local model without that explicit permission to say it doesn't know is, if anything, more likely to fill a gap with a confident-sounding guess than a larger cloud model would be.
Verifying Nothing Actually Leaves the Machine
An architecture diagram claiming something is private is not the same as confirming it. The difference matters enough to actually check, not just assume.
Turn off networking and run the whole pipeline again
Disconnect from the network entirely, then re-run indexing and a query. If either step fails or hangs waiting on a connection, something in the chain wasn't actually local, and the failure will point directly at which call it was.
Watch outbound connections while the pipeline runs
A simple netstat or a network monitor running alongside the indexing and query steps should show no outbound connections beyond localhost. This catches the case a code review might miss, a dependency that phones home for telemetry or update checks without it being obvious from the code that calls it.
Re-check after every dependency upgrade, not just once
A library that was purely local when this was first built can add a network call in a later version, a telemetry ping, a model registry lookup, an update check. Treat "confirmed private" as a property to re-verify periodically, not a fact established once and assumed to hold forever.
A Worked Trace
Indexing a small folder of internal policy documents, then asking two questions.
| Step | What happens |
|---|---|
| 1 | load_documents("policies/") chunks and embeds every .txt file in the folder, saved to assistant.db. No network activity beyond localhost:11434. |
| 2 | ask(conn, "What's the policy on remote work equipment reimbursement?") embeds the query, retrieves the four most relevant chunks, and generates an answer citing the specific policy document. |
| 3 | ask(conn, "What's our company's stock price target for next quarter?") embeds the query, retrieves the four closest chunks anyway, cosine similarity always returns something, but the model, following the grounding instruction, responds that the context doesn't contain this information rather than guessing. |
| 4 | The network connection is disabled and both questions are asked again. Both answers come back identical, confirming nothing in the pipeline depended on connectivity that happened to be available the first time. |
Step 3 is the one worth paying attention to
A retrieval system always returns its top-k results, even when none of them are actually relevant, cosine similarity has no concept of "not a good enough match." Whether the assistant admits that or fabricates an answer from weakly related context depends entirely on the grounding instruction in the prompt, not on anything the retrieval step itself decided.
When Local Isn't the Right Call
Privacy is one requirement, not the only one
A document set that genuinely needs frontier-level reasoning, dense legal analysis, complex multi-step synthesis across many sources, will show the capability gap between a small local model and a large cloud model more clearly than casual use ever does. Where that gap matters more than the privacy requirement, the honest options are a stronger local model if the hardware supports it, or a cloud model behind the access controls covered in AI Agent Identity, not pretending the local setup is equally capable when it measurably isn't.
| If the situation is... | The right call is probably... |
|---|---|
| Documents genuinely can't leave a specific boundary, compliance or contractual | Fully local, and worth the capability tradeoff |
| Casual internal use, no hard privacy requirement, complex reasoning needed | A cloud model is probably the better fit |
| Privacy matters and the hardware can run a capable enough local model | Fully local, as built in this article |
| Privacy matters but local hardware can't run a model capable enough for the task | A stronger local model if hardware allows, otherwise a tightly access-controlled cloud option |
The Bottom Line
A private AI assistant isn't a local LLM with a nice prompt, it's a pipeline where every component, chunking, embedding, storage, retrieval, and generation, runs on the machine the documents already live on, with nothing assumed private that wasn't actually verified. The mechanics are the same RAG pipeline covered from first principles in RAG From Scratch. What changes is where every one of those mechanics actually executes, and whether anyone bothered to check.
The test worth running before this touches anything sensitive
Disconnect the network, run the full pipeline end to end, indexing and a question, and confirm it still works exactly the same. If it does, the privacy claim is verified, not assumed. If it doesn't, that failure just told you exactly which part of the pipeline wasn't actually local.
The documents never had to leave the machine. Building the assistant so that's actually true, not just architecturally implied, is the entire point of doing this locally at all.
Written by
Chetan Yamger
Cloud Engineer · AI Automation Architect · Modern Workplace Consultant
Cloud Engineer, AI Automation Architect, and Modern Workplace Consultant based in Amsterdam, Netherlands. Specializing in scalable, secure enterprise solutions with Microsoft Azure, Intune, PowerShell, and AI-driven automation using ChatGPT, Gemini, and modern LLM technologies.
Stay in the loop.
New articles, straight to you.
Deep-dive technical articles on Intune, PowerShell, and AI — no noise, no spam.
Discussion
Share your thoughts — your email stays private
Leave a comment