Lab: Docs Assistant — Naive RAG
You will hand-author a compose.yaml service by service, start a ChromaDB vector store and a Streamlit RAG app, ingest Acme's runbooks, and ask a real question — watching the naive-RAG pipeline retrieve the right chunk and generate a grounded answer.
What you build: ChromaDB 1.5.9 (container) + genai-app (container) + native Ollama — the growing Day-2 compose.yaml.
Prerequisites
Ollama must be running and both models must be pulled on the host before the containers start.
ollama pull qwen3.5:2b
ollama pull nomic-embed-text
Expected output:
pulling manifest
pulling ...
verifying sha256 digest
writing manifest
success
Confirm Ollama is listening:
curl -s localhost:11434/api/tags | grep -o '"name":"[^"]*"'
Expected output (models present):
"name":"qwen3.5:2b"
"name":"nomic-embed-text:latest"
Step 1: Navigate to the lab directory
cd labs/m5
All paths in this lab are relative to labs/m5/.
Step 2: Author the compose.yaml
This is your Day-2 growing compose file. You are adding a vector database and an application layer to the native-Ollama pattern established in Module 2. Write each block deliberately — understanding every line before you move on.
Create compose.yaml and add the opening comment and the first service:
# M5 · Docs Assistant (naive RAG) — the growing compose.yaml, Day 2 edition.
#
# Apple-Silicon pattern: the MODEL SERVER runs NATIVELY (Ollama, Metal) on the host;
# only the vector DB and the app are containerized and reach Ollama via
# host.docker.internal. On Linux/NVIDIA you could add an `ollama` service instead.
#
# Prereqs on the host: ollama pull qwen3.5:2b && ollama pull nomic-embed-text
services:
# Vector database — pinned to 1.x: client (chromadb==1.5.9) and server must share a major.
chromadb:
image: chromadb/chroma:1.5.9
container_name: chromadb
ports:
- "8000:8000"
volumes:
- chroma_data:/data
environment:
- IS_PERSISTENT=TRUE
- ANONYMIZED_TELEMETRY=FALSE
deploy:
resources:
limits:
memory: 768M
restart: unless-stopped
Why these settings matter:
chromadb/chroma:1.5.9— pinned because the app's Python client (chromadb==1.5.9, used throughlangchain-chroma==0.2.6) and the server must share a major version. Chroma 1.0 replaced the/api/v1HTTP routes with/api/v2, so a 0.x client and a 1.x server are not a supported pair. Pin the server to the same release as the client and the question never comes up.chroma_datavolume, mounted at/data— vectors survivedocker compose downand are still there when you bring the stack back up. Chroma 1.x writes its database under/data; the 0.5 image used an older path under/chroma. A volume mounted at that old path would not capture the data: Chroma would write into the container's own filesystem, anddocker compose downwould delete it along with the container.IS_PERSISTENT=TRUE— tells ChromaDB to write to disk, not memory-only mode.ANONYMIZED_TELEMETRY=FALSE— suppresses outbound telemetry in a course environment.
Now append the application service below the chromadb block:
# The Docs Assistant — Streamlit RAG app. Embeds + generates via NATIVE Ollama.
genai-app:
build:
context: ./app
dockerfile: Dockerfile
container_name: genai-app
ports:
- "8501:8501"
environment:
- OLLAMA_BASE_URL=http://host.docker.internal:11434
- CHROMA_HOST=chromadb
- CHROMA_PORT=8000
- LLM_MODEL=qwen3.5:2b
- EMBEDDING_MODEL=nomic-embed-text
depends_on:
- chromadb
deploy:
resources:
limits:
memory: 1G
restart: unless-stopped
Why these settings matter:
OLLAMA_BASE_URL=http://host.docker.internal:11434— this is the Apple-Silicon pattern. The container cannot reachlocalhost:11434(that is the container's own loopback);host.docker.internalresolves to the host machine, where Ollama is running natively with Metal acceleration.CHROMA_HOST=chromadb— Docker's internal DNS resolves the service name. The app talks to ChromaDB over the Docker network, not via localhost.build: context: ./app— the app is built from source rather than pulled from a registry. TheDockerfilein./app/produces a trimmed image: nosentence-transformers, no PyTorch, just the LangChain + ChromaDB + Streamlit stack.
Finally, add the volumes stanza at the end of the file:
volumes:
chroma_data:
Step 3: Start the stack
docker compose up -d --build
The --build flag rebuilds the genai-app image from ./app/. On the first run this takes two to four minutes while pip installs the dependencies.
Expected output:
[+] Building ...
=> [genai-app] ...
[+] Running 2/2
✔ Container chromadb Started
✔ Container genai-app Started
Step 4: Verify both services
ChromaDB heartbeat
curl -s localhost:8000/api/v2/heartbeat
Expected output:
{"nanosecond heartbeat":1792579530171181950}
Check the HTTP status code:
curl -s -o /dev/null -w '%{http_code}' localhost:8000/api/v2/heartbeat
Expected output:
200
Streamlit health
curl -s -o /dev/null -w '%{http_code}' localhost:8501/_stcore/health
Expected output:
200
App log confirmation
docker logs genai-app | grep -A2 Streamlit
Expected output (Streamlit prints the message and the URL on separate lines — -A2 grabs both):
You can now view your Streamlit app in your browser.
URL: http://0.0.0.0:8501
Step 5: Ingest Acme's runbooks
Open the Docs Assistant in your browser:
http://localhost:8501
You will see the Streamlit UI with a sidebar containing a Document Upload section. The Vector Database metrics show 0 chunks and 0 documents — the index is empty.
Ingest the runbook file:
- In the sidebar under Document Upload, click Browse files
- Navigate to
labs/m5/docs/acme-runbooks.mdand select it - Click Process Documents
The app will show progress: loading the file, splitting into chunks (chunk_size=500, overlap=50), then generating embeddings via nomic-embed-text.
The first embedding call warms up nomic-embed-text on Ollama. Expect 10–30 seconds on the first run. Subsequent embeddings are fast.
After processing, the sidebar metrics update:
Chunks: 2 Documents: 1
The four runbook sections (Payments, Database backups, Checkout errors, On-call escalation) are short, so the 500-character splitter packed them into two chunks. (Larger documents produce more — the count depends on the text, chunk_size, and chunk_overlap.)
Step 6: Ask a question and see the grounded answer
In the chat input at the bottom of the page, type:
How do I restart the payments service?
With Show RAG Details enabled in the sidebar (it's on by default), a 🔍 RAG Pipeline Running panel — the course's "Learning Mode" — expands and shows the pipeline in real time:
Step 1: Converting query to embedding...
✅ Generated 768-dim vector (0.Xs)
Step 2: Searching for similar chunks...
✅ Found 2 relevant chunks (0.Xs)
Step 3: Retrieved Context:
📄 Chunk 1 (Page ?): Acme Platform Runbooks Payments service To restart the Acme payments service, run: kubectl rollout restart deploy/payments -n prod. The payments ser...
Step 4: Generating response with LLM...
✅ Response generated (X.Xs)
The model's answer will be grounded in the retrieved context:
To restart the payments service, run: `kubectl rollout restart deploy/payments -n prod`.
The wording varies from run to run (the app samples at temperature=0.7), but the command does not. The app also passes reasoning=False to the model. qwen3.5:2b is a thinking model, and without that switch it spends its token budget on hidden reasoning and can return an empty answer.
This is the validated naive-RAG round-trip: the question's embedding matched the payments runbook chunk by semantic similarity, that chunk was passed to qwen3.5:2b as context, and the model answered with the correct kubectl command — not a hallucinated one.
You can also expand View Source Chunks below the answer to see the exact text that was retrieved from ChromaDB.
Step 7: Tear down
docker compose down
Expected output:
[+] Running 3/3
✔ Container genai-app Removed
✔ Container chromadb Removed
✔ Network m5_default Removed
The chroma_data volume is preserved — your ingested chunks survive the teardown. To also remove the volume:
docker compose down -v
Troubleshooting
Symptom: With an older 0.5.x client against a 1.x server, the heartbeat and list_collections() still worked in our test. Raw HTTP calls to the retired /api/v1 routes returned HTTP 405 Method Not Allowed, so the lab's scripted round-trip (rag_roundtrip.py) failed while the containers looked healthy.
Cause: The app's chromadb client is 1.5.9. A server from a different major (an old 0.5.x image, or a future 2.x that latest may point to) speaks a different HTTP API. Chroma 1.0 moved the routes from /api/v1 to /api/v2, and a raw call to a retired route returns 405 Method Not Allowed.
Fix: Keep chromadb/chroma:1.5.9 in your compose.yaml, matching chromadb==1.5.9 in app/requirements.txt. Never use latest for a version-sensitive component.
Symptom: genai-app logs show httpx.ConnectError: [Errno 111] Connection refused when trying to embed or generate, even though ollama serve is running.
Cause: Ollama defaults to binding on 127.0.0.1 (the host's loopback). Containers cannot reach 127.0.0.1 on the host via host.docker.internal — they need Ollama to listen on 0.0.0.0.
Fix: Restart Ollama with OLLAMA_HOST set:
OLLAMA_HOST=0.0.0.0 ollama serve
Or add OLLAMA_HOST=0.0.0.0 to your Ollama systemd/launchd service and restart it. Confirm with:
curl -s http://localhost:11434/api/tags
Symptom: The progress bar stalls at "Generating embeddings" for more than 60 seconds.
Cause: nomic-embed-text needs to be loaded into memory by Ollama on the first call. On Apple Silicon with 16 GB RAM, the first load takes 10–30 seconds.
Fix: Wait. If it times out, check ollama ps to confirm the model is loading, then retry the Document Upload. Subsequent runs are fast because the model stays warm in memory.
What's next
You now have a working naive-RAG Docs Assistant. But you have also seen its limits — the pipeline retrieves once, without rewriting the query or verifying whether the retrieved chunks are actually sufficient. Ask "What do I do if the checkout page is slow?" and the answer depends entirely on how well that phrasing matches the embeddings in the index.
Module 6 introduces agentic RAG: replacing the single-pass retriever with an agent that can rewrite queries, run multiple retrieval passes, and decide when it has enough evidence to answer — fixing the failure modes from the lesson.
Go deeper
Your Docs Assistant answers questions — but why chunk at 500 characters? Why top-3? The Deep Dive (Part 2) opens the hood: what every retrieval parameter controls, how to see the stored chunks and distances directly, and a chunking experiment that shows the trade-offs on real data.