Skip to main content
Build with Inferbase ยท RAG

RAG over a knowledge base

Retrieval stays in your stack; the generation call goes to Inferbase with the model set to auto, routed per question on how hard it is, with the context window sized to your retrieval and the privacy decision made where the data flows. Built from scratch on Postgres with pgvector, with the agentic shape included.

What you build

A question-answering service over a knowledge base of your documents: an ingestion job that chunks and embeds them into Postgres with pgvector, a retrieval step that searches, reranks and assembles a prompt, and a generation call to Inferbase with the model set to auto. Each answer cites the passages it used and carries the routing decision and the cost. The same service grows into the agentic shape, where a model decides what to retrieve, without moving the boundary. Embeddings and rerank run through the same key as generation, so the service holds one credential.

When you are done

  • An ingestion job that turns documents into cited, searchable passages, with the embedding model pinned
  • A retrieval step with vector search and a rerank, through the same API as generation
  • A generation call routed per question, with the context window sized from your retrieval and a session that keeps a conversation on one model
  • The decision record and the cost beside every answer, read from the response
  • An evaluation that measures retrieval recall and answer faithfulness, and compares auto with a pinned model on cost and quality

The components

Ingestion job
Reads documents, splits them at headings and paragraphs into passages of about 1,200 characters with a small overlap, embeds them in batches and writes them to Postgres with their source URL and heading.
Passage store
Postgres with the pgvector extension: one table of passages with a 1,024-dimension vector column and an HNSW index for approximate nearest-neighbour search.
Retrieval
Embeds the question with the pinned model, takes the 20 nearest passages from pgvector, reranks them through the rerank endpoint, and keeps the best 6.
Prompt assembly
Numbers the passages, places them in the system prompt with the citation rules, and sizes the context window the request will require.
Generation
One chat completion with the model set to auto, the routing object carrying the context floor and the session, and thinking off. The response carries the decision and the receipt.
Inferbase
The key, the routing policy with its rule pool and objective, and the three endpoints the service calls: embeddings, rerank and chat completions.
Service
A FastAPI application with one endpoint that takes a question and a conversation id and streams the answer with its sources and receipt.
Evaluation
A labelled question set: for each question, the passages that answer it. Scripts for recall at k, for answer faithfulness through a judge call, and for the auto-versus-pinned comparison.

The problem

In a RAG pipeline the retrieval is the same for every question, and the generation is not.

Retrieval-augmented generation has two halves. Retrieval finds the passages: an embedding, a vector search, perhaps a rerank. Generation reads them and writes the answer. The first half costs about the same for every question. The second half does not: reconciling one retrieved fact with the question is easy, and reasoning across six passages that partly disagree is not.

A pipeline that sends every generation call to the same model pays the hard-question price on the easy ones, or answers the hard ones with a model that cannot hold six passages in mind. The spread between those two is where the money is, and it is invisible when the model is a constant in the code.

The privacy question lives at the same step. Retrieved passages carry whatever the documents carry, names and account numbers included, and the generation call is where they leave your stack. Which models may see them, and whether the request is kept, has to be decided there.

This guide changes one thing in an existing pipeline, and builds the rest from scratch for a new one. Retrieval, chunking, the vector store and the prompt assembly stay exactly where they are. The generation call is sent to Inferbase with the model set to auto, so each question is classified, the context window is checked against what retrieval produced, and the answer is served by the model that wins on your objective, with the decision returned beside it.

Who has it

  • A team with a RAG pipeline in production and one model hard-coded at the generation step
  • A knowledge base whose questions range from lookups to multi-document reasoning
  • Anyone who has to say which model saw a retrieved passage, and keep that record

Architecture

System overview

Every component, grouped by who runs it. The product sits at the generation call; everything in your lanes is yours and unchanged by this design.

CLIENTSYour applicationA chat, a ticket system, aninternal toolDocumentsFiles, pages, exportsYOUR RAG SERVICEIngestion jobChunk, embed in batches, writePostgres + pgvectorPassages, vectors, HNSW indexRetrievalNearest 20, rerank, keep 6Prompt + generationNumbered passages, routing objectINFERBASERouting policyRule pool, objective, caching,session caps/embeddingsPinned model, 512 inputs a call/rerankCohere shape, 1,000 documents acall/chat/completionsmodel auto, decision returnedPROVIDERSEmbedding modelPinned into the indexRerank modelFirst of its type in the poolChat models in the poolWinner per question, withfallbacksdocumentsbatches of passagespassages + vectorsquestion + conversation idembed the question20 candidatesprompt, min context, sessionserveserveserveFigure 1. Your service owns the documents, the store and the prompt. Inferbase holds one key for three endpoints. The embedding model is pinned atingestion and reused at query time; the chat model is a decision per question.inferbase.ai

