Vectors & kNN
XERJ stores your vectors in full at ingest time and maintains a persisted HNSW graph alongside them. An unfiltered knn on a full-precision cosine field (≥1,024 docs) is served by an approximate HNSW beam search whose candidates are exact-rescored, so returned scores match the exact path bit-for-bit — measured recall@10 1.00 on the official bench query, 100-probe mean 0.976 (ES 8.13.4 same protocol: 0.937). num_candidates sets the beam width (floored at 800 to match Elasticsearch's per-segment candidate semantics). A filter, non-cosine similarity, SQ8 quantization, a small index, or a stale graph falls back to the exact brute-force scan: every candidate is scored and the true nearest neighbours returned (recall 1.00; latency scales with vectors scanned). Distance metric and quantization are set in config. See KNN query below.
Embedding backends — how vectors get made
Everything above assumes a vector already exists on the doc. XERJ can produce that vector for you from a semantic_text field, and you pick how once at the server with --embed-mode (or embedding.mode in config, or XERJ_EMBED_MODE). The mapping and the semantic query are identical across all three:
all-MiniLM-L6-v2) running in-process via candle — pure Rust, no Python, no external service. The model auto-downloads (~90 MB) once, or point embedding.local_model_dir at a pre-staged copy for a fully air-gapped install. Captures meaning with no shared term (rotate ≈ regenerate)./v1/embeddings (OpenAI, Cohere, a local vLLM). Set embedding.default_endpoint. Embedding happens at ingest time.Turn on the built-in neural embedder with a single server flag — no mapping or query change:
xerj --insecure --data-dir ./data --embed-mode neural
See the Semantic search & RAG recipe for the end-to-end walkthrough.
hnsw_* config keys — accepted, no effect on search
Quantization
Quantization is off by default — [vector] default_quantization = "none", full-precision f32. Opt in per field with index_options.type: int8_hnsw (or int8_flat) on the mapping, or index-wide by setting the key.
- none — full-precision f32. The default. Use when recall matters more than RAM.
- scalar8 — 8-bit per dimension, 1–2% recall loss. The one quantizer the serving path reads. It buys precision, not memory: the scan reads the full-precision vector from
_sourceand quantizes it per query, so nothing shrinks in RAM, and ascalar8_score(and the order it produces) depends on the candidate set — see the quantization recipe and #392. - binary — 1-bit per dimension. Not implemented: the server refuses to start on
default_quantization = "binary"rather than silently storing full precision while claiming a 32× saving.
A 4-bit quantizer exists in the vector crate but no config or mapping value reaches it, so scalar4 is not a mode you can select in this build.
A mapping's quantization value is honoured or refused — never accepted and ignored (#275, fixed). "scalar8" (alias "int8") and "none" are the values this build implements. Anything else — "binary", "scalar4", "int4", or a typo such as "sq8" — is now a 400 mapper_parsing_exception naming the value and the accepted set, on both PUT /<index> and PUT /<index>/_mapping, and the index is not created. Before the fix those values answered 200, were echoed back verbatim by GET /_mapping, and were then ignored, so the field stored full-precision f32 while the mapping you read back to confirm it said otherwise. This is the same guard Config::validate applies to [vector] default_quantization at startup, now at the per-field surface that sets the same field.
The strictness is confined to XERJ's own quantization key. Elasticsearch's index_options.type stays permissive — hnsw, flat, int4_* and bbq_* mappings still create, served by the exact f32 scan — because an ES mapping must not be rejected on a wire-compatibility surface.
KNN query
{
"knn": {
"field": "embedding",
"query_vector": [0.12, 0.08, -0.31, ...],
"k": 20,
"num_candidates": 200
}
}
num_candidates is the ANN beam width on the HNSW-served (unfiltered, ≥1,024-doc) path — larger values trade latency for recall, floored at 800 to match Elasticsearch's per-segment candidate semantics. On the exact scan (filtered kNN, small indexes, SQ8 fields) it has no effect on results.
Hybrid — BM25 + KNN in one planner pass
{
"hybrid": {
"fusion": "rrf",
"queries": [
{ "match": { "message": "kernel panic on reboot" } },
{ "knn": { "field": "embedding", "query_vector": [...], "k": 50 } }
]
}
}
Source · engine/crates/xerj-engine/src/index.rs (run_knn_hnsw + exact fallback)