Vector query (kNN)

Vector query (kNN) #

Finds the k nearest neighbors of a query vector — similarity search over dense_vector / sparse_vector fields. The query vector can be supplied directly, or as natural-language text that the serving layer embeds with the field’s configured model (schema-driven semantic search).

How it works #

Execution depends on where the documents live:

  • Realtime (not yet flushed) — an exact brute-force scan over the mutable store: every vector is scored, a size-k min-heap keeps the best, and a filter (see below) is tested before any distance is computed.
  • Flushed segments — the field’s index_type decides: flat scans the stored f32 vectors (exact, O(N·dims)); the graph types (hnsw_flat, turbo_quant_hnsw, bbq_hnsw) traverse the persisted HNSW graph with distances computed on the flat vectors — scores stay exact and only an ef-bounded neighborhood is evaluated; the quantized scans (turbo_quant, bbq_flat) score compact codes for a k × oversampling candidate pool and rescore it with full precision (approximate). The per-type breakdown is in Index types.

Scores follow the field’s similarity (overridable per query, but it must match the schema): cosine is the cosine similarity in [-1.0, 1.0]; dot_product the raw dot product; l2 scores as the negated squared Euclidean distance — 0 means identical vectors. Higher is always more similar, so one top-k heap serves all three.

k bounds the vector leg, the request’s size bounds the page — a standalone vector query returns min(k, size) hits. Setting min_score changes the shape entirely: every vector above the threshold is returned, no heap, no k.

Filters (bool with the vector clause in must and other clauses in filter) never silently lose results: the brute-force paths test the filter first and skip the distance; the graph path widens its beam to roughly k / selectivity (clamped to the segment size) and post-filters; and when a filter passes under ~5% of the segment and supports ordered iteration, the engine switches to a filter-led exact scan over just the filter’s documents — cheaper than widening the beam a thousandfold.

When the request carries "query" text instead of a vector, the serving layer embeds it through the field’s configured model before execution (see embedding endpoints).

Examples #

POST /my-collection/_search
{
  "query": {
    "vector": {
      "field": "embedding",
      "query_vector": [0.12, 0.45, 0.1, 0.0, -0.3, 0.2, 0.05, -0.1],
      "k": 10
    }
  }
}

Text in, embedding server-side (requires an embedding endpoint — see below):

POST /my-collection/_search
{
  "query": {
    "vector": {
      "field": "embedding",
      "query": "find documents about search engines",
      "k": 10
    }
  }
}

Embedding endpoints (server-side inference) #

Endpoints are declared in pizza.yml under node.embedding — the full key reference lives in the configuration page.

Pizza embeds text through external, OpenAI-compatible embedding services — configure them once and both the write path (schema-driven) and the search path (text queries) work with plain text:

node:
  embedding:
    endpoints:
      - name: openai
        url: "https://api.openai.com/v1/embeddings"
        api_key: "sk-…"
        models: ["text-embedding-3-small"]
      - name: local
        url: "http://127.0.0.1:11434/v1/embeddings"   # Ollama
        models: ["nomic-embed-text"]

Each models entry may also declare its dims, its features (embedding, the default, or generation — served through the POST /_chat facade once the endpoint declares chat_url) and its inputs (text, the default, or image) — see AI Services.

  • Write time — a dense_vector field mapped with vector_options.source (a text field) and model derives its vector from the source text when the document does not supply one; explicit client vectors always win.
  • Search time — a vector query (or hybrid retrieve vector leg) carrying "query" text and no vector is embedded before execution. The model comes from the field schema, so the search must target exactly one collection.
  • API — POST /_embedding exposes the same capability with the standard OpenAI request/response shapes ({"model", "input"} in, the data[].embedding list out).
  • A model id routes to the endpoint listing it; with exactly one endpoint configured, every model routes there (the backend decides what it serves). Schema declarations (semantic_text, semantic multi-fields, source-mapped dense_vector) are stricter: the model must be listed by exactly one service, or the service must be named — see AI services.

