Skip to content
Table of contents9 sections · tap to jump
  1. Step 1: Chunk your documents
  2. Step 2: Embed each chunk
  3. Step 3: Store the vectors
  4. Step 4: Retrieve the nearest chunks
  5. Step 5: Assemble the prompt
  6. Step 6: Show your sources
  7. Beyond the basics
  8. Where this breaks
  9. FAQ
How to build a basic RAG pipeline for a local LLM

TutorialaiDeep read4 min read

How to build a basic RAG pipeline for a local LLM

Signal DeskAug 10, 2026

Wire up retrieval-augmented generation from scratch — chunk your documents, embed them, store the vectors, and feed the right context into a local model so it answers from your own data. No cloud required.

A deep read — the full picture, with the receipts.

Signaldefinitive2independent sources

By the end of this walkthrough you will understand how a retrieval-augmented generation (RAG) pipeline lets a language model answer questions from your own documents instead of only what it absorbed during training. You need a local LLM runtime — an Ollama setup works well — an embedding model, and a small collection of text you want to query: notes, docs, a knowledge base, anything. No cloud services are required, and nothing you index leaves your machine.

RAG exists to solve one problem: a model only knows what it was trained on, and its context window is finite, so you cannot just paste your whole corpus in front of every question. RAG fetches the handful of passages most relevant to a question and drops them into the prompt, so the model answers from fresh, specific, private data it never saw in training.

Step 1: Chunk your documents#

Models read a fixed-size context, so you cannot dump a whole library into one prompt. Split each document into chunks of a few hundred tokens — very roughly a few paragraphs' worth of text. Overlap adjacent chunks slightly so a sentence stranded across a boundary still lands intact in at least one piece.

text
chunk_size = 400 tokens
overlap    = 50 tokens

Where you can, split on natural boundaries — paragraph breaks, headings, sentence ends — rather than slicing blindly every N tokens. A chunk that stops mid-sentence carries half a thought and embeds poorly. Fixed-size splitting is the crude baseline; structure-aware splitting that keeps each chunk semantically whole retrieves noticeably better.

Chunk too small and each piece loses the context that gives it meaning. Chunk too large and retrieval gets coarse — you pull in a whole page to answer a one-line question and waste prompt space. A few hundred tokens with light overlap is a sound default to tune from.

Step 2: Embed each chunk#

An embedding model turns a chunk of text into a vector: a fixed-length list of numbers that encodes its meaning. Passages about similar topics land near each other in this vector space. Run every chunk through a local embedding model — nomic-embed-text is a small, fast, widely used default that runs on a CPU; mxbai-embed-large (higher-dimensional vectors), bge-m3 (strong multilingual), and Google's embeddinggemma are common alternatives.

bash
curl http://localhost:11434/api/embed -d '{
  "model": "nomic-embed-text",
  "input": "search_document: Your chunk of text goes here."
}'

The response contains an embeddings array — one vector per input. (The older /api/embeddings route, with a singular prompt and embedding, still works but has been superseded by /api/embed, which also accepts an array of inputs so you can embed a batch in one call.)

Two rules keep the vectors comparable. First, embed your query with the exact same model you used for the chunks — mixing models breaks the comparison, because two models do not place text in the same coordinate system. Second, respect your model's task prefix if it has one: nomic-embed-text expects search_document: in front of stored chunks and search_query: in front of queries, and dropping those prefixes quietly degrades retrieval even though the model is technically the same. Check your model's card; not every model uses prefixes, but several of the popular local ones do.

Step 3: Store the vectors#

Keep each chunk's vector alongside the original text and a reference back to its source. Each stored record needs three things:

  • the embedding vector,
  • the original chunk text,
  • metadata such as the source document, title, and position.

The metadata is what lets you cite where an answer came from later, so do not skip it.

For a few thousand chunks, brute force is fine: hold the vectors in memory (or a file) and compare the query against every one. Past that, exact comparison gets slow, and you want a dedicated vector store that builds an approximate-nearest-neighbor (ANN) index — HNSW is the common one — to search millions of vectors in milliseconds. Local-friendly options include sqlite-vec, Chroma, LanceDB, Qdrant, FAISS, and pgvector if you are already on Postgres. The trade is exactness for speed: an ANN index can occasionally miss a true nearest neighbor, which for RAG almost never matters.

Step 4: Retrieve the nearest chunks#

At query time, embed the user's question (with the query prefix, if your model uses one), then find the stored vectors closest to it. Closeness is measured with cosine similarity, which compares the angle between two vectors rather than their length — so a short passage and a long one on the same topic still score as close. Return the top few matches, commonly three to five:

text
query_vector = embed("search_query: " + question)
top_k        = nearest_by_cosine(query_vector, stored_vectors, k=4)

Those top-k chunks are your evidence. top_k is your first tuning knob: retrieve too many and you flood the prompt with noise that dilutes the model's attention; too few and you risk missing the passage that actually holds the answer. (Many embedding vectors are normalized to unit length, in which case cosine similarity and a plain dot product rank results identically — a small but handy shortcut.)

Step 5: Assemble the prompt#

Now stitch the retrieved chunks into a clear instruction. The structure matters more than the wording:

text
Use only the context below to answer. If the answer is not
in the context, say you do not know.

Context:
<retrieved chunk 1>
<retrieved chunk 2>

Question: <the user question>

