This feature sends your data off the machine. A search that carries a
rerankblock POSTs the question and the text of up towindowhits (default 30, maximum 300) to a third-party API. It is the only search-time feature that sends document text off the node. Two other features send text off the node, both operator configuration and off by default:[embedding] default_endpoint(--embed-mode proxy) sends document text at write time and query text at search time to an external embeddings API, and the WAL tap (PUT /_xerj/wal_tap) replays every write on tapped indices to an external_bulkendpoint. The node's other outbound connections carry no document or query text: the one-time HuggingFace model download for--embed-mode neural, and Raft messages to your own peers in cluster mode. Reranking is off until an operator supplies a provider key, a request only triggers it by asking for it, and an operator can forbid it outright with[rerank] enabled = false. Read Every way data leaves a XERJ node before you turn it on.
rerank is an optional second stage on POST /{index}/_search. The engine
retrieves and ranks as usual; the stage then hands the top hits to an external
relevance judge, which returns a probability per document that it answers the
question. Hits ar
8000
e reordered by that probability, and the probability replaces
_score.
It exists for one reason above ordering. A BM25 or fused score orders results
but has no absolute meaning: a _score of 7.2 is not comparable across queries
and cannot be used as a cut-off. The judge's probability is comparable enough
to order by — and the full three-corpus run also measured where that stops:
against BEIR relevance the probabilities are not calibrated (ECE 0.10–0.31,
worst on FiQA, where documents the judge rated ~0.93 confidence were relevant
34 % of the time).
So the working rule is: rank by the probability, never threshold the raw value
with min_score as if the same number meant the same thing on every query.
Measured with the real model (2026-09-20, pilot). Over the first 40 judged SciFact test queries, BM25 shortlists (default lexical embedder, no vectors), model pinned
jev-1.13.0: this stage scored 0.8299 nDCG@10 against BM25's 0.7750. Until this change the stage put every candidate in sharedstateand asked an untargeted "does this document answer the query" per key — the model read that as one question about the pile and answered near-uniformly, which scored 0.3822; each candidate now rides inside its own question'sinstructions, the shape the provider's own docs prescribe. The test suite still runs against test doubles, and verifies the mechanism: the wire format, the reorder, paging, pruning, the failure policy, and what is and is not sent. All numbers and method: Quality numbers.
The stage is in engine/crates/xerj-api/src/rerank_stage.rs; the provider
client, the request parser and the failure policy are the leaf crate
engine/crates/xerj-rerank. The end-to-end tests are
engine/crates/xerj-api/tests/rerank_stage_http.rs.
The separate opt-in judge block can run a deterministic lexical scorer on
the returned candidate page. It keeps _score and adds _p_relevant; the
response identifies this scorer as judged.scorer: "lexical". The lexical
arm needs no hosted provider or model. A node configured with a local judge
model uses that model's own tokenizer instead.
The lexical arm splits CJK runs into single characters and adjacent pairs.
For example, a query containing 雷同 can match inside 内容存在雷同需要核查;
the pair contributes evidence beyond two isolated or reversed characters.
Punctuation, non-CJK runs and the title/body boundary break pairs. Non-CJK
alphanumeric runs retain their existing lowercase word tokens. This is
character n-gram overlap, not dictionary segmentation or semantic relevance.
CJK scores and judge.min_p filtering can change with this tokenizer; no
cross-language calibration is claimed. The judge only reorders/filters the
candidates it receives. It cannot recover a document missed by the first
stage, and this change does not change index analyzers or require reindexing.
A node needs a provider key. There are two places to put it, and a value in
the config file wins over the environment — the same rule as
cluster.auth_secret, so a stray variable in a service's environment cannot
silently re-point where document text is sent.
# xerj.toml
[rerank]
enabled = true # false = refuse every rerank request (403)
api_key = "ts-..." # or the TYPESAFE_API_KEY environment variable
endpoint = "https://api.typesafe.ai/v1/systemone" # or TYPESAFE_ENDPOINT; this is the default| Setting | Environment fallback | Default | Meaning |
|---|---|---|---|
rerank.enabled |
none | true |
false refuses every rerank request with HTTP 403, whatever the environment holds. Nothing is sent. |
rerank.api_key |
TYPESAFE_API_KEY |
empty | The provider key. Empty in both places means reranking is not configured: requests get HTTP 503. |
rerank.endpoint |
TYPESAFE_ENDPOINT |
https://api.typesafe.ai/v1/systemone |
Where documents are POSTed. Must be an absolute http(s):// URL with no user:password@ in it. The node refuses to start otherwise, whichever place the value came from — the config file at config load, the environment variable at boot (TYPESAFE_ENDPOINT: rerank.endpoint must not carry credentials…). |
The settings are resolved once, at startup. Changing the environment of a running node does nothing; restart it.
There is deliberately no per-request key. A key in a search body would land in slow-query logs, audit trails and client-side request dumps.
curl -s -H "Authorization: ApiKey $ADMIN_KEY" http://localhost:9200/_xerj/rerank{
"enabled": true,
"configured": true,
"providers": ["jev"],
"api_key": { "set": true, "source": "config" },
"endpoint": { "url": "https://api.typesafe.ai/v1/systemone", "source": "default" },
"defaults": { "provider": "jev", "model": "jev-latest", "window": 30, "batch": 30,
"max_concurrency": 8, "max_doc_chars": 1200, "timeout_ms": 10000 },
"limits": { "max_docs_per_call": 30, "max_window": 300, "max_concurrency": 16,
"max_doc_chars": 8000, "max_timeout_ms": 60000,
"max_instructions_chars": 2000, "max_query_chars": 4000,
"max_model_chars": 128, "max_fields": 64, "max_field_name_chars": 256 },
"data_egress": "A search that carries a `rerank` block sends the text of up to `window` hits, and the query, to the endpoint above. It is the only search-time feature that sends document text off the node. Two other features send text off the node, both operator configuration and off by default: `[embedding] default_endpoint` (`--embed-mode proxy`) sends document text at write time and query text at search time to an external embeddings API, and the WAL tap (`PUT /_xerj/wal_tap`) replays every write on tapped indices to an external `_bulk` endpoint. The node's other outbound connections carry no document or query text: the one-time HuggingFace model download for `--embed-mode neural`, Raft messages (index names, mappings, shard assignments) to the configured peers in cluster mode, and object storage — `xerj autoindex s3://` (with `--watch`, on an interval), which names buckets and keys to the endpoint you configure and reads objects IN. One exception, since v1.0.0-rc.77: with `storage.backend = "s3"` the segment path constructs the same `S3Backend` client and writes index bundles — which carry stored document text — to the bucket you configured, as the index's home rather than a third-party egress."
}The endpoint reports that a key is set and where it came from
("config" or "env"). It never returns the key, and the URL is printed with
any userinfo, query string and fragment removed. configured is false when a
key exists but enabled = false: a key on a node that forbids reranking is not
a working configuration.
/_xerj/* is the node's operator namespace and is superuser-only. A caller
without superuser rights finds out from the search itself: HTTP 503 means no
key, HTTP 403 means the operator disabled it.
POST /kb/_search
{
"query": { "match": { "body": "vitamin d supplementation bone density" } },
"size": 5,
"rerank": { "window": 30 }
}"rerank": {} is valid and uses every default.
| Field | Type | Default | Limit | Meaning |
|---|---|---|---|---|
provider |
string | "jev" |
jev, typesafe (alias), none, disabled |
none / disabled skips the call and reports applied: false. Anything else is a 400. |
model |
string | "jev-latest" |
≤ 128 characters | Sent to the provider verbatim. |
window |
integer | 30 | 1–300 | How many of the engine's top hits are judged. Every document in the window is a paid judgement. |
min_score |
number | none | 0–1 | Drop hits whose probability is below this. It is a probability; 7 is a 400. The measured calibration (ECE 0.10–0.31 across the three BEIR corpora) says a fixed cut-off does not mean the same thing on every query — rank by the probability rather than thresholding it. |
query |
string | inferred | non-empty, ≤ 4,000 characters | The question documents are judged against. The limit also applies to a question read from the search query. See The question. |
fields |
string[] | every returned string field | 1–64 names, each ≤ 256 characters | Which returned fields are sent. Exhaustive: ["body"] sends the body and nothing else, not even the title. See What leaves the machine. |
instructions |
string | a generic relevance question | ≤ 2,000 characters | Overrides what "relevant" means. A support corpus and a code corpus do not mean the same thing by it. The provider's wire format carries the instructions inside every per-document question, so this string is sent once per judged document — which is why it has a ceiling. |
timeout_ms |
integer | 10000 | 1–60000 | Wall-clock budget for the whole stage, not per call. |
max_doc_chars |
integer | 1200 | 1–8000 | Title and text are each cut to this many characters, on a character boundary. |
batch |
integer | 30 | clamped to 30 | Documents per provider call. Values above 30 are lowered to 30, not refused: the ceiling is the provider's, not a caller mistake. |
max_concurrency |
integer | 8 | 1–16 | Provider calls in flight at once. |
Unknown keys are refused by name, not ignored. A caller who misspells
min_score as treshold gets a 400 naming treshold, instead of unpruned
results that look pruned.
The ceilings are the server's because the caller picks the window and the
operator pays for it. That includes the strings. instructions is repeated
once per judged document, so before it had a ceiling a single size: 1 search
carrying a 1 MB instructions string at window: 40 put 40 MB on the wire to
the provider (measured in review, against a test double). With the ceilings,
the most one search can send is window × (title + text, each cut at
max_doc_chars, + instructions) plus the question once per call. That is
arithmetic, not a measurement: at the default window and max_doc_chars with
instructions at their limit, 30 × (1,200 + 1,200 + 2,000) + 4,000 = 136,000
characters; at every maximum at once, 300 × (8,000 + 8,000 + 2,000) +
10 × 4,000 = 5.44 million characters, before JSON framing. An over-long value
is a 400 that names the field and the limit; the value itself is never echoed
back. The ceilings are also readable from GET /_xerj/rerank under limits.
The judge needs one natural-language question. XERJ reads it from the search query only when the query is a shape that carries exactly one:
| Query shape | Question read from |
|---|---|
match, match_phrase |
the field's query string |
multi_match, semantic, simple_query_string |
query |
anything else — bool, hybrid, term, match_all, a top-level knn with no text query, no query at all |
refused (400): pass rerank.query |
This is deliberately shallow. A bool tree has no single question in it, and
picking one would rank against a guess without anyone noticing. rerank.query
always wins when it is present, and the _rerank block reports the question
that was actually used.
The stage adds one top-level block, _rerank, placed before hits on the
wire so that a reader which truncates long output from the bottom still sees
which ranking it is holding.
Applied:
{
"_rerank": {
"applied": true,
"provider": "jev",
"model": "jev-latest",
"score_kind": "probability",
"window": 4,
"judged": 4,
"unjudged": 0,
"skipped_no_text": 0,
"pruned_below_min_score": 2,
"dropped_unjudged": 0,
"partial_failures": 0,
"usage": { "input_tokens": 100, "output_tokens": 10 },
"query": "vitamin d supplementation bone density",
"took_ms": 212
},
"took": 215,
"hits": { "total": { "value": 4, "relation": "eq" }, "max_score": 0.9, "hits": [ ... ] }
}Degraded (the provider missed the deadline — see Failure policy):
{
"_rerank": {
"applied": false,
"reason": "rerank deadline exceeded after 301ms",
"provider": "jev",
"score_kind": "engine",
"query": "vitamin d supplementation bone density",
"took_ms": 301
},
"hits": { ... the engine's order, the engine's scores ... }
}_rerank field |
Meaning |
|---|---|
applied |
true: the order is the judge's. false: the order and scores are the engine's; reason says why. Always read this. |
score_kind |
"probability" when _score is a 0–1 relevance probability, "engine" when it is still BM25 / fusion. |
window |
Hits considered: min(rerank.window, hits the engine returned). |
judged |
Documents the provider returned a verdict for. One verdict per document: an answer keyed to a document that was never sent, or sent in another call, is not counted. This is the figure xerj_rerank_documents_judged_total meters. |
unjudged |
Hits in the response that have no verdict — the provider did not answer for them, or they were skipped as blank. Their _score is null and they sort after every judged hit. Without min_score, judged + unjudged equals window. |
skipped_no_text |
Hits in the window with no string content in the response. They are not sent: a blank document costs a judgement and a verdict on nothing means nothing. |
pruned_below_min_score |
Judged hits removed by rerank.min_score. |
dropped_unjudged |
With min_score set, hits that were never judged cannot be shown to clear the bar, so they are dropped and counted here. |
fields_without_text |
Present only when there is something to report: the names in rerank.fields that no hit in the window returned any text for — a typo, a field these hits lack, or a field the projection removed. Those documents were judged without it. |
partial_failures |
Provider calls that did not deliver a
8000
verdict while others did: calls that failed, and calls the deadline stopped from ever being sent. The scored hits are still correctly ordered — each probability is absolute — but the window was not fully covered; unjudged says by how much. |
usage |
Tokens the provider reported, summed over every call that answered. Zero when the provider omits it. |
query |
The question that was judged, inferred or given. |
took_ms |
Time spent in the stage. The response's own took includes it. |
What changes on a hit: _score becomes the probability, and hits.max_score
becomes the top hit's probability. Hits that were not judged get _score: null and sort after every judged hit, in the engine's order — an unjudged
document is not evidence of irrelevance, but it cannot be ranked against
documents that were scored. Equal probabilities keep the engine's order.
_score is one kind per response. When applied is true, every
_score is a probability or null; the engine's BM25 value is never left on
some hits beside probabilities on others. A client that sorts by _score or
scales by hits.max_score cannot tell a 1.63 BM25 from a 0.9 probability,
and max_score would be smaller than a later hit's score. null is what
Elasticsearch itself puts in _score when a hit has no comparable score, so
every client already handles it. _score and hits.max_score carry the
provider's probability at double precision: 0.9 from the provider is 0.9 on
the wire, so a threshold applied client-side agrees with rerank.min_score.
If the provider answered but not for a single document that was sent (every
answer keyed to something else), that is not a reranking: the response is the
degraded shape, applied: false, with the reason, and the operator's meters
count it as degraded, not applied.
The stage owns exactly one thing: the order, and with min_score the
membership, of hits.hits. Everything else in the response stays the engine's
and describes the full match set, not the window. Each row below is pinned
by a test in rerank_stage_http.rs.
| Combined with | Behaviour |
|---|---|
aggs |
Unchanged. Aggregations are computed over every matching document, and are identical with and without rerank — including when min_score prunes the page to nothing. |
hits.total, track_total_hits |
The engine's total. true, an integer cap and false each report exactly what they report without rerank. Four documents matched and two were judged irrelevant: total is 4, and pruned_below_min_score is 2. Both facts are true and the response says both. |
size, from |
Paging happens inside the reranked window. XERJ widens the engine's page to window, judges all of it, then cuts from/size out of the judge's order, so page two continues page one as long as the provider gives the same probabilities on a repeat call — there is no verdict cache, so every page request judges the whole window again (and pays for it again; see Cost and concurrency facts). Whether the real model is deterministic has not been verified by this project. To page without that dependency, ask for the window once (size = window) and page client-side. from + size must be ≤ window, or the request is a 400: a page cut across the window boundary would mix two rankings. |
highlight, fields, inner_hits, matched_queries |
Travel with their hit. The stage moves rendered hits, so nothing is recomputed and nothing is swapped between hits. |
"fields": ["_passage"] |
The matching passage is judgeable, and leads the text the judge reads. Name it in rerank.fields to send only the passage. |
the large-response hint (_xerj.hints) |
Its ready-to-send corrected request keeps the rerank block. A suggestion without it would be a different search, returning the engine's order. Its narrower projection also narrows what the judge is sent. |
_source filtering |
Decides what the judge sees — see the next section. _source: false with no fields, docvalue_fields, stored_fields or script_fields clause is a 400 (nothing can be judged); beside one that returns the text, it works, and the judge reads what came back — with or without rerank.fields: "_source": false, "fields": ["body"], "rerank": {} judges the body values returned under fields. |
explain |
The engine's explanation is kept, unaltered, under a new top node whose value equals the new _score and whose description says the score is a rerank probability. An _explanation.value that disagreed with _score would mislead. |
profile |
Unchanged; it profiles the engine. The stage reports its own time in _rerank.took_ms. |
top-level min_score |
The engine's threshold, applied to engine scores before the window is cut. It shapes the match set and hits.total exactly as it does without rerank, and it is never compared against a probability. rerank.min_score is the probability threshold. The two compose: engine bar first, probability bar second. |
knn (top level) |
Works. A vector is not a question, so a knn with no text query needs rerank.query. Beside a text query the question is read from the text query, and vector-only hits are inside the window the judge sees. Vectors are never sent. |
rescore |
Runs first, inside the engine. rerank has the last word. |
pit (point in time) |
Works: a PIT fixes the snapshot, not the order. Paging it with search_after is refused like any other search_after. |
index.max_result_window smaller than rerank.window |
400, in the caller's terms: `rerank.window` (30) exceeds `index.max_result_window` (20) on `kbsmall`. The stage asks the engine for window hits, so every participating index — including each one a wildcard expands to — must be able to return that many. Lower the window or raise the setting. |
"rerank": null |
The same as no rerank key, on every surface: _search runs without the stage, and an _msearch item or template carrying it is not refused. |
multi-index and wildcard searches (/a,b/_search, /kb*/_search) |
Work. The window is cut from the merged result, so hits from different indices are ranked against each other; _index stays with its _id. |
sort (body or ?sort=) |
400. An explicit sort already fixes the order. |
search_after, collapse, scroll |
400. Each of them depends on the engine's order. |
size: 0 |
400. The provider would be paid to judge documents the response does not return. |
Only POST /{index}/_search (and its GET and no-index forms) runs the stage.
These endpoints answer a body that carries rerank with a 400 that says so,
rather than dropping the key and returning the engine's order under a 200:
| Endpoint | Refusal |
|---|---|
_msearch |
Per item. The item that carries rerank gets a status: 400 entry; the rest of the batch still runs. |
_search/template, _msearch/template |
400 when the rendered template contains rerank (per item for _msearch/template). |
_async_search |
400. A stored search would also be a stored third-party call that nobody is waiting on. |
_search_scroll, ?scroll=, and the _search/scroll continuation |
400. A scroll streams the engine's order. |
_rank_eval |
Per request. A requests[] entry whose request carries rerank is reported under failures by its id and contributes nothing to metric_score; the other requests are still evaluated. _rank_eval runs the engine's ranking only, so scoring it while ignoring the block would publish "reranking changed nothing" about an order the judge never saw. To measure a reranked order, run the _search requests yourself and score the returned ids. |
_count, _validate/query and _explain take a query, not a search body, and
return no ordering; they ignore a rerank key as they ignore every other
search-body key. The native /v1 search API and the gRPC Search RPC refuse the
block the same way the endpoints above do, and "rerank": null is an absent
block on all of them.
The judge sees exactly what the response returns, and nothing else.
Candidate text is read from each hit as the response will carry it: the
projected _source, then the hit's fields. That makes the rule auditable —
whatever you were about to receive is what was sent.
Sent to the provider, per search:
- the question (
rerank.query, or the one read from the query); rerank.instructions, if given, and the model name;- for each hit in the window, cut to
max_doc_charscharacters each:- with no
rerank.fields: thetitlefield if the hit returns one; then the matching passage, if the request asked for"fields": ["_passage"]; then every other top-level string field (and array of strings) in the returned_source; then every string value the hit returns underfields— which is wherefields,docvalue_fields,stored_fieldsandscript_fieldsput theirs — for a name_sourcedid not already return, in name order. A value present in both is sent once. The passage goes first because text is cut atmax_doc_chars: on a long document an appended passage would be the part that gets cut, and it is the part that says why the hit matched; - with
rerank.fields: those fields and nothing else. The list is exhaustive.["body"]does not send the title;["title", "body"]does. A name is looked up in the returned_sourcefirst, then in the hit'sfields."_passage"is accepted as a name and sends the passage text;
- with no
- the provider key, as a bearer token in the
Authorizationheader and nowhere else. If the provider echoes it back in an error body, XERJ redacts it before the body reaches the search caller or the log: whoever runs a search is not necessarily the operator who owns the key.
Never sent: document _ids and index names (documents are keyed d0, d1, … by
position), numbers, booleans, vectors, nested objects, and any field the
response does not return.
Reranking is the only thing a search request can do that sends document
text off the node. It is not the only outbound connection a node can make. This
is the complete list, taken from the engine source — every outbound HTTP or TCP
client under engine/crates — and kept complete by a test
(engine/crates/xerj-rerank/tests/egress_inventory.rs) that fails when a new
client appears in a source file this list does not account for:
| Path | What leaves the node | When | Default |
|---|---|---|---|
Rerank provider — [rerank], TYPESAFE_API_KEY / TYPESAFE_ENDPOINT |
The question, rerank.instructions, the model name, and the returned text of up to window hits (above) |
A search that carries a rerank block |
Off: no key is configured |
Proxy embeddings — [embedding] default_endpoint, --embed-mode proxy |
Document text of the fields it embeds, and the text of queries it embeds | Document text at write time (index, _bulk, update, reindex); query text at search time, for every query it embeds (semantic, hybrid, semantic memory recall) |
Off: default_endpoint is empty |
WAL tap — [wal_tap], PUT /_xerj/wal_tap |
Every write on the tapped indices (indexed documents and deletes), as _bulk to {target_url}/_bulk; never system indices |
Continuously, every poll_interval_ms |
Off: enabled = false |
Neural model download — --embed-mode neural |
HTTPS requests to the HuggingFace Hub naming the model (config.json, tokenizer.json, model.safetensors); no document or query text |
The first time the neural embedder loads without embedding.local_model_dir; later starts read the local cache |
Off: the default embedder is lexical |
Cluster transport — [cluster] enabled = true |
Raft messages to the configured peers: index names, mappings, shard assignments, node addresses, cluster settings; no document text and no queries |
While cluster mode runs | Off: single node |
Object storage backend — [storage], S3Backend |
S3 requests to the configured endpoint: bucket and key names, ranged reads, and the credentials from the environment. The index bundles it writes when it is the active home DO carry stored document text — no query text ever leaves | Since v1.0.0-rc.77 (#1008 closing #965): one immutable ZBM1 bundle per segment family plus snapshot.json, when storage.backend = "s3" and storage.s3_bucket names an existing bucket |
Off: the default backend is local |
xerj autoindex s3:// (client, not the node) — --endpoint-url, AWS_* |
S3 requests to the configured endpoint: bucket and key names and the credentials; the objects travel INTO the machine, and their text then goes to the node URL you gave the client | While that command runs | Off: only when you pass an s3:// root |
xerj autoindex s3:// --watch (client, not the node) — --poll-interval, --endpoint-url, AWS_* |
The same S3 requests as above, repeated every poll interval: a ListObjectsV2 page per cycle plus a GET per changed object. Billed to the operator's object-store account, so the run keeps a spend ledger and stops at its budget |
Every --poll-interval (default 300 s) while the watch runs |
Off: only with --watch on an s3:// root |
xerj corpus build (client, not the node) — recipe [[sources]] url |
HTTPS GETs to the source URLs your recipe names (one per http-zip source per build, plus the git clone/fetch a git source runs). Records travel INTO the machine; no document text and no credentials are sent upstream — it is a read-only fetch |
While xerj corpus build runs |
Off: only when a recipe declares an http-zip or git source |
xerj hub pull (client, not the node) — XERJ_HUB_URL |
GETs to the hub (https://hub.xerj.org unless XERJ_HUB_URL names another): the signed corpus catalogue (a conditional GET that carries the ETag of your cached copy, when there is one) and its .sig, then the one pack you named and its .sig. The requests name the catalogue and that pack, so the hub can see which corpus you pulled. Packs travel INTO the machine; no document text, no query text and no credentials are sent upstream — it is a read-only fetch. The pack is then restored into the node URL you gave the client |
While xerj hub pull runs; a catalogue cached within the last 24 hours is reused without a request |
Off: only when you run xerj hub pull |
xerj hub sync (client, not the node) — XERJ_HUB_URL, --install-timer |
The signed corpus catalogue and its .sig from the hub, as for xerj hub pull but on every run (a conditional GET that carries the ETag of your cached copy, when there is one), not reused for 24 hours. For each pack you already hold whose pin moved, xerj hub sync then runs xerj hub pull: the new pack and its .sig, from the pack_url the signed catalogue names (a GitHub release asset in the published catalogue). The requests name the catalogue and the packs you update, so the hub and GitHub can see which corpora you hold. Packs travel INTO the machine; no document text, no query text and no credentials are sent upstream — it is a read-only fetch |
While xerj hub sync runs: once when you run it, and every 6 hours for as long as the systemd user timer or launchd agent that xerj hub sync --install-timer writes is enabled |
Off: only when you run xerj hub sync, or enable the timer it wrote (the command writes the file and enables nothing) |
xerj hub feedback and the automatic corpus reviews (client, not the node) — XERJ_CORPUS_FEEDBACK_URL, --disable-feedback / XERJ_DISABLE_FEEDBACK |
One JSON POST to the hub (https://hub.xerj.org/api/corpus-feedback unless XERJ_CORPUS_FEEDBACK_URL names another): the corpus slug and pack pin, the agent family and version, scores (usefulness, found, freshness), problem kinds and a short note, coverage gaps, the query's shape with every word outside the template replaced by <term>, and a random install id made on the machine. No document text, no query text and no credentials |
Automatically after xerj hub pull (whether it worked and whether it was slow) and after the MCP hub tools answer, at most once per corpus per week and never holding the command longer than 4 s; on demand from xerj hub feedback and the xerj_corpus_feedback MCP tool |
On for the hub commands above; --disable-feedback or XERJ_DISABLE_FEEDBACK=true sends nothing |
A node started with the defaults opens none of these connections, and makes no telemetry, update-check or licence call. Two things outside the node complete the picture:
- The Console in a browser asks Google Fonts for its typefaces. That is the browser's request, not the node's, and it carries no index data; the air-gapped recipe names the three pages that link the fonts, and a blocked request falls back to system fonts.
- The command-line clients in the same binary (
xerj autoindex,xerj mcp,xerj batch-search,xerj search,xerj code,xerj corpus list,xerj huband the others) send what they read to the node URL you give them,http://localhost:9200unless you say otherwise. On an empty corpus ledger,xerj codeandxerj corpus listalso POST one catalog search to that same node URL (autoindex run prefixes, and the API key when one is configured); no document text and no user query. After a successful search,xerj searchand MCPxerj_searchPOST one catalog search to that same node URL (the autoindex run for the prefix, and the API key when one is configured) so a folder older than its files can be named; no document text and no user query.xerj batch-searchsends the batch's queries or identifiers to/_msearchand optionally reads index mappings at that node, with the configured API key when present (--url/--authoverrideXERJ_URL/XERJ_AUTH).xerj feedback --open-prruns yourghto open a GitHub pull request with the report it drafted; without that flag it does nothing over the network.
So _source filtering is also an egress control:
{ "query": { "match": { "body": "refund" } },
"_source": ["title"],
"rerank": {} }sends titles only. The body text is not in the response, so it is not in the
request to the provider either.
To send the matching passage and nothing else — usually the best trade on long documents, for both the judge and the amount of text that leaves:
{ "query": { "match": { "body": "refund" } },
"_source": ["title"],
"fields": ["_passage"],
"rerank": { "fields": ["_passage"] } }Whether judging the passage ranks better than judging the head of the document has not been measured; what is certain is that less text is sent.
What was returned is checked on the rendered hits, not predicted from the request, so a document is never judged blind:
rerank.fieldsnames a field that_sourcefiltering removes andfieldsdoes not request: 400 before the search runs, naming the field;- no hit in the window returns any text to judge: 400
"nothing to judge", and nothing is sent; - some named fields return text and others return none anywhere in the window:
the search proceeds, and
_rerank.fields_without_textlists the ones that contributed nothing.
Known limitation: today a fields entry is dropped when an explicit _source
includes list omits that field (#932),
so "_source": ["title"], "fields": ["body"], "rerank": {"fields": ["body"]}
is refused as nothing to judge. "_source": false, "fields": ["body"] works.
To rule reranking out entirely on a node, set [rerank] enabled = false.
A blanket "fall back to the engine's order on any error" would be the wrong default. A caller who asked for calibrated relevance and silently got lexical order has been misled. So the policy is split:
| What happened | Result |
|---|---|
The provider did not answer inside timeo
681C
ut_ms |
Degrade. HTTP 200, the engine's order and scores, _rerank.applied: false with the reason. A slow third party is not a reason to deny the caller results it already has. |
rerank.provider is none / disabled |
Degrade, same shape. |
Malformed rerank block, unknown provider, a refused combination |
400 illegal_argument_exception. Nothing is sent. |
The operator set enabled = false |
403 rerank_exception. Nothing is sent. |
| No key configured | 503 rerank_exception, naming [rerank] api_key and TYPESAFE_API_KEY. Nothing is sent. A search without rerank on the same node is unaffected. |
| Provider answered 401 / 403 / another 4xx | 502 rerank_exception with the provider's status. Not retried. |
| Provider answered 200 with a body that is not the documented shape | 502 rerank_exception. |
| Provider answered 200 with a body over 2 MiB | 502 rerank_exception ("larger than 2097152 bytes"). A real answer for 30 documents is a few kilobytes; the node stops reading at the ceiling instead of letting the endpoint choose how much memory a search allocates. |
| Provider unreachable | 502 rerank_exception. |
| Provider answered 429, 529 or 5xx | Retried, up to 3 attempts per call, with a short exponential back-off and an extra pause after a 429 — but never past the deadline. If the deadline runs out first, degrade; if the retries run out first, 502. |
Contract and configuration faults do not fix themselves, and hiding them costs the operator more than it saves. A surfaced fault returns no hits: handing back the engine's order next to an error would invite a caller to ignore the error.
If some provider calls succeed and others fail, or the deadline stops later
calls from being sent, the stage keeps what it has: the judged hits are ordered
by probability, the rest follow with _score: null in the engine's order, and
partial_failures says how many calls did not deliver.
The split follows the one Meilisearch uses for its reranking call in its
personalization module (return the original hits when the deadline is exceeded,
return the error otherwise). The approach, and the retry back-off constants,
were adapted from that module (MIT); the code comment in xerj-rerank cites
the lines.
Every measurable fact in this section — the 30-doc batch arithmetic (
window: 35→ two calls,window: 300→ ten), the no-verdict-cache behaviour, the 8/16 in-flight ceilings, the degrade-on-deadline response and the no-key 503 — is reproduced end-to-end, against a local test double, byscripts/bench/rerank-stage.sh(9 contract assertions; last run's output is inscripts/bench/README.md).
- 30 documents per provider call. The
hev/jev-rerankREADME reports a ~32k-token request budget, "~30–50 typical passages per call", and uses 30; XERJ uses the same figure and has not measured the provider's limit itself. A larger window is split:window: 35is two calls (30 + 5), andwindow: 300, the maximum, is ten. - Scores from different calls are comparable, because each answer is an absolute probability rather than a rank within its batch. That is what makes splitting safe.
- 8 calls in flight by default, 16 at most, sent in waves. The provider's rate limits are undocumented; the same README reports sustained 429s at about 24 requests in flight (their observation, not ours), and both ceilings sit under that. A search that provokes a 429 is slower than one that paced itself.
- One question is asked per document rather than one multiple-choice
question across them. Real corpora have more than one relevant document, and a
multiple-choice question forces a single winner. This one-
noul-per-document approach is the one used byhev/jev-rerank(Apache-2.0). - The node keeps one HTTP connection pool for the provider, so consecutive searches do not pay a TLS handshake each.
- Every request is judged from scratch. There is no verdict cache: three
page requests (
from0, 10, 20) over one 30-document window are three provider calls and 90 paid judgements, not 30. Fetch the window once and page client-side when cost or page-to-page consistency matters. _rerank.usageis the meter reading for the search. XERJ does not price it. It is advisory: a provider that reports a token count that is not a non-negative number meters as zero for that count, and never costs the caller a ranking whose verdicts were all valid.
xerj_search and xerj_hybrid_search take an optional rerank argument with
the same fields. true or {} means defaults.
- With a plain-string
query, the MCP server passes your string asrerank.query, because the plain-string path expands into aboolquery that the node will not read a question from. xerj_hybrid_searchrequiresrerank.query: a hybrid search has several sub-queries and no single question.- The MCP server cannot supply a provider key. Without one on the node the tool
call returns the node's 503; repeat the search without
rerank.
XERJ has run the Jev model through this stage — once, as a pilot (2026-09-20;
rows below). The figures for Jev, Voyage and Cohere in the first table are
still published in the hev/jev-rerank
README, were not run by this project, and rerank that project's own first-stage
shortlist, which is not XERJ's. Same datasets, same metric, not a controlled
comparison — the second table is.
nDCG@10 on the BEIR test split:
| SciFact | NFCorpus | Run by | |
|---|---|---|---|
| Jev (TypeSafe AI), 30 Nouls in one request — the shape this stage sends | 0.768 | 0.358 | hev/jev-rerank README — not by us |
| Voyage rerank-3 | 0.755 | 0.357 | hev/jev-rerank README — not by us |
| Cohere rerank-v3.5 | 0.745 | 0.340 | hev/jev-rerank README — not by us |
| XERJ BM25 | 0.6572 | 0.3016 | us |
| XERJ MiniLM vectors only | 0.6764 | 0.3291 | us, --embed-mode neural |
| XERJ BM25 top-30, reordered by MiniLM | 0.6855 | 0.3323 | us, --embed-mode neural |
| XERJ hybrid RRF | 0.6993 | 0.3448 | us, --embed-mode neural |
Pilot run by this project (2026-09-20): first 40 judged SciFact test queries,
BM25 top-30 shortlists from the default lexical embedder (no vectors), model
pinned jev-1.13.0, scoring byte-identical to benchmarks/beir-hybrid:
| SciFact (40 queries) | Run by | |
|---|---|---|
| XERJ BM25 shortlist | 0.7750 | us, controlled |
XERJ BM25 → this stage, jev-1.13.0 |
0.8299 | us, controlled |
| the same, request shape as shipped before this change | 0.3822 | us, controlled |
| raw provider API, fixed shape, same shortlists | 0.8389 | us, controlled |
Paired vs BM25: 9 wins / 6 losses / 25 ties; 1,200/1,200 documents judged, no
degrades; stage latency p50 399 ms. The provider is not deterministic
(re-asking the same window moves probabilities by up to ±0.05), which is why
the stage and raw-API rows need not agree to the digit. Per-query logs and the
runner: benchmarks/beir-hybrid/results/2026-09-20-rerank-pilot/.
Our rows were measured with --embed-mode neural and the
all-MiniLM-L6-v2 model, not with the default embedder. XERJ's default
embedder is lexical feature hashing, which has no model in it; do not read the
vector or hybrid rows as what a default node scores. The BM25 row uses no
embedder at all. Method, raw output and the reproduce commands are in
benchmarks/beir-hybrid.
Two things those runs do say:
- Reordering a BM25 shortlist with the same bi-encoder scored below fusing with it, on both datasets. That is why there is no "local MiniLM" rerank provider: it would be a regression with a good name.
- Where a team already has labelled history, some yes/no and pick-one decisions
need no judge model at all —
benchmarks/decisions-as-retrievalmeasures that, also under--embed-mode neural.
- The one-
noul-per-document request shape:hev/jev-rerank, MIT. - The degrade-on-deadline, surface-on-contract failure policy, and the retry
back-off shape (exponential in the attempt, a flat extra pause after a 429):
Meilisearch's personalization module (MIT, not part of its Enterprise
Edition). Approach and constants adapted; cited in
xerj-rerank/src/lib.rs. - The provider API is TypeSafe AI's System One
(
POST /v1/systemone, https://docs.typesafe.ai/api).