One request, end to end

The boundary sits between prompt assembly and generation. Nothing about retrieval changes; the model becomes a decision per question instead of a constant.

01 Questionyour stackFrom a user, a ticket, or anothersystem.02 Retrievalyour stackYour embeddings, your vectorstore, your rerank. Unchanged.- embed the question- search the store- rerank the candidates- assemble the prompt03 InferbaseInferbaseThe generation call, routed onthe question and the context itcarries.- classify task and complexity- drop models whose window is too small- pick the winner on the objective- return the decision with the answer04 Answeryour stackCited from the passages, with themodel named and the decision onrecord.Figure 2. The same request as a flow: what each stage decides and hands on.inferbase.ai
One question, from the client to a cited answer
  1. 01ClientServicePOST /ask with the question and a conversation id.The conversation id becomes the routing session.
  2. 02ServiceInferbase /embeddingsEmbeds the question with the model pinned in the store, with the query instruction the model expects.Measured p50 0.70 s at 10 in flight.
  3. 03ServicePostgresNearest 20 passages by cosine distance through the HNSW index.Single-digit milliseconds on a table of tens of thousands of rows.
  4. 04ServiceInferbase /rerankScores the 20 against the question; keeps the best 6.Cohere shape; results carry an index and a relevance score.
  5. 05ServiceServiceNumbers the passages, assembles the system prompt, estimates the tokens, sets min_context_window to the estimate plus the answer with headroom.A model whose confirmed window is below it is excluded before ranking.
  6. 06ServiceInferbase /chat/completionsStreams a request with model auto, the routing object (min_context_window, session, optimize if the caller set one), thinking off.The decision is the first event of the stream.
  7. 07InferbaseInferbaseClassifies the question, drops pool models whose window is too small or whose evidence is missing, ranks on the objective, keeps the session on its model if it still qualifies, serves the winner.Time to decide: p50 18 to 31 ms on live traffic.
  8. 08InferbaseServiceRouting event, then the answer chunks, then usage with cost and latency.The event names the model and whether the session stayed.
  9. 09ServiceClientStreams the sources, the answer and the receipt; stores the request id with the answer.The request id reads back the full decision record later.
  10. 10ClientServiceA follow-up question with the same conversation id.Session affinity keeps it on the same model unless that model cannot serve or something beats it by the margin.
Figure 3. Three Inferbase calls per question, one key. The generation call is the only one that routes; embeddings and rerank take the first model of their type in the pool, which is why the model is pinned into the store.

Deployment

Where each part runs, and what talks to what over the network.

CLIENTSYour applicationsCall the service over HTTPYOUR SERVICEFastAPI serviceOne container; scaleshorizontallyIngestion jobA scheduled run or a queueconsumerPostgres + pgvectorManaged Postgres with theextensionINFERBASEapi.inferbase.aiembeddings, rerank, chat; yourpolicyPROVIDERSModel providersManaged by Inferbase, or your ownkeysPOST /askvector searchwrite passagesHTTPS, bearer keyembed batchesserveFigure 4. Two processes and one database on your side. The service and the job share the key; the key never reaches a client. Postgres is the only state.inferbase.ai

What stays in your stack

  • Chunking, embeddings and the vector store
  • Rerank, if you use one
  • Prompt assembly and the citation format
  • Evaluation of answer quality

Technology stack

One choice per layer, the alternative that was considered, and the reason. Swap any row; the Inferbase call does not change.

LayerChoiceAlternativesWhy
Language and frameworkPython 3.12 with FastAPINode with Express; a LangChain or LlamaIndex pipelineMost RAG pipelines are Python. FastAPI streams a response and types the request. The frameworks work unchanged, with the base URL swapped and the routing object passed as an extra body.
API clientopenai 3.x with base_url set to Inferbasehttpx by handOne client for embeddings, rerank is one HTTP call, and chat; extra_body carries the routing object.
Passage storePostgres with pgvector, HNSW indexQdrant, Pinecone, Weaviate; in memory for a small corpusYou already run Postgres. One table holds the text, the metadata and the vector; the index gives millisecond search to millions of rows.
ChunkingHeadings, then paragraphs packed to about 1,200 characters with a 150-character overlapFixed token windows; semantic splittersA chunk that ends at a heading reads whole. The overlap keeps a sentence split across two chunks findable from either.
EmbeddingsThe pool's embedding model through Inferbase, pinned at ingestionA separate embeddings vendorSame key and bill. Pinned because an index is comparable only to vectors from the model that built it.
RerankThe pool's rerank model through the rerank endpointNo rerank; a cross-encoder you hostVector search is recall; rerank is precision. Twenty candidates in, six out, through the same API.
Generationmodel auto under a routing policy, with the routing object per requestOne pinned modelThe policy decides the pool and the objective; the request states the context floor and the session. Changing the model is a policy edit.
EvaluationA labelled set, recall at k, and a judge call through Inferbase with a JSON schemaA hosted evaluation productCheap and yours. The judge is one more routed call with structured output.
HostingA container for the service and a scheduled job, managed PostgresServerless functionsThe service holds a database pool and streams; a long-lived process suits it. The ingestion job runs on a schedule or a queue.