Declarative form — semantic_text #

The simplest wiring is the semantic_text field type: one declaration, plain-text writes, pure-semantic search (no inverted index on the field):

PUT /my-collection
{
  "schema": {
    "properties": {
      "body": {
        "type": "semantic_text",
        "service": "zai",
        "model": "GLM-4.6V",
        "dims": 3072
      }
    }
  }
}

dims may be omitted when the service’s models entry declares it; service disambiguates same-named models across services.

Declarative form — the semantic multi-field #

The explicit form underneath: declare a dense_vector sub-field on the text field itself, and the parent becomes the source implicitly (adding "index": false on the parent reproduces the pure-semantic semantic_text layout; by default the parent stays BM25-searchable — hybrid out of the box):

PUT /my-collection
{
  "schema": {
    "properties": {
      "body": {
        "type": "text",
        "fields": {
          "embedding": {
            "type": "dense_vector",
            "vector_options": { "model": "text-embedding-3-small", "dims": 1536 }
          }
        }
      }
    }
  }
}

Writes stay plain-text ({"body": "some text"}) — the vector derives server-side into body.embedding, and vector/knn queries naming the parent auto-rewrite to the sub-field (the plain dotted path works too).

Preconditions, each enforced with a 400 at schema-apply time:

  • The parent must be a top-level text or keyword field.
  • The sub-field must be dense_vector (sparse_vector sub-fields are not supported).
  • vector_options.model is required and must be listed by exactly one service (or the named vector_options.service must list it) — list the model under a service in the embedding endpoints configuration first.
  • vector_options.dims is required unless the service’s models entry declares it (probe a service once with _test to learn the width).
  • vector_options.source must not be set — the source is implicitly the parent field.

Semantic query #

The semantic query removes the last piece of boilerplate — the field name. (Full parameter reference on the semantic query page.) A collection whose schema declares a semantic field — a semantic_text field, a semantic multi-field, or the standalone dense_vector with source + model — is searchable with plain text alone:

POST /my-collection/_search
{"query": {"semantic": "find documents about search engines"}}

# object form — k / field / similarity / min_score all optional
{"query": {"semantic": {"query": "search engines", "k": 10}}}

# composes like any leaf — hybrid search without the retriever DSL
{"query": {"bool": {"should": [
  {"semantic": "search engines"},
  {"match": {"field": "title", "query": "engines"}}
]}}}
  • The field resolves from the schema: exactly one semantic field is inferred; with several, name one via the semantic query’s field (400 names the candidates otherwise).
  • The text embeds server-side through the field’s configured model — the same inference path as explicit text vector queries.
  • The sugar is translated at the gateway into the native vector query; the search must target exactly one collection (the schema is where the field and model live).

Quantized search with per-query overrides:

POST /my-collection/_search
{
  "query": {
    "vector": {
      "field": "embedding",
      "query_vector": [0.12, 0.45, ...],
      "k": 10,
      "num_candidates": 200,
      "vector_options": {
        "index_type": "turbo_quant",
        "bit_width": 4,
        "oversampling": 5.0
      }
    }
  }
}

Query parameters #

Parameters accepted inside the vector query object.

ParameterTypeRequiredDefaultDescription
fieldstringyes—Vector field name (dense_vector or sparse_vector).
query_vectornumber arrayone of—The query embedding. (The retrieve-style retriever DSL spells this key vector; inside a vector query it is query_vector.) On a sparse_vector field, non-zero positions are read as dimension ids (position-encoded form).
query_sparseobject / pair arrayone of—Sparse query as dimension-id → value pairs: {"5": 0.3, "17": 0.9} or [[5, 0.3], [17, 0.9]]. For sparse_vector fields; takes precedence over query_vector.
querystringone of—Natural-language text; the field’s configured embedding model converts it into the query vector when query_vector is absent.
kintegerno10Nearest neighbors the vector leg produces. The final page is capped by the request’s size — a standalone vector query returns min(k, size) hits; in a retrieve/fuse pipeline k bounds each leg and size bounds the merged result.
num_candidatesintegernounsetGraph beam width for the query (HNSW ef). Unset uses the field’s ef_search. Values below k are clamped up to k. Higher = better recall, more work.
similaritystringnocosineSimilarity metric for ranking: cosine, l2 or dot_product. Must match the field schema’s similarity.
min_scorenumberno—Minimum score threshold. When set, returns all vectors above the score instead of top-k — useful for hybrid text+vector intersection.
vector_optionsobjectno—Per-query overrides of the field schema’s index/search knobs. See below.

