3.5 KiB
ADR 0009: No RAG framework — hand-written orchestration behind ports
Status
Accepted. Recorded retrospectively during the 2026-08-12 documentation pass: the decision is unambiguous in the implementation, but no ADR existed for it.
Context
The system performs retrieval-augmented generation with query understanding, multiple retrieval strategies, reranking, prompt construction, structured output parsing, and post-generation verification — the exact feature set LangChain and LlamaIndex exist to provide.
Decision
Neither framework is used. There is no RAG or agent library of any kind.
Verifiable from the repository:
apps/ai-service/pyproject.tomldeclares six runtime dependencies:fastapi,httpx,psycopg,pydantic-settings,qdrant-client,uvicorn. Optional extras addprometheus-client,anthropicand three OpenTelemetry packages.apps/ai-service/Dockerfileinstalls that set plusboto3.- No file imports
langchain,llama_index,haystackor any equivalent.
Instead:
- Orchestration is a plain class with an explicit branch table
(
rag/agent.py::_route). - Prompts are module-level constants with JSON schemas (
rag/prompt.py). - Providers are injected through
typing.Protocols (rag/ports.py) and implemented inadapters/, which is the only package importing an SDK — and always lazily, inside a method. bootstrap.pyis the single composition root.
Consequences
Enabled by this choice
rag/imports no SDK, so the entire domain — including every safety check — is unit-testable offline with stub objects. All 278 ai-service tests run in 2.6 s with no network.- Behaviour is inspectable: the retrieval route for a given turn is a readable
ifchain, not framework dispatch. - Failure semantics are chosen per call site. The fail-closed/fail-open asymmetry in 02-system-architecture.md is deliberate and would be hard to express through a framework's uniform error handling.
- Prompt text is reviewable as domain policy in one file, and swapping providers cannot silently change what the model was told.
Costs
- Retrieval strategies, rank fusion, context packing and evaluation harnesses
are all hand-written. Two of them (
fusion.py,expansion.py) were written and never wired (27-technical-debt.md). - Optional retriever capabilities are discovered with
getattrrather than declared, so the real interface is wider thanports.pydocuments (D-14). - No community tooling for tracing, caching or evaluation applies; the observability layer is bespoke.
Rationale
Partially recoverable. The code does not state "we chose not to use a
framework", but the ports-and-adapters discipline is documented repeatedly in
module docstrings, and one of them makes the intent explicit —
rag/understanding.py:
rag/imports no SDK: the LLM is injected as aJsonLlmprotocol … and a deterministic stub runs the whole path offline in tests.
rag/prompt.py gives the parallel reason for prompts:
This is domain policy, not infrastructure … it lives here so it can be read, reviewed and tested without an SDK, and so swapping the provider cannot silently change what the model was told.
The consistent theme is testability and reviewability of the safety layer. Whether cost, lock-in or framework maturity also weighed in the decision is not recoverable from the repository.