What carries it

The Inferbase features this design rests on. Each exists in the product today and is linked to its documentation.

Routing on task and complexity
Each generation call is classified before a model is chosen. A lookup and a six-passage synthesis are different tasks to the router, and they land on different models. Documentation
min_context_window on the request
Size it from your retrieval: chunks times chunk size plus the question and the answer. A model whose window is too small, or not confirmed, is excluded before ranking. Documentation
Session affinity
A conversation id sent as the routing session keeps follow-up questions on the model that answered, unless it can no longer serve or another model wins by more than the margin, and groups the turns under one run. Documentation
Prompt caching where the prefix repeats
A long system prompt or a stable set of passages can be marked for the provider cache. The decision says whether the preference was met and how the chosen provider caches. Documentation
Embeddings and rerank on the same key
The embeddings endpoint takes up to 512 strings a call; the rerank endpoint, in Cohere's shape, scores up to 1,000 documents a call. Auto takes the first model of each type in your pool, so the index is built by one model deliberately. Documentation
The decision record
The response id reads back the decision: classification, the eligible pool, the fallback chain and the per-step trail. The record is kept separately from the request content. Documentation
Zero retention and a pool you choose
Retrieved passages transit the generation prompt. Request content is kept for up to 30 days unless zero retention is on, and the policy pool decides which models may see it. Documentation
Tool-capable routing for the agentic shape
When the model drives retrieval through a search tool, routing keeps only models with confirmed tool support, and the loop stays on the model that opened it. Documentation

Build it step by step

Eight steps from an empty database to a cited, routed answer, then the agentic shape on top. The code is Python throughout; the generation call is also given in TypeScript in the section after the policy. Each step ends with the thing it turns on.

Before you start

  • An Inferbase account, a project, and a key with a spend cap for this service.
  • Postgres 15 or later with the pgvector extension (any managed Postgres offers it).
  • Python 3.12, the openai package, psycopg or SQLAlchemy, and FastAPI. Your documents as text: Markdown, HTML stripped to text, or PDF text layers.

Step 1: Create the routing policy

The policy does three jobs here. It names the models that may see your passages, it puts one embedding model and one rerank model at the top of their types so auto on those endpoints is a decision you made, and it sets the objective. Use a rule pool with a minimum context window if the pool may grow on its own; use Models I choose when the pool is a compliance list. Turn prompt caching on, set a spend cap per session, and attach the policy to the service's key.

If the documents carry personal data, decide retention now: zero retention on the key or the organisation keeps request content out of the log, and the decision record is kept without it. The policy table in this guide lists every value with its reason.

Order matters on the embedding row of the pool. Moving a different embedding model to the top changes which model builds new vectors, and an index is only comparable to vectors from the model that made it. Reorder that one deliberately, and rebuild the index when you do.

Step 2: The passage table

One table: the passage text, where it came from, which heading it sits under, and its vector. Vector dimensions match the embedding model; Qwen3-Embedding 0.6B returns 1,024. An HNSW index on cosine distance gives approximate nearest-neighbour search in milliseconds; build it after the first bulk load, not before.

schema.sql
sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE passages (
  id          bigserial PRIMARY KEY,
  source_url  text NOT NULL,
  heading     text NOT NULL,
  position    int  NOT NULL,          -- order within the source
  text        text NOT NULL,
  embedding   vector(1024) NOT NULL,  -- the pinned model's dimension
  model       text NOT NULL,          -- the embedding model that made the vector
  updated_at  timestamptz NOT NULL DEFAULT now()
);

-- After the bulk load. Cosine distance, so vectors need not be normalised.
CREATE INDEX passages_embedding_idx ON passages USING hnsw (embedding vector_cosine_ops);

Step 3: Chunk the documents

Split each document at its headings, then pack paragraphs into passages of about 1,200 characters with a 150-character overlap carried from the previous passage, so a sentence that straddles a boundary is found from either side. Prepend the document title and the heading to the text that is embedded, so a passage stands on its own: "Refund policy > Section 7: Custom-engraved items are final sale".