k vs size, live over the embeddings dataset (24 documents in three well-separated clusters): ask the vector leg for the 8 nearest neighbors but cap the page at 3 — exactly 3 hits come back, ranked by similarity:

Per-query vector_options #

Every key is optional; each overrides the field schema’s value for this query only.

KeyTypeApplies toDescription
index_typestringallForces a specific read path for this query: flat, hnsw_flat, bbq_flat, bbq_hnsw, turbo_quant, turbo_quant_hnsw. E.g. flat forces an exact scan even when the segment carries quantized codes.
bit_widthintegerTurboQuantQuantization width (2, 3, 4) for the realtime layer’s on-the-fly scan. Persisted segments serve codes at the schema’s width — set it there.
oversamplingnumberquantizedCandidate-pool multiplier before rescoring (k × oversampling candidates).
rescore_vectorbooleanquantizedRerank the candidate pool with full-precision vectors (recommended true).
ef_searchintegergraph typesQuery-time graph beam width. The top-level num_candidates takes precedence when both are set.
m, ef_construction——Build-time graph parameters — schema-level only, ignored in queries.

Field schema parameters #

The vector field declares dimensionality, similarity and index type once in the collection schema. Parameters live in the property itself and in its vector_options object:

PUT /my-collection
{
  "schema": {
    "properties": {
      "embedding": {
        "type": "dense_vector",
        "source": false,
        "vector_options": {
          "dims": 128,
          "similarity": "cosine",
          "index_type": "turbo_quant_hnsw",
          "bit_width": 4,
          "oversampling": 3.0,
          "rescore_vector": true,
          "m": 16,
          "ef_construction": 128,
          "ef_search": 64
        }
      }
    }
  }
}

Property-level parameters #

ParameterTypeDefaultDescription
typestring—dense_vector (or sparse_vector).
sourcebooleantrueWhen false, the raw vector is excluded from _source — it never appears in GET or search-hit rendering, saving storage for large embeddings. Search keeps working through the dedicated vector sections, and merges/reshards recover the vectors from FIELD_VECTOR. Note: this is the storage switch; the embedding input field is vector_options.source below.
column, storebooleanfalseGeneric storage switches; dense vectors do not use columnar or separate-store paths — leave unset.

vector_options parameters #

ParameterTypeRange / valuesDefaultDescription
dimsinteger1..=4096—Vector dimensionality (required for dense_vector; for sparse_vector it is the dimension-id space hint). TurboQuant additionally requires dims >= 8 and dims % 8 == 0.
similaritystringcosine, l2, dot_productcosineSimilarity metric used at index and query time.
index_typestringsee table belowflatIndex strategy: brute-force, graph, and/or quantization.
bit_widthinteger2, 3, 44TurboQuant scalar-quantization width. 2-bit is experimental and requires rescore_vector: true.
oversamplingnumber≥ 1.03.0Candidate-pool multiplier for quantized scans (k × oversampling candidates before rescore). Low-dimension BitQ fields need a higher factor (e.g. 8.0) to hold recall.
rescore_vectorboolean—trueRerank quantized candidates with full-precision vectors.
minteger2..=25616HNSW graph connectivity — max neighbors per node per layer. Higher = better recall, more build time and memory.
ef_constructioninteger1..=4096128HNSW build-time beam width.
ef_searchinteger1..=409664HNSW query-time beam width (the default ef for queries against this field).
sourcestringfield name—Embedding input: a text field whose content is embedded into this vector field at index time (schema-driven embeddings).
modelstringmodel id—Embedding model — a HuggingFace id for local ONNX inference (e.g. all-MiniLM-L6-v2) or the model name for remote endpoints. Effective together with source.

