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
  1. Is RAGAgent → GPTModel an is-a or has-a relationship?
  2. Which OOP concept describes that relationship?
  3. Why would class RAGAgent(GPTModel) usually be the wrong design?
  4. If LocalModel also has generate(), can we swap GPTModel for LocalModel?
  5. What two OOP concepts work together to make components swappable?

Answer:

  1. has-a
  2. Composition
  3. if we need to change model we have to change class RAGAgent. The workflow may need to be redesigned
  4. Yes
  5. 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:

  1. What does agent1.answer("What is RAG?") return?
  2. Which retriever does agent1 use?
  3. Which model does agent1 use?
  4. What does agent2.answer("What is RAG?") return?
  5. Which retriever and model does agent2 use?
  6. Did we need to change the RAGAgent class to create these two different combinations?

Answers:

  1. "GPT answer"
  2. VectorRetriever
  3. GPTModel
  4. "Local answer"
  5. KeywordRetriever
  6. 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:

Current Level: 4/5 — Modify

Modify this setup so we create a third agent that uses:

Start from:

agent1 = RAGAgent(
    retriever=VectorRetriever(),
    model=GPTModel()
)

agent2 = RAGAgent(
    retriever=KeywordRetriever(),
    model=LocalModel()
)

Create:

agent3 = RAGAgent(
    retriever=VectorRetriever(),
    model=LocalModel()
)

Then answer:

  1. What does agent3.answer("What is RAG?") return?
  2. Which retriever does it use?
  3. Which model does it use?