ADR-0002: Pluggable Provider Pattern for Multi-Source Retrieval¶
- Date: 2026-05-06
- Authors: Matteo Rizzo
- Status: Accepted
- Approval State: Approved (Approved by: Matteo Rizzo on 2026-05-06)
- Implementation State: Completed
1. Context and Problem Statement¶
AgenticRAGSkill performs retrieval-augmented generation across heterogeneous sources: a Neo4j knowledge graph, an Azure AI Search vector index, the public web (via the agent browser), and an optional reranker (Cohere Rerank, Qwen3-Reranker). Each source has a different SDK, different auth model, different latency profile, and different operational story. None of the four are appropriate for every client — a customer with no Neo4j cluster shouldn't have to install neo4j>=5.20 to use the Azure-only path.
If AgenticRAGSkill were written with hardcoded calls to neo4j.AsyncDriver and azure.search.documents.aio.SearchClient, the skill would grow a combinatorial test matrix, every new backend would require editing the skill itself, and every client would pay the install-time cost of every backend. We need a way to keep the skill (orchestration: query planning, token budgeting, citation assembly) decoupled from the backends (the actual retrieval calls).
2. Decision Drivers (Forces)¶
- Backend swappability: A client should be able to ship Neo4j-only, Azure-only, or web-only without touching skill code.
- Test isolation: Unit tests for the skill must run without a live Neo4j or Azure account; provider tests must run without exercising the orchestration layer.
- Static checkability: Mypy
--strictmust catch a missing or misshapen provider method at build time. - No registry lookup at runtime: Providers are passed in by construction, not discovered via a global. This keeps
AgenticRAGSkilla plain Python class with no import-time side effects. - Symmetric type contract: All providers in the same family (graph, vector, web, reranker) must satisfy the same
Protocol, so the skill can hold them asSequence[ProviderProtocol]and call them uniformly. - Optional dependencies: Backend SDKs are gated behind extras (see ADR-0003). The provider abstraction must allow a missing extra to surface as a typed exception (e.g.
Neo4jUnavailableError) rather than a genericImportError.
3. Considered Options¶
- Option 1: Hardcode both Neo4j and Azure inside
AgenticRAGSkill. - Option 2: Strategy pattern via abstract base classes (ABCs). Each provider subclasses
BaseGraphProvider, etc. - Option 3:
Protocol-based duck typing. Each provider satisfies a typedProtocol; no inheritance required. - Option 4: Plugin discovery via entry points. Providers are looked up at runtime through
importlib.metadata.entry_points.
4. Decision Outcome¶
Chosen option: Option 3 (Protocol-based duck typing), because it gives mypy --strict enforcement without forcing third-party SDK wrappers to inherit from one of our base classes (which would be impossible for SDKs we don't control) and without the runtime cost or magic of entry-point lookup.
The mirai_shared_skills/agentic_rag/providers/ package defines four Protocol types — one per provider family:
class GraphProvider(Protocol):
async def expand(self, query: RetrievalQuery) -> list[RAGContextChunk]: ...
class VectorSearchProvider(Protocol):
async def search(self, query: RetrievalQuery) -> list[RAGContextChunk]: ...
class WebSearchProvider(Protocol):
async def fetch(self, query: RetrievalQuery) -> list[RAGContextChunk]: ...
class RerankerProvider(Protocol):
async def rerank(self, query: str, chunks: Sequence[RAGContextChunk]) -> list[RAGContextChunk]: ...
Concrete providers shipped in this package:
| Family | Concrete provider | Backend |
|---|---|---|
| Graph | Neo4jGraphProvider |
Neo4j 5.x via the official async driver, gated by the neo4j extra. |
| Vector | AzureSearchProvider |
Azure AI Search via azure-search-documents, gated by the azure extra. |
| Web | BrowserWebSearchProvider |
The agent browser (no extra dep — uses httpx already in core). |
| Reranker | NoOpRerankerProvider (default), Cohere Rerank v4, Qwen3-Reranker (HTTP-only) |
Reranker SDKs gated by the reranker extra. |
AgenticRAGSkill accepts each provider as a constructor argument:
skill = AgenticRAGSkill(
graph=Neo4jGraphProvider(uri=..., user=..., password=...),
vector=AzureSearchProvider(endpoint=..., api_key=..., index=...),
web=BrowserWebSearchProvider(),
reranker=NoOpRerankerProvider(), # or Cohere, or Qwen3
)
A client that doesn't want graph retrieval just doesn't pass a graph= argument; the skill detects the missing provider and skips that branch of the query plan.
4.1. Validation / Compliance¶
mypy --strictenforces that every concrete provider satisfies itsProtocol.- Each provider has its own unit test file with an in-memory fake (no live backend) plus an integration test gated on the optional extra being installed.
- The skill's own tests construct it with
MagicMock(spec=GraphProvider)etc. — never with real providers.
5. Pros and Cons of the Options¶
Option 1: Hardcode backends¶
- Pros: Trivial to write. No abstraction.
- Cons: Combinatorial test matrix; every new backend touches skill code; clients pay install cost of every backend regardless of use.
Option 2: ABCs¶
- Pros: Familiar OO pattern; explicit subclass relationship.
- Cons: Forces every wrapper to inherit from our base class — incompatible with the principle that providers should be plain wrappers around vendor SDKs.
Option 3 (chosen): Protocol-based duck typing¶
- Pros:
- Mypy-checked without inheritance.
- Providers can be defined in user code without importing our base class.
- Mocking is trivial (
MagicMock(spec=Protocol)). - Cons:
- Errors at the call site rather than at provider definition — but mypy catches this in practice.
Option 4: Entry points¶
- Pros: Runtime extensibility for third parties.
- Cons: Magic. Every provider load is a metadata scan. Premature for an internal-shared package.
6. Consequences¶
- Positive Consequences:
- Adding a new graph backend (e.g. Neptune) means writing one new class against
GraphProviderand shipping it as a sibling extra. No skill changes. - Tests don't need live backends — the protocol is a contract, not an implementation.
- The skill's public surface stays minimal: backends come and go, but
AgenticRAGSkill.__init__only mentions protocol types. - Negative Consequences / Trade-offs:
- Newcomers to the codebase have to learn the
Protocolidiom (rather than the more familiar ABC). - Each provider is responsible for its own auth and connection management — there's no shared base to put retry logic. We accept this; the alternative (mixin classes) re-introduces inheritance.
- Risks & Mitigations:
- Risk: Two providers in the same family drift apart in subtle ways (e.g. one returns chunks pre-sorted, one doesn't). Mitigation: the skill normalizes provider output before downstream processing, and provider tests assert the post-condition explicitly.
7. Implementation Plan & Status Updates¶
- Target Milestone/Release: v0.1.0 (current).
- Implementation Notes:
- 2026-05-06: ADR formalizes the pattern already implemented in
mirai_shared_skills/agentic_rag/providers/. No code changes required.
8. References / Related Documents¶
mirai_shared_skills/agentic_rag/providers/— the four protocol definitions and concrete providers.mirai_shared_skills/agentic_rag/skill.py— orchestration layer.- ADR-0003: Optional-Dependency Extras for Backend Drivers — how the
neo4j/azure/rerankerextras gate provider availability. - ADR-0006: Token-Budgeted RAG Context Assembly — how the orchestration layer composes provider outputs.