Drop sections that are lists of links, such as "Related" or "See also". They match many questions and answer none.

ingest/chunk.py
python
import re
from dataclasses import dataclass

@dataclass
class Passage:
    source_url: str
    heading: str
    position: int
    text: str

HEADING = re.compile(r"^#{1,6}\s+(.*)$")
SKIP = re.compile(r"^(related|see also|next steps|further reading)$", re.I)


def chunk(source_url: str, title: str, markdown: str, max_chars: int = 1200, overlap: int = 150) -> list[Passage]:
    sections: list[tuple[str, list[str]]] = [(title, [])]
    for line in markdown.splitlines():
        m = HEADING.match(line)
        if m:
            sections.append((f"{title} > {m.group(1).strip()}", []))
        else:
            sections[-1][1].append(line)

    passages: list[Passage] = []
    for heading, lines in sections:
        if SKIP.match(heading.split(" > ")[-1]):
            continue
        paragraphs = [p.strip() for p in "\n".join(lines).split("\n\n") if p.strip()]
        current, tail = "", ""
        for para in paragraphs:
            if current and len(current) + len(para) > max_chars:
                passages.append(Passage(source_url, heading, len(passages), current.strip()))
                tail = current[-overlap:]
                current = tail + "\n" + para
            else:
                current = (current + "\n\n" + para).strip()
        if current.strip():
            passages.append(Passage(source_url, heading, len(passages), current.strip()))
    return passages


def embedding_text(p: Passage) -> str:
    """What is embedded: the heading first, so the vector carries where the passage sits."""
    return f"{p.heading}\n{p.text}"

Step 4: Embed in batches and pin the model

The embeddings endpoint takes up to 512 strings a call; use batches of 128 to keep each request small. Send auto on the first batch, read the model Inferbase reports it used, and send that model by name from then on. Store the model name on every row. A rebuild with a different model is a new table, not an update.

A single-provider embedding model can refuse a burst. Retry a 429 with a short backoff rather than failing the job, and keep a second embedding model in the pool for a rebuild.

ingest/embed.py
python
import time
from openai import OpenAI, RateLimitError

client = OpenAI(api_key="inf_your_api_key", base_url="https://api.inferbase.ai/api/v1/inference")


def embed_all(texts: list[str], model: str = "auto", batch: int = 128) -> tuple[str, list[list[float]]]:
    """Embed every text; returns the model that did it (pinned after the first batch) and the vectors."""
    vectors: list[list[float]] = []
    for i in range(0, len(texts), batch):
        chunk = texts[i : i + batch]
        for attempt in range(4):
            try:
                result = client.embeddings.create(model=model, input=chunk)
                break
            except RateLimitError:
                time.sleep(2 ** attempt)  # a single-provider model refuses bursts; back off, do not fail the job
        else:
            raise RuntimeError("embeddings kept refusing")
        model = result.model  # pin: every later batch and every query uses this model
        vectors.extend(item.embedding for item in result.data)
    return model, vectors

Step 5: Retrieve: nearest twenty, rerank to six

Embed the question with the pinned model. Instruction-tuned embedding models expect a query to carry its task in front; Qwen3-Embedding's model card asks for an "Instruct:" line, and without it short questions drift toward any passage that shares a word. Take the twenty nearest passages from pgvector, then rerank them against the question through the rerank endpoint and keep six. The rerank response carries an index into the documents you sent and a relevance score.

Number the kept passages in rank order; the numbers become the citations.

service/retrieve.py
python
import httpx
import psycopg

BASE = "https://api.inferbase.ai/api/v1/inference"
HEADERS = {"Authorization": "Bearer inf_your_api_key"}


def query_text(model: str, question: str) -> str:
    if "qwen3-embedding" in model:
        return f"Instruct: Given a question, retrieve knowledge base passages that answer it\nQuery: {question}"
    return question


def retrieve(conn: psycopg.Connection, model: str, question: str, k: int = 6, candidates: int = 20) -> list[dict]:
    vector = client.embeddings.create(model=model, input=query_text(model, question)).data[0].embedding
    rows = conn.execute(
        "SELECT id, source_url, heading, text FROM passages "
        "ORDER BY embedding <=> %s::vector LIMIT %s",
        (str(vector), candidates),
    ).fetchall()
    documents = [f"{heading}\n{text}" for _, _, heading, text in rows]
    reranked = httpx.post(
        f"{BASE}/rerank",
        headers=HEADERS,
        json={"model": "auto", "query": question, "documents": documents, "top_n": k},
        timeout=30,
    ).json()
    kept = []
    for n, result in enumerate(reranked["results"], start=1):
        _, url, heading, text = rows[result["index"]]
        kept.append({"n": n, "url": url, "heading": heading, "text": text, "score": result["relevance_score"]})
    return kept

