Skip to content

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 --strict must 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 AgenticRAGSkill a 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 as Sequence[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 generic ImportError.

3. Considered Options

  1. Option 1: Hardcode both Neo4j and Azure inside AgenticRAGSkill.
  2. Option 2: Strategy pattern via abstract base classes (ABCs). Each provider subclasses BaseGraphProvider, etc.
  3. Option 3: Protocol-based duck typing. Each provider satisfies a typed Protocol; no inheritance required.
  4. 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 --strict enforces that every concrete provider satisfies its Protocol.
  • 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 GraphProvider and 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 Protocol idiom (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.