Composition as the Main AI-System Pattern
Current Level: 1/5 — Read, simplified
Think of RAGAgent as a manager.
It does not itself retrieve documents or generate answers. It uses other objects that know how to do those jobs.
Here is the full example:
class GPTModel:
def generate(self, question: str, context: str) -> str:
return "GPT answer"
class LocalModel:
def generate(self, question: str, context: str) -> str:
return "Local answer"
class VectorRetriever:
def retrieve(self, question: str) -> str:
return "Context from vector search"
class KeywordRetriever:
def retrieve(self, question: str) -> str:
return "Context from keyword search"
class Memory:
def __init__(self):
self.messages = []
class RAGAgent:
def __init__(self, retriever, model, memory):
self.retriever = retriever
self.model = model
self.memory = memory
def answer(self, question: str) -> str:
# Ask whichever Retriever object we received
context = self.retriever.retrieve(question)
# Ask whichever Model object we received
answer = self.model.generate(question, context)
return answer
Now create the components:
retriever = VectorRetriever()
model = GPTModel()
memory = Memory()
Give them to the agent:
agent = RAGAgent(
retriever=retriever,
model=model,
memory=memory
)
Then:
result = agent.answer("What is RAG?")
print(result)
Output:
GPT answer
What is composition here?
Look at:
class RAGAgent:
def __init__(self, retriever, model, memory):
self.retriever = retriever
self.model = model
self.memory = memory
This means:
RAGAgent has-a Retriever
RAGAgent has-a Model
RAGAgent has-a Memory
That is composition.
The agent is not a Model.
It simply has a Model and uses it.
Why is this useful?
Suppose tomorrow we don't want GPT.
We want a local model.
We only change:
model = LocalModel()
Then:
agent = RAGAgent(
retriever=VectorRetriever(),
model=LocalModel(),
memory=Memory()
)
We do not change this:
class RAGAgent:
And we do not change:
def answer(...)
because both models understand:
generate(question, context)
So this:
self.model.generate(question, context)
works with either:
GPTModel
or
LocalModel
That's polymorphism.
We can also change the Retriever:
agent = RAGAgent(
retriever=KeywordRetriever(),
model=LocalModel(),
memory=Memory()
)
Now the same agent workflow becomes:
question
↓
KeywordRetriever.retrieve()
↓
context
↓
LocalModel.generate()
↓
answer
Again, RAGAgent does not change.
The simplest mental model
RAGAgent = coordinator
Retriever = retrieves
Model = generates
Memory = remembers
And:
Composition:
RAGAgent HAS these objects.
Polymorphism:
We can replace one Retriever/Model
with another object that has the same method.
So if you remember only one thing from this lesson:
Composition means building a bigger system by plugging smaller objects together.
For AI systems, that may look like:
RAGAgent
|
├── Retriever
├── Model
└── Memory
You can change those components without rewriting the whole agent.
Level 1 Check
For this design:
RAGAgent
├── GPTModel
├── VectorRetriever
└── Memory
- Is
RAGAgent → GPTModelan is-a or has-a relationship? - Which OOP concept describes that relationship?
- Why would
class RAGAgent(GPTModel)usually be the wrong design? - If
LocalModelalso hasgenerate(), can we swapGPTModelforLocalModel? - What two OOP concepts work together to make components swappable?
Answer:
- has-a
- Composition
- if we need to change model we have to change class RAGAgent. The workflow may need to be redesigned
- Yes
- Composition and Polymorphism
Correct — all five.
For #3, one sharper phrasing: RAGAgent is not a kind of GPTModel. It simply uses a model. Inheritance would create the wrong relationship and tightly couple the workflow to one model type.
Current Level: 2/5 — Predict
Given:
class GPTModel:
def generate(self, question: str, context: str) -> str:
return "GPT answer"
class LocalModel:
def generate(self, question: str, context: str) -> str:
return "Local answer"
class VectorRetriever:
def retrieve(self, question: str) -> str:
return "Vector context"
class KeywordRetriever:
def retrieve(self, question: str) -> str:
return "Keyword context"
class RAGAgent:
def __init__(self, retriever, model):
self.retriever = retriever
self.model = model
def answer(self, question: str) -> str:
context = self.retriever.retrieve(question)
return self.model.generate(question, context)
Now:
agent1 = RAGAgent(
retriever=VectorRetriever(),
model=GPTModel()
)
agent2 = RAGAgent(
retriever=KeywordRetriever(),
model=LocalModel()
)
Predict:
- What does
agent1.answer("What is RAG?")return? - Which retriever does
agent1use? - Which model does
agent1use? - What does
agent2.answer("What is RAG?")return? - Which retriever and model does
agent2use? - Did we need to change the
RAGAgentclass to create these two different combinations?
Answers:
- "GPT answer"
- VectorRetriever
- GPTModel
- "Local answer"
- KeywordRetriever
- No
Current Level: 3/5 — Trace
Trace agent2:
agent2 = RAGAgent(
retriever=KeywordRetriever(),
model=LocalModel()
)
result = agent2.answer("What is RAG?")
Complete:
agent2.answer("What is RAG?")
↓
self = ______
↓
self.retriever refers to ______
↓
self.retriever.retrieve(question)
↓
context = ______
↓
self.model refers to ______
↓
self.model.generate(question, context)
↓
result = ______
agent2.answer("What is RAG?")
↓
self = agent2
↓
self.retriever refers to KeywordRetriever()
↓
self.retriever.retrieve(question)
↓
context = "Keyword context"
↓
self.model refers to LocalModel()
self.model.generate(question, context)
↓
result = "Local answer"
iny precision improvement:
self.retrieverrefers to the KeywordRetriever objectself.modelrefers to the LocalModel object
Current Level: 4/5 — Modify
Modify this setup so we create a third agent that uses:
VectorRetrieverLocalModel
Start from:
agent1 = RAGAgent(
retriever=VectorRetriever(),
model=GPTModel()
)
agent2 = RAGAgent(
retriever=KeywordRetriever(),
model=LocalModel()
)
Create:
agent3 = RAGAgent(
retriever=VectorRetriever(),
model=LocalModel()
)
Then answer:
- What does
agent3.answer("What is RAG?")return? - Which retriever does it use?
- Which model does it use?