Rerank is where precision comes from. On a knowledge base with near-duplicate pages, vector search returns the duplicates together; the reranker sorts the one that answers the question to the top.

Step 6: Assemble the prompt and size the context window

Place the numbered passages in the system prompt, then the rules: answer from the passages, cite with [n], say which applies when passages conflict, say so when they do not settle the question. Estimate the tokens of what you are about to send (characters divided by four is close enough for English) and set min_context_window to that estimate plus the answer, with headroom. The floor is a routing filter: a model whose confirmed window is below it is never considered, and one whose window is not confirmed is excluded too.

service/prompt.py
python
RULES = (
    "Answer from the passages below and cite them as [1], [2] after the sentence they support. "
    "If the passages conflict, say which applies and why. If they do not settle the question, say so."
)


def build_prompt(passages: list[dict]) -> str:
    numbered = "\n\n".join(f"[{p['n']}] {p['heading']}\n{p['url']}\n{p['text']}" for p in passages)
    return f"{RULES}\n\nPassages:\n\n{numbered}"


def context_floor(system_prompt: str, history: list[dict], max_answer_tokens: int) -> int:
    chars = len(system_prompt) + sum(len(m["content"]) for m in history)
    estimate = chars // 4 + max_answer_tokens
    return max(8_000, int(estimate * 1.5))  # headroom; the floor is a filter, not a budget

Step 7: Generate with the routing object

One chat completion with the model set to auto. The routing object carries the context floor and the conversation id as the session; the caller may also state an objective for this call, which narrows the policy and never widens it. Thinking off: the answer is read from passages and needs no reasoning tokens. Read the routing object and the usage from the response, and store the request id with the answer so the full decision can be read back.

service/generate.py
python
def answer(passages: list[dict], history: list[dict], conversation_id: str, max_answer_tokens: int = 500) -> dict:
    system_prompt = build_prompt(passages)
    completion = client.chat.completions.create(
        model="auto",
        max_tokens=max_answer_tokens,
        messages=[{"role": "system", "content": system_prompt}, *history],
        extra_body={
            "routing": {
                "min_context_window": context_floor(system_prompt, history, max_answer_tokens),
                "session": conversation_id,  # follow-ups stay on the same model
            },
            "extended_thinking": False,
        },
    )
    routing = completion.model_extra["routing"]
    usage = completion.usage.model_extra
    return {
        "request_id": completion.id,  # reads back the full decision record
        "answer": completion.choices[0].message.content or "",
        "sources": [{"n": p["n"], "url": p["url"], "heading": p["heading"]} for p in passages],
        "model": routing["model"],
        "task": routing["task"],
        "complexity": routing["complexity"],
        "routing_time_ms": routing["routing_time_ms"],
        "cost_usd": usage["cost_micros"] / 1e6,
        "latency_ms": usage["latency_ms"],
    }

Step 8: Serve it, streamed

A FastAPI endpoint that takes the question and the conversation id, runs retrieval, and streams the answer: the sources first, then the routing event as it arrives, then the text, then the receipt. With stream set to true the SDK yields chunks; Inferbase's routing and usage events arrive among them as chunks whose object field says what they are.

service/app.py
python
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()


@app.post("/ask")
def ask(body: dict):
    passages = retrieve(conn, EMBEDDING_MODEL, body["question"])
    system_prompt = build_prompt(passages)
    history = body["history"] + [{"role": "user", "content": body["question"]}]

    def events():
        yield json.dumps({"type": "sources", "sources": passages}) + "\n"
        stream = client.chat.completions.create(
            model="auto",
            stream=True,
            max_tokens=500,
            messages=[{"role": "system", "content": system_prompt}, *history],
            extra_body={"routing": {"min_context_window": context_floor(system_prompt, history, 500), "session": body["conversation_id"]}, "extended_thinking": False},
        )
        for chunk in stream:
            raw = chunk.model_dump()
            if raw.get("object") == "routing":
                yield json.dumps({"type": "routing", **{k: v for k, v in raw.items() if k != "object"}}) + "\n"
            elif raw.get("object") == "usage":
                yield json.dumps({"type": "usage", **(raw.get("usage") or raw)}) + "\n"
            elif raw.get("choices") and raw["choices"][0].get("delta", {}).get("content"):
                yield json.dumps({"type": "delta", "text": raw["choices"][0]["delta"]["content"]}) + "\n"

    return StreamingResponse(events(), media_type="application/x-ndjson")