Out-of-range values are rejected with HTTP 400 at collection creation.

Index types #

Six index types cover the exact / approximate × unquantized / quantized space. Both quantization families persist their codes in the segment (FIELD_TURBOQUANT, FIELD_BBQ), and every graph variant persists the graph (FIELD_HNSW).

index_typeQuantizationCompressionGraphPrecision of served scoresParameters that apply
flatnone1xnoexactdims, similarity
hnsw_flatnone1xyesexactdims, similarity, m, ef_construction, ef_search
bbq_flatBitQ binary (1 bit/dim)~32xnoapproximate + rescoredims, similarity, oversampling, rescore_vector
bbq_hnswBitQ binary (1 bit/dim)~32xyesexact (graph) / approx (codes fallback)dims, similarity, oversampling, rescore_vector, m, ef_construction, ef_search
turbo_quantTurboQuant scalar (2–4 bit)~8x @ 4-bitnoapproximate + rescoredims (≥8, %8==0), similarity, bit_width, oversampling, rescore_vector
turbo_quant_hnswTurboQuant scalar (2–4 bit)~8x @ 4-bityesexact (graph) / approx (codes fallback)dims (≥8, %8==0), similarity, bit_width, oversampling, rescore_vector, m, ef_construction, ef_search

Choosing between them:

  • Exact results needed, small-to-medium data — flat.
  • Exact results, larger data — hnsw_flat (graph traversal, ~15x faster than the flat scan on a 2 000 × 128d segment, scores identical to brute force).
  • Minimal memory footprint — bbq_flat / bbq_hnsw (~32x compression). BitQ works at any dimensionality; budget oversampling for recall.
  • Balanced quantization, zero training — turbo_quant / turbo_quant_hnsw (~8x at 4-bit). The graph variant measured 12 176 QPS vs 830 (exact flat) with recall@10 0.982 vs the quantized scan’s 0.801 on a 2 000 × 128d segment.

How each type executes #

  • flat scans the stored f32 vectors — exact, O(N·dims).
  • turbo_quant / bbq_flat scan the quantized codes for a candidate pool of k × oversampling, then rescore that pool with the flat vectors (rescore_vector: true).
  • Graph types (hnsw_flat, turbo_quant_hnsw, bbq_hnsw) traverse the persisted HNSW graph once the segment is memory-mapped. Distances are computed on the flat vectors, so scores are exact and only an ef-bounded neighborhood is evaluated; the quantized-code scan (for the two quantized variants) serves as fallback when a segment predates the graph or fails to load it.
  • Filtered kNN (bool { must: [vector], filter: [...] }) widens the graph beam proportionally to the filter’s selectivity (ef ≈ max(ef, k · n / |filter|)) and traverses the same graph; quantized-only fields fall back to an exact scan over the filter set.

Storage footprint #

Two facts drive on-disk size (measured at 1 024d, bytes per vector, whole segment file / vector count):

ConfigurationB/vectorvs raw f32
raw f32 payload (reference)4 0961.00x
flat8 2942.02x
flat + source: false4 1341.01x
turbo_quant9 3422.28x
turbo_quant_hnsw + source: false5 3361.30x
  • By default the vector is stored twice — the FIELD_VECTOR section (the scan/rescore source of truth) and a copy inside the stored document (_source). source: false removes the _source copy: that is the disk-footprint lever.
  • Quantization buys query speed and scan I/O, not disk: the codes are an additional section (TurboQuant 4-bit ≈ 0.5 B/coordinate, BitQ ≈ 0.125 B/coordinate) and the HNSW graph adds ≈ 150 B/vector at m: 16. The flat section must stay — merges, resharding and rescoring rebuild from it (quantization is lossy).
  • The practical speed + storage configuration is a quantized graph index with source: false: 1.30x the raw payload (vs 2.02x default flat) with ~12–15x query speedup.

