Search plugin deployment (post-P12)¶
Since the P12 pivot (#4598), the Python in-process search daemon is gone. The Rust nexus-search-plugin cdylib, hosted by nexusd-cluster and reached over gRPC, is the sole search backend for the Nexus server. A server deployment that does not run the plugin host boots with search disabled — the boot probe fail-softs (logs a warning, /api/v2/search/* returns 503) rather than failing the boot.
This page is the deployment contract: which processes you run, how they are wired, and what changed for corpora indexed under the pre-P12 stack.
Required processes¶
| Process | Image | Role |
|---|---|---|
| Nexus server | ghcr.io/nexi-lab/nexus | FastAPI/gRPC server; proxies search calls to the plugin |
| Plugin host | ghcr.io/nexi-lab/nexusd-cluster | nexusd-cluster with the signed nexus-search-plugin cdylib in /plugins; owns FTS + ANN indices and embeddings |
Both images are published from the same commit with the same tag set (edge on develop/main, stable/latest/semver on version tags, plus the commit SHA), so a deployable pairing is pinnable by digest. Publishing is done by .github/workflows/docker-publish.yml (#4613); the e2e-edge smoke job runs the exact published plugin-host image, not an in-CI build.
The plugin-host image is reproducible locally from a nexus + nexus-vfs checkout (the nexus-vfs rev is pinned in the workspace Cargo.toml):
docker build \
-f dockerfiles/Dockerfile.nexusd-cluster-plugins \
--build-context nexus-vfs-src=../nexus-vfs \
--secret id=plugin_signing_key,env=PLUGIN_SIGNING_PRIVKEY \
-t nexusd-cluster:latest .
Wiring¶
- Start the plugin host with the plugin dir enabled:
docker run -d --name nexus-cluster \
-p 2126:2126 \
-v nexus-workspace:/workspace \
ghcr.io/nexi-lab/nexusd-cluster:stable \
--bind-addr 0.0.0.0:2126 \
--data-dir /app/data \
--plugin-dir /plugins
(--no-tls --insecure-no-auth are available for trusted loopback / compose topologies; production should keep TLS + auth on.)
- Point the server at it:
The server's boot probe (server/lifespan/search.py) health-checks the target with a 5 s timeout. Unreachable ⇒ search disabled for the whole server lifetime with a loud warning; fix the wiring and restart the server.
- Shared filesystem: on
Index/Refresh(directory walks) the plugin reads files by absolute path through its own VFS/filesystem — the server and the plugin host must see the same bytes at the same paths (shared volume in Docker, same host mount, or the kernel VFS in federated topologies).IndexDocuments(whatfiles/writeindex: true,search/refreshandsearch/indexuse) carries the text inline and needs no shared filesystem.
Write-to-searchable contract (#4736)¶
A write does not index. The server relays nothing to the plugin on a write: the plugin's NotifyFileChange carries no text, so a create / update event would be a pure ack (it answers skipped and mutates nothing), and the server's VFS hooks only send delete for deleted and renamed paths. The plugin has no background poller or scheduler: text enters the index only through an explicit indexing call. A document written with a bare POST /api/v2/files/write is therefore never searchable, however long you wait — pair the write with one of the calls below. The same holds for overwrites: writing new bytes to an already-indexed path leaves the previous text searchable until the path is re-indexed, so pair every write of an indexed file with index: true or a refresh.
All indexing calls are synchronous: the response returns after the plugin has committed, so there is no "queued" state to poll for.
Every indexing call indexes into the caller's token zone — the same zone GET /api/v2/search/query reads (the ?zone= override on files/write wins when given) — so what you index is what your queries hit. GET /api/v2/search/stats is scoped the same way and echoes the zone as zone_id.
| Call | What it indexes | Returns |
|---|---|---|
POST /api/v2/files/write with "index": true or "index": {"text": "…"} | The file just written, in the same request | index.status: indexed + index.index_seq, skipped + reason, or error |
POST /api/v2/files/batch/write with request-level "index": true and/or per-file index | Every requested file, one plugin round-trip after the writes land | per-result index verdict |
POST /api/v2/search/refresh?path=P&change_type=update | One existing path, read server-side through the VFS with the caller's permissions | 200 {"status": "indexed", "index_seq"}; 404 missing; 409 {"status": "skipped", "reason"} |
POST /api/v2/search/refresh?path=P&change_type=delete | Evicts one path (chunks dropped, tombstone recorded) | 200 {"status": "deleted", "index_seq"} |
POST /api/v2/search/index {"documents": [{"path", "text"}]} | Caller-supplied text, any number of documents | count, skippedCount, skippedPaths, indexSeq |
nexus search index <dir> / plugin Index + Refresh RPCs | A directory walk through the plugin host's VFS | counts |
POST /api/v2/admin/reindex with target all or search (admin) | Every path in the replayed operation log: deletes evict, the rest are read server-side and indexed in bounded batches (non-UTF-8 or > 2 MiB files are skipped) | search_paths_indexed / _deleted / _skipped (+ reason histogram), search_index_errors + failed paths, search_index_seq |
index: true on a write and search/refresh decode the written bytes as UTF-8. A binary payload is reported skipped with a reason instead of being silently acknowledged — pass index: {"text": "<extracted text>"} to index it. Empty or whitespace-only content is skipped too (409 on refresh).
When index is requested and the search plugin is unreachable, the write fails with 503 before anything is written, so a write+index request never half-succeeds. A plugin failure after the write landed is reported as index.status = "error" (HTTP 200: the write itself succeeded); re-drive indexing with search/refresh or search/index.
Confirming a document is served¶
Every indexing call returns the plugin's index_seq: a plugin-wide, persisted, monotonic sequence stamped after the commit that made the batch visible. GET /api/v2/search/stats reports:
| Field | Meaning |
|---|---|
last_index_seq | Sequence of the last committed index mutation. last_index_seq >= <your index_seq> means your batch is served. |
pending | Documents accepted by in-flight search/index / write+index calls and not yet returned. |
last_successful_index_at | ISO-8601 UTC instant of the last committed mutation (null = never). last_index_refresh carries the same instant as float epoch seconds for pre-P12 pollers. |
indexing_in_progress | In-flight Index / IndexDocuments / Refresh operations (#4623). |
zone_id | The zone these counters describe: the caller's token zone, or the root zone for a token-less poller. |
Stall signature — the #4725 class, index acknowledged but nothing served: pending > 0 or indexing_in_progress > 0 for longer than one indexing call should take, while last_successful_index_at does not advance. Alert on that instead of running a synthetic write probe.
The sequence lives in <data>/plugins/search/index_seq.json and survives plugin-host restarts, so a seq captured before a restart still compares correctly afterwards.
Environment reference (plugin host)¶
Set these on the nexusd-cluster process — the plugin reads process env directly.
| Variable | Default | Meaning |
|---|---|---|
NEXUS_PLUGIN_DIR | /plugins (in image) | --plugin-dir scan target |
NEXUS_SEARCH_MODEL_DIR | <data>/plugins/search/models | Local embedding model directory (see below) |
ORT_DYLIB_PATH | (unset) | Absolute path to onnxruntime.{so,dylib,dll}; required for local semantic search |
NEXUS_SEARCH_BATCH_CONCURRENCY | 4 (clamp 1..=16) | BatchQuery inner-query concurrency (#4610) |
NEXUS_SEARCH_EMBED_API_URL | (unset) | OpenAI-compatible /v1/embeddings endpoint; presence selects the remote embedder (#4614) |
NEXUS_SEARCH_EMBED_MODEL | — | Remote model name (required with URL) |
NEXUS_SEARCH_EMBED_DIM | — | Remote embedding dim (required with URL) |
NEXUS_SEARCH_EMBED_API_KEY | (unset) | Bearer token for the endpoint |
NEXUS_SEARCH_EMBED_TIMEOUT_SECONDS | 30 | Per-request timeout |
And on the server:
| Variable | Default | Meaning |
|---|---|---|
NEXUS_SEARCH_PLUGIN_TARGET | 127.0.0.1:2126 | gRPC dial target for the plugin |
NEXUS_SEARCH_DAEMON | auto (on when a DB URL is present) | true/false force search proxy on/off |
NEXUS_SEARCH_PLUGIN_TLS | (unset) | true ⇒ TLS channel to the plugin host |
NEXUS_SEARCH_PLUGIN_TLS_CA | (system roots) | CA bundle path for server verification |
NEXUS_SEARCH_PLUGIN_TLS_CERT / _KEY | (unset) | Client cert+key pair for mTLS |
NEXUS_SEARCH_PLUGIN_ALLOW_INSECURE | (unset) | Explicit opt-in for plaintext to a non-loopback target (trusted network only) — without it the server refuses the channel and boots with search disabled |
Transport security: plaintext is only accepted to same-machine targets — loopback addresses and Docker's host.docker.internal alias (which by construction resolves to the machine running the container). A cross-host deployment must either terminate TLS on the plugin host (pair with NEXUS_SEARCH_PLUGIN_TLS* on the server) or explicitly set NEXUS_SEARCH_PLUGIN_ALLOW_INSECURE=true and accept that anyone on the network path can read queries and index content. There is NO blanket default for the opt-in: changing NEXUS_SEARCH_PLUGIN_TARGET to a remote host fails closed (search disabled with a loud boot warning) until you choose TLS or plaintext deliberately.
Plugin signing¶
The kernel refuses unsigned plugins — PluginLoader verifies an Ed25519 detached signature (<plugin>.so.sig next to the dylib) against compile-time trusted keys. The published image ships the signed pair; if you build your own, sign with scripts/sign_plugin.py and a key the kernel trusts (CI uses the PLUGIN_SIGNING_PRIVKEY secret). An unsigned or foreign-signed plugin is skipped at --plugin-dir scan time and search stays down.
Embedding backends and model provisioning¶
Keyword (BM25/tantivy) search works out of the box. The semantic / hybrid lane needs an embedder, one of:
Local ONNX (default)¶
The plugin loads multilingual-e5-small (384-dim) from NEXUS_SEARCH_MODEL_DIR. Provision the directory with the layout a huggingface-cli download intfloat/multilingual-e5-small produces:
and set ORT_DYLIB_PATH to a matching ONNX Runtime library (1.19+ for the pinned ort 2.0 rc). Neither the model nor the ORT dylib is baked into the published image (size + licensing); mount them and set the two env vars. Missing model/dylib degrades gracefully: semantic queries return a typed "unavailable" error, keyword search keeps working.
Remote / API (#4614)¶
Set NEXUS_SEARCH_EMBED_API_URL + _MODEL + _DIM (+ _API_KEY) and the plugin embeds via any OpenAI-compatible endpoint instead of local ONNX — no model files or ORT dylib needed. This is the migration path for deployments whose corpus quality was tuned on API embeddings (e.g. 1536-dim models) under the pre-P12 stack. A configured remote endpoint wins over the local model; a partially configured one (URL without model/dim) fails loudly rather than silently falling back.
Migrating a pre-P12 corpus¶
The pre-P12 stack embedded via API models into pgvector; the post-P12 plugin owns its own tantivy (FTS) + HNSW (ANN) indices under <data>/plugins/search/<zone>/. There is no index migration — a corpus indexed under the previous stack must be fully reindexed after the upgrade (nexus reindex --target search, then nexus search index <path> or your indexing pipeline).
Choose the embedding backend before reindexing:
- Local mE5-small (384-dim): zero external dependencies, but a different (generally weaker) vector space than 1536-dim API models — re-validate retrieval quality and fusion tuning (
alpha, etc.) on your own eval set. - Remote/API embedder pointed at your previous model: preserves the vector space your tuning was measured on. The ANN index is still rebuilt (different storage engine), but embedding quality is unchanged.
The ANN directory tag encodes the embedder (ann-<tag>-v<n>, e.g. ann-mE5-small-v1-v2 or ann-api-text-embedding-3-small-1536-v2), so switching embedders later re-tags and rebuilds alongside the old directory instead of corrupting it.