Skip to content

Serve

An HTTP service around one configuration: three endpoints, Prometheus metrics, a span per stage and a drift signal. It holds no user state and stores nothing.

pip install "cairn-pipeline[serve]"
cairn serve --config configs/default.yaml --host 127.0.0.1 --port 8400

Endpoints

POST /answer

curl -s localhost:8400/answer \
  -H 'content-type: application/json' \
  -d '{"question": "How often must employees change their password?"}'

The body is the Answer model: the text, the claims with their verified citations, the confidence and the threshold in force, the passages, the usage and cost, and the latency per stage. An optional threshold in the request overrides the calibrated one for that call, which is useful for exploring the trade and wrong as a default.

An abstention is a 200 with status: "abstained", the fixed refusal text, no claims, and the passages still attached. It is an answer to the question, not an error.

Status Meaning
200 answered or abstained
422 the question is empty or too long
502 the provider failed or refused
503 no index, or its manifest does not match the configuration

GET /health

Reports the configuration name and hash, the provider and model, a summary of the index manifest, the calibrated threshold and its alpha, and the drift signal. Before an index exists the status is index unavailable with the reason, because a service that cannot boot cannot tell you why; the pipeline is loaded lazily on the first request that needs it.

GET /metrics

Prometheus text:

Metric Type Labels
cairn_answers_total counter status is answered or abstained
cairn_answer_latency_seconds histogram
cairn_stage_latency_seconds histogram stage is retrieve, rerank, generate, verify
cairn_cost_usd_total counter
cairn_confidence histogram
cairn_cache_hits_total counter
cairn_cache_misses_total counter

cairn_confidence is the histogram to watch next to the threshold: it shows how close live traffic sits to the abstention boundary, which is the early warning that the answer rate is about to move.

The collectors live on their own registry, so several apps in one process keep separate counters and nothing another library registers appears here.

Tracing

Spans are cairn.answer with cairn.retrieve, cairn.rerank, cairn.generate and cairn.verify beneath it, so a slow answer is attributed to a stage rather than guessed at.

CAIRN_OTEL_CONSOLE=1 cairn serve --config configs/default.yaml   # spans to stdout
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 cairn serve    # spans over OTLP

With neither variable set the spans are non-recording and cost almost nothing. OTLP export needs the exporter package; without it the service logs a warning naming the package and carries on rather than failing to start.

Drift

Every reported number is conditional on live questions resembling the golden set. Two cheap signals catch the common ways that stops being true, and cairn calibrate saves the golden distribution of both:

Signal What a large value means
centroid_distance_z the questions moved away from the ones the system was measured on
top1_score_z strongly negative means the corpus stopped matching the questions

They appear on /health as z-scores of a rolling window against the saved baseline:

{
  "drift": {
    "baseline": true,
    "baseline_n": 120,
    "window": 200,
    "centroid_distance_z": 0.42,
    "top1_score_z": -0.11
  }
}

baseline: false means no baseline was saved for this configuration; run cairn calibrate. A null z-score means there is not enough data yet, or the baseline has no spread in that signal. Drift never fails a request: a signal about the service must not turn a good answer into an error.

Treat a sustained z-score beyond about 3 as a reason to re-examine the golden set, not as an outage. What it says is that the guarantee's exchangeability assumption is weakening, so the numbers are drifting out of scope.

Mounting it in your own app

create_app(cfg) returns a FastAPI application, so the service composes:

from fastapi import FastAPI

from cairn.core.config import load_config
from cairn.serve.app import create_app

parent = FastAPI()
parent.mount("/cairn", create_app(load_config("configs/default.yaml")))

Operational notes

  • Build the index in the image, not at boot. cairn ingest at build time makes start-up a load rather than a rebuild, and the manifest guarantees the loaded index matches the configuration.
  • Bind to localhost unless you mean otherwise. There is no authentication here by design; put it behind whatever your setup already uses.
  • Re-calibrate when the configuration changes. A stale record is ignored with a warning and the service answers uncalibrated, which /health shows as a null threshold.
  • Watch cairn_answers_total{status="abstained"} against your alpha. A rising abstention rate is usually the first sign that the corpus and the questions have diverged.