Step 9: The agentic shape: let the model drive retrieval

Two shapes, same boundary. In the staged shape a planning call decides what to search for, retrieval runs, and a generation call answers; each call is routed on its own, so the planning call often lands on a smaller model than the synthesis. In the tool-driven shape the model is given a search tool and calls it in a loop until it has enough to answer. With tools on the request, routing keeps only models with confirmed tool support, the loop stays on the model that opened it, and the policy's session caps bound a loop that does not stop.

The tool returns the reranked passages as text. Keep the session id on every call so the whole loop is one run under Usage, and cap the iterations in your code as well as in the policy.

service/agentic.py
python
SEARCH_TOOL = {
    "type": "function",
    "function": {
        "name": "search_knowledge_base",
        "description": "Search the knowledge base. Returns numbered passages with their sources.",
        "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
    },
}


def answer_agentic(question: str, conversation_id: str, max_steps: int = 4) -> dict:
    messages = [
        {"role": "system", "content": "Answer the question. Search the knowledge base as many times as you need, then answer from what you found, citing passages as [n]."},
        {"role": "user", "content": question},
    ]
    for _ in range(max_steps):
        step = client.chat.completions.create(
            model="auto",
            max_tokens=600,
            messages=messages,
            tools=[SEARCH_TOOL],
            extra_body={"routing": {"session": conversation_id}, "extended_thinking": False},
        )
        choice = step.choices[0]
        messages.append(choice.message)
        if choice.finish_reason != "tool_calls":
            return {"answer": choice.message.content, "model": step.model_extra["routing"]["model"], "request_id": step.id}
        for call in choice.message.tool_calls:
            query = json.loads(call.function.arguments)["query"]
            found = retrieve(conn, EMBEDDING_MODEL, query)
            messages.append({"role": "tool", "tool_call_id": call.id, "content": build_prompt(found)})
    return {"answer": "I could not find enough to answer.", "model": None, "request_id": None}

The planning call in the staged shape is the cheapest call in the pipeline and the one most often sent to the most expensive model. Give it its own objective: routing.optimize cost on the plan, quality on the synthesis.

The routing policy

Balanced as the objective, a pool you name or a rule on context window, the embedding and rerank models at the top of their types, prompt caching on, and session affinity so a multi-turn conversation over the same documents stays on one model.

StageSettingValueWhy
PoolHow the pool growsModels that meet a rule: context window at least 32kA rule pool admits new models as they arrive and never admits one that cannot hold your retrieval. Use Models I choose instead when the pool is a compliance decision.
PoolOrder of the embedding and rerank modelsOne of each, at the top of its typeAuto on those endpoints takes the first model of that type. The one that built the index must stay first until you rebuild.
RoutingOptimization modeBalancedEasy questions should go cheap and hard ones should go strong. Balanced lowers the quality bar on simple prompts and holds it on hard ones.
RoutingEligibilityStandardThe default evidence bar. Raise it to High for a knowledge base where a wrong answer is expensive.
FallbackRetries2 attempts, model may switchA pipeline should not fail a question because one provider was busy.
SessionPrompt cachingOnA stable system prompt and repeated passages are the prefix a provider cache is for.
SessionMaximum spend per sessionYour per-conversation budgetA conversation that keeps retrieving cannot run without a ceiling, and the agentic loop needs one most.

Create it under Policies in the portal and attach it to the key the application uses; the steps are in Create a routing policy.

The call

The generation call with retrieval already done: your passages in the system prompt, the question as the user turn, the context window sized to what retrieval produced, and a session identifier so follow-up questions over the same documents stay on one model. The decision comes back with the answer.

python
from openai import OpenAI

client = OpenAI(api_key="inf_your_api_key", base_url="https://api.inferbase.ai/api/v1/inference")

# Your retrieval, unchanged: the passages your store returned for the question, numbered.
passages = """[1] Refund policy, section 4
https://example.com/kb/refunds#4
Orders cancelled within 14 days of delivery are refunded in full to the original payment method within 5 business days.

[2] Refund policy, section 7
https://example.com/kb/refunds#7
Custom-engraved items are final sale and excluded from the 14-day window."""

completion = client.chat.completions.create(
    model="auto",
    max_tokens=400,
    messages=[
        {
            "role": "system",
            "content": (
                "Answer from the passages below and cite them as [1], [2]. "
                "If the passages conflict, say which applies and why. "
                "If they do not settle the question, say so.\n\nPassages:\n\n" + passages
            ),
        },
        {"role": "user", "content": "I ordered an engraved watch 10 days ago and want to return it. Can I get a refund?"},
    ],
    extra_body={
        "routing": {
            # Sized from your retrieval: chunks x chunk size + question + answer.
            "min_context_window": 32000,
            # Follow-up questions over the same documents stay on one model.
            "session": "kb-conversation-1842",
        },
        "extended_thinking": False,
    },
)

