RAG Pipeline - Retrieval Augmented Generation with Ollama and Qdrant
Status: Active
Last Updated: 2026-08-26
Category: AI/ML - Phase 4: RAG Systems
Prerequisites: embeddings-vector-db, ollama-api
Time: 2-3 hours
Tags: rag, retrieval-augmented-generation, qdrant, ollama, chunking, embeddings, llm, python
Summary
RAG (Retrieval Augmented Generation) fixes the two biggest LLM weaknesses β stale knowledge and confident hallucination β by retrieving relevant chunks of your own documents at query time and handing them to the model as context. This guide builds a complete minimal RAG pipeline in Python: load a document, chunk it with sensible size/overlap, embed chunks into Qdrant, retrieve top matches for a question, assemble a grounded prompt, and generate an answer with a local Ollama chat model β plus evaluation tips and the failure modes that quietly wreck answer quality.
π― What You'll Learn
By the end of this article, you'll be able to:
- β Explain what RAG is, why it beats fine-tuning for knowledge injection
- β Choose chunk sizes and overlaps appropriate for your embedding model's context window
- β Load, chunk, embed, and store document text in Qdrant
- β Retrieve relevant chunks and assemble an anti-hallucination prompt
- β Run an end-to-end minimal RAG script using Ollama chat + Qdrant
- β Evaluate retrieval quality and diagnose common failure modes
Table of Contents
- What RAG Is and Why
- Document Loading
- Chunking Strategies: Size and Overlap
- Embedding and Storing Chunks in Qdrant
- Retrieval and Prompt Assembly
- End-to-End Minimal RAG Script
- Evaluation Tips
- Failure Modes
- Troubleshooting & Common Pitfalls
- Sources & Related
- Change Log
1. What RAG Is and Why
An LLM only knows what was in its training data plus what you put in its prompt. RAG closes that gap:
Question β embed β search Qdrant β top-k chunks
β
LLM β "Answer ONLY from this context: <chunks> Question"
β
Grounded answer (+ citations)
Why RAG instead of fine-tuning?
| Fine-tuning | RAG | |
|---|---|---|
| Adds new facts/knowledge | Poorly (teaches style, not facts) | β Exactly what it's for |
| Update frequency | Retrain per update | Re-index a doc in seconds |
| Citations / provenance | None | β Chunk-level sources |
| Cost & hardware | GPU training runs | CPU-only local stack |
| Hallucination control | Weak | β "answer from context" constraint |
You already have all the primitives from Phase 3 (embeddings-vector-db). RAG just wires them into a loop around a chat model.
2. Document Loading
Start simple β plain text and Markdown cover most home-lab use:
from pathlib import Path
def load_text(path: str) -> str:
return Path(path).read_text(encoding="utf-8")
For real corpora, add loaders incrementally: PDF (pypdf), HTML (beautifulsoup4 + html2text), or a directory walk over *.md storing {text, source} records. Keep source paths in payloads from day one β answers without provenance are hard to trust.
3. Chunking Strategies: Size and Overlap
Embedding models cap input length (~2K tokens for nomic-embed-text), so long documents must be split. Chunking is the single highest-leverage knob in a RAG pipeline: too big dilutes the semantic signal (one vector can't represent 10 unrelated topics); too small loses surrounding context.
Rules of thumb:
- Size: 300β800 tokens is a solid default range. Start ~500 tokens.
- Overlap: 10β20% of chunk size (e.g., 50β100 tokens). Overlap prevents sentences that span boundaries from being severed from their context.
- Respect structure: split on paragraphs/headings first, then fall back to token windows. A chunk should contain one coherent idea.
- Stay under budget: chunk tokens + retrieved context + question must fit the embedding AND chat model contexts.
A simple word-overlap chunker (good enough to start; swap in a tokenizer-based one later):
def chunk_text(text: str, size_words=350, overlap_words=60):
"""Split text into overlapping word-window chunks."""
words = text.split()
step = size_words - overlap_words
return [
" ".join(words[i:i + size_words])
for i in range(0, len(words), step)
if len(words[i:i + size_words]) > 20 # drop tiny tail fragments
]
Alternatives worth knowing: recursive character splitting (LangChain's RecursiveCharacterTextSplitter), sentence-aware splitting, and semantic chunking (split where embedding similarity between consecutive sentences drops).
4. Embedding and Storing Chunks in Qdrant
import ollama
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams
MODEL = "nomic-embed-text" # 768 dims, 2K context
client = QdrantClient(url="http://localhost:6333")
client.recreate_collection(
collection_name="docs",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
)
def index_document(path: str):
text = load_text(path)
chunks = chunk_text(text)
vectors = ollama.embed(model=MODEL, input=chunks)["embeddings"]
client.upsert(
collection_name="docs",
points=[
{
"id": f"{path}-{i}",
"vector": vec,
"payload": {"text": chunk, "source": path, "chunk": i},
}
for i, (chunk, vec) in enumerate(zip(chunks, vectors))
],
)
print(f"indexed {len(chunks)} chunks from {path}")
Batching all chunk embeddings into one ollama.embed(input=[...]) call is dramatically faster than one request per chunk.
5. Retrieval and Prompt Assembly
Retrieval mirrors Phase 3: embed the question, query_points, take top-k (start with k=3β5).
Prompt assembly is where hallucination is won or lost. Three rules:
- Constrain: instruct the model to answer only from the provided context.
- Admit ignorance: explicitly allow "I don't know" when the context doesn't contain the answer.
- Attribute: ask the model to cite which sources it used.
PROMPT_TEMPLATE = """You are a helpful assistant. Answer the user's question
using ONLY the context below. If the context does not contain enough
information, say "I don't know" - do not invent facts.
Context:
{context}
Sources: {sources}
Question: {question}
Answer:"""
6. End-to-End Minimal RAG Script
Complete working script β index a Markdown file, then answer questions from it. Requires: Ollama running with ollama pull nomic-embed-text and any chat model (e.g. llama3.1 or qwen2.5), plus Qdrant from Phase 3.
pip install ollama qdrant-client
"""rag.py β minimal end-to-end RAG: Ollama embeddings + Qdrant + Ollama chat."""
import sys
import ollama
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams
from pathlib import Path
EMBED_MODEL = "nomic-embed-text" # 768 dims
CHAT_MODEL = "llama3.1" # any Ollama chat model works
COLLECTION = "docs"
client = QdrantClient(url="http://localhost:6333")
def ensure_collection():
if not client.collection_exists(COLLECTION):
client.create_collection(
COLLECTION,
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
)
def chunk_text(text, size_words=350, overlap_words=60):
words, step = text.split(), size_words - overlap_words
return [" ".join(words[i:i+size_words]) for i in range(0, len(words), step)
if len(words[i:i+size_words]) > 20]
def index(paths):
points, offset = [], 0
for p in paths:
for i, chunk in enumerate(chunk_text(Path(p).read_text())):
vec = ollama.embed(model=EMBED_MODEL, input=chunk)["embeddings"][0]
points.append({"id": offset, "vector": vec,
"payload": {"text": chunk, "source": p}})
offset += 1
client.upsert(COLLECTION, points)
print(f"Indexed {offset} chunks.")
PROMPT = """Answer ONLY from the context below. If it's not there, say "I don't know".
Context:
{context}
Question: {question}
Answer:"""
def ask(question, k=4):
qv = ollama.embed(model=EMBED_MODEL, input=question)["embeddings"][0]
hits = client.query_points(COLLECTION, query=qv, limit=k).points
context = "\n\n---\n\n".join(h.payload["text"] for h in hits)
print("Sources:", ", ".join({h.payload["source"] for h in hits}))
reply = ollama.chat(model=CHAT_MODEL, messages=[
{"role": "user", "content": PROMPT.format(context=context, question=question)}
])
print(reply["message"]["content"])
if __name__ == "__main__":
ensure_collection()
if sys.argv[1] == "index":
index(sys.argv[2:])
else:
ask(" ".join(sys.argv[1:]))
Usage:
python rag.py index ./kb/aiml/*.md
python rag.py how do I choose a chunk size?
7. Evaluation Tips
Before adding features, measure. Cheap, effective checks:
- Retrieval-first testing: most bad RAG answers are actually bad retrievals. Build a set of ~20 questionβexpected-source pairs and check whether the expected chunk lands in the top-k. Hit rate @k is your north-star metric.
- Score inspection: log similarity scores per query. A flat score distribution means nothing discriminates β revisit chunking or the embedding model.
- Golden-set spot checks: run 10 known-answer questions weekly after any change (new model, new chunk size) and diff outputs.
- Refusal check: verify the pipeline says "I don't know" for questions outside the corpus β a RAG that always answers confidently is broken even when it sounds great.
- Latency budget: time each stage (embed / search / chat). Local stacks usually find chat dominates; keep k small.
Upgrade path once basics work: hybrid search (dense + keyword/BM25 fusion), reranking the top-k with a cross-encoder, and metadata filtering by source.
8. Failure Modes
| Failure | Symptom | Fix |
|---|---|---|
| Bad chunking | Right topic, wrong snippet; answers cut mid-thought | Adjust size/overlap; split on headings first |
| Retrieval miss | Model says "I don't know" despite the answer being in docs | Increase k; check chunking; try a better embedding model |
| Context stuffing | Answer degrades with many chunks; slow | Reduce k; filter low-score hits above a threshold |
| Hallucination despite context | Confident answer contradicting sources | Strengthen prompt constraints; switch to a stronger chat model |
| Model/context mismatch | Vector errors or nonsense results after changing models | Re-index the whole collection whenever the embedding model changes |
| Stale index | Answers reflect deleted/outdated docs | Delete old points on re-index; version collections |
Troubleshooting & Common Pitfalls
Click to expand
- Everything indexed but every answer is wrong β test retrieval alone first (print top-k chunks before invoking the LLM). In ~80% of broken pipelines, retrieval is the culprit, not generation.
size mismatchon upsert/search β collection created with a different dimension than current model. Drop and recreate the collection.- Slow indexing β embedding chunks one-by-one via REST. Batch with
ollama.embed(input=[list]). - Answers ignore the "only from context" rule β small local chat models comply loosely; put constraints at the start of the prompt, keep them short and imperative.
- Chunks exceed the 2K-token embed window β nomic-embed-text silently truncates, producing weak vectors. Count tokens, not characters.
- Duplicate content in answers β re-ran indexing without clearing;
upsertwith stable deterministic IDs (e.g.,path-chunkN) makes re-indexes idempotent.
Sources & Related
Web sources consulted during research:
- https://ollama.com/blog/embedding-models
- https://qdrant.tech/documentation/quickstart/
- https://www.pinecone.io/learn/retrieval-augmented-generation/
- https://qdrant.tech/documentation/guides/
Related knowledge-base articles:
- embeddings-vector-db β Phase 3 prerequisite: embeddings, cosine distance, Qdrant setup
- model-selection-guide β choosing chat vs embedding models
- ollama-api β
/api/embedand/api/chatreference
Change Log
- 2026-08-26: Initial draft created via headless-browser web research session.