Skip to content

Extend the seams

Sci RAG Kit has no plug-in registry. It has five small boundaries where real projects commonly vary. Extend the narrowest one that matches the change, then keep its surrounding invariants visible in tests and evaluation.

Choose the seam

Need Contract Primary file Evidence to add
New document type Produce the shared parsed block model src/sci_rag/ingest/parsers.py Parser and chunking tests with a small fixture
New source system Produce CorpusEntry values src/sci_rag/ingest/manifest.py Metadata, rights, pagination, and resume tests
New post-fusion ranking Implement Reranker src/sci_rag/retrieve/rerank.py Failure fallback test and before/after ablation
New embedding or generation model Implement EmbeddingProvider or LLMClient src/sci_rag/embed/, src/sci_rag/llm/ Offline contract tests plus version/dimension checks
New identity system Implement AuthBackend and wire the app factory src/sci_rag/server/auth.py Auth, scope, rate, REST, and MCP coverage

1. Add a document parser

parse_file() dispatches by suffix and returns one ParsedDocument. Structured parsers should produce ordered Block values (heading, text, or table); the chunker then owns normalization, token sizing, overlap, and section breadcrumbs.

Keep these properties:

  • A parser reports the route it used when a fallback changes fidelity.
  • Tables remain one block when feasible.
  • The first document title is not duplicated into every section path.
  • Unsupported suffixes fail with the supported list rather than returning blank text.
  • A fixture test demonstrates the block order and metadata.

Add the suffix to SUPPORTED_SUFFIXES, add a branch in parse_file(), and let the existing ingester handle deduplication, embedding, and transactions.

2. Add a corpus collector

A collector does not need to know about chunking or storage. It emits CorpusEntry records containing a local path plus whatever source metadata it can establish:

src/sci_rag/ingest/manifest.py
path: Path
title: str | None
authors: list[str]
year: int | None
doi: str | None
journal: str | None
url: str | None
license_class: str
source: str

Resolve remote bytes to stable local paths before ingestion. Normalize identifiers, retain provider source references, rate-limit external APIs, and make resume behavior explicit. When the collector cannot establish redistribution rights, leave license_class as unknown.

3. Add a reranker

Reranker is a structural protocol with one async operation:

src/sci_rag/retrieve/rerank.py
class Reranker(Protocol):
    name: str

    async def rerank(
        self, query: str, items: list[RetrievedItem], *, top_k: int
    ) -> list[RetrievedItem]: ...

The orchestrator passes a wider fused pool and expects a reordered, truncated list. Any exception must leave a visible rerank trace and fall back to the fused order. Add the adapter selection to the validated domain tuning model only if users need to configure it.

Do not enable a new reranker by default from intuition. Run sci-rag eval retrieval --ablation with and without it on the target corpus, including latency and confidence intervals.

4. Add a model provider

An embedding provider exposes version, dim, and one batch method:

src/sci_rag/embed/provider.py
class EmbeddingProvider(ABC):
    version: str
    dim: int

    async def embed(
        self, texts: list[str], *, task: EmbeddingTask
    ) -> list[list[float]]: ...

Stamp a provider-specific version that changes when stored vectors become incompatible. Assert every returned dimension, distinguish document and query tasks where the model supports asymmetric embeddings, and normalize vectors if the provider's reduced dimensions require it.

An LLM client implements full generation and streaming. JSON consumers use the shared generate_json() helper, which requests deterministic JSON mode and strips a surrounding code fence before parsing.

Provider additions also need a deliberate selection path in get_embedder() or get_llm(). That small factory is preferable to a general plug-in loader because supported providers remain visible in one file.

Generation providers that ship with the kit

Three adapters live beside each other in src/sci_rag/llm/, selected by a provider:model spec. A bare model id belongs to SCI_RAG_LLM_PROVIDER; a prefixed one overrides it for that role.

Provider Reaches Credentials Extra
google Gemini SCI_RAG_GOOGLE_API_KEY or SCI_RAG_GCP_PROJECT built in
anthropic Claude, on Vertex or the direct API SCI_RAG_GCP_PROJECT (ADC) or SCI_RAG_ANTHROPIC_API_KEY anthropic
openai-compatible Vertex partner models (Grok, Llama, Mistral, DeepSeek), OpenAI, self-hosted vLLM/Ollama SCI_RAG_GCP_PROJECT (ADC) or SCI_RAG_OPENAI_API_KEY (+ optional SCI_RAG_OPENAI_BASE_URL) openai