answer = completion.choices[0].message.content or ""
routing = completion.model_extra["routing"]
usage = completion.usage.model_extra

print(answer)
print(routing["task"], routing["complexity"], "->", routing["model"], f'({routing["routing_time_ms"]} ms to decide)')
print(f'{usage["cost_micros"] / 1e6:.6f} USD, {usage["latency_ms"]} ms; decision record: {completion.id}')

Both snippets were run against api.inferbase.ai on 2026-10-13: the answer applied the engraved-item exception from [2] over the general rule in [1], and the decision named a DeepSeek V4 Flash deployment in 5 to 54 ms.

Open in PlaygroundAPI reference

Security and privacy

The passages are the sensitive thing. They leave your stack at the generation call and nowhere else, so that is where the controls go.

Which models may see a passage
The policy pool. A pool you name is a list you can show an auditor; a rule pool is a rule you can show them. Nothing outside the pool is ever chosen, and a request can narrow the pool but never widen it.
Whether the request is kept
Request content is kept for up to 30 days by default, deletable at any time. Zero retention on the key or the organisation keeps it out of the log entirely; the decision record stays, without the content.
Your own keys
For documents that must not leave a contracted provider, build the pool from a provider key or cloud account you connect. The generation call runs on your credential with the model still chosen per question.
Injection in the documents
A passage that tells the model to ignore its rules is text, and the prompt says so. For documents from outside your organisation, run the guardrail injection check in record mode at the prompt door first and read the verdicts under Usage before turning it to refuse; the classifier also refuses questions about injection.
Personal data in passages
Redact at ingestion what the answer never needs: account numbers, addresses. What is redacted is never embedded, never retrieved, never sent.
The key
One key per service, with a spend cap, held by the service and the ingestion job. Clients call your service, never the API.

Evaluation

Three measurements, cheapest first. Retrieval recall tells you whether the right passages reach the prompt. Faithfulness tells you whether the answer stays inside them. The auto-versus-pinned comparison tells you what routing changed. All three run on one labelled set: questions, and for each the passages that answer it.

Recall at k
For each question, embed, search, rerank, and check whether a labelled passage is among the k kept. Report the fraction. Run it after any change to chunking, the embedding model or the rerank; it costs cents.
Faithfulness
Ask a judge, through Inferbase with a JSON schema, whether each sentence of the answer is supported by the cited passage. The judge call is one more routed request with structured output; log its verdicts beside the answers.
Auto against a pinned model
Run the set twice, once with model auto and once pinned to the model you use today. Compare faithfulness, cost from the receipts, and latency. The decision record on each auto answer says which model answered and why.
Read the decisions
Group the auto run by task and complexity from the routing object. The distribution tells you how much of your traffic is simple, which is where the savings are, and which models are winning.
When to run which
Recall after ingestion changes. Faithfulness after prompt or policy changes. The comparison once, then whenever the pool changes.
eval/recall.py
python
cases = [
    {"question": "Can I return an engraved item?", "expect_urls": ["https://example.com/kb/refunds#7"]},
    # ...
]

hits = 0
for case in cases:
    kept = retrieve(conn, EMBEDDING_MODEL, case["question"], k=6)
    if any(p["url"] in case["expect_urls"] for p in kept):
        hits += 1
print(f"recall@6 = {hits / len(cases):.2%}")

Operations

What to watch once it serves, and where each number comes from.

The decision per answer
Store the request id with every answer. When an answer is wrong, read the decision record first: the task, the eligible pool, the filter trail, the fallback chain. Then read the sources the retrieval kept. One of the two explains it.
Cost from the receipts
Sum cost_micros from the responses by task and by model. It is the amount charged, so it matches Usage, and it tells you which questions are expensive.
Routing time
routing_time_ms on every response. It scales with prompt length, so a question with many passages classifies slower than a sentence; read your own distribution.
Stale passages
Re-ingest changed documents on a schedule and delete passages whose source is gone. A cited answer from a deleted page is the failure mode nobody reports.
Session hops
The routing event says whether the session stayed on its model and why it moved. A conversation that moves often is a sign the pool is close on the objective; a rule for the task settles it.
Policy changes
The pool and the objective have a revision history. A change applies to the next request, so make it with the comparison in hand.

Scaling and limits

The limits are the endpoints' batch sizes, the key's rate limit, and the store. Each has a known fix.

