Tutorial D7: RAG Foundations (LlamaIndex)
Embeddings, vector stores, and answers you can trace back to a source line
β CORE MISSION OF THIS TUTORIAL
By the end of this tutorial, the reader will be able to:
- β Ingest industrial documents into chunked, citation-ready "evidence units"
- β Understand embeddings + vector stores as the retrieval backbone of RAG
- β Implement top-K retrieval that is deterministic enough to audit
- β Produce answers that include citations (source + line range + chunk id)
- β Log query β retrieval β citations as an audit trail for shift handover + compliance
This is the retrieval layer that D12 (RAG for PLC Documentation) builds on.
π VENDOR-AGNOSTIC ENGINEERING NOTE
This tutorial uses:
- βΈ LlamaIndex (RAG orchestration) or equivalent retrieval framework
- βΈ Vector stores: FAISS, Chroma, Pinecone, pgvector (swap per plant constraints)
- βΈ Sources: PDF manuals, SOPs, PLC code exports, CMMS/SCADA change logs
All examples are read-only and advisory. No live PLC connections.
01 Β· The question a plain LLM can't answer
Ask a plain LLM why a SINAMICS G120 keeps throwing F30005 and you'll get a sensible answer: DC link overvoltage, so check the decel ramp, the braking resistor, and the incoming line voltage. It will sound sure of itself, and it may well be right.
Then the maintenance lead asks the obvious next question: where does it say that? Which manual, which page? Did it know someone shortened the decel ramp last week? A plain model can't tell you. It has no way to separate what it read in your documents from what it picked up in training, and "the model said so" won't get through a shift handover review.
Retrieval-augmented generation (RAG) fixes this by changing the order of work. First you find the relevant passages in your own documents. Then the model answers from those passages only, and cites them. If retrieval finds nothing useful, the right output is "not enough evidence", not a guess.
The goal for this tutorial: every answer comes with the lines that support it, and a reviewer can check them in under a minute.
There's a shortcut people usually try first: paste every manual into the prompt. It works in a demo. It fails on cost, because you pay for the whole library on every question, and on citations, because the model sees one big blob and can't point you to a line. Experiment 1 runs that shortcut so you can see both problems.
02 Β· The pipeline, one part at a time
RAG gets talked about like a single feature, but it's a pipeline of small parts. Each part can be tested on its own, which matters: when an answer is wrong, you want to know which part to blame.
- βΈ Ingestion: parse manuals, SOPs and code into text, keeping metadata
- βΈ Chunking: split text into small units you can cite (line range plus id)
- βΈ Embeddings: turn each chunk into a vector so you can compare meaning
- βΈ Vector store: index the vectors and metadata, and return the closest K
- βΈ Retrieval: fetch the few chunks that matter, not the whole library
- βΈ Citations and audit log: record what was used, and when
graph LR
A[Docs: Manuals/SOPs/PLC Code]:::cyan --> B[Ingest + Chunk]:::purple
B --> C[Embed Chunks]:::purple
C --> D[Vector Store]:::cyan
D --> E[Retrieve Top-K]:::purple
E --> F[Answer + Citations]:::green
E --> G[Audit Log]:::cyan
classDef cyan fill:#04d9ff,stroke:#04d9ff,color:#000
classDef purple fill:#6366f1,stroke:#6366f1,color:#fff
classDef green fill:#00ff7f,stroke:#00ff7f,color:#000 LlamaIndex can assemble all of this in a few lines, and you'll see how at the end. I've built the pipeline by hand first, with plain Python and scikit-learn, on purpose. The decisions that make RAG auditable (where chunks break, what metadata they carry, what gets logged) are exactly the ones a framework makes quietly on your behalf. I'd rather you make them deliberately.
03 Β· What a reviewer needs to see
In a plant, a RAG answer is judged less on whether it's right than on whether someone can justify it afterwards. So every answer ships with two things besides the answer itself.
The evidence pack is the set of chunks the answer was built from, each with a citation key: file, line range, and chunk id. The retrieval log records what was asked, what came back, and when, so you can reconstruct a run days later. With both, review means clicking through to the source instead of re-deriving the answer from scratch.
A word on cost. Embedding your documents is a batch job: you run it once, and again when documents change. Pasting manuals into the prompt charges you for the whole library on every question. Prices change often, so the dollar figures in this tutorial are 2025 list prices. Check current rates before you budget anything.
04 Β· Experiments: build a small RAG pipeline with citations
You'll start with the shortcut, watch it fail, and then add chunking, retrieval, citations and an audit log one step at a time. Everything runs locally on four small documents. They're made up, but shaped like the real thing: a drive manual excerpt, a line SOP, some Structured Text, and a change log. There are no API calls, so it costs nothing to run.
Tested with Python 3.11 and scikit-learn 1.9.1 (September 2026). Every expected output below comes from that run.
Experiment 1: the shortcut
Load the four documents, count the tokens, then try whole-document retrieval and look at what ranks first.
SETUP CELL
Experiment 1A: a minimal document model
Define a small data structure that keeps each document's text together with where it came from.
from dataclasses import dataclass
@dataclass
class Doc:
source: str # Document identifier (e.g., vendor manual, SOP, PLC code)
text: str # Full document text Explanation
- - Two fields for now: where the text came from, and the text.
- - A real system also needs revision, effective date and equipment ID. You will want them the first time someone asks which version of the manual an answer used.
- - The source field becomes the first part of every citation later on.
Takeaway
Keep metadata separate from content from the very first line.
SETUP CELL
Experiment 1A: what prompt stuffing costs
Estimate what it costs to paste whole manuals into every prompt.
# Estimated cost of prompt stuffing (2025 list prices)
# Example: 2 vendor PDFs (200 pages each) β 400k-1M tokens
# At $0.15/1M input tokens (gpt-4o-mini as of 2025):
# 400k tokens Γ $0.15/1M = $0.06 per query
# 1M tokens Γ $0.15/1M = $0.15 per query
#
# Problem: cost grows with doc set size, not query complexity
# 1,000 queries/day Γ $0.15 = $150/day = $4,500/month
#
# Additional issues:
# - No stable citations (can't reference specific lines)
# - Answers drift as docs change (no version hash)
# - Context window limits (can't fit 10 manuals) Explanation
- - Pasting everything is the easiest thing to build, and cost grows with your whole document library rather than with the question.
- - Once a manual is pasted in as one blob, the model has no reliable way to point at "line 47 of the vendor manual".
- - When the manual changes, the same question can produce a different answer, and nothing records why.
Takeaway
Prompt stuffing is fine for a demo. I wouldn't ship it.
EXPERIMENT CELL
Experiment 1A: load the documents and count tokens
Load the four demo documents and estimate how many tokens pasting them would take.
import math
import textwrap
docs = [
Doc(
source="vendor_manual_sinamics_g120.txt",
text=textwrap.dedent("""
SINAMICS G120 β Fault Codes (Excerpt)
F30005: DC link overvoltage.
Possible causes:
- Regenerative energy during deceleration (ramp too fast).
- Line voltage too high.
- Braking resistor missing or undersized.
Recommended actions:
- Increase deceleration ramp time (P1121).
- Enable/size braking resistor; verify wiring.
- Check incoming line voltage.
Notes: Clear fault after cause removed. Document ID: G120-FLT-EX-01
""").strip()
),
Doc(
source="line_sop_packaging_a.txt",
text=textwrap.dedent("""
Packaging Line A β Troubleshooting SOP (Excerpt)
Symptom: Conveyor stops after ~3 seconds, HMI shows "Photoeye Timeout".
Checklist:
1) Verify photoeye PE-17 alignment and clean lens.
2) Check PE-17 input in PLC tag table; confirm it toggles when blocked/unblocked.
3) Inspect conveyor motor overload relay OL-2; reset if tripped.
4) Review last 10 alarms in SCADA for recurring stop causes.
Safety: Lockout/Tagout required before mechanical intervention.
SOP ID: SOP-PL-A-07
""").strip()
),
Doc(
source="plc_code_conveyor_st.txt",
text=textwrap.dedent("""
// Structured Text (Excerpt) β Conveyor Control
IF StartButton AND NOT EmergencyStop THEN
MotorRunning := TRUE;
StartTimer(IN:=TRUE, PT:=T#3S); // Start grace period
END_IF
// Fault: photoeye should clear within 3 seconds of motor start
IF MotorRunning AND PhotoEye_Blocked THEN
IF StartTimer.Q THEN
Fault_PhotoEye_Timeout := TRUE;
MotorRunning := FALSE;
END_IF
END_IF
// Drive fault mapping
IF DriveFault_F30005 THEN
AlarmText := 'DC link overvoltage';
END_IF
""").strip()
),
Doc(
source="change_log_2025w41.txt",
text=textwrap.dedent("""
Change Log β Packaging Line A (Week 41)
- Updated VFD decel ramp: P1121 from 1.0s to 0.6s to improve throughput.
- Disabled braking resistor monitoring due to nuisance trips (temporary).
- Added alarm mapping for DriveFault_F30005.
CHG ID: PL-A-CHG-2025W41
""").strip()
),
]
def approx_tokens(s: str) -> int:
# Crude but useful for planning: ~1 token per 4 characters
return math.ceil(len(s) / 4)
total_tokens = sum(approx_tokens(d.text) for d in docs)
print(f"Docs loaded: {len(docs)}")
print(f"Approx prompt tokens if you paste everything: {total_tokens:,} tokens")
print("Reality check: 2 vendor PDFs (~200 pages each) can easily be 400kβ1M tokens.")
print("Naive approach risk: high cost + no audit trail (no citations to exact lines).") Expected output
Docs loaded: 4 Approx prompt tokens if you paste everything: 418 tokens Reality check: 2 vendor PDFs (~200 pages each) can easily be 400kβ1M tokens. Naive approach risk: high cost + no audit trail (no citations to exact lines).
Explanation
- - The four demo files come to 418 tokens, which is nothing. A real drive manual runs to hundreds of pages, and with prompt stuffing you pay for all of it on every question.
- - Even when the answer is right, you cannot cite a line from a pasted blob.
Common mistake
Treating RAG as attaching PDFs to the prompt instead of building retrieval and citations.
Takeaway
Easy to demo, hard to budget, and impossible to audit.
SETUP CELL
Experiment 1B: why whole-document retrieval is too coarse
See why retrieving whole documents gives citations a reviewer can't use.
# Why doc-level retrieval fails for industrial review:
#
# 1. Granularity mismatch:
# - A 200-page manual is one "document"
# - Citing "vendor_manual.pdf" is too vague for review
# - Reviewer needs: "page 47, section 3.2.1, lines 5-8"
#
# 2. Keyword over-matching:
# - Doc-level matching often surfaces keyword mentions, not root causes
# - Example: PLC code mentions "F30005" (the fault code)
# - But the vendor manual explains the root cause
# - Doc-level TF-IDF might rank PLC code higher (more "F30005" density)
#
# 3. No stable citations:
# - "Top document: vendor_manual.pdf" is not actionable
# - Human reviewer cannot verify the claim without reading the entire doc Explanation
- - Whole-document matching rewards files that mention the fault code often, not the file that explains it.
- - A citation like "see vendor_manual.pdf" moves the search onto the reviewer. They need a line range.
Takeaway
A whole-document citation just hands the search to the reviewer.
EXPERIMENT CELL
Experiment 1B: run whole-document retrieval
Rank the four documents against a real question and look at what comes first.
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
query = "Why did Drive fault F30005 start appearing after the decel ramp change?"
vectorizer = TfidfVectorizer(stop_words="english")
X = vectorizer.fit_transform([d.text for d in docs])
qv = vectorizer.transform([query])
sims = cosine_similarity(qv, X)[0]
ranked = sorted(
[(docs[i].source, float(sims[i])) for i in range(len(docs))],
key=lambda x: x[1],
reverse=True
)
print("Top docs (doc-level retrieval):")
for src, score in ranked[:3]:
print(f" - {src:28s} score={score:.3f}")
print("\nNaive answer (no citations):")
print("- Likely linked to a decel ramp change or braking resistor monitoring changes.")
print("- Recommended: review P1121, braking resistor config, and line voltage.")
print("\nProblem: this answer is plausible, but you cannot prove which document line drove it.") Expected output
Top docs (doc-level retrieval): - plc_code_conveyor_st.txt score=0.210 - change_log_2025w41.txt score=0.196 - vendor_manual_sinamics_g120.txt score=0.189 Naive answer (no citations): - Likely linked to a decel ramp change or braking resistor monitoring changes. - Recommended: review P1121, braking resistor config, and line voltage. Problem: this answer is plausible, but you cannot prove which document line drove it.
Explanation
- - Look at the ranking. The PLC file comes first, but it only maps F30005 to an alarm text. The explanation of the fault is in the vendor manual, which comes last.
- - The change log, which holds the likely cause (P1121 shortened from 1.0 s to 0.6 s), comes second. The scores are too close together to mean much.
- - Even if you trusted the top result, "see plc_code_conveyor_st.txt" is not something a reviewer can check quickly.
Common mistake
Returning the top document as evidence instead of returning the specific chunk(s) that support the claim.
Takeaway
Without chunk-level citations, you are one reviewer question away from losing trust.
CHECKPOINT CELL
Checkpoint: what failed, and what comes next
Before moving on, be clear about why both shortcuts fail.
Explanation
- - Prompt stuffing fails on budget: cost follows the size of your library, not the question.
- - Whole-document retrieval fails on precision: it has no way to point to the lines that support an answer.
- - Next: split documents into small citable chunks and retrieve those instead.
- - Target: every answer cites evidence at chunk level (file, line range, chunk id).
Takeaway
Until RAG can show its evidence, it isn't ready for a plant.
Experiment 2: chunk, retrieve, cite
Split the documents into small citable pieces, search over those, and attach citations to the answer.
SETUP CELL
Experiment 2A: a chunk you can cite
Model a chunk with its source file, line range, and a short id.
from dataclasses import dataclass
import hashlib
@dataclass
class Chunk:
chunk_id: str # Hash-based stable identifier
source: str # Document name
start_line: int # First line in this chunk (1-indexed)
end_line: int # Last line in this chunk
text: str # The actual chunk text Explanation
- - Chunking decides what you can cite. This model keeps the file, the line range and the text together.
- - chunk_id is a short hash of the file name, line range and text. Same inputs, same id, so a log entry can be matched to its chunk later. It is not stable across edits, though. Cell 11 shows why.
- - Line numbers start at 1 to match what editors show.
Takeaway
A chunk is an evidence unit. Size it so a reviewer can read it at a glance.
SETUP CELL
Experiment 2A: line-based chunks with overlap
Decide how big chunks should be and why neighbouring chunks share a line.
# Line-based chunking strategy:
#
# max_lines=4: Each chunk is 4 lines maximum
# overlap=1: Chunks share 1 line with the next chunk
#
# Example document (5 lines):
# Line 1: Fault F30005 definition
# Line 2: Possible cause 1
# Line 3: Possible cause 2
# Line 4: Recommended action
# Line 5: Document ID
#
# Chunks with max_lines=4, overlap=1:
# Chunk 0: Lines 1-4
# Chunk 1: Lines 4-5 (overlap at line 4)
#
# Why overlap matters:
# - Multi-line fault descriptions can span chunk boundaries
# - Overlap ensures you don't lose context at the boundary
# - Trade-off: slight storage/compute increase for better retrieval Explanation
- - Line ranges are easy for a reviewer to check: open the file and go to the line.
- - Overlap stops a fault description that straddles a boundary from being split in half.
- - max_lines=4 suits these short excerpts. For real manuals I would start larger, somewhere around 20 to 40 lines, and tune it against questions you already know the answers to.
- - The alternatives are sentence-based chunks (better units of meaning, but harder citations to check) and section-based chunks (good if your manuals have consistent headings, which many do not).
Takeaway
Overlap is cheap insurance against splitting a fault description in half.
SETUP CELL
Experiment 2A: implement chunk_by_lines
Build a chunking function that splits documents into fixed-size line-based chunks with overlap.
from typing import List
def chunk_by_lines(doc: Doc, max_lines: int = 4, overlap: int = 1) -> List[Chunk]:
"""Chunk a document by lines, with overlap to preserve boundary context."""
lines = [ln.strip() for ln in doc.text.splitlines() if ln.strip()]
out: List[Chunk] = []
i = 0
while i < len(lines):
start = i
end = min(len(lines), i + max_lines)
chunk_text = "\n".join(lines[start:end])
# Generate deterministic chunk_id from content + metadata
raw = f"{doc.source}:{start}:{end}:{chunk_text}"
cid = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:10]
out.append(Chunk(
chunk_id=cid,
source=doc.source,
start_line=start + 1, # 1-indexed for human readability
end_line=end,
text=chunk_text
))
if end == len(lines):
break
i = end - overlap # Slide forward with overlap
return out Explanation
- - Deterministic: the same document always produces the same chunks and the same ids.
- - About 25 lines, and enough for plain text. PDFs with tables or scanned pages need a proper parser before this step.
- - max_lines and overlap are the two knobs. Tune them against real questions, not by feel.
Common mistake
Chunking too large (citations become vague) or too small (retrieval becomes noisy).
Takeaway
Small, deterministic, and easy to check beats clever.
EXPERIMENT CELL
Experiment 2A: chunk all four documents
Run the chunker over every document and look at a citation key.
# Apply chunking to all loaded documents
chunks: List[Chunk] = []
for d in docs:
chunks.extend(chunk_by_lines(d, max_lines=4, overlap=1))
print(f"Total chunks: {len(chunks)}")
print("Sample citation key:", f"{chunks[0].source}#L{chunks[0].start_line}-L{chunks[0].end_line}|{chunks[0].chunk_id}") Expected output
Total chunks: 14 Sample citation key: vendor_manual_sinamics_g120.txt#L1-L4|19a7dcc007
Explanation
- - Four documents become 14 citable chunks.
- - Each key is something a person can follow: file, line range, id.
Takeaway
Chunking is what turns documents into evidence.
EXPERIMENT CELL
Experiment 2A: what happens to citations when a document changes
Add one line to the top of the vendor manual and check whether the old citation keys still point at the same text.
original = chunk_by_lines(docs[0])
revised_doc = Doc(
source=docs[0].source,
text="Revision 2 (2025-11)\n" + docs[0].text, # one new line at the top
)
revised = chunk_by_lines(revised_doc)
print("Before the edit:")
for c in original:
print(f" L{c.start_line}-L{c.end_line} {c.chunk_id}")
print("After adding one line at the top:")
for c in revised:
print(f" L{c.start_line}-L{c.end_line} {c.chunk_id}") Expected output
Before the edit: L1-L4 19a7dcc007 L4-L7 8a76d73485 L7-L10 8bc9f15cfd L10-L11 d332400778 After adding one line at the top: L1-L4 25f1be901a L4-L7 58ba4baebb L7-L10 1fc9036468 L10-L12 6119288a4a
Explanation
- - Every chunk id changed, and every line range now points one line off. An audit log written last week would cite lines that no longer hold the same text.
- - Adding a line at the end changes only the last chunk. Edits near the top of a document are the ones that break old citations.
- - This is why the audit log in Experiment 3 needs a document version, not just a citation. Store a hash of each file with every log entry, or keep old revisions so you can open exactly what the model saw.
Common mistake
Assuming line-number citations stay valid after a document is revised.
Takeaway
A citation only means something alongside the document version it points into.
SETUP CELL
Experiment 2B: embeddings and top-K retrieval
See how a question gets matched to the closest chunks.
# Vector embeddings and top-K retrieval:
#
# 1. Embeddings convert text into vectors (arrays of numbers)
# - Example: "DC link overvoltage" β [0.23, -0.41, 0.87, ...]
# - Similar text β similar vectors (measured by cosine similarity)
#
# 2. Vector stores enable fast top-K similarity search
# - FAISS, Chroma, Pinecone, pgvector
# - Index all chunk vectors, query with question vector
# - Return K most similar chunks (K=4 is common)
#
# 3. TF-IDF vs OpenAI embeddings:
# - TF-IDF: Simple, no API calls, good for demos
# - OpenAI embeddings: Semantic, API calls, better for production
# - Cosine similarity measures how "close" vectors are (0=orthogonal, 1=identical) Explanation
- - Embeddings turn text into vectors, so you can compare meaning rather than exact words.
- - A vector store indexes those vectors and returns the K chunks closest to a question.
- - This tutorial uses TF-IDF because it needs no API key and gives the same result every time. For real use I would switch to an embedding model: TF-IDF matches words, so a question about "high bus voltage" will miss a chunk that only says "DC link overvoltage".
Takeaway
Vector search connects a plain-language question to chunks you can cite.
CORE CELL
Experiment 2B: retrieve chunks with scores
Build the retrieval function and run it against the F30005 question.
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
def make_citation(c: Chunk) -> str:
return f"[{c.source}#L{c.start_line}-L{c.end_line}|{c.chunk_id}]"
chunk_vectorizer = TfidfVectorizer(stop_words="english")
CX = chunk_vectorizer.fit_transform([c.text for c in chunks])
def retrieve_chunks(query: str, k: int = 4):
qv = chunk_vectorizer.transform([query])
sims = cosine_similarity(qv, CX)[0]
idxs = sims.argsort()[::-1][:k]
return [(chunks[i], float(sims[i])) for i in idxs]
query = "Drive fault F30005 after decel ramp change braking resistor"
hits = retrieve_chunks(query, k=4)
print("Top chunks:")
for c, score in hits:
print(f" - {make_citation(c)} score={score:.3f}") Expected output
Top chunks: - [change_log_2025w41.txt#L1-L4|eecc307bbd] score=0.323 - [vendor_manual_sinamics_g120.txt#L1-L4|19a7dcc007] score=0.227 - [vendor_manual_sinamics_g120.txt#L4-L7|8a76d73485] score=0.219 - [plc_code_conveyor_st.txt#L10-L13|d34c04c401] score=0.209
Explanation
- - The result is a list of citable chunks instead of whole files. The change log chunk with the ramp change ranks first, and the manual's F30005 definition comes second. Compare that with Experiment 1B.
- - Keep the scores. They let you set a floor: if the best chunk scores below it, answer "not enough evidence".
- - If you swap in a paid embedding model, cost is small: text-embedding-3-small was listed at $0.02 per million tokens in 2025, so these 14 chunks cost a tiny fraction of a cent.
Why this matters
A reviewer needs to trace each claim to its source. The chunk is the smallest unit that makes that practical.
Takeaway
A vector store earns its place only if what it returns can be cited and logged.
EXPERIMENT CELL
Experiment 2C: an answer with citations
Build an evidence pack and format an answer that cites it.
import json
from typing import Dict, Any
def evidence_pack(query: str, hits) -> Dict[str, Any]:
pack = []
for c, score in hits:
if score <= 0:
continue
pack.append({
"citation": make_citation(c),
"score": round(score, 3),
"text": c.text
})
return {"query": query, "chunks": pack}
pack = evidence_pack(query, hits)
answer_lines = [
"Likely cause: DC link overvoltage during deceleration after the ramp was shortened.",
"Most actionable checks:",
"- Increase decel ramp time (e.g., P1121) or revert the recent change.",
"- Re-enable/verify braking resistor sizing/wiring; monitoring being disabled can hide a failure.",
"- Verify incoming line voltage is within spec.",
"",
"Citations:"
]
answer_lines += [f" - {c['citation']}" for c in pack["chunks"][:3]]
answer = "\n".join(answer_lines)
print(answer)
print("\nEvidence pack (first chunk, trimmed):")
first = dict(pack["chunks"][0])
first["text"] = first["text"].splitlines()[0] + " ..."
print(json.dumps(first, indent=2)) Expected output
Likely cause: DC link overvoltage during deceleration after the ramp was shortened.
Most actionable checks:
- Increase decel ramp time (e.g., P1121) or revert the recent change.
- Re-enable/verify braking resistor sizing/wiring; monitoring being disabled can hide a failure.
- Verify incoming line voltage is within spec.
Citations:
- [change_log_2025w41.txt#L1-L4|eecc307bbd]
- [vendor_manual_sinamics_g120.txt#L1-L4|19a7dcc007]
- [vendor_manual_sinamics_g120.txt#L4-L7|8a76d73485]
Evidence pack (first chunk, trimmed):
{
"citation": "[change_log_2025w41.txt#L1-L4|eecc307bbd]",
"score": 0.323,
"text": "Change Log \u2014 Packaging Line A (Week 41) ..."
} Explanation
- - To be upfront: the answer text in this cell is written by hand. The point here is the shape of the output (answer, citations, evidence pack). In D12 an LLM writes the answer, restricted to the evidence pack.
- - The evidence pack is what you store, replay, and show in a review.
- - json.dumps escapes non-ASCII characters by default, which is why the dash in the change log title prints as \u2014. Pass ensure_ascii=False if you want it readable.
- - When an LLM does write the answer, a prompt this size costs well under a cent with gpt-4o-mini at 2025 prices.
Common mistake
Letting the model answer without requiring citations. Sooner or later it fills the gaps from memory.
Takeaway
If an answer can't cite evidence, it should say insufficient evidence instead.
CHECKPOINT CELL
Checkpoint: what you have so far
Make sure each part of the pipeline is clear before adding the audit log.
Explanation
- - Documents carry metadata. Chunks carry a citation key. Retrieval returns scored chunks. Answers cite them.
- - You have also seen the weak spot: citations depend on the document version.
- - Next: an audit log, so you can answer "what evidence did we use for this recommendation?" days later.
Takeaway
That's a minimal RAG pipeline. Next, make it reviewable after the fact.
Experiment 3: an audit log you can replay
Write one JSON line per question, recording what was asked, what was retrieved, and what was answered.
SETUP CELL
Experiment 3A: what to log
Decide what a log entry needs so a run can be reconstructed later.
# Minimum viable audit record:
# - timestamp: When was this query run?
# - run_id: Unique identifier for this session/shift
# - query: What did the operator ask?
# - top_k: How many chunks were retrieved?
# - citations: Which chunks were used as evidence?
# - answer_preview: What was the answer (first line for quick scan)
#
# Production additions:
# - model_version: Which LLM/embedding model was used?
# - doc_set_hash: Hash of the document set (detect version drift)
# - retrieval_scores: Similarity scores for each chunk
# - operator_id: Who ran this query?
#
# Format: JSONL (one record per line)
# - Easy to grep/process with command-line tools
# - Append-only (no risk of corrupting existing records)
# - Each line is valid JSON (can parse one line at a time) Explanation
- - The minimum is timestamp, question, citations and an answer preview.
- - After cell 11, I would treat the document-set hash as required, not a production extra. Without it, old citations can quietly point at the wrong lines.
- - JSONL (one JSON object per line) is easy to grep and easy to append to.
Takeaway
If it isn't logged, it can't be audited.
CORE CELL
Experiment 3A: write an audit record
Append one JSONL record with enough context to answer: what evidence did we use?
import json
from pathlib import Path
def write_audit_log(pack, answer: str, path: str = "rag_audit_log.jsonl") -> dict:
record = {
"ts": "2025-12-26T00:00:00Z",
"run_id": "demo0001",
"query": pack["query"],
"top_k": len(pack["chunks"]),
"citations": [c["citation"] for c in pack["chunks"]],
"answer_preview": answer.splitlines()[0] # Single-line preview
}
Path(path).write_text(
(Path(path).read_text() if Path(path).exists() else "") + json.dumps(record) + "\n",
encoding="utf-8"
)
return record
record = write_audit_log(pack, answer)
print(json.dumps(record, indent=2)) Expected output
{
"ts": "2025-12-26T00:00:00Z",
"run_id": "demo0001",
"query": "Drive fault F30005 after decel ramp change braking resistor",
"top_k": 4,
"citations": [
"[change_log_2025w41.txt#L1-L4|eecc307bbd]",
"[vendor_manual_sinamics_g120.txt#L1-L4|19a7dcc007]",
"[vendor_manual_sinamics_g120.txt#L4-L7|8a76d73485]",
"[plc_code_conveyor_st.txt#L10-L13|d34c04c401]"
],
"answer_preview": "Likely cause: DC link overvoltage during deceleration after the ramp was shortened."
} Explanation
- - The timestamp and run_id are fixed so the output is reproducible here. Use real values in your own code.
- - Before using this for real, add the model version, the retrieval scores, and the document-set hash.
- - This version re-reads and rewrites the whole file on every call. That is fine for a demo; for anything bigger, open the file in append mode ("a").
Why this matters
When a shift lead asks why the system recommended something, the answer should be a log entry you can open.
Takeaway
Log enough to replay the run, not just to prove it happened.
SETUP CELL
Experiment 3B (optional): the same pipeline in LlamaIndex
See the LlamaIndex version. If the library is not installed, the cell says so and stops.
# LlamaIndex wiring (optional): same architecture, less glue code
# This code will fall back to the TF-IDF pipeline if llama_index is not installed.
def llamaindex_available() -> bool:
try:
import llama_index # noqa: F401
return True
except Exception:
return False
if not llamaindex_available():
print("llama_index not installed here β using the tested TF-IDF pipeline above.")
print("In your environment: pip install llama-index")
else:
# Modern LlamaIndex import style (0.10+)
from llama_index.core import VectorStoreIndex, Document
li_docs = [Document(text=d.text, metadata={"source": d.source}) for d in docs]
index = VectorStoreIndex.from_documents(li_docs)
engine = index.as_query_engine(similarity_top_k=4)
resp = engine.query("Drive fault F30005 after decel ramp change braking resistor")
print(resp.response)
print("\nCitations:")
for n in resp.source_nodes:
src = n.node.metadata.get("source")
print(f" - {src} score={n.score:.3f}") Expected output
llama_index not installed here β using the tested TF-IDF pipeline above. In your environment: pip install llama-index
Explanation
- - LlamaIndex handles ingestion, indexing, retrieval and source tracking in a few lines.
- - Look at what it prints as a citation: just the file name. To get line-level citations back, you would attach line metadata to each node yourself.
- - Chunk boundaries you can defend, the citation format, and what gets logged are still your decisions.
Common mistake
Assuming a framework automatically makes outputs auditable without defining citation + logging contracts.
Takeaway
A framework saves glue code. It doesn't make answers auditable.
CHECKPOINT CELL
Checkpoint: the minimum for plant use
Check your design against what a reviewer will ask for.
Explanation
- - You retrieve chunks, not whole documents.
- - Every answer cites chunk ids with file and line range.
- - Every run writes an audit record that includes the document version.
- - Weak evidence produces "not enough evidence" instead of an answer.
- - Still to do: LLM generation, metadata filters, and chunk sizes tuned to your own documents.
Takeaway
RAG is ready for a plant when a reviewer can check every answer against its source.
05 Β· Decisions to make before you ship this
- βΈ Chunking strategy: line-based (easy to check, fragile across revisions) vs sentence-based vs section-based
- βΈ Metadata discipline: source file, page/line, equipment type, revision, effective date, change ticket id
- βΈ Vector store choice: local (FAISS/Chroma) vs managed (Pinecone/pgvector) based on latency + governance
- βΈ Evaluation: build a small "golden set" of plant questions + expected citations, then regression test retrieval
If you do only one of these first, make it the evaluation set. Twenty questions from your own maintenance team, each paired with the chunk that should answer it, will tell you more about chunk size and retrieval quality than any amount of tuning by feel.
One boundary, stated plainly: RAG can suggest procedures and parameter values, but a person approves and makes every change. Never let a RAG answer write to a PLC or a drive.
β KEY TAKEAWAYS
- β RAG is a pipeline: ingest β chunk β embed β store β retrieve β cite β log. Test each part on its own.
- β Chunk size decides what a reviewer can check quickly.
- β Whole-document retrieval ranked the wrong file first. Chunk-level retrieval found the change log and the fault definition.
- β Citations point into a specific document version. Log the version, or a revised manual will quietly break old citations.
- β If the model cannot cite evidence, the answer should be "not enough evidence".
- β LlamaIndex saves glue code, but the citation format and the audit log are still yours to design.
Further Reading
Official Documentation
-
LlamaIndex Core Concepts
Documents, nodes, indexes, and query engines explained
-
Vector Store Index Guide
Building and querying vector indexes with metadata filtering
-
OpenAI Embeddings Guide
Understanding embeddings, dimensions, and similarity search
Industrial Patterns
-
RAG for Knowledge-Intensive Tasks (Paper)
Original RAG paper demonstrating retrieval-augmented generation
-
Chunking Strategies for RAG
Fixed-size, sentence-based, and semantic chunking trade-offs
-
LLM Patterns: RAG Best Practices
Production RAG patterns including hybrid search and reranking
π NEXT TUTORIAL
D8 β Agent Memory Systems for Industrial Context
Persist line-scoped context across shifts with LangGraph checkpointing.