Local RAG in practice: Ollama, a vector store, a model
Last updated: ยท Reviewed quarterly
Retrieval-augmented generation is five stages: split documents into chunks, turn each chunk into a vector, store the vectors, retrieve the nearest ones for a question, and let a model answer from them. All five run on a laptop with no API key in the path. The stages that decide whether the answers are any good are chunking and retrieval, not the model.
The five stages, and what each one decides
RAG exists because a model cannot read your files. Retrieval finds the passages that bear on a question and puts them in the prompt, so the model reasons over your material instead of its training memory. Each stage in that chain makes exactly one decision.
Chunking decides the unit of retrieval. Nothing smaller than a chunk can be returned, and nothing larger arrives whole. The default recipe is a recursive character splitter: cut on paragraph breaks, then sentences, then characters, targeting a few hundred tokens with a small overlap. That default is a fallback, not a design. Splitting on structure instead (Markdown headings, function definitions, speaker turns) produces chunks that are about one thing, and that property is what retrieval depends on.
Embedding decides what similar means. An embedding model maps a string to a fixed-length vector so that distance approximates relatedness in meaning. Locally, nomic-embed-text and mxbai-embed-large are the common choices, and both run acceptably on CPU, which matters because embedding is the one stage that runs over the entire corpus. Two constraints are easy to miss: the model has a maximum input length, past which text is silently truncated rather than rejected; and every vector in an index must come from the same model, so changing it means re-embedding everything.
The vector store decides how you search and what you can filter on. Underneath it is an approximate nearest-neighbour index, usually HNSW, that finds close vectors without comparing against all of them. Around it sits the part you use daily: metadata beside each vector, so a query can be restricted to one folder, one author or a date range before similarity is computed at all.
Retrieval decides what the model is allowed to see. Embed the question, take the nearest k chunks, optionally apply a similarity floor, optionally rerank with a cross-encoder that scores each candidate against the question directly. One default is worth stating plainly: a top-k retriever always returns k chunks, whether or not any is relevant. Relevance is imposed, not given.
Generation decides the wording. The passages go into the prompt with the question and an instruction to answer from them, cite the file behind each claim, and say so when they contain no answer. Llama, Qwen, Mistral and Gemma in quantised form all do this competently. It is the least interesting stage and by a wide margin the one people tune most.
Where quality is actually won
Swapping a 7B model for a 14B one changes the phrasing of a wrong answer. Fixing a chunk boundary changes whether the correct passage was in the context at all. If the right chunk is not retrieved, no model recovers it, and fluency guarantees the failure looks like a confident answer rather than an error.
So evaluate the retriever on its own, separately from the chat. Write twenty or thirty questions you already know the answers to, record which file each answer lives in, and measure how often the right chunk appears in the top k. That number is the only part of a RAG system that is cheap to measure objectively, and it tells you whether the next hour belongs to the splitter or the prompt. Rerun it after every change to chunk size, overlap or embedding model.
Most disappointing answers, once you print the retrieved chunks, turn out to be reasonable responses to bad input: three near-duplicate passages from one page, or confident prose on a nearby topic that was merely the closest thing available.
The tools, and what each one is for
- Ollama runs the models. It pulls quantised weights with one command, serves them over a local HTTP API, and handles embedding models as well as chat models, so one process covers two of the five stages.
- llama.cpp is the inference engine much of the ecosystem is built on, and using it directly buys control: quantisation format, context length, GPU layer offloading, and a server with an OpenAI-compatible API. Reach for it when a runner's defaults are in your way.
- Chroma is the simplest vector store to start with: Python-native, embedded (no server), persisted to a folder, and easy to throw away and rebuild, which you will do several times.
- Qdrant is the production-shaped option: a Rust service with real payload filtering, built-in hybrid search, quantisation and snapshots, embeddable from Python too.
- LanceDB and pgvector cover the edges. LanceDB is embedded like Chroma but stores vectors in a columnar format built for larger-than-memory data; pgvector adds a vector column and index to Postgres, so similarity search is a clause in a normal query and one fewer system needs operating.
- LlamaIndex is a framework specialised in indexing and retrieval: loaders, node parsers, query engines, rerankers and evaluation helpers, with retrieval as the organising idea rather than one feature among many.
- LangChain is the broader orchestration framework (chains, agents, tool calling, hundreds of integrations), worth its larger surface when RAG is one part of a bigger application.
- Open WebUI is an interface rather than a library: a self-hosted chat front end over Ollama with upload and retrieval built in. The fastest route to something usable if you want to query documents rather than build a system.
When you do not need a framework at all. A working local RAG is roughly a hundred lines: a splitter, a loop calling the embedding endpoint, a list of vectors on disk, a cosine similarity and a prompt template. At a few thousand chunks, brute-force similarity in NumPy runs in milliseconds and needs no index and no store. Frameworks earn their place when you need many document loaders, routing and reranking, or an abstraction your team already knows. Debugging someone else's abstraction over four function calls is a poor trade. Start without one, and add it when you can name what it would give you.
Choosing a store and choosing a framework
At personal scale every option below works, and the choice is about operational shape rather than performance. The differences start to bite in the tens of millions of vectors, well past a folder of documents.
| Vector store | Shape | Filtering and hybrid | Best when |
|---|---|---|---|
| NumPy or SQLite | A file and a function you wrote | Whatever you code | Under roughly 100k chunks and you want no dependency |
| Chroma | Embedded, persists to a folder | Metadata filters; hybrid via the framework | Getting started, and most personal corpora |
| LanceDB | Embedded, columnar files on disk | Metadata filters and full-text search | Large corpus, single machine, memory pressure |
| Qdrant | Rust service, or embedded from Python | Rich payload filters, native hybrid, quantisation | Filtering and hybrid search are part of the design |
| pgvector | An extension to a database you already run | Anything SQL can express | Postgres is already in the stack |
The framework decision is a different question, because the honest first row is that you may not need one.
| Option | What it gives you | What it costs | Best when |
|---|---|---|---|
| No framework | Complete visibility; every decision is yours | You write the loaders and the retry logic | One document type, one retrieval strategy |
| LlamaIndex | Loaders, node parsers, query engines, rerankers, evaluation | A moderate abstraction layer over retrieval | Retrieval is the product |
| LangChain | Orchestration, agents, tool calling, wide integrations | A large surface and faster-moving APIs | RAG is one component of a larger application |
| Open WebUI | A finished chat interface with upload and retrieval | Little control over chunking and retrieval | You want to use it, not build it |
Standing one up over a folder of your own documents
- Install a runner and pull two models.
ollama pull llama3.1for generation andollama pull nomic-embed-textfor embedding. They are different jobs, and using a chat model to embed is a common early mistake. - Size the corpus before indexing it. A quick
wc -wacross the tree tells you whether this is five hundred pages or fifty thousand. Under a few thousand, everything here is fast. - Split on structure, then cap the size. Cut on headings or document sections first, then split anything still oversized to a few hundred tokens with about a tenth of that as overlap. Prepend the heading path to each chunk so a retrieved passage carries where it came from.
- Attach metadata and a stable id. Store the file path, the heading and the modification time with every chunk, and derive the id from path plus chunk index. A stable id means re-indexing a changed file updates its chunks instead of duplicating them.
- Embed in batches, and record the model name. Batch the calls to the embedding endpoint, and write the model's name next to the index. Months later that note explains why a newly added document matches nothing.
- Retrieve more than you show. Fetch twenty candidates, narrow to four or five with a threshold or a reranker, and print the survivors. Wire the model in once those passages look like the ones you would have picked by hand.
- Prompt for grounding and citation. Instruct the model to answer only from the supplied passages, name the file behind each claim, and say explicitly when they do not contain the answer. That last instruction is what turns a plausible system into a trustworthy one.
- Automate the re-index. A scheduled job that re-embeds files whose modification time is newer than the index costs a few seconds of compute a day. Without it, the index quietly becomes a snapshot of the day you built it.
Hybrid search, and when grep beats both
Vector search is good at paraphrase and bad at exact tokens. Ask it about scheduling policy and it finds the page that says roster rules, without either phrase matching. Ask it for error code E4021, a ticket reference or a function name, and the embedding blurs the thing you were looking for, because rare identifiers carry little semantic signal.
Keyword search has the opposite profile. BM25, the ranking function behind most full-text search, scores on term frequency weighted by rarity, so a rare exact token is its strongest signal and a paraphrase is invisible to it. Hybrid search runs both and fuses the ranked lists, usually with reciprocal rank fusion, which scores a result by its position in each list rather than by incomparable similarity numbers.
Use hybrid when the corpus holds identifiers, product names, jargon, code or proper nouns, and when you cannot predict whether someone will type a concept or a string. That covers most technical document sets, which is why hybrid is a sensible default rather than an optimisation. Pure vector is enough when the material is continuous prose and the questions are conceptual, and it is cheaper to operate.
Plain grep beats both more often than the RAG literature admits, for a specific reason: retrieval ranks, grep enumerates. Given a distinctive string, rg -i postgres notes/ returns every occurrence in every file in milliseconds, with no index, no embedding model, and no chance the answer was ranked sixth when you asked for five. It also answers the negative case honestly. A retriever cannot tell you nothing matched, because it always returns something. Grep returning nothing is proof.
A workable rule: identifiers to grep, exact phrases to keyword search, questions to vector search, and a corpus that gets all three to hybrid retrieval with grep still beside it. Grep answers whether a word was ever written; retrieval answers what was concluded.
What running locally buys, and what it costs
The case for local is strong, and stronger stated without exaggeration.
- Privacy that is structural rather than contractual. Documents and questions never leave the machine. With a hosted model, by contrast, the index can sit locally and every retrieved chunk is still transmitted on every query, so the sensitive material travels regardless of where the vectors live.
- Offline operation. The pipeline works on a plane, on a locked-down network, or anywhere outbound calls are not permitted.
- No per-token cost. Generation is free after the hardware, and so is embedding, which is where hosted setups surprise people: a one-off per document, but the document set is large and all of it must be redone when you change embedding models.
- No vendor dependency. No deprecated model version, no rate limit, no pricing change, no terms update. The weights are a file on your disk and will still run in five years.
Against that, four costs, all real.
- You need the hardware. A quantised 7B or 8B model fits on a machine with 16 GB of unified memory or a mid-range GPU; larger models want considerably more. CPU-only is fine for embedding, but generation drops to a speed that changes how you use the system.
- Quality trails hosted frontier models. Local models answer well from supplied passages, and remain behind on synthesis across many documents, on following long instructions, and on noticing that two passages contradict each other.
- Context is limited, often more than you think. Runners frequently default to a context window smaller than the model supports, and overflow is dropped rather than reported. Check the configured value before blaming retrieval.
- You now maintain an index. Every edited document is stale until something re-embeds it, and a changed embedding model invalidates all of it. That is a background job, a failure mode and a small piece of infrastructure you own.
Retrieval at query time, or compilation ahead of it
RAG searches raw material at the moment you ask. The main alternative inverts that: let a model compile the sources ahead of time into structured, linked Markdown pages, and query those instead. Andrej Karpathy published that pattern as a gist in September 2024, and it is covered in LLM Wiki. Retrieval is cheap to build, complete (everything is reachable, including the aside nobody thought mattered) and weak at synthesis. Compilation is expensive, lossy by construction, and much better at questions of the form how did our thinking change.
They compose rather than compete: Karpathy's own design runs ordinary retrieval over the compiled pages, on the grounds that a clean corpus retrieves better than a noisy one. If you are starting today, start with retrieval. It needs no schema decisions, it fails visibly, and the index is not wasted if you add a compiled layer above it later. Models embedded directly in note systems are covered in AI-enhanced PKM, the case for keeping the stack on your own machines in local-first AI at work, the pipeline from spoken material to something queryable in from meeting to knowledge base, and the plain-text substrate under all of it in PKM for developers.
Chunk on structure. Measure retrieval before tuning the model. Keep grep beside the vector store, because only one of the two can tell you that nothing matched.
Sources
- Ollama, official site
- llama.cpp, GitHub
- Chroma, official site
- Qdrant, official site
- LlamaIndex, official site
- LangChain, official site
- Open WebUI, official site
- Running LLMs locally with Ollama and llama.cpp, daily.dev
- The complete guide to local LLMs, SitePoint
- Local RAG with llama.cpp, Khoa Nguyen
- Building a persistent knowledge base RAG system with FastAPI, llama.cpp, Chroma and Open WebUI, DEV Community
- 15 best open-source RAG frameworks, Firecrawl
- LLM Wiki, Andrej Karpathy