Progress · 0/6 phases
🤖 Phase 5 — LLMs & Applied GenAI Engineering (Days 71–90)
15 min read · Days 71–90 · Notion
Core insight: Everything before this phase taught you how LLMs work internally. This phase teaches you how to build real products with them: prompting reliably, grounding them in your own data (RAG), making them efficient and customized (fine-tuning), giving them tools (agents), and proving they actually work (evals). This is the layer where most AI engineering jobs actually live.
Day 71–73 — Prompt Engineering and Sampling
# Temperature, top-k, top-p: controlling randomness in generation
#
# The model outputs a probability distribution over the next token.
# Sampling strategy determines HOW you pick from that distribution.
# Temperature: scales the logits before softmax
# temperature -> 0: nearly deterministic, always picks highest-probability token
# temperature = 1: sample exactly from the model's learned distribution
# temperature > 1: flatter distribution, more random/creative, more mistakes
import torch.nn.functional as F
def sample_with_temperature(logits, temperature=1.0):
scaled_logits = logits / temperature
probs = F.softmax(scaled_logits, dim=-1)
return torch.multinomial(probs, num_samples=1)
# Top-k sampling: only consider the K most likely next tokens, renormalize, sample
def top_k_sampling(logits, k=50):
values, indices = torch.topk(logits, k)
probs = F.softmax(values, dim=-1)
sampled_idx = torch.multinomial(probs, num_samples=1)
return indices[sampled_idx]
# Top-p (nucleus) sampling: consider the smallest set of tokens whose
# cumulative probability exceeds p (more adaptive than fixed top-k)
def top_p_sampling(logits, p=0.9):
sorted_logits, sorted_indices = torch.sort(logits, descending=True)
cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
mask = cumulative_probs <= p
mask[0] = True # always keep at least the top token
filtered_indices = sorted_indices[mask]
return filtered_indices
# Prompt engineering patterns that actually work (not folklore):
# 1. Few-shot examples: show 2-5 examples of input->output before the real task
# 2. Chain-of-thought: ask the model to "think step by step" before answering
# -- works because it gives the model more forward passes (tokens) to reason
# 3. System prompts: set persistent behavior/role/constraints
# 4. Structured output: ask for JSON, provide a schema, often with function calling
# instead of hoping the model formats text correctlyKey concepts
- Temperature, top-k, top-p — the three knobs that control generation randomness, and the actual math behind each
- Few-shot prompting and chain-of-thought — why they work (more relevant context, more "thinking tokens")
- The difference between asking nicely and giving the model structure (schemas, function calling) for reliable output
Day 74–77 — Embeddings and Vector Search
# Embeddings (recap from Phase 4, now applied): represent text as a dense vector
# such that SEMANTICALLY SIMILAR text has vectors that are CLOSE TOGETHER
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer('all-MiniLM-L6-v2')
embeddings = model.encode(["The cat sat on the mat", "A feline rested on the rug"])
# Cosine similarity: measures the ANGLE between vectors, ignoring magnitude
# This is THE standard similarity metric for embeddings
def cosine_similarity(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
sim = cosine_similarity(embeddings[0], embeddings[1])
print(sim) # high, even though no words overlap -- semantic similarity!
# The problem: brute-force search over millions of vectors is O(n) per query, too slow
# Solution: Approximate Nearest Neighbor (ANN) algorithms
# FAISS: Facebook's library for efficient similarity search
import faiss
dimension = 384 # matches the embedding model's output size
index = faiss.IndexFlatL2(dimension) # exact search, fine for small data
# For large scale: faiss.IndexIVFFlat or faiss.IndexHNSWFlat (approximate, much faster)
index.add(embeddings.astype('float32'))
query_embedding = model.encode(["a cat on a mat"]).astype('float32')
distances, indices = index.search(query_embedding, k=2) # top-2 nearest
# HNSW (Hierarchical Navigable Small World): the algorithm behind most
# production vector databases (Pinecone, Weaviate, Qdrant, Chroma)
# Builds a multi-layer graph for O(log n) approximate search instead of O(n)
# This directly solves the curse-of-dimensionality problem from Phase 2's KNNKey concepts
- Embeddings as the bridge between unstructured text and mathematical similarity search
- Cosine similarity vs Euclidean distance for comparing vectors
- Why brute-force nearest neighbor search doesn't scale, and how approximate methods (HNSW, IVF) solve it
- This is the same curse-of-dimensionality problem from Phase 2's KNN, now solved at scale
Day 78–82 — RAG (Retrieval-Augmented Generation)
# RAG solves: LLMs don't know your private data, and their training data
# has a cutoff date. RAG retrieves relevant documents at query time and
# injects them into the prompt, grounding the LLM's answer in real, current,
# private information instead of relying purely on what it memorized during training.
# The full RAG pipeline:
# 1. INDEXING (offline, done once per document set):
# documents -> chunk into pieces -> embed each chunk -> store in vector DB
# 2. RETRIEVAL (online, per query):
# user query -> embed query -> search vector DB -> get top-K relevant chunks
# 3. GENERATION (online, per query):
# relevant chunks + user query -> prompt template -> LLM -> grounded answer
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain.chains import RetrievalQA
from langchain_openai import ChatOpenAI
# Step 1: Chunking -- this is THE most underrated lever in RAG quality
# Too small: loses context. Too large: dilutes relevance, wastes tokens.
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50, # overlap prevents losing context at chunk boundaries
separators=["\n\n", "\n", ". ", " "] # try to split on natural boundaries
)
chunks = text_splitter.split_documents(documents)
# Step 2: Embed and store
embeddings = HuggingFaceEmbeddings(model_name='all-MiniLM-L6-v2')
vectorstore = Chroma.from_documents(chunks, embeddings)
# Step 3: Retrieve + Generate
retriever = vectorstore.as_retriever(search_kwargs={'k': 4})
llm = ChatOpenAI(model='gpt-4', temperature=0)
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
retriever=retriever,
return_source_documents=True # always return sources for verification/citation
)
result = qa_chain.invoke({"query": "What is our refund policy?"})
print(result['result'])
print(result['source_documents']) # show what the answer was grounded in
# Advanced RAG techniques:
# - Hybrid search: combine dense (embedding) + sparse (BM25/keyword) retrieval
# - Re-ranking: retrieve more candidates than needed, then re-rank with a
# cross-encoder model for higher precision on the final top-K
# - Query expansion/rewriting: rewrite the user's query before retrieval
# to improve recall (especially for vague or conversational queries)
# - Parent-child chunking: embed small chunks for precise retrieval,
# but return the larger parent chunk for full context to the LLMKey concepts
- The 3-stage RAG pipeline: index → retrieve → generate
- Chunking strategy is the highest-leverage decision in RAG quality — chunk size, overlap, and splitting boundaries all matter
- Hybrid search and re-ranking as techniques to improve retrieval precision beyond naive vector search
- Always returning source documents for verifiability — a RAG system without citations is a black box
Day 83–86 — Fine-tuning LLMs: LoRA and QLoRA
# Full fine-tuning updates ALL parameters of a model (billions of them) --
# expensive in compute and memory, and risks "catastrophic forgetting"
# (the model loses general capabilities while learning the new task)
# LoRA (Low-Rank Adaptation): freeze the original weights, inject small
# trainable "adapter" matrices into each layer. Train only the adapters
# (often <1% of total parameters), then add their effect back to the frozen weights.
#
# Key insight: W_new = W_frozen + (A @ B), where A and B are small
# low-rank matrices (e.g., rank 8 or 16), dramatically fewer parameters
# than the full weight matrix W
from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
model = AutoModelForCausalLM.from_pretrained('meta-llama/Llama-2-7b-hf')
tokenizer = AutoTokenizer.from_pretrained('meta-llama/Llama-2-7b-hf')
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=8, # rank of the low-rank matrices (lower = fewer params, less capacity)
lora_alpha=32, # scaling factor for the LoRA update
lora_dropout=0.1,
target_modules=["q_proj", "v_proj"] # which layers to adapt (usually attention projections)
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Output: trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.06%
# This is THE number that makes fine-tuning feasible on consumer GPUs
# QLoRA: LoRA + quantization (load the frozen base model in 4-bit precision)
# Reduces memory footprint further -- enables fine-tuning 7B+ models on a
# single consumer GPU (e.g., 24GB VRAM) that couldn't otherwise fit the model
from transformers import BitsAndBytesConfig
import torch
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16
)
model = AutoModelForCausalLM.from_pretrained(
'meta-llama/Llama-2-7b-hf',
quantization_config=bnb_config
)
# Training loop (standard Hugging Face Trainer)
training_args = TrainingArguments(
output_dir='./lora-output',
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # simulates a larger batch size with less memory
num_train_epochs=3,
learning_rate=2e-4,
fp16=True,
logging_steps=10,
)
trainer = Trainer(model=model, args=training_args, train_dataset=dataset)
trainer.train()Key concepts
- Full fine-tuning vs LoRA: train all parameters vs train a small low-rank adapter, freeze the base
- QLoRA: LoRA + 4-bit quantization of the frozen base model, enabling fine-tuning of large models on consumer hardware
- When to fine-tune vs when to use RAG vs when to just prompt engineer (a decision framework, not a default)
- Pretraining vs fine-tuning vs RLHF vs prompting: four distinct ways to shape model behavior, at decreasing cost and decreasing depth of change
Day 87–88 — Agents and Tool Use
# An LLM agent: a model that can decide to call external TOOLS (functions,
# APIs, code execution, search) to accomplish a task, rather than relying
# purely on its internal knowledge.
# Function calling: the model outputs a structured request to call a specific
# function with specific arguments, instead of free-text
import openai
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"}
},
"required": ["location"]
}
}
}]
response = openai.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What's the weather in Bangalore?"}],
tools=tools
)
# The model returns a tool_call instead of text. YOUR code executes the
# actual function, then sends the result back to the model for a final answer.
if response.choices[0].message.tool_calls:
tool_call = response.choices[0].message.tool_calls[0]
args = json.loads(tool_call.function.arguments)
result = get_weather(args['location']) # your actual implementation
# Send result back to the model to generate the final natural-language response
# The ReAct pattern (Reasoning + Acting): the model alternates between
# "thinking" (reasoning about what to do) and "acting" (calling a tool),
# observing results, and repeating until it has enough information to answer
#
# Thought: I need to find the current weather in Bangalore
# Action: get_weather(location="Bangalore")
# Observation: 28C, partly cloudy
# Thought: I now have enough information to answer
# Final Answer: It's 28C and partly cloudy in Bangalore.
# Multi-agent systems: specialized agents (researcher, coder, reviewer)
# collaborate, each with different tools/prompts/responsibilities
# Frameworks: LangGraph, CrewAI, AutoGenKey concepts
- Function calling: structured tool invocation instead of hoping the model formats text correctly
- The ReAct pattern: alternating reasoning and acting, with observations feeding back into the next reasoning step
- Why agents are powerful (extend the model beyond its training data, enable real-world actions) and where they're fragile (compounding errors across multi-step chains, unpredictable tool selection)
Day 89 — Evaluation: Proving Your LLM System Actually Works
# "It looks good when I tried it" is not evaluation. LLM applications need
# systematic evals just like classical ML models need test set metrics.
# RAG-specific evaluation dimensions (using a framework like Ragas):
# - Faithfulness: is the generated answer actually supported by the retrieved context?
# (catches hallucination even when retrieval was correct)
# - Answer relevance: does the answer actually address the question asked?
# - Context precision: are the retrieved chunks actually relevant?
# - Context recall: did retrieval find ALL the relevant information needed?
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision, context_recall
from datasets import Dataset
eval_dataset = Dataset.from_dict({
'question': [...],
'answer': [...], # your system's generated answers
'contexts': [...], # retrieved chunks for each question
'ground_truth': [...] # the correct/reference answer
})
results = evaluate(
eval_dataset,
metrics=[faithfulness, answer_relevancy, context_precision, context_recall]
)
print(results)
# LLM-as-judge: use a strong LLM to score outputs against a rubric
# (common when there's no single "correct" answer to compare against)
judge_prompt = """
Rate the following response on a scale of 1-5 for helpfulness and accuracy.
Question: {question}
Response: {response}
Reference: {reference}
Provide a score and brief justification.
"""
# Building a golden eval set: curate 50-200 representative question/answer
# pairs covering edge cases, ambiguous queries, and common failure modes
# Re-run this eval set EVERY time you change a prompt, model, or retrieval
# parameter -- this is your regression test suite for an LLM applicationKey concepts
- Why "it looks good" is not evaluation — the same rigor classical ML applies to test metrics must apply to LLM systems
- RAG-specific metrics: faithfulness (hallucination detection), relevance, precision, recall
- LLM-as-judge for evaluating open-ended outputs without a single correct answer
- A golden eval set as a regression test suite — re-run on every change
Day 90 — Phase 5 Capstone: Production RAG Application with Evals
Deliverable: a complete, deployable RAG application
Required components:
1. Document ingestion pipeline
- Load documents (PDFs, markdown, or scraped web content)
- Chunk with a documented, justified strategy (explain your chunk_size/overlap choice)
- Embed and store in a vector database (Chroma or FAISS, locally is fine)
2. Retrieval + Generation pipeline
- Query embedding + vector search
- Optional: hybrid search or re-ranking (bonus)
- Prompt template that includes retrieved context + explicit instructions
to cite sources and say "I don't know" when context is insufficient
- LLM call (OpenAI API, Anthropic API, or local model via Ollama)
3. Evaluation harness
- A golden set of 20-30 question/answer pairs for your document domain
- Run faithfulness and relevance metrics (Ragas or custom LLM-as-judge)
- Report a baseline score, then make ONE improvement (better chunking,
re-ranking, or prompt tweaking) and show the score improvement
4. API and basic UI
- FastAPI endpoint: POST /query -> returns answer + sources
- Simple frontend: Streamlit or Gradio (functional, not polished)
- Basic error handling: what happens when retrieval finds nothing relevant?
What happens when the LLM API call fails or times out?
5. Documentation
- README explaining architecture, how to run it, and your eval results
- A section titled "Known limitations" — every real system has them,
and naming them explicitly is what separates engineers from demosThis is your portfolio centerpiece. It demonstrates the full stack: data processing, embeddings, vector search, prompt engineering, LLM integration, evaluation rigor, and basic deployment — everything covered across all 90 days, working together in one system.
Common mistakes
Mistake 1
❌ Treating RAG as "just stuff documents into a vector DB and it works."
RAG quality is dominated by unglamorous details: chunk size, overlap, embedding model choice, and prompt template wording. Teams that skip tuning these ship RAG systems that retrieve irrelevant context and hallucinate confidently.
✅ Correct approach: Treat chunking strategy, retrieval k, and prompt template as hyperparameters to be evaluated empirically against your golden eval set — not set-and-forget defaults.
Mistake 2
❌ Fine-tuning when the actual problem is retrieval (or vice versa).
Fine-tuning teaches a model HOW to respond (style, format, task-specific behavior). It does NOT reliably teach a model new FACTS, and it definitely doesn't help with information that changes after training. Using fine-tuning to inject a company's product catalog is almost always the wrong tool — that's a RAG problem.
✅ Correct approach: Use RAG for factual/current/private knowledge. Use fine-tuning for behavior, style, format, or domain-specific reasoning patterns. Often the right answer is both: RAG for knowledge, light fine-tuning or prompting for behavior.
Mistake 3
❌ Shipping an LLM application with no evaluation, relying on "it seemed fine when I tested it."
LLM outputs are non-deterministic and a handful of manual tests do not represent the distribution of real user queries. Silent failures (hallucination, irrelevant retrieval, format breaks) ship to production undetected.
✅ Correct approach: Build a golden eval set early, even a small one (20-30 examples covering realistic + edge-case queries). Re-run it on every meaningful change. Track faithfulness/relevance scores over time the same way you'd track accuracy for a classical ML model.
You've completed the 90-day AI/LLM Engineering roadmap
After 90 days you can:
- Implement linear regression, logistic regression, and a neural network from scratch using only NumPy
- Train and evaluate classical ML models (trees, ensembles, SVMs) with proper cross-validation and avoid data leakage
- Build, train, and debug deep learning models in PyTorch, including CNNs and RNNs
- Implement self-attention and a full Transformer block from scratch, and explain exactly why each architectural choice exists
- Fine-tune pretrained transformer models for real tasks
- Build a production-grade RAG application with proper chunking, retrieval, and generation
- Fine-tune large LLMs efficiently using LoRA/QLoRA on consumer hardware
- Build tool-using agents with function calling and the ReAct pattern
- Design and run rigorous evaluations for LLM applications instead of relying on vibes
What's next:
- Read recent papers (arXiv, Hugging Face papers) to stay current — this field moves monthly, not yearly
- Contribute to an open-source LLM tooling project (LangChain, LlamaIndex, vLLM)
- Build a second, more ambitious capstone: a multi-agent system, a fine-tuned domain-specific model, or a production deployment with real users and monitoring
- Study LLM serving and inference optimization (quantization, batching, vLLM/TGI) if you want to go deeper into the infrastructure side
- Pair this roadmap with your Go/backend skills: build the production infrastructure layer around the AI systems you now know how to design