On Google Cloud the third row is the only route to the non-Google partner models: Vertex serves them behind an OpenAI-compatible endpoint rather than a native API, so one adapter covers every current and future partner model. Model ids there keep their publisher prefix, as in xai/grok-4.1-fast-reasoning; sending the bare id is rejected as a malformed publisher model.

Partner models are not served from every region

SCI_RAG_GCP_LOCATION defaults to us-central1, which serves Gemini but not Claude or Grok. Both are reachable from global, and Grok is only offered there. Set SCI_RAG_GCP_LOCATION=global when generating with a partner model; Google embeddings work from global too, though noticeably slower than from a region. A model that is not served where you asked fails with a clear 400 ... is not servable in region or 404 ... not found, so sci-rag doctor --probe will catch it before a pipeline run does.

Which models a project can reach also depends on what is enabled in its Model Garden, so treat the ids above as examples to check with doctor, not a guaranteed menu.

What a new adapter has to normalize

LLMClient presents one signature to every call site, so an adapter absorbs the differences between providers rather than exposing them:

generate() argument google anthropic openai-compatible
system system_instruction system= leading system message
temperature forwarded dropped forwarded
max_tokens max_output_tokens max_tokens (required) max_tokens
json_mode response_mime_type + thinking_budget=0 output_config={"effort": "low"} response_format

Two of those cells are easy to get wrong:

  • Current Claude models removed the sampling parameters. Forwarding temperature returns a 400, so the Anthropic adapter drops it. The intent behind a low temperature maps onto effort instead.
  • Lowering effort is not the same as disabling thinking. Disabling it on current Claude models can leak reasoning tags or write a tool call into visible text, which would corrupt the JSON the extraction and judge call sites parse.
  • Not every Claude model accepts the effort knob. claude-sonnet-5 takes it; claude-haiku-4-5 rejects it with 400 output_config.effort: Extra inputs are not permitted. The adapter probes once per client and remembers the result, because re-learning it per call would double the request count across a graph-extraction run.

Where a provider may reject a knob, the adapters retry once without it rather than failing the call. Retry policy itself is shared: retry_async() in llm/client.py owns the backoff, and the SDK clients are constructed with max_retries=0 so their own retries do not compound with it.

Embeddings are Google-only on purpose

SCI_RAG_EMBEDDING_PROVIDER accepts google or local-hash, and there is no third option by design. Anthropic ships no embedding API, and on Vertex the only managed text embeddings are Google's; every alternative means deploying and paying for your own Model Garden endpoint.

More to the point, an embedder is not a runtime-swappable choice here. A migration bakes SCI_RAG_EMBEDDING_DIM into the pgvector column (see ADR 0002), and each chunk stores the version that produced it. Changing embedder means a migration, a full re-embed, and an index rebuild. sci-rag embed plan exists to scope exactly that work. Point SCI_RAG_EMBEDDING_MODEL at a different Google embedding model freely; treat anything beyond that as a data migration, not a configuration change.

5. Add an authentication backend

AuthBackend has two synchronous operations: authenticate a bearer token into an AuthContext, and enforce its rate limit. The shipped factory selects open local mode or static JSON keys from SCI_RAG_API_KEYS.

A deployment that adds OAuth or institutional identity should:

  1. Map the external identity and claims to the existing scope vocabulary.
  2. Implement authenticate() and check_rate().
  3. Wire the backend into its application factory so the same instance guards REST and the /mcp mount.
  4. Preserve stable application/problem+json error codes.
  5. Test REST and MCP access together.

The current create_app() constructs the shipped backend from settings; it is not a run-time plug-in registry. Keep any factory change explicit rather than importing arbitrary authentication code from configuration.

The invariants around every seam

  • Rights scope precedes ranking. A new retrieval path must apply all document conditions before its candidate limit.
  • Degradation is visible. An optional component may fail without killing a request, but its trace must say so.
  • Stored model output is validated. Unknown ontology values and malformed JSON are dropped or rejected, not guessed into shape.
  • REST and MCP share behavior. Add domain logic to RagService or a lower facade before exposing it through either adapter.
  • Tests run offline by default. Put credentialed smoke coverage behind the cloud marker.
  • Retrieval changes bring receipts. Include ablation results in the pull request.

See Architecture for ownership, Contributing for the change bar, and Evaluation for the measurement workflow.