04 · DATA MODEL

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

KEY
TYPE
DEFAULT
DESCRIPTION
hnsw_m
u32
16
Accepted and validated; the built-in HNSW graph currently builds with fixed M=16 — this key is not yet wired to the build.
hnsw_ef_construction
u32
200
Accepted; must be ≥ hnsw_m. The graph currently builds with fixed ef_construction=200 — this key is not yet wired to the build.
hnsw_ef_search
u32
100
Accepted but not read by the serving path — the ANN beam width comes from the request's num_candidates (floored at 800).
default_metric
enum
"cosine"
"cosine" · "dot_product" · "euclidean".

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.

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)