An LLM knows what was in its training data, not your runbooks, tickets or policies. Retrieval augmented generation (RAG) fixes that by looking up relevant passages from your documents and putting them into the prompt, so the model answers from them.
The four steps
Indexing (once, then on change) Answering (every question)
1. Split documents into chunks 3. Embed the question, retrieve top-k chunks
2. Embed chunks, store vectors 4. Send question + chunks to the LLM, return answer + sources
Step 1: chunk
import pathlib, re
def chunk(text, size=800, overlap=150):
words = text.split()
step = size - overlap
return [" ".join(words[i:i + size]) for i in range(0, max(len(words) - overlap, 1), step)]
chunks = []
for path in pathlib.Path("runbooks").glob("*.md"):
text = path.read_text(encoding="utf-8")
for n, c in enumerate(chunk(text, size=250, overlap=40)):
chunks.append({"id": f"{path.stem}#{n}", "source": path.name, "text": c})
print(len(chunks), "chunks")
Chunks of a few hundred words with some overlap are a reasonable start. Splitting on headings (##) usually works better than fixed sizes for Markdown runbooks.
Step 2: embed and store
import numpy as np
from openai import AzureOpenAI
client = AzureOpenAI(azure_endpoint="https://my-aoai.openai.azure.com", api_key=KEY, api_version="2024-10-21")
def embed(texts):
resp = client.embeddings.create(model="text-embedding-3-small-prod", input=texts)
v = np.array([d.embedding for d in resp.data])
return v / np.linalg.norm(v, axis=1, keepdims=True)
vecs = np.vstack([embed([c["text"] for c in chunks[i:i + 64]]) for i in range(0, len(chunks), 64)])
For a few thousand chunks a NumPy array is enough. Production systems use a vector database or search service.
Step 3: retrieve
def retrieve(question, k=4):
q = embed([question])[0]
scores = vecs @ q
top = np.argsort(-scores)[:k]
return [dict(chunks[i], score=float(scores[i])) for i in top]
Step 4: generate a grounded answer
The prompt is what keeps the model honest:
SYSTEM = """You answer questions for IT support engineers using ONLY the provided context.
If the context does not contain the answer, say "I don't know based on the runbooks" and stop.
Cite sources as [source] after each statement that uses them. Be concise and give commands exactly as written."""
def answer(question):
hits = retrieve(question)
context = "\n\n".join(f"[{h['source']}]\n{h['text']}" for h in hits)
r = client.chat.completions.create(
model="gpt-4o-mini-prod",
messages=[{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"Context:\n{context}\n\nQuestion: {question}"}],
temperature=0.1, max_tokens=500)
return r.choices[0].message.content, [h["source"] for h in hits]
text, sources = answer("How do I free space when the root disk on a Linux VM is full?")
print(text); print("Sources:", sources)
Tip
Always show the sources to the user. It lets them check the answer, and it makes wrong retrievals obvious during testing.
When answers are wrong: check retrieval first
Most bad RAG answers are retrieval problems, not model problems.
for h in retrieve("root disk full linux"):
print(round(h["score"], 3), h["id"], h["text"][:100])
| Symptom | Likely cause | First fix |
|---|---|---|
| Right document exists, not retrieved | Chunk too big or too small; question uses different terms | Re-chunk by headings; add hybrid keyword search |
| Retrieved but answer ignores it | Too many irrelevant chunks; weak prompt | Lower k, add a reranker, tighten the system prompt |
| Confident wrong answer | No relevant chunk; model fills the gap | "I don't know" instruction; minimum score threshold |
| Outdated answer | Index not refreshed | Re-index changed documents on a schedule |
| Exact error codes not found | Pure vector search misses exact tokens | Hybrid search (BM25 + vector) |
Keep it safe
- Permissions: only retrieve documents the asking user may read. Filter by metadata (group, tenant) at retrieval time.
- Sensitive data: do not index secrets, passwords or personal data that should not appear in answers.
- Prompt injection: retrieved text can contain instructions ("ignore previous instructions"). Treat context as data; the system prompt should say so, and tools the model can call should be limited.
Measure before you tune
Collect 30-50 real questions with expected answers or expected source documents. After any change (chunking, model, k), run them and compare:
- Did the right source appear in the top-k? (retrieval hit rate)
- Is the answer correct and supported by the sources? (manual review or LLM-as-judge)
Prevent it next time
- Store source, section, last-updated date and access group with every chunk.
- Re-index on document change, not by hand.
- Keep a fixed evaluation set and run it on every change.