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 ingestat 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
/healthshows 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.