Every RAG prototype we have seen starts the same way: a rag/ folder with a loader, a chunker, a vector store wrapper, a prompt file and a FastAPI route, all importing each other. It works until the second engineer joins. Then a change to the chunker silently changes what the API returns, a re-index runs inside a web worker, and nobody can say which index version answered a customer's question last Tuesday.

The fix is not a framework. It is a repository layout that matches how the system actually runs: an offline pipeline that builds indexes, an online service that reads them, and an evaluation harness that judges both. This post shows the layout we use, the rules that keep it honest, and the CI wiring that turns it into a guard rather than a diagram.

Two pipelines, one contract

A RAG system has two very different runtimes:

  • Offline ingestion loads documents, cleans them, chunks them, embeds them and writes an index. It is batch work, can take hours, and needs credentials for source systems.
  • Online serving receives a question, retrieves chunks, builds a prompt, calls a model and returns an answer. It must be fast, stateless and safe to scale horizontally.

They meet in exactly one place: the index and the shape of what is stored in it. If that shape is defined in one module and both sides depend on it, the two pipelines can evolve independently. If each side defines its own idea of a chunk, they drift.

The layout

rag-platform/
  pyproject.toml
  Makefile
  .importlinter
  configs/
    ingestion.yaml
    serving.yaml
  src/
    ragcore/            # shared contracts, no I/O
      schemas.py
      index_manifest.py
    ingestion/          # offline: load, clean, chunk, embed, publish
      sources/
      chunking.py
      embedding.py
      publish.py
      cli.py
    retrieval/          # query-time search, filters, reranking
      search.py
      rerank.py
    serving/            # HTTP API, prompt assembly, model client
      api.py
      prompts/
      llm_client.py
    evals/              # datasets, metrics, runners
      datasets/
      metrics.py
      run.py
  tests/
    unit/
    integration/
  docker/
    ingestion.Dockerfile
    serving.Dockerfile

Each top-level package has a single reason to change:

Package Owns Must not
ragcore Pydantic schemas, index manifest format, ids Import anything else in the repo, do I/O
ingestion Source connectors, cleaning, chunking, embedding, index publishing Be imported by serving at runtime
retrieval Search, metadata filters, reranking Know about HTTP or prompts
serving API, auth, prompt templates, model calls, streaming Write to the index
evals Golden sets, metrics, comparison reports Be shipped in the serving image

retrieval sits between the two pipelines on purpose. Ingestion uses it in its smoke tests to confirm a freshly built index answers known queries; serving uses it for every request. Neither needs the other.

The shared contract

ragcore/schemas.py is small and boring, which is the point:

# src/ragcore/schemas.py
from datetime import datetime
from pydantic import BaseModel, Field

SCHEMA_VERSION = 3

class Chunk(BaseModel):
    chunk_id: str
    doc_id: str
    text: str
    source_uri: str
    section: str | None = None
    tenant_id: str
    updated_at: datetime
    token_count: int = Field(ge=1)

class RetrievedChunk(BaseModel):
    chunk: Chunk
    score: float
    retriever: str  # "dense", "bm25", "hybrid"

Any change to these models bumps SCHEMA_VERSION. Ingestion writes it into the index; serving refuses to start against an index with a different major version. That one check has saved us from two deploys where the API expected a field the live index did not have.

Versioned indexes and a manifest

Ingestion never overwrites the live index. It builds a new one under a versioned name and publishes a manifest describing exactly how it was made:

# src/ragcore/index_manifest.py
from pydantic import BaseModel

class IndexManifest(BaseModel):
    index_name: str            # e.g. "kb_v2025_03_14_1"
    schema_version: int
    embedding_model: str
    embedding_dim: int
    chunker: str               # e.g. "markdown_headings"
    chunk_size_tokens: int
    chunk_overlap_tokens: int
    source_snapshot: str       # commit or export id of the corpus
    doc_count: int
    chunk_count: int
    created_at: str

The serving config points at a manifest, not at an index name, and every answer logs the index_name it used. Promotion is a config change that can be rolled back, and when someone asks why an answer changed we can diff two manifests instead of guessing. The embedding model and dimension in the manifest also stop the classic mismatch where queries are embedded with a different model than the documents.

Enforcing the boundaries

Folder names are suggestions until a tool fails the build. We use import-linter, which checks import graphs against declared contracts:

# .importlinter
[importlinter]
root_packages =
    ragcore
    ingestion
    retrieval
    serving
    evals

[importlinter:contract:core-is-leaf]
name = ragcore imports nothing from the repo
type = forbidden
source_modules = ragcore
forbidden_modules =
    ingestion
    retrieval
    serving
    evals

[importlinter:contract:serving-no-ingestion]
name = serving never imports ingestion or evals
type = forbidden
source_modules = serving
forbidden_modules =
    ingestion
    evals

[importlinter:contract:retrieval-no-http]
name = retrieval knows nothing about serving
type = forbidden
source_modules = retrieval
forbidden_modules =
    serving
    ingestion
uv run lint-imports

The first run on our old code reported eleven violations, including the API importing the PDF loader "just for a helper function". Each one was a real coupling bug waiting to happen.

Separate images, separate dependencies

The two Dockerfiles install different extras from pyproject.toml:

[project.optional-dependencies]
ingestion = ["pypdf", "beautifulsoup4", "tiktoken"]
serving = ["fastapi", "uvicorn[standard]"]
evals = ["pandas"]

The serving image does not contain PDF parsers or source-system credentials, which shrinks it and removes a class of attack surface from the internet-facing service. The ingestion image runs as a scheduled job with read access to sources and write access to the index store, and nothing else.

Evals as a first-class package

evals/ holds versioned golden datasets (question, expected source documents, reference answer notes) and runners that score two things separately:

  • Retrieval quality: did the expected documents appear in the top k? We track recall at 5 and mean reciprocal rank.
  • Answer quality: is the answer grounded in the retrieved text and does it address the question? Scored by rubric, partly automated, partly reviewed.

Splitting them matters: when answer quality drops, the first question is whether retrieval changed. The runner takes a manifest and a serving config, so we can compare a candidate index against the live one before promoting it:

uv run python -m evals.run --manifest manifests/kb_v2025_03_14_1.json --baseline manifests/live.json --fail-under-recall 0.85

Wiring it into CI

.PHONY: lint test evals-smoke
lint:
	uv run ruff check src tests
	uv run lint-imports

test:
	uv run pytest tests/unit -q

evals-smoke:
	uv run python -m evals.run --dataset smoke --manifest manifests/live.json --fail-under-recall 0.80

Every pull request runs lint and test. Changes under ingestion/, retrieval/ or serving/prompts/ also run evals-smoke against a 50-question set, which takes about two minutes. The full eval set runs nightly and before any index promotion.

Common mistakes

Mistake Why it hurts Do this instead
Chunk shape defined in two places Pipelines drift silently One schema module both sides import
Re-indexing in place No rollback, mixed versions during rebuild Versioned indexes plus a manifest switch
Serving image includes ingestion code Bigger image, credentials and parsers exposed Separate images with separate extras
Evals in notebooks only Nobody runs them before a change A runnable package with CI thresholds
Boundaries enforced by convention Shortcuts accumulate import-linter contracts in CI

Key takeaways

  • Lay out the repo around runtimes: offline ingestion, online serving, and the retrieval code both share.
  • Keep the contract between them in a tiny, dependency-free module with an explicit schema version.
  • Publish indexes as immutable versions with a manifest, and log which index answered each request.
  • Enforce package boundaries with import contracts so the layout survives deadlines.
  • Treat evals as code that runs in CI, scoring retrieval and answers separately.

References