Send that to your local model — any small instruct model will do, and the same runtime that serves your generation model serves this prompt. Because the relevant passages sit right there in the prompt, the model can ground its answer in your data and quote specifics. The instruction to admit ignorance when the context lacks an answer is what keeps it from papering over gaps with invented facts.

One ordering detail earns its keep: models pay the most attention to the start and end of a long context and tend to skim what is buried in the middle — the "lost in the middle" effect. If you are packing in several chunks, put the strongest match first or last, not in the center.

Step 6: Show your sources#

Since every chunk carries metadata, append the source of each retrieved passage to the answer. This turns an opaque response into one a reader can check, and it is the single most valuable habit in any RAG system. It also makes retrieval auditable: when an answer looks wrong, you can see at a glance whether the model reasoned badly or the retriever simply handed it the wrong passage.

Beyond the basics#

The six steps above are a complete, working pipeline. Three upgrades matter once you outgrow it, in rough order of payoff.

Reranking. Embedding similarity is fast but blunt: it scores the query and each chunk separately, so "close in vector space" is not always "actually answers the question." A reranker — a cross-encoder such as bge-reranker — reads the query and a candidate chunk together and scores the pair directly. Retrieve a wide net with vectors (say the top 20), then rerank down to the 3 you actually send. It is the highest-leverage quality fix in most pipelines.

Hybrid search. Dense vectors are weak at exact strings — product codes, error numbers, rare names, acronyms — because those carry little semantic signal. Run a keyword search (BM25) alongside the vector search and merge the results, and you get both meaning and literal matches.

Query rewriting. Users ask terse or ambiguous questions. Having the model expand or rephrase the query before you embed it — or splitting a multi-part question into several retrievals — often surfaces passages a raw query would miss.

Where this breaks#

The failure most people hit first is bad chunking. If retrieval keeps returning passages that are almost-but-not-quite relevant, chunk size is usually the culprit — re-chunk with different sizes and overlap, and split on natural boundaries, before you touch anything else. If the chunks themselves are right but the ranking is still slightly off, that is the case reranking fixes.

The second is the embedding mismatch. Using one model to embed documents and another to embed the query puts the two in different coordinate systems and makes similarity meaningless. The subtler version is the same model with the wrong task prefix — technically matched, still degraded. Embed both sides with the same model and the same prefix convention.

Third, retrieval is only as good as what is in the store. If a fact was never ingested, no amount of prompt tuning will surface it, and the model will either say it does not know or quietly hallucinate. RAG extends a model's knowledge; it does not make the model omniscient. When an answer goes wrong, read what was actually retrieved before you blame the model — most "model" failures are retrieval failures wearing a disguise.

Frequently asked questions

What do I need to build a basic RAG pipeline locally?

A local LLM runtime (an Ollama setup works well), an embedding model, and a small collection of text you want to query, such as notes, docs, or a knowledge base. No cloud services are required, and nothing you index leaves your machine.

What chunk size and overlap should I start with?

Start around 400-token chunks with roughly 50 tokens of overlap, and split on natural boundaries — paragraphs, headings — where you can. Too small loses the context that gives a chunk meaning; too large makes retrieval coarse and wastes prompt space. Treat it as a default to tune, not a rule.

Why must I use the same embedding model for documents and the query?

Different embedding models place text in different coordinate systems, so mixing them makes similarity comparisons meaningless. Use one model for both sides — and match its task prefix too: models like nomic-embed-text expect searchdocument: on chunks and searchquery: on queries, and dropping the prefix degrades retrieval even with the right model.

How are the most relevant chunks retrieved at query time?

You embed the user's question with the same model, then find the stored vectors closest to it using cosine similarity, which compares the angle between vectors rather than their length. Return the top few matches, commonly three to five.

How do I keep a RAG model from making up answers?

Instruct it to use only the provided context and to say it does not know when the answer is not there. Append each retrieved passage's source so readers can verify it. And remember that most wrong answers are retrieval failures — check what was fetched before blaming the model.

Which local embedding model should I use?

nomic-embed-text is a safe default: small, fast, and it runs on a CPU. mxbai-embed-large trades size for higher-dimensional vectors, bge-m3 is strong for multilingual corpora, and Google's embeddinggemma is a compact Gemma-3-derived option. Any of them works — just pick one and use it (with its prefix convention) on both sides.

How do I improve retrieval quality beyond the basics?

Add a reranker: retrieve a wide candidate set with vectors, then use a cross-encoder (such as bge-reranker) to score query-and-chunk pairs together and keep the best few. Combine it with hybrid search — keyword BM25 alongside vectors — to catch exact strings like codes and names that embeddings miss.

What is the most common reason RAG retrieval goes wrong?

Bad chunking is the failure most people hit first; if retrieval keeps returning almost-relevant passages, re-chunk with different sizes and overlap and split on natural boundaries. Embedding mismatch — wrong model, or the same model with the wrong task prefix — and facts that were never ingested are the other common causes.

Sources

  1. Lewis et al. — Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks (arXiv)arxiv.org
  2. Karpukhin et al. — Dense Passage Retrieval for Open-Domain QA (arXiv)arxiv.org
  3. Malkov & Yashunin — Approximate Nearest Neighbor Search using Hierarchical Navigable Small World graphs (arXiv)arxiv.org
  4. OpenAI — Vector embeddings (API guide)developers.openai.com

AI-written by Signal Desk · reviewed by BitByteCore

Ask about this article

Answered only from this piece — the AI never invents.

React
ShareXLinkedInBluesky

More in aiMore in ai

Discussion