Segments holding vector data always merge in the FireV11 format, which preserves the quantized and graph sections across compaction. Rolling upgrades must reach every data node before relying on this: nodes running older builds still merge vector-bearing segments through the legacy FireV10 path, which drops the quantized sections.

Sparse vectors #

sparse_vector fields store per-document (dimension id, value) pairs — the natural shape for learned-sparse embeddings (SPLADE-style) and tag/feature vectors where most dimensions are zero. Only the non-zero entries are stored, so a sparse field costs memory proportional to the number of populated dimensions, not to dims.

PUT /my-collection
{
  "schema": {
    "properties": {
      "tags": {
        "type": "sparse_vector",
        "vector_options": {
          "dims": 65536,
          "similarity": "dot_product"
        }
      }
    }
  }
}

Documents accept two value forms (they serialize to the same rows):

PUT /my-collection/_doc/s1
{ "title": "one", "tags": {"2": 1.0, "5": 1.0} }     # object form
PUT /my-collection/_doc/s2
{ "title": "two", "tags": [[2, 0.5], [7, 1.0]] }      # pair-array form

Queries take the same two forms through query_sparse:

POST /my-collection/_search
{
  "query": {
    "vector": {
      "field": "tags",
      "query_sparse": {"2": 1.0, "5": 1.0},
      "k": 10,
      "similarity": "dot_product"
    }
  }
}

How a sparse field executes:

  • Scoring is a merge-join over the sorted id lists (dot product or cosine) — O(nnz(query) + nnz(doc)) per document, no dense materialization anywhere.
  • Realtime documents are searchable immediately through the mutable store’s sparse index; flush builds a dedicated FIELD_SPARSE section (CSR layout: one row per document, zero-copy from the memory-mapped segment).
  • Filtered kNN (bool { must: [vector], filter: [...] }) scores only the filter’s candidate set.
  • Merges and source: false are safe: compaction rebuilds sparse rows from the FIELD_SPARSE section, so excluding the field from _source (the storage lever, same as dense) never loses the data.
  • The dense index types and their quantization/graph parameters (index_type, bit_width, oversampling, m, …) do not apply to sparse fields; dims and similarity are the relevant knobs. Storage is 8 bytes per non-zero entry plus 8 bytes of per-document row overhead.

Combine several vector queries (multi-field, mixed dense/sparse) with the multi_vector query.

Use cases #

  • Semantic similarity search. Match by meaning where keywords fail — synonyms, paraphrases, cross-lingual text. The teaching embeddings dataset is built for this: 24 documents in three well-separated 8-document clusters (search, storage, vector), and querying with the search-cluster centroid at k = 8 returns exactly the eight search documents.
  • Hybrid retrieval. Vector recall beside BM25 precision, fused with RRF — the retrieve/fuse pipeline runs vector legs and text legs side by side over the same dataset.
  • Recommendations and more-like-this. Embed the item the user is looking at and take its nearest neighbors as the candidate list — no query text ever exists.
  • Thresholded recall for pipelines. min_score returns all vectors above a similarity floor, feeding deduplication, clustering or RAG chunk selection where a fixed k would be arbitrary.
  • Learned-sparse ranking. sparse_vector fields carry SPLADE-style weighted term ids and score by dot product — the same kNN machinery, memory proportional to the populated dimensions.
  • multi_vector — several vector fields (or dense + sparse) searched at once and fused into one ranking.
  • semantic — the same query with the field and model resolved from the schema; the request is plain text.
  • Hybrid search pipeline — vector legs inside the retrieve/fuse/rescore chain.
  • Hybrid search tutorial — a walkthrough over the embeddings dataset.
Calendar October 6, 2026
Edit Edit this page