Lab: a fully local RAG pipeline
You can run RAG fully on this machine, with the embedding model it already has. We embed the course notes with nomic-embed-text in Ollama. The vectors go in a NumPy matrix (the vector index). Retrieval uses cosine similarity. The model expects task prefixes (search_document: and search_query:) for best quality.
Lab · runs on your own machine
uv run labs/11_local_rag.pyDownload all labs (.zip)Needs uv and, for the live parts, Ollama with a small chat model: ollama pull qwen3:0.6b.
Show the code
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy"]
# ///
import _local as local
import numpy as np
notes = [
"Decode is memory-bandwidth bound: every token re-reads all active weights from VRAM.",
"Prefill processes the whole prompt in one parallel pass and is compute bound; it sets time to first token.",
"The KV cache stores keys and values per layer and token; grouped-query attention shrinks it.",
"Q4_K_M is a GGUF k-quant of about 4.85 bits per weight and the usual quality/size sweet spot.",
"NVFP4 uses E2M1 values in blocks of 16 with an FP8 scale and runs natively on Blackwell tensor cores.",
"DGX Spark has 128 GB of unified LPDDR5X at 273 GB/s: huge capacity, modest bandwidth.",
"The RTX 5090 has 32 GB of GDDR7 on a 512-bit bus at 1,792 GB/s.",
"Continuous batching admits new requests at every decode step; PagedAttention stores KV in blocks.",
"Partial offload to system RAM is slow because per-token time is the sum of the GPU and CPU reads.",
"Mixture-of-experts models need memory for all experts but only read the active ones per token.",
"Windows ML runs ONNX models and can use the TensorRT for RTX execution provider on NVIDIA GPUs.",
"llama-server, Ollama, LM Studio and vLLM all expose an OpenAI-compatible chat completions API.",
]
emb_model = (local.embed_models() or [None])[0]
if emb_model:
doc_vecs = np.array(local.embed(emb_model, [f"search_document: {n}" for n in notes]))
doc_vecs /= np.linalg.norm(doc_vecs, axis=1, keepdims=True)
def retrieve(question, k=3):
q = np.array(local.embed(emb_model, [f"search_query: {question}"])[0]); q /= np.linalg.norm(q)
scores = doc_vecs @ q
top = np.argsort(-scores)[:k]
return [(float(scores[i]), notes[i]) for i in top]
for question in ["Why is my laptop slow when the model doesn't fit in VRAM?", "Which machine should run a 120B model?", "What format should I ship for Blackwell?"]:
print("Q:", question)
for score, text in retrieve(question):
print(f" {score:.3f} {text}")
else:
print("Run: ollama pull nomic-embed-text")
model = local.need_chat_model()
if model and emb_model:
question = "Why is my laptop slow when the model doesn't fit in VRAM?"
context = "\n".join(f"- {t}" for _, t in retrieve(question, 3))
r = local.generate(model, f"Answer using only these notes:\n{context}\n\nQuestion: {question}\nAnswer:", num_predict=120, think=False)
print(r["text"].strip())
print(f"\nprompt {r['prompt_tokens']} tokens · prefill {r['prefill_s']*1000:.0f} ms · decode {r['decode_tps']:.0f} tok/s")
Sample output on this course's Mac (Apple M4, 32 GB)
Q: Why is my laptop slow when the model doesn't fit in VRAM? 0.665 Partial offload to system RAM is slow because per-token time is the sum of the GPU and CPU reads. 0.616 Decode is memory-bandwidth bound: every token re-reads all active weights from VRAM. 0.593 Windows ML runs ONNX models and can use the TensorRT for RTX execution provider on NVIDIA GPUs. Q: Which machine should run a 120B model? 0.566 The RTX 5090 has 32 GB of GDDR7 on a 512-bit bus at 1,792 GB/s. 0.562 NVFP4 uses E2M1 values in blocks of 16 with an FP8 scale and runs natively on Blackwell tensor cores. 0.555 Windows ML runs ONNX models and can use the TensorRT for RTX execution provider on NVIDIA GPUs. Q: What format should I ship for Blackwell? 0.552 NVFP4 uses E2M1 values in blocks of 16 with an FP8 scale and runs natively on Blackwell tensor cores. 0.539 Continuous batching admits new requests at every decode step; PagedAttention stores KV in blocks. 0.505 llama-server, Ollama, LM Studio and vLLM all expose an OpenAI-compatible chat completions API. Your laptop is slow because the model does not fit in VRAM, which limits the amount of data that can be processed at once. Here's why: 1. **VRAM Limitation**: If the model's size exceeds VRAM capacity, each token would require reading all active weights from VRAM, which is memory-intensive. This causes the system to slow down as it needs to perform operations like memory reads and compute. 2. **Partial Offload to System RAM is Slow**: Even if partial offloading is done, the time required for GPU and CPU reads still adds up, making the process prompt 106 tokens · prefill 28 ms · decode 102 tok/s
Embeddings match wording, not reasoning. The question "which machine should run a 120B model?" usually misses the DGX Spark note, because the note never says "120B". Real pipelines add query rewriting, hybrid keyword and vector search, and a reranker (a small cross-encoder) over the top 20–50 hits.
The cost of retrieval
Retrieved text is prompt text. Each chunk adds c tokens of prefill, so k chunks add k\cdot c tokens. TTFT (time to first token) and KV memory grow with them (steps 1 and 2 above). Use better chunks, not more:
- Use a reranker and a tighter k.
- Keep a shared system prompt in the prefix cache.
- Choose an embedding model small enough to share the GPU. nomic-embed-text has ~137M parameters, or ~270 MB at F16.
Agents
An agent is a loop of inference calls on a growing context. It decodes a plan or tool call, runs the tool, appends the result and prefills again. A 20-step task makes 20 or more inference calls. Four things decide whether it works locally:
- Tool-call reliability: the model must emit valid JSON in its trained format. Chat templates and grammar-constrained decoding (llama.cpp grammars, SGLang structured output) matter more than raw speed.
- Prefix caching stops the runtime from re-prefilling the system prompt, tool schemas and history at every step (steps 3 and 4).
- KV budget: the context grows at each step (Chapter 4's tanks; step 5).
- MCP (Model Context Protocol) gives local agents a standard way to reach tools and data.
Other modalities, other bottlenecks
Chat decode is bandwidth bound; almost everything else (prefill, VLM image tokens, diffusion, embeddings) is compute bound.
| Workload | Typical local model | Bottleneck | Notes |
|---|---|---|---|
| Vision-language | Qwen2.5-VL, Gemma 3, Llama 3.2 Vision | prefill: an image becomes hundreds to thousands of tokens | the vision encoder runs first; resolution sets the token count |
| Speech-to-text | Whisper, NVIDIA Parakeet | encoder compute plus streaming latency | small models; real-time factor is the metric |
| Text-to-speech | Kokoro, Orpheus, VoxCPM | autoregressive decode, latency to first audio | streaming chunk size matters |
| Image generation | FLUX, Stable Diffusion 3.5 | compute bound: 4–50 denoising steps of a large network | here tensor-core FLOPS and FP8/FP4 formats pay off directly |
| Embeddings / rerankers | nomic-embed-text, bge, Qwen3-Embedding | prefill-like batch compute | cheap; batch them |
This is why RTX tensor-core generations matter more for creative and multimodal apps than tokens/s charts suggest.
Explain it
- "A private document assistant on RTX workstations?" Use local embeddings, a vector store, a 14–32B instruct model at Q4/FP8 and a cached system prompt. Add citations, an RTX PRO 6000 for many users, and tests on your own questions.
- "Why do local agents run worse?" The tool-call format may mismatch, prefix caching may be off, and prefill of growing transcripts is slow. Runtime choice and configuration fix all of these.
- "Where do RTX GPUs shine beyond chat?" In compute-bound work (diffusion, video, speech, embeddings), where tensor-core speed and FP8/FP4 help directly.
Exercises
- Split any file into 400-token chunks, embed them, and test retrieval on five questions you write.
- Extend the RAG lab into a two-step agent with a
calculator()tool, and count the prompt tokens per step.