Real time, measured
A search-and-answer interface, not autocomplete. Measured on 2026-10-14: embedding the question took 0.6 to 2.5 s, the rerank 0.8 to 2.5 s, and the first token of the answer 1.5 to 3.5 s under the latency objective, so the first words arrive 3 to 8 s after the question, with the answer streaming from there. Routing adds tens of milliseconds. For a chat, stream and show the sources first; for anything that must answer in under a second, cache the retrieval and drop the rerank.
Ingestion throughput
The embeddings endpoint takes 512 strings a call; batches of 128 with a retry on 429 keep a job steady. A single-provider embedding model refuses bursts; keep a second one in the pool for rebuilds.
Rerank size
Up to 1,000 documents a call and about 8,000 characters of query. Twenty candidates is the sweet spot; a hundred costs more and rarely changes the top six.
Requests per minute
A key is 60 requests a minute on the free plan and 600 on Pro; a question is three requests. Raise the limit on the key before launch.
The store
An HNSW index answers in milliseconds to millions of rows. Partition by tenant or by source when one table grows past that, and keep the embedding model per table.
Long documents
A question that needs thirty passages needs a model with the window for them. The context floor handles it per request; the pool rule keeps small-window models out.
Cost at volume
Prompt caching on the policy, a stable system prompt, and the simple questions landing on cheap models are the three levers. The routing object lets a caller cap the price per million tokens per request when a workload needs a hard ceiling.

Troubleshooting

What goes wrong when this is built, what it means, and what to do. Each one was met while building or running it.

SymptomCauseFix
The request fails saying no model qualifiesmin_context_window is above every pool model's confirmed window, or the pool models' windows are not confirmed.Lower the floor to the estimate with less headroom, or add a long-context model to the pool. Check the decision record's filter trail: it names the step that emptied the pool.
Answers arrive empty or cut offThinking is on by default and counts toward max_tokens; a reasoning model spent the budget thinking.Send extended_thinking false, as the code does, or raise max_tokens when you want reasoning.
The rerank call returns 403The policy pool holds no rerank model, or auto on rerank found none.Add a rerank model to the pool; the policy editor names which one auto will use.
Retrieval quality dropped after a rebuildThe embedding model changed: auto resolved to a different model, or the pool order moved.Pin the model by name at ingestion and query time; store it on every row; rebuild the whole table when it changes.
The ingestion job fails on 429 "temporarily unavailable"The embedding model has one route and the burst exceeded it.Retry with backoff, as the code does; smaller batches; a second embedding model in the pool for rebuilds.
A conversation keeps switching modelsTwo models are close on the objective, so a fresh pick beats the session by the margin.Add a task rule that tries one model first, or raise the eligibility bar so fewer models tie.
Short questions retrieve passages that share a word but not the meaningThe query was embedded without the instruction prefix the model expects.Prefix queries for instruction-tuned models, as the retrieval code does for Qwen3-Embedding.

Extending

Each extension changes one component and leaves the generation call as it is.

Hybrid search
Add a full-text index in Postgres and merge its top results with the vector results before the rerank. Exact identifiers and part numbers are what vectors miss.
Several knowledge bases
One table per source or tenant, each with its own pinned model, selected by the request. The prompt and the policy are shared.
Answer feedback
A thumbs up or down with the request id gives you labelled answers; the ones marked wrong become faithfulness cases.
Enterprise sources through MCP
For sources behind their own APIs, declare an MCP server under Connections and let the model search it as a tool, governed by the policy's allow-list and declared servers.
Your own keys
Build the pool from a provider key or cloud account. The service does not change: it still sends model auto, and the policy decides whose credential serves.
A chat on your site
The customer support assistant guide is this pipeline with a web frame, a handoff and abuse controls; its quickstart runs on an in-memory index that this store replaces.

What was measured

What a question costs in time on the public API: the three calls in order. The decision is the smallest part; retrieval and the provider's first token are the rest.

MeasurementValue
Embedding of the question (3 runs)0.6 to 2.5 s
Rerank of 20 candidates (3 runs)0.8 to 2.5 s
Streamed answer, first token, latency objective (3 runs)1.5 to 3.5 s
Time to decide (classify and rank), live traffic, 2026-10-13p50 18 to 31 ms
Streamed chat on auto, first token, 24 in flight, 2026-10-12 load testp50 1.4 s, p95 3.0 s

Measured from our own infrastructure in India against api.inferbase.ai on 2026-10-14, three runs each, client-side wall time including the network hop (about 0.25 s each way), thinking off. Every response returns its own figures.

Further reading

Frequently asked questions

Put intelligence in the middle.

One gateway in front of every model, with your policies applied and every decision on record. Start on the free plan with 5,000 requests a month, no card required.