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.
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. ~4× smaller vectors, 1–2% recall loss. The one quantizer the serving path reads.
- 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.
Known gap — a mapping's quantization value is not validated (#275). Startup refuses an unimplemented [vector] default_quantization, but the per-field mapping key does not: "quantization": "scalar4" — or "binary", or a typo such as "sq8" — is accepted with a 200, echoed back verbatim by GET /_mapping, and then ignored, so the field is stored at full-precision f32 while the mapping you read back says otherwise. Measured on rc.14. Until it is a 400, "scalar8" (or "int8", or index_options.type: int8_hnsw/int8_flat) is the only value that changes anything, and reading the mapping back is not a way to confirm it took.
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)